From da74bf13a4099061f71d6816f6e703fb72abf43d Mon Sep 17 00:00:00 2001
From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:50:46 +0800
Subject: [PATCH 001/137] fix(gemini): discover antigravity mappings in native
model lists
---
.../gateway_model_allowlist_listing_test.go | 8 +-
.../handler/gemini_mixed_models_test.go | 136 ++++++++++++++++++
.../internal/handler/gemini_v1beta_handler.go | 96 ++++++++++---
.../handler/gemini_v1beta_handler_test.go | 6 +-
.../service/gemini_messages_compat_service.go | 27 ++++
5 files changed, 249 insertions(+), 24 deletions(-)
create mode 100644 backend/internal/handler/gemini_mixed_models_test.go
diff --git a/backend/internal/handler/gateway_model_allowlist_listing_test.go b/backend/internal/handler/gateway_model_allowlist_listing_test.go
index 55a663dbc0ac..cb1fd5d5fa7e 100644
--- a/backend/internal/handler/gateway_model_allowlist_listing_test.go
+++ b/backend/internal/handler/gateway_model_allowlist_listing_test.go
@@ -91,7 +91,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
- 41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
+ 41: {{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Status: service.StatusActive, Schedulable: true}},
},
}}
h := &GatewayHandler{
@@ -109,7 +109,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
- Models: []string{"gemini-2.5-pro", "gemini-3-*"},
+ Models: []string{"gemini-3.8-flash-high", "gemini-3-*"},
},
},
})
@@ -129,8 +129,8 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
names = append(names, model.Name)
}
// models/ 前缀的候选形式也应命中条目。
- require.Contains(t, names, "models/gemini-2.5-pro")
- require.Contains(t, names, "models/gemini-3-pro-preview")
+ require.Contains(t, names, "models/gemini-3.8-flash-high")
+ require.Contains(t, names, "models/gemini-3-flash")
require.NotContains(t, names, "models/gemini-2.5-flash")
require.NotContains(t, names, "models/gemini-2.0-flash")
}
diff --git a/backend/internal/handler/gemini_mixed_models_test.go b/backend/internal/handler/gemini_mixed_models_test.go
new file mode 100644
index 000000000000..6a0e2c2eb5b6
--- /dev/null
+++ b/backend/internal/handler/gemini_mixed_models_test.go
@@ -0,0 +1,136 @@
+package handler
+
+import (
+ "encoding/json"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
+ "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"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ forced, mixed, allowlist bool
+ status int
+ }{
+ {"mixed", false, true, false, 200},
+ {"mixed allowlist", false, true, true, 200},
+ {"disabled mixed", false, false, false, 503},
+ {"forced without mixed opt-in", true, false, false, 200},
+ {"forced allowlist", true, false, true, 200},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ groupID := int64(45)
+ repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
+ groupID: {{ID: 1, Platform: service.PlatformAntigravity,
+ Extra: map[string]any{"mixed_scheduling": tt.mixed},
+ Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}},
+ }}}
+ h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
+ c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini,
+ ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}},
+ }})
+ if tt.forced {
+ c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
+ }
+ h.GeminiV1BetaListModels(c)
+ require.Equal(t, tt.status, rec.Code, rec.Body.String())
+ if tt.status != 200 {
+ return
+ }
+ var got gemini.ModelsListResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
+ names := []string{}
+ for _, model := range got.Models {
+ names = append(names, model.Name)
+ require.Contains(t, model.SupportedGenerationMethods, "generateContent")
+ }
+ require.Contains(t, names, "models/gemini-synced-custom")
+ require.NotContains(t, names, "models/claude-custom")
+ require.NotContains(t, names, "models/gemini-2.0-flash")
+ if tt.allowlist {
+ require.Equal(t, []string{"models/gemini-synced-custom"}, names)
+ }
+ })
+ }
+}
+
+func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) {
+ body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`)
+ extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")}
+ merged, ok := appendUpstreamGeminiModels(body, extra)
+ require.True(t, ok)
+ require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged))
+ filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}})
+ require.True(t, ok)
+ require.True(t, dropped)
+ require.NotContains(t, string(filtered), "gemini-native")
+ require.Contains(t, string(filtered), "gemini-synced-custom")
+ for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} {
+ got, ok := appendUpstreamGeminiModels([]byte(invalid), extra)
+ require.False(t, ok)
+ require.Equal(t, invalid, string(got))
+ }
+}
+
+// Exercise the real handler's native-upstream branch, including scope fallback.
+type geminiMixedModelsUpstream struct {
+ service.HTTPUpstream
+ status int
+ body string
+}
+
+func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
+ return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil
+}
+func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ status int
+ body string
+ expectedNative string
+ }{
+ {"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"},
+ {"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"},
+ {"upstream error", 429, `{"error":"rate limited"}`, ""},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ id := int64(46)
+ repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: {
+ {ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}},
+ {ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}},
+ }}}}
+ upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body}
+ h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})}
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
+ c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}})
+ h.GeminiV1BetaListModels(c)
+ if tt.status == 429 {
+ require.Equal(t, 429, rec.Code)
+ require.JSONEq(t, tt.body, rec.Body.String())
+ return
+ }
+ require.Equal(t, 200, rec.Code, rec.Body.String())
+ require.Contains(t, rec.Body.String(), tt.expectedNative)
+ require.Contains(t, rec.Body.String(), "models/gemini-synced-custom")
+ if tt.status == 200 {
+ require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`)
+ require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`)
+ require.Equal(t, "native-id", rec.Header().Get("X-Request-Id"))
+ }
+ })
+ }
+}
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index d8ec24670bdb..a7ae9170c115 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -60,30 +60,24 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return filtered
}
- // 强制 antigravity 模式:返回 antigravity 支持的模型列表
+ agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity)
+ if err != nil {
+ googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models")
+ return
+ }
+ agModels := make([]gemini.Model, 0, len(agModelIDs))
+ for _, id := range agModelIDs {
+ agModels = append(agModels, gemini.FallbackModel(id))
+ }
if forcePlatform == service.PlatformAntigravity {
- if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
- agModels := antigravity.DefaultGeminiModels()
- filtered := make([]antigravity.GeminiModel, 0, len(agModels))
- for _, model := range agModels {
- if apiKey.Group.ModelAllowlist.Allows(model.Name) {
- filtered = append(filtered, model)
- }
- }
- c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
- return
- }
- c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
+ c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
return
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
- // 没有 gemini 账户,检查是否有 antigravity 账户可用
- hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
- if hasAntigravity {
- // antigravity 账户使用静态模型列表
- c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
+ if len(agModels) > 0 {
+ c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
return
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
@@ -97,9 +91,15 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
if shouldFallbackGeminiModels(res) {
- c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
+ c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))})
return
}
+ if res.StatusCode == http.StatusOK && len(agModels) > 0 {
+ if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok {
+ res.Body = merged
+ }
+ }
+
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
@@ -110,6 +110,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
writeUpstreamResponse(c, res)
}
+// mergeGeminiModelLists keeps native metadata when both sources advertise a model.
+func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model {
+ result := append([]gemini.Model{}, native...)
+ seen := make(map[string]bool, len(native))
+ for _, model := range native {
+ seen[model.Name] = true
+ }
+ for _, model := range extra {
+ if !seen[model.Name] {
+ result = append(result, model)
+ seen[model.Name] = true
+ }
+ }
+ return result
+}
+
+// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields.
+func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) {
+ var envelope map[string]json.RawMessage
+ if json.Unmarshal(body, &envelope) != nil || envelope == nil {
+ return body, false
+ }
+ var models []json.RawMessage
+ raw, exists := envelope["models"]
+ if !exists || json.Unmarshal(raw, &models) != nil {
+ return body, false
+ }
+ seen := make(map[string]bool, len(models))
+ for _, raw := range models {
+ var model struct {
+ Name string `json:"name"`
+ }
+ if json.Unmarshal(raw, &model) != nil {
+ return body, false
+ }
+ seen[model.Name] = true
+ }
+ changed := false
+ for _, model := range extra {
+ if seen[model.Name] {
+ continue
+ }
+ raw, err := json.Marshal(model)
+ if err != nil {
+ return body, false
+ }
+ models = append(models, raw)
+ seen[model.Name] = true
+ changed = true
+ }
+ if !changed {
+ return body, true
+ }
+ envelope["models"], _ = json.Marshal(models)
+ merged, err := json.Marshal(envelope)
+ return merged, err == nil
+}
+
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
// models[].name,其余信封字段(如 nextPageToken)原样保留。
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go
index 2bf25a6b9679..8604b07b8816 100644
--- a/backend/internal/handler/gemini_v1beta_handler_test.go
+++ b/backend/internal/handler/gemini_v1beta_handler_test.go
@@ -39,7 +39,11 @@ func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T)
})
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
- (&GatewayHandler{}).GeminiV1BetaListModels(c)
+ groupID := int64(42)
+ key, _ := middleware.GetAPIKeyFromContext(c)
+ key.GroupID = &groupID
+ repo := &geminiAllowlistAccountRepoStub{}
+ (&GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}).GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got antigravity.GeminiModelsListResponse
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index edd727ce5eec..306d84d8f413 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -16,6 +16,7 @@ import (
mathrand "math/rand"
"net/http"
"regexp"
+ "sort"
"strings"
"time"
@@ -483,6 +484,32 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin
return normalized, nil
}
+// AntigravityGeminiModelIDs returns the effective Gemini mappings of schedulable
+// Antigravity accounts. Mixed routes must additionally respect the account opt-in.
+func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Context, groupID *int64, requireMixed bool) ([]string, error) {
+ accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
+ if err != nil {
+ return nil, err
+ }
+ seen := make(map[string]struct{})
+ for _, account := range accounts {
+ if account.Platform != PlatformAntigravity || (requireMixed && !account.IsMixedSchedulingEnabled()) {
+ continue
+ }
+ for model := range account.GetModelMapping() {
+ if isAntigravityGeminiModel(model) {
+ seen[model] = struct{}{}
+ }
+ }
+ }
+ models := make([]string, 0, len(seen))
+ for model := range seen {
+ models = append(models, model)
+ }
+ sort.Strings(models)
+ return models, nil
+}
+
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context, groupID *int64) (bool, error) {
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
From 881f3202694c6bc932446931a30c27d9675178b9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 09:02:51 +0000
Subject: [PATCH 002/137] chore: sync VERSION to 0.2.5 [skip ci]
---
backend/cmd/server/VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index abd410582dea..3a4036fb450f 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.2.4
+0.2.5
From d7ee1ab6beb618cf413bf7616ecc482251a62157 Mon Sep 17 00:00:00 2001
From: xO-ox-ai <104205598+xO-ox-ai@users.noreply.github.com>
Date: Tue, 15 Sep 2026 18:44:50 +0800
Subject: [PATCH 003/137] fix(openai): persist response affinity after client
cancel
---
.../openai_gateway_response_handling.go | 15 +++++-
.../service/openai_gateway_service_test.go | 48 +++++++++++++++++++
2 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go
index 2563dbc3e33b..efb9f2e0d8d8 100644
--- a/backend/internal/service/openai_gateway_response_handling.go
+++ b/backend/internal/service/openai_gateway_response_handling.go
@@ -1464,12 +1464,23 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g
if store == nil {
return
}
+ // The client may close the stream immediately after receiving its terminal
+ // event, canceling the request context before these durable affinity writes
+ // run. Preserve request values, but give all Redis writes one bounded budget
+ // independent of the downstream connection lifecycle.
+ bindBaseCtx := context.Background()
+ if ctx != nil {
+ bindBaseCtx = context.WithoutCancel(ctx)
+ }
+ bindCtx, cancel := context.WithTimeout(bindBaseCtx, openAIWSStateStoreRedisTimeout)
+ defer cancel()
+
groupID := getOpenAIGroupIDFromContext(c)
ttl := s.openAIWSResponseStickyTTL()
- logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
+ logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(bindCtx, groupID, responseID, account.ID, ttl))
if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok {
if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 {
- if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
+ if err := s.BindOpenAIHTTPResponseOwner(bindCtx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
logger.L().Warn(
"openai.http_bind_response_owner_failed",
zap.Int64("group_id", groupID),
diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go
index 69113a59d9fe..cf4611ccceb6 100644
--- a/backend/internal/service/openai_gateway_service_test.go
+++ b/backend/internal/service/openai_gateway_service_test.go
@@ -589,6 +589,37 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) {
require.False(t, owned)
}
+func TestOpenAIGatewayService_BindHTTPResponseAccount_DetachesCanceledRequestContext(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
+ groupID := int64(4201)
+ c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID})
+ SetOpenAIHTTPResponseOwner(c, 601, 501)
+
+ cache := &responseBindContextProbeCache{}
+ svc := &OpenAIGatewayService{cache: cache}
+ account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
+ requestCtx, cancelRequest := context.WithCancel(context.Background())
+ cancelRequest()
+ startedAt := time.Now()
+
+ svc.bindHTTPResponseAccount(requestCtx, c, account, "resp_http_canceled_001")
+
+ require.Len(t, cache.setContextErrors, 3)
+ for _, contextErr := range cache.setContextErrors {
+ require.NoError(t, contextErr, "response affinity writes must survive downstream cancellation")
+ }
+ require.Len(t, cache.setDeadlines, 3)
+ for _, deadline := range cache.setDeadlines {
+ require.True(t, deadline.After(startedAt))
+ require.LessOrEqual(t, deadline.Sub(startedAt), openAIWSStateStoreRedisTimeout+100*time.Millisecond)
+ }
+ require.Len(t, cache.sessionBindings, 3)
+ require.Contains(t, cache.sessionBindings, openAIWSResponseAccountCacheKey("resp_http_canceled_001"))
+}
+
func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{}
@@ -710,6 +741,23 @@ type stubGatewayCache struct {
deletedSessions map[string]int
}
+type responseBindContextProbeCache struct {
+ stubGatewayCache
+ setContextErrors []error
+ setDeadlines []time.Time
+}
+
+func (c *responseBindContextProbeCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
+ c.setContextErrors = append(c.setContextErrors, ctx.Err())
+ if deadline, ok := ctx.Deadline(); ok {
+ c.setDeadlines = append(c.setDeadlines, deadline)
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return c.stubGatewayCache.SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl)
+}
+
func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
if id, ok := c.sessionBindings[sessionHash]; ok {
return id, nil
From 21532add46331a54d03493871abafc6c9662f309 Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Tue, 15 Sep 2026 20:04:35 +0800
Subject: [PATCH 004/137] fix(payment): reset the order page when changing
status
---
frontend/src/views/user/UserOrdersView.vue | 2 +-
.../__tests__/UserOrdersView.filters.spec.ts | 54 +++++++++++++++++++
2 files changed, 55 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/views/user/__tests__/UserOrdersView.filters.spec.ts
diff --git a/frontend/src/views/user/UserOrdersView.vue b/frontend/src/views/user/UserOrdersView.vue
index c3ed80eb59ef..003357a09883 100644
--- a/frontend/src/views/user/UserOrdersView.vue
+++ b/frontend/src/views/user/UserOrdersView.vue
@@ -4,7 +4,7 @@
-
+
+
+ {{ t('common.total') }}: {{ historyTotal }} {{ t('pagination.results') }}
+
+
+
+
@@ -375,6 +399,11 @@ const errorMessage = ref('')
// History data
const history = ref([])
const loadingHistory = ref(false)
+const historyPage = ref(1)
+const historyPageSize = ref(20)
+const historyTotal = ref(0)
+let historyRequest = 0
+let loadedHistoryPageSize = 20
const contactInfo = ref('')
// Helper functions for history display
@@ -420,14 +449,25 @@ const formatHistoryValue = (item: RedeemHistoryItem) => {
}
}
-const fetchHistory = async () => {
+const fetchHistory = async (page = 1) => {
+ const request = ++historyRequest
+ const pageSize = historyPageSize.value
loadingHistory.value = true
try {
- history.value = await redeemAPI.getHistory()
+ const result = await redeemAPI.getHistory(page, pageSize)
+ if (request !== historyRequest) return
+ history.value = result.items
+ historyTotal.value = result.total
+ historyPage.value = page
+ historyPageSize.value = pageSize
+ loadedHistoryPageSize = pageSize
} catch (error) {
+ if (request !== historyRequest) return
+ historyPageSize.value = loadedHistoryPageSize
+ appStore.showError(t('redeem.historyLoadFailed'))
console.error('Failed to fetch history:', error)
} finally {
- loadingHistory.value = false
+ if (request === historyRequest) loadingHistory.value = false
}
}
diff --git a/frontend/src/views/user/__tests__/RedeemView.spec.ts b/frontend/src/views/user/__tests__/RedeemView.spec.ts
index 7c71092fcaa4..85bfc9e6dac9 100644
--- a/frontend/src/views/user/__tests__/RedeemView.spec.ts
+++ b/frontend/src/views/user/__tests__/RedeemView.spec.ts
@@ -45,7 +45,7 @@ describe('RedeemView refresh after redemption', () => {
beforeEach(() => {
vi.clearAllMocks()
redeem.mockResolvedValue({ type: 'balance', value: 20, message: 'Code applied' })
- getHistory.mockResolvedValue([])
+ getHistory.mockResolvedValue({ items: [], total: 0 })
refreshUser.mockResolvedValue({ balance: 30, concurrency: 2 })
fetchActiveSubscriptions.mockResolvedValue([])
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -59,9 +59,9 @@ describe('RedeemView refresh after redemption', () => {
'keeps a successful %s redemption when profile refresh fails', async (type) => {
redeem.mockResolvedValue({ type, value: 20, message: 'Code applied' })
refreshUser.mockRejectedValue({ status: 503, message: 'Service unavailable' })
- getHistory.mockResolvedValueOnce([]).mockResolvedValueOnce([{
+ getHistory.mockResolvedValueOnce({ items: [], total: 0 }).mockResolvedValueOnce({ total: 1, items: [{
id: 1, code: 'REDEEM-CODE', type, value: 20, used_at: '2026-03-08T00:00:00Z',
- }])
+ }] })
const wrapper = await submitCode()
@@ -84,6 +84,122 @@ describe('RedeemView refresh after redemption', () => {
}
)
+ it('pages on the server, changes size, and resets page and total after redeeming', async () => {
+ getHistory.mockResolvedValue({ items: [], total: 101 })
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ expect(getHistory).toHaveBeenLastCalledWith(1, 20)
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 20)
+ await button('pagination.previous').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 20)
+ expect(wrapper.findAll('select option').map(o => o.text())).toEqual(['20', '50', '100'])
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ await wrapper.get('select').setValue('100')
+ await flushPromises()
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 100)
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ getHistory.mockResolvedValue({ items: [], total: 102 })
+ await wrapper.get('input#code').setValue('NEW-CODE')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 100)
+ expect(wrapper.text()).toContain('102')
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ wrapper.unmount()
+ })
+
+ it('restores the loaded size and keeps rows and navigation usable after a size request fails', async () => {
+ const item = { id: 1, code: 'OLD-ROWS', type: 'balance', value: 20, used_at: '2026-03-08T00:00:00Z' }
+ getHistory.mockResolvedValue({ items: [item], total: 61 })
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ let rejectRequest!: (error: Error) => void
+ getHistory.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRequest = reject }))
+ await wrapper.get('select').setValue('50')
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ rejectRequest(new Error('Network error'))
+ await flushPromises()
+ expect(wrapper.get('select').element.value).toBe('20')
+ expect(wrapper.text()).toContain('OLD-ROWS')
+ expect(wrapper.text()).toContain('61')
+ expect(button('pagination.previous').attributes('disabled')).toBeUndefined()
+ expect(button('pagination.next').attributes('disabled')).toBeUndefined()
+ expect(showError).toHaveBeenCalledWith('redeem.historyLoadFailed')
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(3, 20)
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ expect(wrapper.get('select').element.value).toBe('50')
+ expect(button('pagination.previous').attributes('disabled')).toBeDefined()
+ wrapper.unmount()
+ })
+
+ it.each(['success', 'failure'])('ignores a stale history %s after a newer size request succeeds', async (outcome) => {
+ let resolveOld!: (value: unknown) => void
+ let rejectOld!: (error: Error) => void
+ getHistory.mockImplementationOnce(() => new Promise((resolve, reject) => {
+ resolveOld = resolve
+ rejectOld = reject
+ }))
+ const wrapper = mount(RedeemView, {
+ global: { stubs: { AppLayout: { template: '
' }, Icon: true } },
+ })
+ getHistory.mockResolvedValue({ items: [{
+ id: 2, code: 'NEW-ROWS', type: 'balance', value: 30, used_at: '2026-03-08T00:00:00Z',
+ }], total: 61 })
+ // Force overlapping requests to exercise responses arriving out of order.
+ await wrapper.get('select').setValue('50')
+ await flushPromises()
+ const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)!
+ await button('pagination.next').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(2, 50)
+ if (outcome === 'success') {
+ resolveOld({ items: [], total: 0 })
+ } else {
+ rejectOld(new Error('Stale network error'))
+ }
+ await flushPromises()
+ expect(wrapper.get('select').element.value).toBe('50')
+ expect(wrapper.text()).toContain('NEW-ROWS')
+ expect(wrapper.text()).toContain('61')
+ expect(button('pagination.previous').attributes('disabled')).toBeUndefined()
+ expect(button('pagination.next').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('select').attributes('disabled')).toBeUndefined()
+ expect(showError).not.toHaveBeenCalled()
+ await button('pagination.previous').trigger('click')
+ await flushPromises()
+ expect(getHistory).toHaveBeenLastCalledWith(1, 50)
+ wrapper.unmount()
+ })
+
+ it('disables both paging buttons for empty history', async () => {
+ const wrapper = await submitCode()
+ for (const button of wrapper.findAll('button').filter(b => b.text().startsWith('pagination.'))) {
+ expect(button.attributes('disabled')).toBeDefined()
+ }
+ wrapper.unmount()
+ })
+
it('finishes normally without a warning when profile refresh succeeds', async () => {
const wrapper = await submitCode()
From 2f16e0984ee97ba6c9000745c6613c6d280b7332 Mon Sep 17 00:00:00 2001
From: Xiangzhe
Date: Mon, 14 Sep 2026 03:08:00 +0000
Subject: [PATCH 012/137] fix(openai): avoid duplicate manifest parsing
---
.../service/openai_codex_models_service.go | 15 ++++-----------
1 file changed, 4 insertions(+), 11 deletions(-)
diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go
index a0253a42412a..4e4cef85603a 100644
--- a/backend/internal/service/openai_codex_models_service.go
+++ b/backend/internal/service/openai_codex_models_service.go
@@ -2461,25 +2461,18 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage)
}
func validateCodexModelsManifestEnvelope(body []byte) error {
- var envelope map[string]json.RawMessage
+ var envelope *struct {
+ Models []json.RawMessage `json:"models"`
+ }
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("decode JSON object: %w", err)
}
if envelope == nil {
return errors.New("expected a JSON object")
}
- models, ok := envelope["models"]
- if !ok {
+ if envelope.Models == nil {
return errors.New("missing top-level models array")
}
- models = bytes.TrimSpace(models)
- var entries []json.RawMessage
- if len(models) == 0 || models[0] != '[' {
- return errors.New("top-level models field is not an array")
- }
- if err := json.Unmarshal(models, &entries); err != nil {
- return fmt.Errorf("decode top-level models array: %w", err)
- }
return nil
}
From 31f3003ff5dfc192a0ba1abb8ac9df6204294950 Mon Sep 17 00:00:00 2001
From: Xiangzhe
Date: Mon, 14 Sep 2026 03:37:20 +0000
Subject: [PATCH 013/137] fix(openai): preserve manifest key validation
---
.../internal/service/openai_codex_models_service.go | 11 +++++++----
.../service/openai_codex_models_service_test.go | 5 +++++
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go
index 4e4cef85603a..e2658581e798 100644
--- a/backend/internal/service/openai_codex_models_service.go
+++ b/backend/internal/service/openai_codex_models_service.go
@@ -2461,18 +2461,21 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage)
}
func validateCodexModelsManifestEnvelope(body []byte) error {
- var envelope *struct {
- Models []json.RawMessage `json:"models"`
- }
+ var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("decode JSON object: %w", err)
}
if envelope == nil {
return errors.New("expected a JSON object")
}
- if envelope.Models == nil {
+ models, ok := envelope["models"]
+ if !ok {
return errors.New("missing top-level models array")
}
+ models = bytes.TrimSpace(models)
+ if len(models) == 0 || models[0] != '[' {
+ return errors.New("top-level models field is not an array")
+ }
return nil
}
diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go
index 087b3b8dac35..888a32f55ccd 100644
--- a/backend/internal/service/openai_codex_models_service_test.go
+++ b/backend/internal/service/openai_codex_models_service_test.go
@@ -2562,6 +2562,11 @@ func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
}
}
+func TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) {
+ require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`)))
+ require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`)))
+}
+
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
var calls atomic.Int32
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
From 18bfa4bf20324b36df1885993cd6e16fc85c3c85 Mon Sep 17 00:00:00 2001
From: HypoxanthineOvO
Date: Wed, 16 Sep 2026 16:11:43 +0800
Subject: [PATCH 014/137] fix(gateway): normalize developer roles for strict
Chat upstreams
---
backend/internal/service/openai_chat_roles.go | 82 ++++++++
.../service/openai_chat_roles_test.go | 185 ++++++++++++++++++
.../openai_gateway_chat_completions_raw.go | 5 +
3 files changed, 272 insertions(+)
create mode 100644 backend/internal/service/openai_chat_roles.go
create mode 100644 backend/internal/service/openai_chat_roles_test.go
diff --git a/backend/internal/service/openai_chat_roles.go b/backend/internal/service/openai_chat_roles.go
new file mode 100644
index 000000000000..1e80101f5dcb
--- /dev/null
+++ b/backend/internal/service/openai_chat_roles.go
@@ -0,0 +1,82 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "net/url"
+ "strings"
+)
+
+func requiresSystemChatRole(account *Account, targetURL string) bool {
+ if account == nil || account.Type != AccountTypeAPIKey {
+ return false
+ }
+ switch account.Platform {
+ case PlatformDeepseek, PlatformKimi, PlatformZhipu:
+ return true
+ }
+ // OpenAI-compatible accounts can point at the same strict providers. Match
+ // the selected destination's exact hostname, never the requested model ID.
+ u, err := url.Parse(targetURL)
+ if err != nil {
+ return false
+ }
+ switch strings.ToLower(u.Hostname()) {
+ case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai":
+ return true
+ }
+ return false
+}
+
+// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an
+// account and destination have been selected. Unlike the Responses bridge, it
+// does not reorder/merge messages or downgrade instructions to user messages.
+// RawMessage preserves unknown fields and numeric precision. The input remains
+// untouched so a retry on another account sees the original developer roles.
+func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) {
+ if !requiresSystemChatRole(account, targetURL) {
+ return body, nil
+ }
+ var root map[string]json.RawMessage
+ if json.Unmarshal(body, &root) != nil || root == nil {
+ return nil, errors.New("chat role normalization: invalid request object")
+ }
+ raw, exists := root["messages"]
+ if !exists {
+ return body, nil
+ }
+ var messages []json.RawMessage
+ if json.Unmarshal(raw, &messages) != nil {
+ return nil, errors.New("chat role normalization: invalid messages array")
+ }
+ changed := false
+ for i, raw := range messages {
+ var message map[string]json.RawMessage
+ if json.Unmarshal(raw, &message) != nil || message == nil {
+ return nil, errors.New("chat role normalization: invalid message object")
+ }
+ var role string
+ if json.Unmarshal(message["role"], &role) != nil {
+ return nil, errors.New("chat role normalization: invalid message role")
+ }
+ if role != "developer" {
+ continue
+ }
+ message["role"] = json.RawMessage(`"system"`)
+ updated, err := json.Marshal(message)
+ if err != nil {
+ return nil, errors.New("chat role normalization: cannot encode message")
+ }
+ messages[i] = updated
+ changed = true
+ }
+ if !changed {
+ return body, nil
+ }
+ updated, err := json.Marshal(messages)
+ if err != nil {
+ return nil, errors.New("chat role normalization: cannot encode messages")
+ }
+ root["messages"] = updated
+ return json.Marshal(root)
+}
diff --git a/backend/internal/service/openai_chat_roles_test.go b/backend/internal/service/openai_chat_roles_test.go
new file mode 100644
index 000000000000..27434ad98317
--- /dev/null
+++ b/backend/internal/service/openai_chat_roles_test.go
@@ -0,0 +1,185 @@
+//go:build unit
+
+package service
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+// Only the upstream transport is stubbed. Requests go through the actual Chat
+// dispatcher, model mapping, request construction and response/usage handling.
+type chatRoleContractUpstream struct {
+ httpUpstreamRecorder
+ strict bool
+}
+
+func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ return nil, err
+ }
+ req.Body = io.NopCloser(bytes.NewReader(body))
+ status, contentType := http.StatusOK, "application/json"
+ result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}`
+ if u.strict {
+ for _, message := range gjson.GetBytes(body, "messages").Array() {
+ if message.Get("role").String() == "developer" {
+ status = http.StatusBadRequest
+ result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}"
+ }
+ }
+ }
+ if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() {
+ contentType = "text/event-stream"
+ result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n"
+ }
+ u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))}
+ return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency)
+}
+
+func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, stream := range []bool{false, true} {
+ for _, target := range []struct{ platform, model, base string }{
+ {PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"},
+ {PlatformKimi, "k3", "https://api.kimi.com/coding"},
+ {PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
+ {PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"},
+ {PlatformOpenAI, "k3", "https://api.kimi.com/coding"},
+ {PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"},
+ {PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
+ } {
+ t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) {
+ body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream))
+ original := bytes.Clone(body)
+ upstream := &chatRoleContractUpstream{strict: true}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := rawChatCompletionsTestAccount()
+ account.Platform = target.platform
+ account.Credentials["base_url"] = target.base
+ account.Credentials["api_protocol"] = APIProtocolChatCompletions
+ account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model}
+ account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false}
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 9, result.Usage.InputTokens)
+ require.Equal(t, 1, result.Usage.OutputTokens)
+ require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
+ require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
+ require.Equal(t, original, body, "per-account adaptation must not mutate the retry body")
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Contains(t, recorder.Body.String(), "pong")
+ if stream {
+ require.Contains(t, recorder.Body.String(), "data: [DONE]")
+ }
+
+ // A subsequent attempt with the same body on a compatible account
+ // must still see developer, not the previous account's system role.
+ upstream.strict = false
+ account.Platform = PlatformOpenAI
+ account.Credentials["base_url"] = "https://compatible.example.test/v1"
+ recorder = httptest.NewRecorder()
+ c, _ = gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
+ })
+ }
+ }
+}
+
+func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) {
+ body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`)
+ original := bytes.Clone(body)
+ want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`)
+ for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} {
+ t.Run(platform, func(t *testing.T) {
+ account := &Account{Platform: platform, Type: AccountTypeAPIKey}
+ got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body)
+ require.NoError(t, err)
+ require.JSONEq(t, want, string(got))
+ require.Contains(t, string(got), "9007199254740993")
+ require.Equal(t, original, body)
+ again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got)
+ require.NoError(t, err)
+ require.Equal(t, got, again)
+ })
+ }
+}
+
+func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) {
+ body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`)
+ account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
+ for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} {
+ t.Run(host, func(t *testing.T) {
+ got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got))
+ })
+ }
+}
+
+func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) {
+ // Neither model names nor host-like strings in the path/userinfo may enable
+ // normalization on an otherwise compatible provider.
+ body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }")
+ account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
+ for _, target := range []string{
+ "https://api.openai.com/v1/chat/completions",
+ "https://relay.example.test/v1/chat/completions",
+ "https://api.deepseek.com.example.test/v1/chat/completions",
+ "https://example.test/api.kimi.com/chat/completions",
+ "https://api.deepseek.com@example.test/v1/chat/completions",
+ "://invalid-url",
+ } {
+ got, err := normalizeStrictChatDeveloperRoles(account, target, body)
+ require.NoError(t, err)
+ require.Equal(t, body, got)
+ }
+ for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} {
+ got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body)
+ require.NoError(t, err)
+ require.Equal(t, body, got)
+ }
+}
+
+func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) {
+ account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
+ for _, body := range []string{
+ "{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }",
+ `{"messages":[]}`,
+ `{"input":[{"role":"developer","content":"Responses is not Chat"}]}`,
+ } {
+ got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
+ require.NoError(t, err)
+ require.Equal(t, body, string(got))
+ }
+}
+
+func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) {
+ account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
+ for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} {
+ _, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
+ require.Error(t, err)
+ require.NotContains(t, err.Error(), "secret")
+ }
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
index dd77e700d2e3..f778a682bc9b 100644
--- a/backend/internal/service/openai_gateway_chat_completions_raw.go
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -178,6 +178,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
if err != nil {
return nil, err
}
+ upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody)
+ if err != nil {
+ writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
+ return nil, err
+ }
SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
customUA := account.GetOpenAIUserAgent()
if customUA == "" && account.IsGrokOAuth() {
From 1a32b91eb21c022fb8a02bc35bdf707211fcf462 Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Wed, 16 Sep 2026 18:15:12 +0800
Subject: [PATCH 015/137] fix(pagination): handle numeric jump input
---
frontend/src/components/common/Pagination.vue | 2 +-
.../common/__tests__/Pagination.jump.spec.ts | 38 +++++++++++++++++++
2 files changed, 39 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/common/__tests__/Pagination.jump.spec.ts
diff --git a/frontend/src/components/common/Pagination.vue b/frontend/src/components/common/Pagination.vue
index 9b4ac200e210..cdccd965df05 100644
--- a/frontend/src/components/common/Pagination.vue
+++ b/frontend/src/components/common/Pagination.vue
@@ -230,7 +230,7 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
}
const submitJump = () => {
- const value = jumpPage.value.trim()
+ const value = String(jumpPage.value).trim()
if (!value) return
const pageNum = Number.parseInt(value, 10)
if (Number.isNaN(pageNum)) return
diff --git a/frontend/src/components/common/__tests__/Pagination.jump.spec.ts b/frontend/src/components/common/__tests__/Pagination.jump.spec.ts
new file mode 100644
index 000000000000..59e0870dbae0
--- /dev/null
+++ b/frontend/src/components/common/__tests__/Pagination.jump.spec.ts
@@ -0,0 +1,38 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import Pagination from '../Pagination.vue'
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+enableAutoUnmount(afterEach)
+
+function mountPagination() {
+ return mount(Pagination, {
+ props: { total: 200, page: 1, pageSize: 20, showJump: true, showPageSizeSelector: false },
+ global: { stubs: { Icon: true } }
+ })
+}
+
+describe('pagination jump input', () => {
+ it.each(['click', 'enter'])('jumps to the entered numeric page using %s', async (action) => {
+ const wrapper = mountPagination()
+ const input = wrapper.get('input[type="number"]')
+ await input.setValue('3')
+ if (action === 'click') await wrapper.get('.btn').trigger('click')
+ else await input.trigger('keyup', { key: 'Enter' })
+ expect(wrapper.emitted('update:page')).toEqual([[3]])
+ expect((input.element as HTMLInputElement).value).toBe('')
+ })
+
+ it('clamps an entered page to the last page', async () => {
+ const wrapper = mountPagination()
+ await wrapper.get('input').setValue('99')
+ await wrapper.get('.btn').trigger('click')
+ expect(wrapper.emitted('update:page')).toEqual([[10]])
+ })
+
+ it('ignores an empty jump input', async () => {
+ const wrapper = mountPagination()
+ await wrapper.get('.btn').trigger('click')
+ expect(wrapper.emitted('update:page')).toBeUndefined()
+ })
+})
From ee9ac3e45fc6226ccd1e824ad2cd5247853553a8 Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Wed, 16 Sep 2026 18:15:28 +0800
Subject: [PATCH 016/137] fix(auth): keep TOTP setup digits in sync with state
---
.../user/profile/TotpSetupModal.vue | 1 +
.../__tests__/TotpSetupModal.inputs.spec.ts | 65 +++++++++++++++++++
2 files changed, 66 insertions(+)
create mode 100644 frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts
diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue
index 312ecf7f4b3b..fd9070b4d9c9 100644
--- a/frontend/src/components/user/profile/TotpSetupModal.vue
+++ b/frontend/src/components/user/profile/TotpSetupModal.vue
@@ -135,6 +135,7 @@
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setInputRef(el, index)"
+ :value="code[index]"
type="text"
maxlength="1"
inputmode="numeric"
diff --git a/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts b/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts
new file mode 100644
index 000000000000..28975d95e154
--- /dev/null
+++ b/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts
@@ -0,0 +1,65 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
+import TotpSetupModal from '../TotpSetupModal.vue'
+
+const api = vi.hoisted(() => ({ getVerificationMethod: vi.fn(), initiateSetup: vi.fn(), enable: vi.fn() }))
+vi.mock('@/api', () => ({ totpAPI: api }))
+vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) }))
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+vi.mock('qrcode', () => ({ default: { toDataURL: vi.fn() } }))
+enableAutoUnmount(afterEach)
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ api.getVerificationMethod.mockResolvedValue({ method: 'password' })
+ api.initiateSetup.mockResolvedValue({ secret: 'EXAMPLE', qr_code_url: '', setup_token: 'setup-token' })
+ api.enable.mockResolvedValue({ success: true })
+})
+
+async function openCodeStep() {
+ const wrapper = mount(TotpSetupModal, { attachTo: document.body })
+ await flushPromises()
+ await wrapper.get('input[type="password"]').setValue('password')
+ await wrapper.get('.btn-primary').trigger('click')
+ await flushPromises()
+ await wrapper.get('.btn-primary').trigger('click')
+ const inputs = wrapper.findAll('input[maxlength="1"]')
+ for (let i = 0; i < inputs.length; i++) await inputs[i].setValue(String(i + 1))
+ return wrapper
+}
+
+describe('TOTP setup input synchronization', () => {
+ it('clears displayed digits after verification fails and focuses the first cell', async () => {
+ api.enable.mockRejectedValueOnce({ message: 'Invalid code' })
+ const wrapper = await openCodeStep()
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ const inputs = wrapper.findAll('input[maxlength="1"]')
+ expect(inputs.map(input => (input.element as HTMLInputElement).value)).toEqual(['', '', '', '', '', ''])
+ expect(document.activeElement).toBe(inputs[0].element)
+ expect(wrapper.get('[type="submit"]').attributes('disabled')).toBeDefined()
+ await inputs[0].trigger('paste', { clipboardData: { getData: () => '654321' } })
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(api.enable).toHaveBeenLastCalledWith({ totp_code: '654321', setup_token: 'setup-token' })
+ expect(wrapper.emitted('success')).toHaveLength(1)
+ })
+
+ it('displays retained digits when returning from the QR-code step', async () => {
+ const wrapper = await openCodeStep()
+ await wrapper.findAll('button').find(button => button.text() === 'common.back')!.trigger('click')
+ await wrapper.get('.btn-primary').trigger('click')
+ expect(wrapper.findAll('input[maxlength="1"]').map(input => (input.element as HTMLInputElement).value)).toEqual(['1', '2', '3', '4', '5', '6'])
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(api.enable).toHaveBeenCalledWith({ totp_code: '123456', setup_token: 'setup-token' })
+ })
+
+ it('submits normally entered digits', async () => {
+ const wrapper = await openCodeStep()
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(api.enable).toHaveBeenCalledWith({ totp_code: '123456', setup_token: 'setup-token' })
+ expect(wrapper.emitted('success')).toHaveLength(1)
+ })
+})
From 98321a054ed41c65058966d1d7d705d93dfe902e Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Wed, 16 Sep 2026 18:17:14 +0800
Subject: [PATCH 017/137] fix(payment): restore accepted text after invalid
amount input
---
.../src/components/payment/AmountInput.vue | 8 +++-
.../payment/__tests__/AmountInput.spec.ts | 37 +++++++++++++++++++
2 files changed, 43 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/components/payment/__tests__/AmountInput.spec.ts
diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue
index c3d9aa85e792..0e0c9d339ad3 100644
--- a/frontend/src/components/payment/AmountInput.vue
+++ b/frontend/src/components/payment/AmountInput.vue
@@ -88,8 +88,12 @@ function selectAmount(amt: number) {
}
function handleInput(e: Event) {
- const val = (e.target as HTMLInputElement).value
- if (!AMOUNT_PATTERN.test(val)) return
+ const input = e.target as HTMLInputElement
+ const val = input.value
+ if (!AMOUNT_PATTERN.test(val)) {
+ input.value = customText.value
+ return
+ }
customText.value = val
if (val === '') {
emit('update:modelValue', null)
diff --git a/frontend/src/components/payment/__tests__/AmountInput.spec.ts b/frontend/src/components/payment/__tests__/AmountInput.spec.ts
new file mode 100644
index 000000000000..314cc08d3cd4
--- /dev/null
+++ b/frontend/src/components/payment/__tests__/AmountInput.spec.ts
@@ -0,0 +1,37 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import AmountInput from '../AmountInput.vue'
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+enableAutoUnmount(afterEach)
+
+function mountInput(value: number | null = null) {
+ return mount(AmountInput, { props: { modelValue: value } })
+}
+
+describe('recharge amount input', () => {
+ it.each(['10abc', '10.555', '-10', '1e2'])('restores the accepted amount after rejecting %s', async (value) => {
+ const wrapper = mountInput(10)
+ const input = wrapper.get('input')
+ await input.setValue(value)
+ expect((input.element as HTMLInputElement).value).toBe('10')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('restores the last typed amount rather than a stale prop', async () => {
+ const wrapper = mountInput()
+ const input = wrapper.get('input')
+ await input.setValue('12.50')
+ await input.setValue('12.500')
+ expect((input.element as HTMLInputElement).value).toBe('12.50')
+ expect(wrapper.emitted('update:modelValue')).toEqual([[12.5]])
+ })
+
+ it('preserves decimal editing and allows clearing the amount', async () => {
+ const wrapper = mountInput()
+ const input = wrapper.get('input')
+ for (const value of ['0', '0.', '0.5', '0.50', '']) await input.setValue(value)
+ expect(wrapper.emitted('update:modelValue')).toEqual([[null], [null], [0.5], [0.5], [null]])
+ expect((input.element as HTMLInputElement).value).toBe('')
+ })
+})
From 0838e0e6107fc7fb5b64d4ba9a4456120e8031eb Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Wed, 16 Sep 2026 18:17:29 +0800
Subject: [PATCH 018/137] fix(admin): reject negative platform quota limits
before saving
---
.../admin/user/UserPlatformQuotaModal.vue | 8 +++----
.../__tests__/UserPlatformQuotaModal.spec.ts | 24 ++++++++++++++++++-
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue
index 536f9a842408..57cb4b394def 100644
--- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue
+++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue
@@ -231,14 +231,12 @@ function onClearAll() {
async function onSave() {
if (!props.user) return
- // 校验所有 input:v-model.number 在用户输入"0."等中间状态时会写回 NaN,
- // 之前的 normalizeLimit(NaN) 静默返回 null("无限制"),把"有限额"配置悄悄改成"无限制"。
- // 这里在 save 前显式检测 NaN,提示用户修正后再提交。
+ // 拒绝非法数值,避免 normalizeLimit 将其静默转换为 null(无限额)。
const invalid: string[] = []
for (const row of quotas.value) {
for (const win of ['daily', 'weekly', 'monthly'] as const) {
const v = row[`${win}_limit_usd` as const]
- if (typeof v === 'number' && Number.isNaN(v)) {
+ if (typeof v === 'number' && (!Number.isFinite(v) || v < 0)) {
invalid.push(`${row.platform}.${win}`)
}
}
@@ -268,7 +266,7 @@ async function onSave() {
}
// 仅在合法输入下返回数字:null/undefined/NaN/±Inf/负数 → null(视为"无限额")。
-// 调用方负责在 NaN 路径上做单独的用户提示(见 onSave)。
+// 调用方在保存前拦截非法数值(见 onSave)。
function normalizeLimit(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v
diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts
index d5821262940b..50740b7cef7b 100644
--- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts
+++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts
@@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
const apiMocks = vi.hoisted(() => ({
+ showError: vi.fn(),
getPlatformQuotas: vi.fn(),
updatePlatformQuotas: vi.fn(),
resetPlatformQuotaWindow: vi.fn(),
@@ -20,7 +21,7 @@ vi.mock('@/api/admin', () => ({
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
- showError: vi.fn(),
+ showError: apiMocks.showError,
showSuccess: vi.fn(),
}),
}))
@@ -74,6 +75,27 @@ beforeEach(() => {
})
describe('UserPlatformQuotaModal', () => {
+ it.each([0, 4, 14])('does not turn a negative limit in input %s into unlimited', async (index) => {
+ const w = await mountAndOpen()
+ await w.findAll('input[type=number]')[index].setValue('-1')
+ await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click')
+ await flushPromises()
+ expect(apiMocks.updatePlatformQuotas).not.toHaveBeenCalled()
+ expect(apiMocks.showError).toHaveBeenCalledWith('admin.users.platformQuota.invalidNumber')
+ expect(w.emitted('success')).toBeUndefined()
+ w.unmount()
+ })
+
+ it.each([['0', 0], ['', null]])('preserves the meaning of a limit entered as %j', async (input, expected) => {
+ const w = await mountAndOpen()
+ await w.findAll('input[type=number]')[0].setValue(input)
+ await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click')
+ await flushPromises()
+ expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
+ expect(apiMocks.updatePlatformQuotas.mock.calls[0][1][0].daily_limit_usd).toBe(expected)
+ w.unmount()
+ })
+
it('挂载并 show=true 时调用 getPlatformQuotas', async () => {
await mountAndOpen()
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
From f8e5a0d94f811fad399d02bdd97aeefc7dc1a284 Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Wed, 16 Sep 2026 18:19:41 +0800
Subject: [PATCH 019/137] fix(channels): allow tabbing out of an empty model
input
---
.../admin/channel/ModelTagInput.vue | 8 +++-
.../__tests__/ModelTagInput.keyboard.spec.ts | 39 +++++++++++++++++++
2 files changed, 46 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts
diff --git a/frontend/src/components/admin/channel/ModelTagInput.vue b/frontend/src/components/admin/channel/ModelTagInput.vue
index b91aa1196b9f..a268ae340af5 100644
--- a/frontend/src/components/admin/channel/ModelTagInput.vue
+++ b/frontend/src/components/admin/channel/ModelTagInput.vue
@@ -24,7 +24,7 @@
class="flex-1 min-w-[120px] border-none bg-transparent text-sm outline-none placeholder:text-gray-400 dark:text-white"
:placeholder="models.length === 0 ? placeholder : ''"
@keydown.enter.prevent="addModel"
- @keydown.tab.prevent="addModel"
+ @keydown.tab="handleTab"
@keydown.delete="handleBackspace"
@paste="handlePaste"
@blur="addModel"
@@ -66,6 +66,12 @@ function addModel() {
inputValue.value = ''
}
+function handleTab(event: KeyboardEvent) {
+ if (!inputValue.value.trim()) return
+ event.preventDefault()
+ addModel()
+}
+
function removeModel(idx: number) {
const newModels = [...props.models]
newModels.splice(idx, 1)
diff --git a/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts
new file mode 100644
index 000000000000..44e4cc67b7ea
--- /dev/null
+++ b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import ModelTagInput from '../ModelTagInput.vue'
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+enableAutoUnmount(afterEach)
+
+function pressTab(input: Element, shiftKey = false) {
+ const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, bubbles: true, cancelable: true })
+ input.dispatchEvent(event)
+ return event
+}
+
+describe('model tag keyboard navigation', () => {
+ it.each([false, true])('allows leaving an empty input with Tab (shift: %s)', (shift) => {
+ const wrapper = mount(ModelTagInput, { props: { models: ['gpt-4o'] } })
+ expect(pressTab(wrapper.get('input').element, shift).defaultPrevented).toBe(false)
+ expect(wrapper.emitted('update:models')).toBeUndefined()
+ })
+
+ it('allows leaving a whitespace-only input', async () => {
+ const wrapper = mount(ModelTagInput, { props: { models: [] } })
+ await wrapper.get('input').setValue(' ')
+ expect(pressTab(wrapper.get('input').element).defaultPrevented).toBe(false)
+ expect(wrapper.emitted('update:models')).toBeUndefined()
+ })
+
+ it('still commits a pending model with Tab, then allows the next Tab to leave', async () => {
+ const wrapper = mount(ModelTagInput, { props: { models: ['gpt-4o'] } })
+ const input = wrapper.get('input')
+ await input.setValue('gpt-4.1')
+ expect(pressTab(input.element).defaultPrevented).toBe(true)
+ await nextTick()
+ expect(wrapper.emitted('update:models')).toEqual([[['gpt-4o', 'gpt-4.1']]])
+ expect((input.element as HTMLInputElement).value).toBe('')
+ expect(pressTab(input.element).defaultPrevented).toBe(false)
+ })
+})
From fe36f4a9149bedf06fa40b195e144a257bf6c5a3 Mon Sep 17 00:00:00 2001
From: DanielSong <102885173+Liu8Can@users.noreply.github.com>
Date: Wed, 16 Sep 2026 18:34:52 +0800
Subject: [PATCH 020/137] fix: prevent promo code field flash on registration
---
frontend/src/views/auth/RegisterView.vue | 8 ++-
.../views/auth/__tests__/RegisterView.spec.ts | 51 ++++++++++++++++---
2 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue
index ea43b8965fbc..a3f9c0e4a587 100644
--- a/frontend/src/views/auth/RegisterView.vue
+++ b/frontend/src/views/auth/RegisterView.vue
@@ -422,7 +422,13 @@ const confirmPassword = ref('')
// Public settings
const registrationEnabled = ref(true)
const emailVerifyEnabled = ref(false)
-const promoCodeEnabled = ref(true)
+// Public settings are injected into the app store before Vue mounts. Use that
+// value for the first render so a disabled promo-code field never flashes
+// while the async settings request is still in flight. If injection is
+// unavailable, fail closed until the request explicitly enables the field.
+const promoCodeEnabled = ref(
+ appStore.cachedPublicSettings?.promo_code_enabled === true
+)
const invitationCodeEnabled = ref(false)
const affiliateEnabled = ref(false)
const turnstileEnabled = ref(false)
diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts
index 9626a03b4a55..8a9ba3a30975 100644
--- a/frontend/src/views/auth/__tests__/RegisterView.spec.ts
+++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts
@@ -2,12 +2,25 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RegisterView from '@/views/auth/RegisterView.vue'
-const { getPublicSettingsMock, registerMock, showErrorMock, pushMock, verifyActionMock } = vi.hoisted(() => ({
+const {
+ getPublicSettingsMock,
+ registerMock,
+ showErrorMock,
+ pushMock,
+ verifyActionMock,
+ appStoreMock
+} = vi.hoisted(() => ({
getPublicSettingsMock: vi.fn(),
registerMock: vi.fn(),
showErrorMock: vi.fn(),
pushMock: vi.fn(),
- verifyActionMock: vi.fn()
+ verifyActionMock: vi.fn(),
+ appStoreMock: {
+ cachedPublicSettings: null as { promo_code_enabled?: boolean } | null,
+ showError: (...args: unknown[]) => showErrorMock(...args),
+ showSuccess: vi.fn(),
+ showWarning: vi.fn()
+ }
}))
const publicSettings = {
@@ -49,11 +62,7 @@ vi.mock('vue-i18n', () => ({
vi.mock('@/stores', () => ({
useAuthStore: () => ({ register: (...args: unknown[]) => registerMock(...args) }),
- useAppStore: () => ({
- showError: (...args: unknown[]) => showErrorMock(...args),
- showSuccess: vi.fn(),
- showWarning: vi.fn()
- })
+ useAppStore: () => appStoreMock
}))
vi.mock('@/api/auth', async () => {
@@ -93,12 +102,40 @@ describe('RegisterView', () => {
showErrorMock.mockReset()
pushMock.mockReset()
verifyActionMock.mockReset()
+ appStoreMock.cachedPublicSettings = null
sessionStorage.removeItem('register_data')
verifyActionMock.mockResolvedValue({ token: 'ticket', randstr: 'randstr' })
getPublicSettingsMock.mockResolvedValue(publicSettings)
registerMock.mockResolvedValue({})
})
+ it('does not flash the promo-code field before disabled settings finish loading', async () => {
+ let resolveSettings!: (settings: typeof publicSettings) => void
+ getPublicSettingsMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSettings = resolve
+ })
+ )
+
+ const wrapper = mountRegister()
+
+ expect(wrapper.find('#promo_code').exists()).toBe(false)
+
+ resolveSettings(publicSettings)
+ await flushPromises()
+
+ expect(wrapper.find('#promo_code').exists()).toBe(false)
+ })
+
+ it('uses injected public settings to show an enabled promo-code field on first render', () => {
+ appStoreMock.cachedPublicSettings = { promo_code_enabled: true }
+ getPublicSettingsMock.mockReturnValueOnce(new Promise(() => {}))
+
+ const wrapper = mountRegister()
+
+ expect(wrapper.find('#promo_code').exists()).toBe(true)
+ })
+
it.each([
['', 'auth.confirmPasswordRequired'],
['different-password', 'auth.passwordsDoNotMatch']
From 881ab1b0c6239a928fdccc3c87d2cd011aa337ec Mon Sep 17 00:00:00 2001
From: laoxiechuzheng
Date: Thu, 17 Sep 2026 01:22:22 +0800
Subject: [PATCH 021/137] fix(deepseek): lift Responses tool output images
DeepSeek native Responses rejects Codex view_image results when function_call_output.output contains input_image parts. Move media into a following user message and keep the tool output as a string so call_id pairing remains valid.
---
.../apicompat/responses_tool_output_media.go | 121 ++++++++++++++++++
.../responses_tool_output_media_test.go | 57 +++++++++
backend/internal/service/cn_providers_test.go | 10 ++
.../service/openai_gateway_request_body.go | 21 ++-
4 files changed, 208 insertions(+), 1 deletion(-)
create mode 100644 backend/internal/pkg/apicompat/responses_tool_output_media.go
create mode 100644 backend/internal/pkg/apicompat/responses_tool_output_media_test.go
diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media.go b/backend/internal/pkg/apicompat/responses_tool_output_media.go
new file mode 100644
index 000000000000..9daa4068e69f
--- /dev/null
+++ b/backend/internal/pkg/apicompat/responses_tool_output_media.go
@@ -0,0 +1,121 @@
+package apicompat
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+type responsesToolOutputMedia struct {
+ callID string
+ imageURL string
+}
+
+// LiftResponsesToolOutputMedia moves image parts out of Responses tool outputs
+// and into a following user message. Native Responses endpoints such as
+// DeepSeek accept function_call_output.output as a string, but Codex view_image
+// returns an array containing input_image. Keeping the image inside the tool
+// output makes the upstream report "No tool output found for tool call ...".
+func LiftResponsesToolOutputMedia(input any) (any, bool) {
+ items, ok := input.([]any)
+ if !ok {
+ return input, false
+ }
+
+ rewritten := make([]any, 0, len(items)+1)
+ pending := make([]responsesToolOutputMedia, 0)
+ changed := false
+
+ flushMedia := func() {
+ if len(pending) == 0 {
+ return
+ }
+
+ content := make([]map[string]any, 0, len(pending)*2)
+ lastCallID := ""
+ for _, media := range pending {
+ if media.callID != lastCallID {
+ text := "Tool output media"
+ if media.callID != "" {
+ text = fmt.Sprintf(toolOutputMediaAttribution, media.callID)
+ }
+ content = append(content, map[string]any{
+ "type": "input_text",
+ "text": text,
+ })
+ lastCallID = media.callID
+ }
+ content = append(content, map[string]any{
+ "type": "input_image",
+ "image_url": media.imageURL,
+ })
+ }
+
+ rewritten = append(rewritten, map[string]any{
+ "type": "message",
+ "role": "user",
+ "content": content,
+ })
+ pending = pending[:0]
+ }
+
+ for _, rawItem := range items {
+ item, ok := rawItem.(map[string]any)
+ if !ok || !isResponsesToolOutputItem(item) {
+ flushMedia()
+ rewritten = append(rewritten, rawItem)
+ continue
+ }
+
+ output, exists := item["output"]
+ if !exists {
+ rewritten = append(rewritten, item)
+ continue
+ }
+ outputRaw, err := json.Marshal(output)
+ if err != nil {
+ rewritten = append(rewritten, item)
+ continue
+ }
+
+ outputText, media, didRewrite := extractToolOutputMedia(outputRaw)
+ if !didRewrite {
+ rewritten = append(rewritten, item)
+ continue
+ }
+
+ item["output"] = outputText
+ callID := strings.TrimSpace(stringValue(item["call_id"]))
+ for _, part := range media {
+ if part.ImageURL == nil {
+ continue
+ }
+ imageURL := strings.TrimSpace(part.ImageURL.URL)
+ if imageURL == "" {
+ continue
+ }
+ pending = append(pending, responsesToolOutputMedia{
+ callID: callID,
+ imageURL: imageURL,
+ })
+ }
+ changed = true
+ rewritten = append(rewritten, item)
+ }
+
+ flushMedia()
+ if !changed {
+ return input, false
+ }
+ return rewritten, true
+}
+
+func isResponsesToolOutputItem(item map[string]any) bool {
+ switch strings.TrimSpace(stringValue(item["type"])) {
+ case "function_call_output", "custom_tool_call_output",
+ "tool_search_output", "tool_search_call_output", "mcp_tool_call_output":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media_test.go b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go
new file mode 100644
index 000000000000..37417aba7795
--- /dev/null
+++ b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go
@@ -0,0 +1,57 @@
+package apicompat
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLiftResponsesToolOutputMedia(t *testing.T) {
+ var input any
+ require.NoError(t, json.Unmarshal([]byte(`[
+ {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
+ {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
+ ]`), &input))
+
+ lifted, changed := LiftResponsesToolOutputMedia(input)
+ require.True(t, changed)
+
+ items, ok := lifted.([]any)
+ require.True(t, ok)
+ require.Len(t, items, 3)
+
+ outputItem, ok := items[1].(map[string]any)
+ require.True(t, ok)
+ outputText, ok := outputItem["output"].(string)
+ require.True(t, ok)
+ require.Contains(t, outputText, toolOutputMediaMarker)
+ require.NotContains(t, outputText, "data:image/png")
+
+ mediaMessage, ok := items[2].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "message", mediaMessage["type"])
+ require.Equal(t, "user", mediaMessage["role"])
+
+ parts, ok := mediaMessage["content"].([]map[string]any)
+ require.True(t, ok)
+ require.Len(t, parts, 2)
+ require.Equal(t, "input_text", parts[0]["type"])
+ require.Equal(t, "[Tool output media for call call_image]", parts[0]["text"])
+ require.Equal(t, "input_image", parts[1]["type"])
+ require.Equal(t, "data:image/png;base64,AQID", parts[1]["image_url"])
+}
+
+func TestLiftResponsesToolOutputMediaLeavesPlainOutputUntouched(t *testing.T) {
+ input := []any{
+ map[string]any{
+ "type": "function_call_output",
+ "call_id": "call_text",
+ "output": "plain output",
+ },
+ }
+
+ lifted, changed := LiftResponsesToolOutputMedia(input)
+ require.False(t, changed)
+ require.Equal(t, input, lifted)
+}
diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go
index 7165b2953705..44becd4e5bee 100644
--- a/backend/internal/service/cn_providers_test.go
+++ b/backend/internal/service/cn_providers_test.go
@@ -834,6 +834,16 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool())
require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists())
+ mediaBody := []byte(`{"model":"deepseek-v4.1-flash","store":true,"input":[{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}]}`)
+ mediaNormalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, mediaBody)
+ require.False(t, gjson.GetBytes(mediaNormalized, "store").Bool())
+ require.Equal(t, gjson.String, gjson.GetBytes(mediaNormalized, "input.1.output").Type)
+ require.NotContains(t, gjson.GetBytes(mediaNormalized, "input.1.output").String(), "data:image/png")
+ require.Equal(t, "message", gjson.GetBytes(mediaNormalized, "input.2.type").String())
+ require.Equal(t, "user", gjson.GetBytes(mediaNormalized, "input.2.role").String())
+ require.Equal(t, "input_image", gjson.GetBytes(mediaNormalized, "input.2.content.1.type").String())
+ require.Equal(t, "data:image/png;base64,AQID", gjson.GetBytes(mediaNormalized, "input.2.content.1.image_url").String())
+
// 非 responses 协议(deepseek CC 账号)原样返回
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go
index 27306e34a486..70c10d104a5b 100644
--- a/backend/internal/service/openai_gateway_request_body.go
+++ b/backend/internal/service/openai_gateway_request_body.go
@@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
@@ -141,7 +142,25 @@ func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte
if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
normalized = stripped
}
- return normalized
+
+ var requestBody map[string]any
+ if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil {
+ return normalized
+ }
+ input, exists := requestBody["input"]
+ if !exists {
+ return normalized
+ }
+ liftedInput, changed := apicompat.LiftResponsesToolOutputMedia(input)
+ if !changed {
+ return normalized
+ }
+ requestBody["input"] = liftedInput
+ rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
+ if err != nil {
+ return normalized
+ }
+ return rebuilt
}
func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool {
From aba34524f1d52c4f1b9fb59886fa6f55d70ef3ee Mon Sep 17 00:00:00 2001
From: Cai
Date: Thu, 17 Sep 2026 04:14:14 +0900
Subject: [PATCH 022/137] feat: support native Seedance Ark video task API
---
backend/internal/handler/endpoint.go | 3 +
backend/internal/handler/grok_media.go | 45 +++--
.../internal/handler/grok_media_slots_test.go | 27 ++-
backend/internal/handler/seedance.go | 63 +++++++
backend/internal/handler/seedance_test.go | 97 +++++++++++
backend/internal/server/routes/gateway.go | 5 +
.../routes/gateway_model_allowlist_test.go | 4 +
.../internal/server/routes/seedance_test.go | 34 ++++
backend/internal/service/account.go | 5 +
backend/internal/service/grok_media.go | 13 +-
backend/internal/service/seedance.go | 154 ++++++++++++++++++
backend/internal/service/seedance_test.go | 112 +++++++++++++
docs/seedance-api.md | 45 +++++
.../account/BulkEditAccountModal.vue | 9 +-
.../components/account/CreateAccountModal.vue | 9 +-
.../components/account/EditAccountModal.vue | 11 +-
.../__tests__/BulkEditAccountModal.spec.ts | 12 ++
.../__tests__/EditAccountModal.spec.ts | 13 ++
frontend/src/types/index.ts | 2 +-
19 files changed, 631 insertions(+), 32 deletions(-)
create mode 100644 backend/internal/handler/seedance.go
create mode 100644 backend/internal/handler/seedance_test.go
create mode 100644 backend/internal/server/routes/seedance_test.go
create mode 100644 backend/internal/service/seedance.go
create mode 100644 backend/internal/service/seedance_test.go
create mode 100644 docs/seedance-api.md
diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go
index c1bde61d631c..2115c2d7c010 100644
--- a/backend/internal/handler/endpoint.go
+++ b/backend/internal/handler/endpoint.go
@@ -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):
diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go
index e5ab1dd04c1b..b61cd9515b96 100644
--- a/backend/internal/handler/grok_media.go
+++ b/backend/internal/handler/grok_media.go
@@ -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
diff --git a/backend/internal/handler/grok_media_slots_test.go b/backend/internal/handler/grok_media_slots_test.go
index 3393e1834483..81f14071dafd 100644
--- a/backend/internal/handler/grok_media_slots_test.go
+++ b/backend/internal/handler/grok_media_slots_test.go
@@ -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"
diff --git a/backend/internal/handler/seedance.go b/backend/internal/handler/seedance.go
new file mode 100644
index 000000000000..ce76a43fc987
--- /dev/null
+++ b/backend/internal/handler/seedance.go
@@ -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
+}
diff --git a/backend/internal/handler/seedance_test.go b/backend/internal/handler/seedance_test.go
new file mode 100644
index 000000000000..9ffbecdedafc
--- /dev/null
+++ b/backend/internal/handler/seedance_test.go
@@ -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)
+}
diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go
index bd6e5e7ebe82..86f605993a13 100644
--- a/backend/internal/server/routes/gateway.go
+++ b/backend/internal/server/routes/gateway.go
@@ -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)
diff --git a/backend/internal/server/routes/gateway_model_allowlist_test.go b/backend/internal/server/routes/gateway_model_allowlist_test.go
index 1fd154d709f6..85c509934279 100644
--- a/backend/internal/server/routes/gateway_model_allowlist_test.go
+++ b/backend/internal/server/routes/gateway_model_allowlist_test.go
@@ -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"}`},
diff --git a/backend/internal/server/routes/seedance_test.go b/backend/internal/server/routes/seedance_test.go
new file mode 100644
index 000000000000..301013f01abf
--- /dev/null
+++ b/backend/internal/server/routes/seedance_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 484e3841b405..278d359c6578 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -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
}
diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go
index 24b4dcb1b33f..b73972ae58f4 100644
--- a/backend/internal/service/grok_media.go
+++ b/backend/internal/service/grok_media.go
@@ -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),
diff --git a/backend/internal/service/seedance.go b/backend/internal/service/seedance.go
new file mode 100644
index 000000000000..faa83da49eb0
--- /dev/null
+++ b/backend/internal/service/seedance.go
@@ -0,0 +1,154 @@
+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
+ if endpoint == 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
+ } else if endpoint == 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 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
+}
diff --git a/backend/internal/service/seedance_test.go b/backend/internal/service/seedance_test.go
new file mode 100644
index 000000000000..cc9504b1ebce
--- /dev/null
+++ b/backend/internal/service/seedance_test.go
@@ -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)
+}
diff --git a/docs/seedance-api.md b/docs/seedance-api.md
new file mode 100644
index 000000000000..4480b16e70a9
--- /dev/null
+++ b/docs/seedance-api.md
@@ -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)。
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index f66fb85fda42..22f6f3e788e0 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -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 | null => {
if (applyOpenAIEndpointCapabilities) {
credentials.openai_capabilities =
- openAIEndpointCapabilities.value.length === 2
+ openAIEndpointCapabilities.value.length === 2 && !openAIEndpointCapabilities.value.includes('seedance')
? null
: [...openAIEndpointCapabilities.value]
credentialsChanged = true
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 54a6b2331f7c..c6616c679f5b 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -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) => {
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
- if (capabilities.length === 2) {
+ if (capabilities.length === 2 && !capabilities.includes('seedance')) {
delete credentials.openai_capabilities
return
}
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 0d3c235bea05..4e9bbff7a12a 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -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): OpenAIEndpointCapability[] => {
@@ -3700,7 +3701,7 @@ const readOpenAIEndpointCapabilities = (credentials?: Record):
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) => {
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
- if (capabilities.length === 2) {
+ if (capabilities.length === 2 && !capabilities.includes('seedance')) {
delete credentials.openai_capabilities
return
}
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 817f0a729d20..daae21b9f9cb 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -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'],
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index 6a16c3f22f45..7b104410c1bc 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -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('[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()
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 60eb7e58020c..3ae9838133d7 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -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
From be4a4990ffb960bf797465c01eb448e5a56cf017 Mon Sep 17 00:00:00 2001
From: zhudaguaneren <218366267+zhudaguaneren@users.noreply.github.com>
Date: Thu, 17 Sep 2026 11:57:53 +0800
Subject: [PATCH 023/137] fix(antigravity): strip Claude attribution metadata
from system prompts
---
.gitignore | 1 +
.../pkg/antigravity/attribution_test.go | 88 +++++++++++++++++++
.../pkg/antigravity/request_transformer.go | 22 +++++
docs/ANTIGRAVITY_ATTRIBUTION_429.md | 61 +++++++++++++
4 files changed, 172 insertions(+)
create mode 100644 backend/internal/pkg/antigravity/attribution_test.go
create mode 100644 docs/ANTIGRAVITY_ATTRIBUTION_429.md
diff --git a/.gitignore b/.gitignore
index 20ed12275e87..5dafefb36b0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,6 +133,7 @@ deploy/docker-compose.override.yml
.gocache/
vite.config.js
docs/*
+!docs/ANTIGRAVITY_ATTRIBUTION_429.md
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go
new file mode 100644
index 000000000000..8a1274e95d88
--- /dev/null
+++ b/backend/internal/pkg/antigravity/attribution_test.go
@@ -0,0 +1,88 @@
+package antigravity
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) {
+ const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;"
+ tests := []struct {
+ name string
+ text string
+ want string
+ }{
+ {"metadata only", attribution, ""},
+ {"metadata with newline", attribution + "\n", ""},
+ {"leading whitespace", " \t\n" + attribution, ""},
+ {"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."},
+ {"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"},
+ {"CR", attribution + "\rKeep these instructions.", "Keep these instructions."},
+ {"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."},
+ {"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"},
+ {"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"},
+ {"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"},
+ {"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution},
+ {"later line", "Example:\n" + attribution, "Example:\n" + attribution},
+ }
+ for _, tt := range tests {
+ for _, arrayForm := range []bool{false, true} {
+ name := tt.name + "/string"
+ var system any = tt.text
+ if arrayForm {
+ name = tt.name + "/array"
+ system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}}
+ }
+ t.Run(name, func(t *testing.T) {
+ systemJSON, err := json.Marshal(system)
+ require.NoError(t, err)
+ userJSON, err := json.Marshal(attribution)
+ require.NoError(t, err)
+ input := &ClaudeRequest{
+ Model: "gemini-3.8-flash-high", System: systemJSON,
+ Messages: []ClaudeMessage{{Role: "user", Content: userJSON}},
+ Tools: []ClaudeTool{{Name: "example", Description: attribution,
+ InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}},
+ }
+ before, err := json.Marshal(input)
+ require.NoError(t, err)
+ body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{})
+ require.NoError(t, err)
+ var got V1InternalRequest
+ require.NoError(t, json.Unmarshal(body, &got))
+ var texts []string
+ for _, part := range got.Request.SystemInstruction.Parts {
+ if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" {
+ texts = append(texts, part.Text)
+ }
+ }
+ var want []string
+ if tt.want != "" {
+ want = append(want, tt.want)
+ }
+ if arrayForm {
+ want = append(want, "Keep the next block.")
+ }
+ require.Equal(t, want, texts)
+ // The same literal text in user content and tool descriptions is not metadata.
+ require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text)
+ require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description)
+ after, err := json.Marshal(input)
+ require.NoError(t, err)
+ require.JSONEq(t, string(before), string(after), "do not mutate the incoming request")
+ })
+ }
+ }
+}
+
+func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) {
+ system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.")
+ require.NoError(t, err)
+ got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil)
+ require.Len(t, got.Parts, 1)
+ require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text)
+ require.False(t, strings.Contains(got.Parts[0].Text, "cc_version="))
+}
diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go
index 192c4cda39fd..c4af10dcd85e 100644
--- a/backend/internal/pkg/antigravity/request_transformer.go
+++ b/backend/internal/pkg/antigravity/request_transformer.go
@@ -293,6 +293,26 @@ func filterOpenCodePrompt(text string) string {
return ""
}
+// stripClaudeAttribution removes the leading Claude Code attribution metadata line
+// from Antigravity system text. This is prompt metadata, not an HTTP header; it
+// can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the
+// Antigravity transformer: native Anthropic OAuth may require the attribution.
+func stripClaudeAttribution(text string) string {
+ trimmed := strings.TrimLeft(text, " \t\r\n")
+ if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") {
+ return text
+ }
+ end := strings.IndexAny(trimmed, "\r\n")
+ if end < 0 {
+ return ""
+ }
+ rest := trimmed[end+1:]
+ if trimmed[end] == '\r' {
+ rest = strings.TrimPrefix(rest, "\n")
+ }
+ return rest
+}
+
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent {
var parts []GeminiPart
@@ -305,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
// 尝试解析为字符串
var sysStr string
if err := json.Unmarshal(system, &sysStr); err == nil {
+ sysStr = stripClaudeAttribution(sysStr)
if strings.TrimSpace(sysStr) != "" {
if strings.Contains(sysStr, "You are Antigravity") {
userHasAntigravityIdentity = true
@@ -320,6 +341,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
var sysBlocks []SystemBlock
if err := json.Unmarshal(system, &sysBlocks); err == nil {
for _, block := range sysBlocks {
+ block.Text = stripClaudeAttribution(block.Text)
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
if strings.Contains(block.Text, "You are Antigravity") {
userHasAntigravityIdentity = true
diff --git a/docs/ANTIGRAVITY_ATTRIBUTION_429.md b/docs/ANTIGRAVITY_ATTRIBUTION_429.md
new file mode 100644
index 000000000000..1a50a20ebe14
--- /dev/null
+++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md
@@ -0,0 +1,61 @@
+# Antigravity:Claude Desktop attribution 元数据触发 429 的排查记录
+
+本文记录一次实际问题的定位和修复,适用于 Claude Desktop / Claude Code 经 Anthropic Messages 协议转到 Antigravity 的场景。它不是所有 `429 RESOURCE_EXHAUSTED` 的通用解释:额度耗尽、请求频率、上游容量等仍应独立排查。
+
+## 现象与环境
+
+- 实测部署为 Sub2API 0.2.0,Claude Desktop 2.110.0,内嵌 Claude Code 2.1.271,CC Switch 3.20.3。
+- 请求链路:Claude Desktop → CC Switch 本地代理 → Sub2API Antigravity → Google 上游;映射模型为 `gemini-3.8-flash-high`。
+- 新对话中的简单问候也返回 `429 RESOURCE_EXHAUSTED`,上游信息为 `Resource has been exhausted (e.g. check quota).`。
+- 随后账号进入模型冷却,单账号组还可能出现 `503 No available accounts`。
+- 独立构造的极简请求成功,但真实客户端请求失败。客户端即使只发送一句问候,也会附带 system 提示词和工具定义。
+
+## 定位方法与对照结果
+
+保留一次失败请求,在相同账号、模型和上游端点下逐项删减内容。不要仅用手写的短请求判断完整客户端链路已经恢复,也不要在公开 issue / PR 上传原始请求或访问令牌。
+
+| 对照项 | 本次观察 |
+| --- | --- |
+| 原始客户端请求(约 187 KB、106 个工具) | 429 |
+| 移除工具定义,保留 system 内容 | 429 |
+| 移除 messages 中的 system 角色消息 | 429 |
+| 直接请求 Google,保留原始顶层 system 内容 | 429 |
+| 同一 Google 请求改用简单 system 内容 | 200 |
+| 分别测试 system 文本块 | attribution 元数据块返回 429,其余指令块返回 200 |
+| 原始请求仅移除 attribution 元数据块,保留模型、工具及其他内容 | 200,收到完整回复 |
+
+触发问题的文本形如:
+
+```text
+x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;
+```
+
+这里的 “header” 是请求 JSON 的 `system` 文本中的一行,**不是 HTTP 请求头**。因此,不需要在 Claude Desktop 的 “Custom inference headers” 里增删同名字段;也不应通过修改正常工作的 Bearer 认证来处理这个问题。
+
+这些对照支持“该元数据文本在本次 Google 请求中触发拒绝”的判断,但不能据此确定 Google 内部策略,也不能推断所有账号或模型都具有相同行为。
+
+## 本次修复
+
+在 Antigravity 的 Claude → Gemini 请求转换中,移除顶层 `system` 字符串或文本块**开头**的 `x-anthropic-billing-header:` 行:
+
+- 支持字符串和文本块数组,兼容 LF / CRLF / CR 换行及前导空白。
+- 如果同一文本块后面还有指令,原样保留后续内容;仅元数据的空块不再发送。
+- 不匹配缺少冒号的普通文本、其他字段名,以及正常指令中间引用的相同文本。
+- 不修改用户消息、工具定义、模型映射、认证和账号额度状态。
+- 处理限于 Antigravity 转换器,不修改原生 Anthropic 转发路径。
+
+上线前的临时修复采用相同范围的反向代理 JSON 过滤,并通过原始完整请求和真实 Claude Desktop 会话验证成功。本文 PR 将处理放入转换器,避免用户必须自行维护代理脚本。原部署曾尝试降低账号并发,但未解决问题;定位后已恢复原配置。
+
+如需在旧版本临时处理,应只对目标 Antigravity Messages 路径修改 JSON 的顶层 system 文本。不要用全局字符串替换,否则可能误改消息、工具参数或代码示例。代理实现还需保持空数组和空对象类型、更新请求体长度,并覆盖实际使用的 token-count 路径。面板重新生成代理配置时也可能覆盖自定义过滤规则。
+
+## 不要全局禁用 attribution
+
+Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/docs/en/env-vars) 环境变量,但桌面端是否继承该配置需要实测。本次仅修改全局 Claude 设置并未消除 Desktop 请求中的元数据。
+
+更重要的是,原生 Anthropic OAuth 线路有相反的报告:禁用 attribution 会造成 429,见 [issue #6344](https://github.com/Wei-Shaw/sub2api/issues/6344)。因此,不建议为这个 Antigravity 问题统一设置 `CLAUDE_CODE_ATTRIBUTION_HEADER=0` 或在所有上游请求中删除该文本。
+
+## 验证与仍需排查的情况
+
+回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。
+
+修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。
From 611c30f04fac9081d41bb6f4fa2712d8c7bbe359 Mon Sep 17 00:00:00 2001
From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com>
Date: Thu, 17 Sep 2026 14:33:48 +0800
Subject: [PATCH 024/137] =?UTF-8?q?chore(deps):=20=E5=8D=87=E7=BA=A7=20goo?=
=?UTF-8?q?gle.golang.org/grpc=20=E5=88=B0=20v1.83.2=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=20govulncheck=20=E6=8A=A5=E5=91=8A=E7=9A=84=E4=B8=A4=E6=9D=A1?=
=?UTF-8?q?=E5=85=AC=E5=91=8A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Security Scan / backend-security 自 2026-09-16 起在所有 PR 上失败,govulncheck 对
google.golang.org/grpc v1.82.1 报:
- GO-2026-6443 Server panic via missing authority or Host headers(修复于 v1.83.2)
- GO-2026-6348 Heap Memory Exhaustion via HTTP/2 DATA Frame Fragmentation(修复于 v1.83.1)
调用链在 pkg/pluginapi/v1/runtime.go 与 internal/service/plugin_runtime.go。
go get google.golang.org/grpc@v1.83.2 && go mod tidy。最小版本选择连带抬升了
golang.org/x/{crypto,mod,net,sync,term,sys,text,tools}、genproto/googleapis/rpc 与
go.opentelemetry.io/otel* 的间接依赖,均为补丁级别。
---
backend/go.mod | 28 ++++++++---------
backend/go.sum | 85 +++++++++++++++++++++-----------------------------
2 files changed, 50 insertions(+), 63 deletions(-)
diff --git a/backend/go.mod b/backend/go.mod
index 3cd44df4d99b..941f519d08e7 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -50,13 +50,13 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
- golang.org/x/crypto v0.53.0
+ golang.org/x/crypto v0.55.0
golang.org/x/image v0.41.0
- golang.org/x/mod v0.37.0
- golang.org/x/net v0.56.0
- golang.org/x/sync v0.21.0
- golang.org/x/term v0.44.0
- google.golang.org/grpc v1.82.1
+ golang.org/x/mod v0.38.0
+ golang.org/x/net v0.58.0
+ golang.org/x/sync v0.22.0
+ golang.org/x/term v0.45.0
+ google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -192,20 +192,20 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
- go.opentelemetry.io/otel v1.43.0 // indirect
- go.opentelemetry.io/otel/metric v1.43.0 // indirect
- go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
+ go.opentelemetry.io/otel v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.39.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.12.0 // indirect
- golang.org/x/tools v0.47.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
+ golang.org/x/tools v0.48.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index f639504856cd..caf01c3b6be7 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -253,8 +253,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
-github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -300,8 +298,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
-github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -329,8 +325,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -369,8 +363,6 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -405,8 +397,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -442,8 +432,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
-github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -521,30 +509,30 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
-go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
-go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
-go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
-go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
-go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
-go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
-go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
-go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
-go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
-go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
-go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
-go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
@@ -574,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
@@ -590,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -613,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -626,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -645,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -660,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -674,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -686,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
-golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -702,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -714,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
-google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
-google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
From 017e9e98d1bb5a752da7d27e8304fdd478a3245b Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Thu, 17 Sep 2026 14:56:33 +0800
Subject: [PATCH 025/137] fix(payment): base refund balance warnings on the
requested amount
---
.../admin/payment/AdminRefundDialog.vue | 2 +-
.../AdminRefundDialog.balance.spec.ts | 53 +++++++++++++++++++
2 files changed, 54 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts
diff --git a/frontend/src/components/admin/payment/AdminRefundDialog.vue b/frontend/src/components/admin/payment/AdminRefundDialog.vue
index f58b81038848..277416205089 100644
--- a/frontend/src/components/admin/payment/AdminRefundDialog.vue
+++ b/frontend/src/components/admin/payment/AdminRefundDialog.vue
@@ -214,7 +214,7 @@ const maxRefundable = computed(() => {
const balanceInsufficient = computed(() => {
if (props.userBalance == null || !props.order) return false
- return props.userBalance < props.order.amount
+ return props.userBalance < form.amount
})
watch(() => props.show, (val) => {
diff --git a/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts b/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts
new file mode 100644
index 000000000000..8b7322eac32f
--- /dev/null
+++ b/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import type { PaymentOrder } from '@/types/payment'
+import AdminRefundDialog from '../AdminRefundDialog.vue'
+
+vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
+enableAutoUnmount(afterEach)
+
+async function openRefund(overrides: Partial = {}) {
+ const order: PaymentOrder = {
+ id: 1, user_id: 10, amount: 100, pay_amount: 100, fee_rate: 0,
+ payment_type: 'stripe', out_trade_no: 'order-1', status: 'COMPLETED',
+ order_type: 'balance', created_at: '2026-09-01', expires_at: '2026-09-02',
+ refund_amount: 0, ...overrides
+ }
+ const wrapper = mount(AdminRefundDialog, {
+ props: { show: false, order, userBalance: 50 },
+ global: { stubs: { BaseDialog: { template: '
' } } }
+ })
+ await wrapper.setProps({ show: true })
+ return wrapper
+}
+
+const warning = 'payment.admin.insufficientBalance'
+
+describe('refund balance warning', () => {
+ it('compares the available balance with the edited refund amount', async () => {
+ const wrapper = await openRefund()
+ expect(wrapper.text()).toContain(warning)
+ await wrapper.get('input[type="number"]').setValue('20')
+ expect(wrapper.text()).not.toContain(warning)
+ await wrapper.get('input[type="number"]').setValue('75')
+ expect(wrapper.text()).toContain(warning)
+ })
+
+ it('does not warn when the balance exactly covers the refund', async () => {
+ const wrapper = await openRefund()
+ await wrapper.get('input[type="number"]').setValue('50')
+ expect(wrapper.text()).not.toContain(warning)
+ })
+
+ it('uses the remaining amount for an already partially refunded order', async () => {
+ const wrapper = await openRefund({ status: 'PARTIALLY_REFUNDED', refund_amount: 80 })
+ expect((wrapper.get('input[type="number"]').element as HTMLInputElement).value).toBe('20')
+ expect(wrapper.text()).not.toContain(warning)
+ })
+
+ it('keeps the warning hidden when balance deduction is disabled', async () => {
+ const wrapper = await openRefund()
+ await wrapper.get('#deduct-balance').setValue(false)
+ expect(wrapper.text()).not.toContain(warning)
+ })
+})
From be313735dc32f547f220e5592e160f9122f458f2 Mon Sep 17 00:00:00 2001
From: "chihao.ou"
Date: Thu, 17 Sep 2026 14:56:58 +0800
Subject: [PATCH 026/137] fix(ui): keep dialog title IDs unique across
instances
---
frontend/src/components/common/BaseDialog.vue | 5 ++-
.../common/__tests__/BaseDialog.ids.spec.ts | 32 +++++++++++++++++++
2 files changed, 36 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/common/__tests__/BaseDialog.ids.spec.ts
diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue
index 0a025bfce318..e24c6fc22626 100644
--- a/frontend/src/components/common/BaseDialog.vue
+++ b/frontend/src/components/common/BaseDialog.vue
@@ -42,12 +42,15 @@
+
+
diff --git a/frontend/src/components/admin/account/BatchTestAccountModal.vue b/frontend/src/components/admin/account/BatchTestAccountModal.vue
index c830381bfcfe..ba742a0f821d 100644
--- a/frontend/src/components/admin/account/BatchTestAccountModal.vue
+++ b/frontend/src/components/admin/account/BatchTestAccountModal.vue
@@ -13,6 +13,7 @@
+
{{ t(row.error_code ? 'admin.accounts.batchTest.loadFailed' : 'admin.accounts.batchTest.emptyModels') }}
@@ -41,6 +42,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountOperationDialog from '@/components/admin/account-jobs/AccountOperationDialog.vue'
import AccountTestModelSelect from './AccountTestModelSelect.vue'
+import AccountTestReasoningSelect from './AccountTestReasoningSelect.vue'
import accountJobsAPI, { type AccountJob, type BatchTestModelRow } from '@/api/admin/accountJobs'
import { prepareAccountTestModels, accountTestModelsForMode, defaultAccountTestModel } from '@/utils/accountTestModels'
import { useAppStore } from '@/stores/app'
@@ -53,7 +55,7 @@ const { t } = useI18n()
const operationJob = ref(null)
watch(() => props.show, show => { if (show) operationJob.value = null })
const { prompt, valid: promptValid } = useAccountTestPrompt()
-type Row = BatchTestModelRow & { model: string; loading: boolean }
+type Row = BatchTestModelRow & { model: string; reasoning_effort: string; loading: boolean }
const rows = ref([])
const busy = ref(false)
const page = ref(1)
@@ -94,7 +96,7 @@ watch(() => props.show, async show => {
page.value = 1
applicationResult.value = ''
const ids = [...new Set(props.accountIds)]
- rows.value = ids.map(account_id => ({ account_id, name: '', platform: '', type: '', is_cindy: false, models: [], model: '', loading: true }))
+ rows.value = ids.map(account_id => ({ account_id, name: '', platform: '', type: '', is_cindy: false, models: [], model: '', reasoning_effort: '', loading: true }))
// One batch request at a time; the server bounds upstream discovery.
for (let offset = 0; offset < ids.length && version === generation; offset += 100) await load(ids.slice(offset, offset + 100), version)
}, { immediate: true })
@@ -113,7 +115,7 @@ function applyModel(model: string) {
}
async function submit() {
if (busy.value || !ready.value) return
- const items = rows.value.map(row => ({ account_id: row.account_id, model_id: row.model }))
+ const items = rows.value.map(row => ({ account_id: row.account_id, model_id: row.model, ...(row.reasoning_effort ? { reasoning_effort: row.reasoning_effort } : {}) }))
busy.value = true
try {
operationJob.value = await accountJobsAPI.batchTest(items, prompt.value)
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 7f382508e916..2b38838793e6 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -459,6 +459,8 @@ export default {accounts: {
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}',
+ upstreamQuotaExhausted: 'Upstream quota exhausted',
+ quotaResetUnknown: 'Reset time unknown; awaiting upstream quota update',
modelRateLimitedUntil: '{model} rate limited until {time}',
modelCreditOveragesUntil: '{model} using AI Credits until {time}',
creditsExhausted: 'Credits Exhausted',
@@ -1896,6 +1898,7 @@ export default {accounts: {
usageTrend: '30-Day Cost & Request Trend',
noData: 'No usage data available for this account'
},
+ testReasoning: { label: 'Reasoning effort', default: 'Default (model behavior)' },
usageWindow: {
statsTitle: '5-Hour Window Usage Statistics',
statsTitleDaily: 'Daily Usage Statistics',
@@ -1927,8 +1930,11 @@ export default {accounts: {
grokLastHeadersSeen: 'Headers {time}',
passiveSampled: 'Passive',
activeQuery: 'Query',
- estimatedTotalCost: 'Est. total ${cost}',
- estimatedTotalCostTooltip: 'Estimated total cost at 100% utilization, based on current window cost and utilization'
+ estimatedTotalCost: 'Est. window value ${cost}',
+ estimatedRemainingCost: 'Est. remaining value ${cost}',
+ estimatePending: 'Awaiting paired observations in this window',
+ unknownPeriod: 'Unknown period',
+ estimatedTotalCostTooltip: 'Estimated from account-cost and upstream utilization changes in the same window. Pre-import costs are unknown; model mix and external usage affect the estimate. Overlapping window values cannot be added.'
},
openaiQuotaReset: {
count: 'Credits',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index d70d4953b632..23bcf27c4d07 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -666,6 +666,8 @@ export default {accounts: {
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复',
+ upstreamQuotaExhausted: '上游额度已耗尽',
+ quotaResetUnknown: '恢复时间未知,等待上游额度更新',
modelRateLimitedUntil: '{model} 限流至 {time}',
modelCreditOveragesUntil: '{model} 正在使用 AI Credits,至 {time}',
creditsExhausted: '积分已用尽',
@@ -721,6 +723,7 @@ export default {accounts: {
unavailableDesc: '服务不可用 - 暂停 30 分钟'
}
},
+ testReasoning: { label: '推理强度', default: '默认(沿用模型默认行为)' },
usageWindow: {
statsTitle: '5小时窗口用量统计',
statsTitleDaily: '每日用量统计',
@@ -752,8 +755,11 @@ export default {accounts: {
grokLastHeadersSeen: '响应头 {time}',
passiveSampled: '被动采样',
activeQuery: '查询',
- estimatedTotalCost: '预计总费用 ${cost}',
- estimatedTotalCostTooltip: '根据当前窗口费用和使用率估算达到 100% 使用率时的总费用'
+ estimatedTotalCost: '预计整窗额度价值 ${cost}',
+ estimatedRemainingCost: '预计剩余额度价值 ${cost}',
+ estimatePending: '待估算:同窗口有效样本不足',
+ unknownPeriod: '周期未知',
+ estimatedTotalCostTooltip: '根据同一窗口的账号费用和上游使用率增量估算,不包含可确认的导入前费用;模型使用结构及本站外消耗会影响估值。不同窗口的额度不可相加。'
},
openaiQuotaReset: {
count: '次数',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index b347fef30d43..65efb4454624 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1177,6 +1177,7 @@ export interface OllamaCloudUsageSettings {
}
export interface Account {
+ quota_state?: { blocked: boolean; until: string | null; windows: AccountQuotaWindow[] }
id: number
name: string
notes?: string | null
@@ -1444,6 +1445,18 @@ export interface UsageProgress {
limit_requests?: number
}
+export interface AccountQuotaWindow {
+ id: string
+ window_minutes: number
+ utilization: number
+ observed_at?: string
+ resets_at: string | null
+ expired: boolean
+ remaining_seconds: number
+ window_stats?: WindowStats
+ estimate?: { status: string; total?: number; remaining?: number }
+}
+
// Antigravity 单个模型的配额信息
export interface AntigravityModelQuota {
utilization: number // 使用率 0-100
@@ -1494,6 +1507,7 @@ export interface GrokBillingSummary {
}
export interface AccountUsageInfo {
+ quota_windows?: AccountQuotaWindow[]
source?: 'passive' | 'active'
updated_at: string | null
five_hour: UsageProgress | null
diff --git a/frontend/src/utils/accountQuotaWindows.ts b/frontend/src/utils/accountQuotaWindows.ts
new file mode 100644
index 000000000000..da214388f7a3
--- /dev/null
+++ b/frontend/src/utils/accountQuotaWindows.ts
@@ -0,0 +1,24 @@
+import type { AccountQuotaWindow, AccountUsageInfo } from '@/types'
+
+// Compatibility for an already-open UI receiving a pre-upgrade response. New
+// servers supply the actual periods; never recompute monetary estimates here.
+export function accountQuotaWindows(usage?: AccountUsageInfo | null): AccountQuotaWindow[] {
+ if (!usage) return []
+ if (usage.quota_windows) return usage.quota_windows
+ const out: AccountQuotaWindow[] = []
+ for (const [id, minutes, progress] of [
+ ['5h', 300, usage.five_hour],
+ ['7d', 10080, usage.seven_day]
+ ] as const) {
+ if (!progress) continue
+ out.push({
+ id, window_minutes: minutes,
+ utilization: Number.isFinite(progress.utilization) ? progress.utilization : 0,
+ resets_at: progress.resets_at || null,
+ remaining_seconds: progress.remaining_seconds,
+ expired: false, window_stats: progress.window_stats || undefined,
+ estimate: { status: 'insufficient_data' }
+ })
+ }
+ return out
+}
diff --git a/plugins/codex-runtime/cmd/plugin/main.go b/plugins/codex-runtime/cmd/plugin/main.go
new file mode 100644
index 000000000000..cee41208e3fb
--- /dev/null
+++ b/plugins/codex-runtime/cmd/plugin/main.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+ "github.com/HTExplicit/sub2api-plugins/codexruntime/internal/tickets"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+)
+
+var version = "0.2.7"
+
+func main() {
+ extensionv1.Serve(extensionv1.Definition{ID: "codexrip.codex-runtime", Version: version,
+ Capabilities: []string{extensionv1.CapabilityAdmin, extensionv1.CapabilityScheduling, extensionv1.CapabilityJobs, extensionv1.CapabilityRequest}}, tickets.NewModule())
+}
diff --git a/plugins/codex-runtime/go.mod b/plugins/codex-runtime/go.mod
new file mode 100644
index 000000000000..19834bcd76c1
--- /dev/null
+++ b/plugins/codex-runtime/go.mod
@@ -0,0 +1,24 @@
+module github.com/HTExplicit/sub2api-plugins/codexruntime
+
+go 1.27.0
+
+require github.com/Wei-Shaw/sub2api v0.0.0
+
+require (
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/hashicorp/go-hclog v1.6.3 // indirect
+ github.com/hashicorp/go-plugin v1.8.0 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/oklog/run v1.1.0 // indirect
+ golang.org/x/net v0.58.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/grpc v1.83.2 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+)
+
+replace github.com/Wei-Shaw/sub2api => ../../backend
diff --git a/plugins/codex-runtime/go.sum b/plugins/codex-runtime/go.sum
new file mode 100644
index 000000000000..76372f7247c2
--- /dev/null
+++ b/plugins/codex-runtime/go.sum
@@ -0,0 +1,83 @@
+github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
+github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
+github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
+github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
+golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
+google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/plugins/codex-runtime/internal/tickets/module.go b/plugins/codex-runtime/internal/tickets/module.go
new file mode 100644
index 000000000000..c2b1c46048cc
--- /dev/null
+++ b/plugins/codex-runtime/internal/tickets/module.go
@@ -0,0 +1,431 @@
+package tickets
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+)
+
+type Config struct {
+ Enabled bool `json:"enabled"`
+ FailClosed bool `json:"fail_closed"`
+ ProxyURL string `json:"proxy_url"`
+ Models []string `json:"models"`
+}
+
+type Module struct {
+ mu sync.RWMutex
+ config Config
+ host *extensionv1.Client
+ epoch context.Context
+ cancel context.CancelFunc
+ slots chan struct{}
+}
+
+func NewModule() *Module {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Module{config: Config{FailClosed: true, Models: []string{"gpt-6-astra", "gpt-5.6-sol"}}, epoch: ctx, cancel: cancel, slots: make(chan struct{}, 5)}
+}
+
+func (m *Module) SetHost(host *extensionv1.Client) { m.mu.Lock(); defer m.mu.Unlock(); m.host = host }
+
+func (m *Module) ValidateConfig(_ context.Context, raw json.RawMessage) (json.RawMessage, error) {
+ cfg := Config{FailClosed: true, Models: []string{"gpt-6-astra", "gpt-5.6-sol"}}
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&cfg); err != nil {
+ return nil, errors.New("invalid codex runtime configuration")
+ }
+ if len(cfg.Models) == 0 || len(cfg.Models) > 32 {
+ return nil, errors.New("ticket model list required")
+ }
+ for _, model := range cfg.Models {
+ if strings.TrimSpace(model) != model || model == "" || len(model) > 256 {
+ return nil, errors.New("invalid ticket model")
+ }
+ }
+ if cfg.ProxyURL != "" {
+ parsed, err := url.Parse(cfg.ProxyURL)
+ if err != nil || parsed.Hostname() == "" || !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, parsed.Scheme) || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return nil, errors.New("invalid ticket proxy")
+ }
+ }
+ return json.Marshal(cfg)
+}
+
+func (m *Module) ApplyConfig(ctx context.Context, raw json.RawMessage) error {
+ normalized, err := m.ValidateConfig(ctx, raw)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err = json.Unmarshal(normalized, &cfg); err != nil {
+ return err
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.cancel()
+ m.epoch, m.cancel = context.WithCancel(context.Background())
+ m.config = cfg
+ if cfg.Enabled && m.host != nil {
+ go m.renew(m.epoch, m.host, cfg)
+ }
+ return nil
+}
+
+func (m *Module) renew(ctx context.Context, host *extensionv1.Client, cfg Config) {
+ ticker := time.NewTicker(6 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ var due []extensionv1.DueState
+ if hostCall(ctx, host, extensionv1.HostStateDue, extensionv1.DueStateRequest{Namespace: "tickets", Limit: 100}, &due) != nil {
+ continue
+ }
+ queue := make(chan Operation, len(due))
+ for _, record := range due {
+ var state State
+ if json.Unmarshal(record.Value, &state) != nil || state.Ticket == nil || (state.Phase != "ready" && state.Phase != "retry") {
+ continue
+ }
+ queue <- Operation{AccountID: state.Ticket.AccountID, Model: state.Ticket.Model}
+ }
+ close(queue)
+ var workers sync.WaitGroup
+ for range min(5, len(due)) {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ for operation := range queue {
+ if ctx.Err() != nil {
+ return
+ }
+ _, _ = m.harvest(ctx, host, cfg, operation, false)
+ }
+ }()
+ }
+ workers.Wait()
+ }
+}
+
+func (m *Module) Status(context.Context) (json.RawMessage, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return json.Marshal(map[string]any{"enabled": m.config.Enabled, "proxy_configured": m.config.ProxyURL != "", "models": m.config.Models})
+}
+
+type Operation struct {
+ AccountID int64 `json:"account_id"`
+ Model string `json:"model"`
+ OperationID string `json:"operation_id"`
+ Force bool `json:"force"`
+}
+
+type Outcome struct {
+ Success bool `json:"success"`
+ Code string `json:"code"`
+ HTTPStatus int `json:"http_status,omitempty"`
+ ObservedLength int `json:"observed_length,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+func (m *Module) Invoke(ctx context.Context, in extensionv1.Invocation) (extensionv1.Result, error) {
+ m.mu.RLock()
+ host, cfg, epoch := m.host, m.config, m.epoch
+ m.mu.RUnlock()
+ if host == nil {
+ return extensionv1.Result{}, errors.New("host services unavailable")
+ }
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ stop := context.AfterFunc(epoch, cancel)
+ defer stop()
+ var value any
+ var err error
+ switch {
+ case in.Capability == extensionv1.CapabilityScheduling && in.Operation == "admit":
+ var req extensionv1.SchedulingRequest
+ if json.Unmarshal(in.Payload, &req) != nil {
+ return extensionv1.Result{}, errors.New("invalid admission request")
+ }
+ decision := extensionv1.SchedulingDecision{Allowed: true}
+ if cfg.Enabled && cfg.FailClosed && slices.Contains(cfg.Models, req.Model) && Eligible(req.Account.Platform, req.Account.Type, req.Account.Shadow) {
+ state, _, readErr := readState(ctx, host, req.Account.ID, req.Model)
+ if readErr != nil {
+ return extensionv1.Result{}, readErr
+ }
+ if !state.Ticket.Valid(req.Now, req.Account.ID, req.Account.Identity, req.Model) {
+ decision = extensionv1.SchedulingDecision{Allowed: false, Reason: "ticket_missing", Scope: req.Model}
+ }
+ }
+ value = decision
+ case in.Capability == extensionv1.CapabilityRequest && in.Operation == "inject":
+ var req extensionv1.SchedulingRequest
+ if json.Unmarshal(in.Payload, &req) != nil {
+ return extensionv1.Result{}, errors.New("invalid ticket injection request")
+ }
+ headers := map[string]string{}
+ if cfg.Enabled && slices.Contains(cfg.Models, req.Model) && Eligible(req.Account.Platform, req.Account.Type, req.Account.Shadow) {
+ state, _, readErr := readState(ctx, host, req.Account.ID, req.Model)
+ if readErr != nil {
+ return extensionv1.Result{}, readErr
+ }
+ if state.Ticket.Valid(req.Now, req.Account.ID, req.Account.Identity, req.Model) {
+ headers["x-codex-turn-state"] = state.Ticket.State
+ } else if cfg.FailClosed {
+ return extensionv1.Result{Code: "ticket_missing"}, nil
+ }
+ }
+ value = map[string]any{"headers": headers}
+ case in.Capability == extensionv1.CapabilityAdmin && in.Operation == "harvest", in.Capability == extensionv1.CapabilityJobs && in.Operation == "renew":
+ var req Operation
+ if json.Unmarshal(in.Payload, &req) != nil || req.AccountID <= 0 {
+ return extensionv1.Result{}, errors.New("invalid ticket operation")
+ }
+ value, err = m.harvest(ctx, host, cfg, req, in.Operation == "harvest")
+ case in.Capability == extensionv1.CapabilityAdmin && in.Operation == "stop":
+ var req Operation
+ if json.Unmarshal(in.Payload, &req) != nil || req.AccountID <= 0 {
+ return extensionv1.Result{}, errors.New("invalid ticket operation")
+ }
+ state, revision, readErr := readState(ctx, host, req.AccountID, req.Model)
+ if readErr != nil {
+ return extensionv1.Result{}, readErr
+ }
+ state.Stop()
+ _, err = writeState(ctx, host, req.AccountID, req.Model, state, revision)
+ value = Outcome{Success: err == nil, Code: "ticket_stopped"}
+ default:
+ return extensionv1.Result{}, errors.New("unsupported codex runtime operation")
+ }
+ if err != nil {
+ return extensionv1.Result{}, err
+ }
+ raw, err := json.Marshal(value)
+ return extensionv1.Result{Payload: raw}, err
+}
+
+func stateKey(accountID int64, model string) string {
+ sum := sha256.Sum256([]byte(model))
+ return strconv.FormatInt(accountID, 10) + "." + hex.EncodeToString(sum[:])
+}
+
+func hostCall(ctx context.Context, host *extensionv1.Client, operation extensionv1.HostOperation, value, into any) error {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ result, err := host.Call(ctx, extensionv1.HostInvocation{Operation: operation, Payload: raw})
+ if err != nil {
+ return err
+ }
+ if result.Code != "" {
+ return fmt.Errorf("host operation failed: %s", result.Code)
+ }
+ return json.Unmarshal(result.Payload, into)
+}
+
+func readState(ctx context.Context, host *extensionv1.Client, id int64, model string) (State, int64, error) {
+ var result extensionv1.StateResult
+ err := hostCall(ctx, host, extensionv1.HostStateRead, extensionv1.StateRequest{Namespace: "tickets", Key: stateKey(id, model)}, &result)
+ var state State
+ if err == nil && result.Found {
+ err = json.Unmarshal(result.Value, &state)
+ }
+ return state, result.Revision, err
+}
+
+func writeState(ctx context.Context, host *extensionv1.Client, id int64, model string, state State, revision int64) (int64, error) {
+ raw, err := json.Marshal(state)
+ if err != nil {
+ return 0, err
+ }
+ var result extensionv1.StateResult
+ err = hostCall(ctx, host, extensionv1.HostStateCompareSwap, extensionv1.StateRequest{Namespace: "tickets", Key: stateKey(id, model), ExpectedRevision: revision, Value: raw, NextAt: state.NextAt}, &result)
+ if err == nil && !result.Applied {
+ err = errors.New("ticket_state_changed")
+ }
+ return result.Revision, err
+}
+
+func (m *Module) harvest(ctx context.Context, host *extensionv1.Client, cfg Config, req Operation, manual bool) (Outcome, error) {
+ if !cfg.Enabled {
+ return Outcome{Code: "ticket_disabled"}, nil
+ }
+ if !slices.Contains(cfg.Models, req.Model) {
+ return Outcome{Code: "ticket_model_invalid"}, nil
+ }
+ if cfg.ProxyURL == "" {
+ return Outcome{Code: "ticket_proxy_missing"}, nil
+ }
+ if manual && (req.OperationID == "" || len(req.OperationID) > 256) {
+ return Outcome{Code: "ticket_operation_required"}, nil
+ }
+ select {
+ case m.slots <- struct{}{}:
+ defer func() { <-m.slots }()
+ case <-ctx.Done():
+ return Outcome{Code: "ticket_canceled"}, nil
+ }
+ ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
+ defer cancel()
+ var account extensionv1.Account
+ if err := hostCall(ctx, host, extensionv1.HostAccountRead, extensionv1.AccountQuery{AccountID: req.AccountID}, &account); err != nil {
+ return Outcome{}, err
+ }
+ if !Eligible(account.Platform, account.Type, account.Shadow) {
+ return Outcome{Code: "ticket_ineligible"}, nil
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return Outcome{}, err
+ }
+ lease := extensionv1.LeaseRequest{Namespace: "tickets", Key: stateKey(req.AccountID, req.Model), Owner: hex.EncodeToString(nonce[:]), TTLSeconds: 30}
+ var acquired extensionv1.LeaseResult
+ if err := hostCall(ctx, host, extensionv1.HostLeaseAcquire, lease, &acquired); err != nil {
+ return Outcome{}, err
+ }
+ if !acquired.Acquired {
+ return Outcome{Code: "ticket_busy"}, nil
+ }
+ lease.Generation = acquired.Generation
+ defer func() {
+ releaseCtx, releaseCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
+ defer releaseCancel()
+ var result extensionv1.LeaseResult
+ _ = hostCall(releaseCtx, host, extensionv1.HostLeaseRelease, lease, &result)
+ }()
+ state, revision, err := readState(ctx, host, req.AccountID, req.Model)
+ if err != nil {
+ return Outcome{}, err
+ }
+ now := time.Now().UTC()
+ if manual && !req.Force && state.Ticket.Valid(now, account.ID, account.Identity, req.Model) {
+ return Outcome{Success: true, Code: "ticket_skipped", ExpiresAt: &state.Ticket.ExpiresAt}, nil
+ }
+ if req.OperationID != "" && state.OperationID == req.OperationID {
+ return Outcome{Code: "ticket_interrupted"}, nil
+ }
+ // Owning a fresh lease proves no previous process still owns this attempt.
+ // Never replay its automatic stage; only a new explicit manual operation may
+ // enroll it again.
+ if state.Phase == "manual_running" || state.Phase == "pre_running" || state.Phase == "post_running" {
+ state.Stop()
+ state.LastCode = "ticket_interrupted"
+ revision, err = writeState(ctx, host, req.AccountID, req.Model, state, revision)
+ if err != nil {
+ return Outcome{}, err
+ }
+ if !manual {
+ return Outcome{Code: "ticket_interrupted"}, nil
+ }
+ }
+ if state.Ticket != nil && state.Ticket.Identity != account.Identity {
+ state = State{Phase: "stopped"}
+ }
+ if err = state.Begin(now, manual); err != nil {
+ if errors.Is(err, ErrNotDue) && state.Phase == "retry" {
+ if _, writeErr := writeState(ctx, host, req.AccountID, req.Model, state, revision); writeErr != nil {
+ return Outcome{}, writeErr
+ }
+ }
+ return Outcome{Code: err.Error()}, nil
+ }
+ state.OperationID = req.OperationID
+ revision, err = writeState(ctx, host, req.AccountID, req.Model, state, revision)
+ if err != nil {
+ return Outcome{}, err
+ }
+ var identity extensionv1.OutboundIdentity
+ outcome := Outcome{Code: "ticket_token"}
+ var ticket *Ticket
+ if err = hostCall(ctx, host, extensionv1.HostResolveIdentity, extensionv1.AccountQuery{AccountID: account.ID, PrepareCredentials: true}, &identity); err == nil && identity.Identity == account.Identity {
+ ticket, outcome = probe(ctx, cfg, identity, req.Model)
+ }
+ finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer finishCancel()
+ var current extensionv1.Account
+ if err := hostCall(finishCtx, host, extensionv1.HostAccountRead, extensionv1.AccountQuery{AccountID: account.ID}, ¤t); err != nil || current.Identity != account.Identity {
+ return Outcome{Code: "ticket_stale"}, nil
+ }
+ state.Complete(time.Now().UTC(), ticket, outcome.Code)
+ if _, err = writeState(finishCtx, host, req.AccountID, req.Model, state, revision); err != nil {
+ return Outcome{Code: "ticket_persist"}, err
+ }
+ return outcome, nil
+}
+
+func probe(ctx context.Context, cfg Config, identity extensionv1.OutboundIdentity, model string) (*Ticket, Outcome) {
+ proxy, err := url.Parse(cfg.ProxyURL)
+ if err != nil {
+ return nil, Outcome{Code: "ticket_proxy_protocol"}
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = http.ProxyURL(proxy)
+ transport.DisableKeepAlives = true
+ transport.DisableCompression = true
+ defer transport.CloseIdleConnections()
+ client := &http.Client{Transport: transport, Timeout: 25 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ body, _ := json.Marshal(map[string]any{"model": model, "store": false, "stream": true, "instructions": "Reply with exactly: pong", "input": []any{map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "ping"}}}}})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", bytes.NewReader(body))
+ if err != nil {
+ return nil, Outcome{Code: "ticket_transport"}
+ }
+ req.Header = http.Header(identity.Headers).Clone()
+ req.Header.Set("Authorization", "Bearer "+identity.Token)
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("OpenAI-Beta", "responses=experimental")
+ req.Header.Set("session_id", newSessionID())
+ req.Close = true
+ response, err := client.Do(req)
+ if err != nil {
+ return nil, Outcome{Code: "ticket_transport"}
+ }
+ defer response.Body.Close()
+ value := strings.TrimSpace(response.Header.Get("x-codex-turn-state"))
+ outcome := Outcome{Code: "ticket_upstream", HTTPStatus: response.StatusCode, ObservedLength: len(value)}
+ if response.StatusCode != http.StatusOK {
+ return nil, outcome
+ }
+ if len(value) != Length {
+ outcome.Code = "ticket_length"
+ return nil, outcome
+ }
+ if !strings.HasPrefix(value, "gAAAAA") {
+ outcome.Code = "ticket_prefix"
+ return nil, outcome
+ }
+ now := time.Now().UTC()
+ ticket := &Ticket{State: value, AccountID: identity.AccountID, Identity: identity.Identity, Model: model, CapturedAt: now, ExpiresAt: now.Add(TTL)}
+ outcome.Success = true
+ outcome.Code = "ticket_ready"
+ outcome.ExpiresAt = &ticket.ExpiresAt
+ return ticket, outcome
+}
+
+func newSessionID() string {
+ var value [16]byte
+ _, _ = rand.Read(value[:])
+ value[6] = (value[6] & 15) | 64
+ value[8] = (value[8] & 63) | 128
+ return fmt.Sprintf("%x-%x-%x-%x-%x", value[:4], value[4:6], value[6:8], value[8:10], value[10:])
+}
diff --git a/plugins/codex-runtime/internal/tickets/state.go b/plugins/codex-runtime/internal/tickets/state.go
new file mode 100644
index 000000000000..21728da2e21a
--- /dev/null
+++ b/plugins/codex-runtime/internal/tickets/state.go
@@ -0,0 +1,117 @@
+package tickets
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+const Length = 292
+const TTL = time.Hour
+
+var ErrNotDue = errors.New("ticket_not_due")
+var ErrRunning = errors.New("ticket_attempt_in_progress")
+
+type Ticket struct {
+ State string `json:"state"`
+ AccountID int64 `json:"account_id"`
+ Identity string `json:"identity"`
+ Model string `json:"model"`
+ CapturedAt time.Time `json:"captured_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (t *Ticket) Valid(now time.Time, accountID int64, identity, model string) bool {
+ return t != nil && t.AccountID == accountID && t.Identity == identity && t.Model == model &&
+ len(t.State) == Length && strings.HasPrefix(t.State, "gAAAAA") && !t.CapturedAt.After(now) &&
+ now.Before(t.ExpiresAt) && !t.ExpiresAt.After(t.CapturedAt.Add(TTL))
+}
+
+// State includes the spent automatic attempt before any network IO. A process
+// restart resumes only persisted pending stages, never a running/spent attempt.
+type State struct {
+ OperationID string `json:"operation_id,omitempty"`
+ Phase string `json:"phase"`
+ NextAt *time.Time `json:"next_attempt_at,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ ManualWasEnrolled bool `json:"manual_was_enrolled,omitempty"`
+ LastCode string `json:"last_code,omitempty"`
+ LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
+ Ticket *Ticket `json:"ticket,omitempty"`
+}
+
+func (s *State) Begin(now time.Time, manual bool) error {
+ switch s.Phase {
+ case "manual_running", "pre_running", "post_running":
+ return ErrRunning
+ }
+ if manual {
+ s.ManualWasEnrolled = s.Phase == "ready" || s.Phase == "retry"
+ s.Phase = "manual_running"
+ s.LastAttemptAt = &now
+ s.NextAt = nil
+ return nil
+ }
+ if s.NextAt == nil || now.Before(*s.NextAt) || s.ExpiresAt == nil {
+ return ErrNotDue
+ }
+ switch s.Phase {
+ case "ready":
+ if now.Before(*s.ExpiresAt) {
+ s.Phase = "pre_running"
+ } else {
+ due := s.ExpiresAt.Add(time.Minute)
+ s.Phase = "retry"
+ s.NextAt = &due
+ if now.Before(due) {
+ return ErrNotDue
+ }
+ s.Phase = "post_running"
+ }
+ case "retry":
+ s.Phase = "post_running"
+ default:
+ return ErrNotDue
+ }
+ s.LastAttemptAt = &now
+ s.NextAt = nil
+ return nil
+}
+
+func (s *State) Complete(now time.Time, ticket *Ticket, code string) {
+ prior := s.Phase
+ s.LastCode = code
+ if ticket != nil {
+ s.Ticket = ticket
+ s.ExpiresAt = &ticket.ExpiresAt
+ due := ticket.ExpiresAt.Add(-time.Minute)
+ s.NextAt = &due
+ s.Phase = "ready"
+ return
+ }
+ if s.ExpiresAt != nil {
+ if prior == "manual_running" && s.ManualWasEnrolled && now.Before(*s.ExpiresAt) {
+ due := s.ExpiresAt.Add(-time.Minute)
+ s.Phase = "ready"
+ if !now.Before(due) {
+ due = s.ExpiresAt.Add(time.Minute)
+ s.Phase = "retry"
+ }
+ s.NextAt = &due
+ return
+ }
+ if prior == "pre_running" {
+ due := s.ExpiresAt.Add(time.Minute)
+ s.NextAt = &due
+ s.Phase = "retry"
+ return
+ }
+ }
+ s.Stop()
+}
+
+func (s *State) Stop() { s.Phase = "stopped"; s.NextAt = nil }
+
+func Eligible(platform, accountType string, shadow bool) bool {
+ return platform == "openai" && (accountType == "oauth" || accountType == "setup-token") && !shadow
+}
diff --git a/plugins/codex-runtime/internal/tickets/state_test.go b/plugins/codex-runtime/internal/tickets/state_test.go
new file mode 100644
index 000000000000..d48feff37e89
--- /dev/null
+++ b/plugins/codex-runtime/internal/tickets/state_test.go
@@ -0,0 +1,56 @@
+package tickets
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestRenewalStagesPersistAndStop(t *testing.T) {
+ now := time.Date(2026, 9, 19, 8, 0, 0, 0, time.UTC)
+ ticket := &Ticket{State: "gAAAAA" + strings.Repeat("a", 286), AccountID: 1, Identity: "subject", Model: "astra", CapturedAt: now, ExpiresAt: now.Add(TTL)}
+ state := State{}
+ if err := state.Begin(now, true); err != nil {
+ t.Fatal(err)
+ }
+ state.Complete(now, ticket, "ticket_ready")
+ if !ticket.Valid(now, 1, "subject", "astra") || ticket.Valid(now, 1, "different", "astra") {
+ t.Fatal("ticket ownership mismatch")
+ }
+ if err := state.Begin(now.Add(59*time.Minute), false); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(state)
+ var restarted State
+ if err := json.Unmarshal(raw, &restarted); err != nil {
+ t.Fatal(err)
+ }
+ if !errors.Is(restarted.Begin(now.Add(59*time.Minute), false), ErrRunning) {
+ t.Fatal("restarted running stage was replayed")
+ }
+ state.Complete(now.Add(59*time.Minute), nil, "ticket_length")
+ if !errors.Is(state.Begin(now.Add(time.Hour), false), ErrNotDue) {
+ t.Fatal("post expiry retry ran early")
+ }
+ if err := state.Begin(now.Add(61*time.Minute), false); err != nil {
+ t.Fatal(err)
+ }
+ state.Complete(now.Add(61*time.Minute), nil, "ticket_length")
+ if state.Phase != "stopped" || state.NextAt != nil {
+ t.Fatal("renewal did not stop after its second failure")
+ }
+ if !errors.Is(state.Begin(now.Add(2*time.Hour), false), ErrNotDue) {
+ t.Fatal("stopped renewal restarted")
+ }
+}
+
+func TestTicketEligibilityDoesNotDependOnBusinessStatus(t *testing.T) {
+ if !Eligible("openai", "oauth", false) || !Eligible("openai", "setup-token", false) {
+ t.Fatal("OAuth credentials excluded")
+ }
+ if Eligible("cindy", "apikey", false) || Eligible("openai", "apikey", false) || Eligible("openai", "oauth", true) {
+ t.Fatal("unsupported ticket account admitted")
+ }
+}
From 769361555a9bdb2894552bb65ef7f5a9a5a700fd Mon Sep 17 00:00:00 2001
From: Codex
Date: Sat, 19 Sep 2026 23:17:36 +0800
Subject: [PATCH 040/137] feat: move Codex tickets and model references into
signed plugins
Unify OAuth quota windows and attributable cost observations, preserve per-account test reasoning choices, and replace legacy ticket execution with the extension host. Keep this development branch unpublished until the remaining domains and project audit are complete.
---
.dockerignore | 12 +-
.downstream/coverage-v0.2.7.json | 1565 +++++++++++++++++
.downstream/coverage-v0.2.7.md | 43 +
.downstream/implementation-v0.2.7.md | 111 +-
.github/scripts/test_first_party_plugins.sh | 46 +
.github/workflows/downstream-verify.yml | 6 +-
.gitignore | 4 +
Dockerfile | 46 +-
backend/cmd/package-plugin/main.go | 261 +++
backend/cmd/server/main.go | 7 +
backend/cmd/server/wire.go | 13 +-
backend/cmd/server/wire_gen.go | 28 +-
backend/cmd/server/wire_gen_test.go | 1 +
backend/go.mod | 3 +
backend/go.sum | 2 +
.../handler/admin/account_batch_test_test.go | 31 +
.../handler/admin/account_batch_testing.go | 10 +-
.../admin/account_codex_ticket_test.go | 34 +-
.../internal/handler/admin/account_handler.go | 14 +-
.../internal/handler/admin/plugin_handler.go | 48 +-
.../internal/handler/admin/setting_handler.go | 3 -
.../handler/admin/setting_handler_audit.go | 6 -
.../setting_handler_codex_ticket_test.go | 53 +-
.../handler/admin/setting_handler_update.go | 42 +-
backend/internal/handler/dto/settings.go | 33 +-
backend/internal/handler/gateway_handler.go | 5 +
.../handler/openai_gateway_handler.go | 11 +-
backend/internal/handler/ops_error_logger.go | 6 +
backend/internal/handler/wire.go | 4 -
backend/internal/pkg/proxyutil/dialer.go | 42 +-
.../internal/repository/account_job_repo.go | 16 +-
.../repository/account_plugin_projection.go | 27 +
.../repository/account_quota_estimate.go | 6 +-
...account_quota_estimate_integration_test.go | 80 +
...t_quota_snapshot_order_integration_test.go | 66 +
backend/internal/repository/account_repo.go | 18 +-
.../account_repo_codex_lifecycle.go | 327 ----
...t_repo_codex_lifecycle_integration_test.go | 252 ---
.../repository/codex_ticket_proxy_trust.go | 58 -
.../repository/integration_harness_test.go | 42 +
backend/internal/repository/plugin_bundle.go | 169 ++
.../plugin_bundle_integration_test.go | 96 +
.../repository/plugin_extension_state.go | 79 +-
...plugin_extension_state_integration_test.go | 129 ++
.../repository/plugin_legacy_import.go | 174 ++
backend/internal/repository/plugin_repo.go | 78 +-
backend/internal/repository/quota_activity.go | 81 +
.../repository/quota_activity_test.go | 43 +
.../internal/repository/scheduler_cache.go | 4 +
backend/internal/repository/wire.go | 3 +-
.../internal/service/account_quota_windows.go | 101 +-
.../service/account_quota_windows_test.go | 77 +
.../account_scheduling_threshold_eval.go | 38 +-
.../account_scheduling_threshold_eval_test.go | 7 +-
.../service/account_test_prompt_test.go | 17 +-
.../service/account_test_reasoning.go | 21 +
.../service/account_test_reasoning_test.go | 12 +
.../account_test_reasoning_wire_test.go | 27 +
.../internal/service/account_test_service.go | 5 +
.../account_test_service_opencode_go.go | 8 +-
.../internal/service/account_usage_service.go | 14 +-
.../admin_account_codex_ticket_test.go | 2 +-
.../service/codex_ticket_lifecycle.go | 148 +-
.../internal/service/codex_ticket_manual.go | 170 --
.../service/codex_ticket_manual_test.go | 255 +--
.../internal/service/codex_ticket_plugin.go | 173 ++
.../internal/service/codex_ticket_proxy.go | 436 +----
.../service/codex_ticket_proxy_test.go | 183 --
.../internal/service/gateway_usage_billing.go | 8 +
.../service/model_context_capacity.go | 119 +-
.../service/model_context_capacity_catalog.go | 885 +---------
...text_capacity_catalog_test_fixture_test.go | 13 +
.../openai_account_runtime_block_fastpath.go | 17 +-
.../internal/service/openai_codex_ticket.go | 533 ------
.../openai_codex_ticket_lifecycle_test.go | 229 ---
.../openai_codex_ticket_scheduler_test.go | 22 +-
.../service/openai_codex_ticket_test.go | 521 ++----
.../service/openai_gateway_service.go | 28 +-
.../internal/service/openai_gateway_usage.go | 12 +-
.../service/openai_quota_auto_reset.go | 42 +-
.../internal/service/openai_quota_service.go | 72 +-
backend/internal/service/ops_service.go | 7 +-
backend/internal/service/plugin_bundle.go | 174 ++
.../internal/service/plugin_catalog_test.go | 47 +
.../service/plugin_extension_package_test.go | 80 +
.../service/plugin_extension_runtime_test.go | 86 +
.../service/plugin_extension_state.go | 39 +-
backend/internal/service/plugin_extensions.go | 109 +-
backend/internal/service/plugin_jobs.go | 35 +-
backend/internal/service/plugin_manager.go | 52 +-
backend/internal/service/plugin_manifest.go | 11 +-
backend/internal/service/plugin_package.go | 10 +-
backend/internal/service/plugin_provider.go | 11 +
backend/internal/service/plugin_request.go | 80 +
backend/internal/service/plugin_runtime.go | 50 +-
backend/internal/service/plugin_scheduling.go | 96 +
backend/internal/service/quota_activity.go | 304 ++++
.../internal/service/quota_activity_test.go | 90 +
backend/internal/service/quota_estimate.go | 75 +-
.../service/quota_measurement_test.go | 57 +-
backend/internal/service/ratelimit_service.go | 13 +-
.../service/ratelimit_service_openai_test.go | 22 +-
.../service/setting_codex_ticket_test.go | 95 -
.../service/setting_gateway_runtime.go | 153 --
backend/internal/service/setting_parse.go | 7 -
backend/internal/service/setting_service.go | 34 +-
backend/internal/service/setting_update.go | 7 -
backend/internal/service/settings_view.go | 2 -
backend/internal/service/wire.go | 13 +-
.../migrations/246_plugin_extension_state.sql | 2 +-
.../248_plugin_bundle_bootstrap.sql | 10 +
backend/pkg/extensionapi/proxy/address.go | 105 ++
backend/pkg/extensionapi/proxy/transport.go | 48 +
backend/pkg/extensionapi/ui/bridge.js | 50 +
backend/pkg/extensionapi/v1/api.go | 132 +-
backend/pkg/extensionapi/v1/catalog.go | 65 +
backend/pkg/pluginapi/v1/manifest.schema.json | 11 +-
frontend/src/api/admin/plugins.ts | 19 +-
frontend/src/api/admin/settings.ts | 6 -
.../admin/CodexTicketProxyEditor.vue | 59 -
.../__tests__/CodexTicketProxyEditor.spec.ts | 37 -
.../account-jobs/AccountOperationDialog.vue | 4 +-
.../account-jobs/AccountOperationProgress.vue | 29 +-
.../admin/account/AccountActionMenu.vue | 21 +-
.../admin/account/AccountBulkActionsBar.vue | 7 +-
.../admin/account/CodexTicketHarvestModal.vue | 127 --
.../AccountActionMenu.position.spec.ts | 3 +
.../AccountActionMenu.spark_shadow.spec.ts | 20 +-
.../__tests__/AccountBulkActionsBar.spec.ts | 30 +-
.../__tests__/CodexTicketHarvestModal.spec.ts | 49 -
.../components/plugins/ExtensionDialog.vue | 25 +
.../src/components/plugins/ExtensionSlot.vue | 53 +
.../src/components/plugins/PluginFrame.vue | 116 ++
.../plugins/__tests__/ExtensionSlot.spec.ts | 74 +
.../useAccountSelectionMetadata.spec.ts | 39 +
.../__tests__/useTicketSelection.spec.ts | 40 -
.../useAccountSelectionMetadata.ts | 35 +
.../src/composables/useTicketSelection.ts | 31 -
.../src/i18n/locales/en/admin/accountTasks.ts | 1 +
frontend/src/i18n/locales/en/admin/plugins.ts | 8 +-
.../src/i18n/locales/zh/admin/accountTasks.ts | 1 +
frontend/src/i18n/locales/zh/admin/plugins.ts | 8 +-
frontend/src/stores/pluginExtensions.ts | 35 +
.../utils/__tests__/codexTicketProxy.spec.ts | 21 -
frontend/src/utils/codexTicketEligibility.ts | 15 -
frontend/src/utils/codexTicketProxy.ts | 40 -
frontend/src/views/admin/AccountsView.vue | 16 +-
frontend/src/views/admin/PluginsView.vue | 233 +--
frontend/src/views/admin/SettingsView.vue | 40 +-
.../admin/__tests__/SettingsView.spec.ts | 38 +-
.../internal/tickets/cutover_test.go | 142 ++
.../internal/tickets/identity.go | 43 +
.../internal/tickets/identity_test.go | 26 +
.../codex-runtime/internal/tickets/module.go | 230 ++-
.../internal/tickets/module_test.go | 167 ++
.../codex-runtime/internal/tickets/outcome.go | 40 +
.../codex-runtime/internal/tickets/proxy.go | 216 +++
.../internal/tickets/proxy_test.go | 155 ++
.../codex-runtime/internal/tickets/state.go | 1 +
plugins/codex-runtime/manifest.source.json | 45 +
plugins/codex-runtime/scripts/check-ui.py | 128 ++
plugins/codex-runtime/ui/assets/app.js | 78 +
plugins/codex-runtime/ui/assets/styles.css | 26 +
plugins/codex-runtime/ui/index.html | 14 +
plugins/model-policy/catalog/catalog_data.go | 830 +++++++++
plugins/model-policy/catalog/module.go | 139 ++
plugins/model-policy/catalog/module_test.go | 47 +
plugins/model-policy/cmd/plugin/main.go | 10 +
plugins/model-policy/go.mod | 24 +
plugins/model-policy/go.sum | 83 +
plugins/model-policy/manifest.source.json | 30 +
plugins/model-policy/ui/assets/app.js | 32 +
plugins/model-policy/ui/assets/styles.css | 13 +
plugins/model-policy/ui/index.html | 4 +
174 files changed, 8818 insertions(+), 5600 deletions(-)
create mode 100644 .downstream/coverage-v0.2.7.json
create mode 100644 .downstream/coverage-v0.2.7.md
create mode 100644 .github/scripts/test_first_party_plugins.sh
create mode 100644 backend/cmd/package-plugin/main.go
create mode 100644 backend/internal/repository/account_plugin_projection.go
create mode 100644 backend/internal/repository/account_quota_estimate_integration_test.go
create mode 100644 backend/internal/repository/account_quota_snapshot_order_integration_test.go
delete mode 100644 backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
delete mode 100644 backend/internal/repository/codex_ticket_proxy_trust.go
create mode 100644 backend/internal/repository/plugin_bundle.go
create mode 100644 backend/internal/repository/plugin_bundle_integration_test.go
create mode 100644 backend/internal/repository/plugin_extension_state_integration_test.go
create mode 100644 backend/internal/repository/plugin_legacy_import.go
create mode 100644 backend/internal/repository/quota_activity.go
create mode 100644 backend/internal/repository/quota_activity_test.go
create mode 100644 backend/internal/service/codex_ticket_plugin.go
create mode 100644 backend/internal/service/model_context_capacity_catalog_test_fixture_test.go
delete mode 100644 backend/internal/service/openai_codex_ticket_lifecycle_test.go
create mode 100644 backend/internal/service/plugin_bundle.go
create mode 100644 backend/internal/service/plugin_catalog_test.go
create mode 100644 backend/internal/service/plugin_extension_package_test.go
create mode 100644 backend/internal/service/plugin_extension_runtime_test.go
create mode 100644 backend/internal/service/plugin_provider.go
create mode 100644 backend/internal/service/plugin_request.go
create mode 100644 backend/internal/service/plugin_scheduling.go
create mode 100644 backend/internal/service/quota_activity.go
create mode 100644 backend/internal/service/quota_activity_test.go
create mode 100644 backend/migrations/248_plugin_bundle_bootstrap.sql
create mode 100644 backend/pkg/extensionapi/proxy/address.go
create mode 100644 backend/pkg/extensionapi/proxy/transport.go
create mode 100644 backend/pkg/extensionapi/ui/bridge.js
create mode 100644 backend/pkg/extensionapi/v1/catalog.go
delete mode 100644 frontend/src/components/admin/CodexTicketProxyEditor.vue
delete mode 100644 frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts
delete mode 100644 frontend/src/components/admin/account/CodexTicketHarvestModal.vue
delete mode 100644 frontend/src/components/admin/account/__tests__/CodexTicketHarvestModal.spec.ts
create mode 100644 frontend/src/components/plugins/ExtensionDialog.vue
create mode 100644 frontend/src/components/plugins/ExtensionSlot.vue
create mode 100644 frontend/src/components/plugins/PluginFrame.vue
create mode 100644 frontend/src/components/plugins/__tests__/ExtensionSlot.spec.ts
create mode 100644 frontend/src/composables/__tests__/useAccountSelectionMetadata.spec.ts
delete mode 100644 frontend/src/composables/__tests__/useTicketSelection.spec.ts
create mode 100644 frontend/src/composables/useAccountSelectionMetadata.ts
delete mode 100644 frontend/src/composables/useTicketSelection.ts
create mode 100644 frontend/src/stores/pluginExtensions.ts
delete mode 100644 frontend/src/utils/__tests__/codexTicketProxy.spec.ts
delete mode 100644 frontend/src/utils/codexTicketEligibility.ts
delete mode 100644 frontend/src/utils/codexTicketProxy.ts
create mode 100644 plugins/codex-runtime/internal/tickets/cutover_test.go
create mode 100644 plugins/codex-runtime/internal/tickets/identity.go
create mode 100644 plugins/codex-runtime/internal/tickets/identity_test.go
create mode 100644 plugins/codex-runtime/internal/tickets/module_test.go
create mode 100644 plugins/codex-runtime/internal/tickets/outcome.go
create mode 100644 plugins/codex-runtime/internal/tickets/proxy.go
create mode 100644 plugins/codex-runtime/internal/tickets/proxy_test.go
create mode 100644 plugins/codex-runtime/manifest.source.json
create mode 100644 plugins/codex-runtime/scripts/check-ui.py
create mode 100644 plugins/codex-runtime/ui/assets/app.js
create mode 100644 plugins/codex-runtime/ui/assets/styles.css
create mode 100644 plugins/codex-runtime/ui/index.html
create mode 100644 plugins/model-policy/catalog/catalog_data.go
create mode 100644 plugins/model-policy/catalog/module.go
create mode 100644 plugins/model-policy/catalog/module_test.go
create mode 100644 plugins/model-policy/cmd/plugin/main.go
create mode 100644 plugins/model-policy/go.mod
create mode 100644 plugins/model-policy/go.sum
create mode 100644 plugins/model-policy/manifest.source.json
create mode 100644 plugins/model-policy/ui/assets/app.js
create mode 100644 plugins/model-policy/ui/assets/styles.css
create mode 100644 plugins/model-policy/ui/index.html
diff --git a/.dockerignore b/.dockerignore
index 006dcbf4259b..316b32792a4c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -50,8 +50,16 @@ coverage/
.env.*
!.env.example
-# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。
-/plugins/
+# Only the reviewed first-party module enters the build context. Local plugin
+# packages, generated binaries and publisher keys remain excluded.
+/plugins/*
+!/plugins/codex-runtime/
+!/plugins/codex-runtime/**
+!/plugins/model-policy/
+!/plugins/model-policy/**
+/plugins/codex-runtime/dist/
+/plugins/model-policy/dist/
+/plugins/**/*.key
# Local config
config.yaml
diff --git a/.downstream/coverage-v0.2.7.json b/.downstream/coverage-v0.2.7.json
new file mode 100644
index 000000000000..cc45fb047b17
--- /dev/null
+++ b/.downstream/coverage-v0.2.7.json
@@ -0,0 +1,1565 @@
+{
+ "schema_version": 1,
+ "upstream_base": "aea725f2ea644d5592d0bbb1d63b607efa7e200a",
+ "scope": "Sub2API downstream diff including this working tree; paths are candidate owners, not completed review findings",
+ "total_paths": 2061,
+ "generated_assets_and_locks": 533,
+ "handwritten_and_test_paths": 1528,
+ "review_complete": false,
+ "candidate_owners": {
+ "operations": [
+ ".dockerignore",
+ ".github/scripts/test_agent_protocol.sh",
+ ".github/scripts/test_reasoning_replay.sh",
+ ".github/scripts/test_upstream_automation_contract.py",
+ ".github/scripts/test_upstream_risk.py",
+ ".github/scripts/upstream_promote.py",
+ ".github/scripts/upstream_risk.py",
+ ".github/workflows/backend-ci.yml",
+ ".github/workflows/downstream-release.yml",
+ ".github/workflows/downstream-verify.yml",
+ ".github/workflows/legacy-ghcr-cleanup.yml",
+ ".github/workflows/production-deploy.yml",
+ ".github/workflows/release.yml",
+ ".github/workflows/security-scan.yml",
+ ".github/workflows/upstream-auto-deploy.yml",
+ ".github/workflows/upstream-auto-release.yml",
+ ".github/workflows/upstream-promote.yml",
+ ".github/workflows/upstream-risk-gate.yml",
+ ".github/workflows/upstream-sync.yml",
+ ".gitignore",
+ "deploy/.env.example",
+ "deploy/config.example.yaml",
+ "deploy/docker-compose.dev.yml",
+ "deploy/docker-compose.local.yml",
+ "deploy/docker-compose.standalone.yml",
+ "deploy/docker-compose.yml",
+ "deploy/docker-entrypoint.sh",
+ "deploy/Dockerfile",
+ "deploy/README.md",
+ "deploy/tests/docker-compose-gateway-env-test.sh",
+ "deploy/tests/docker-runtime-resources-test.sh",
+ "deploy/tests/production-deploy-workflow-test.sh",
+ "Dockerfile",
+ "Dockerfile.goreleaser"
+ ],
+ "cindy-provider": [
+ ".downstream/cindy-catalog-refresh-20260905.json",
+ ".downstream/cindy-catalog-refresh-20260911.json",
+ ".superpowers/sdd/cindy-group-account-ops-v180/progress.md",
+ ".superpowers/sdd/cindy-group-account-ops-v180/task-3-report.md",
+ ".superpowers/sdd/cindy-group-account-ops-v180/task-4b-report.md",
+ ".superpowers/sdd/cindy-group-account-ops-v180/task-5-report.md",
+ ".superpowers/sdd/cindy-group-account-ops-v180/task-6-report.md",
+ "backend/ent/schema/user_platform_quota_cindy_test.go",
+ "backend/internal/handler/admin/account_cindy_balance_test.go",
+ "backend/internal/handler/admin/account_cindy_balance.go",
+ "backend/internal/handler/admin/account_cindy_duplicates.go",
+ "backend/internal/handler/admin/cindy_balance_probe_handler_test.go",
+ "backend/internal/handler/admin/cindy_balance_probe_handler.go",
+ "backend/internal/handler/admin/group_cindy_split_test.go",
+ "backend/internal/handler/admin/group_cindy_split.go",
+ "backend/internal/handler/cindy_balance_handler_failover_test.go",
+ "backend/internal/handler/cindy_balance_terminal_test.go",
+ "backend/internal/handler/cindy_catalog_rollback_test.go",
+ "backend/internal/handler/cindy_rollout_testmain_test.go",
+ "backend/internal/handler/dto/account_mapper_cindy_probe_test.go",
+ "backend/internal/handler/openai_alpha_search_cindy_gate_test.go",
+ "backend/internal/handler/openai_cindy_http_wsv2_sticky_test.go",
+ "backend/internal/handler/openai_gateway_cindy_count_tokens_test.go",
+ "backend/internal/handler/openai_legacy_cindy_continuation_test.go",
+ "backend/internal/handler/openai_legacy_cindy_continuation.go",
+ "backend/internal/repository/account_job_cindy_mutation_test.go",
+ "backend/internal/repository/account_job_cindy_mutation.go",
+ "backend/internal/repository/account_repo_cindy_balance_integration_test.go",
+ "backend/internal/repository/account_repo_cindy_balance_test.go",
+ "backend/internal/repository/account_repo_cindy_balance.go",
+ "backend/internal/repository/account_repo_cindy_group_identity_integration_test.go",
+ "backend/internal/repository/account_repo_cindy_group_identity_test.go",
+ "backend/internal/repository/account_repo_cindy_identity_lifecycle_integration_test.go",
+ "backend/internal/repository/api_key_repo_cindy_group_materialization_test.go",
+ "backend/internal/repository/auth_cache_invalidation_cindy_identity_integration_test.go",
+ "backend/internal/repository/cindy_account_stats_reset_test.go",
+ "backend/internal/repository/cindy_balance_probe_complete_integration_test.go",
+ "backend/internal/repository/cindy_balance_probe_dispatch_integration_test.go",
+ "backend/internal/repository/cindy_balance_probe_finalize_integration_test.go",
+ "backend/internal/repository/cindy_balance_probe_lifecycle_integration_test.go",
+ "backend/internal/repository/cindy_balance_probe_repo_test.go",
+ "backend/internal/repository/cindy_balance_probe_repo.go",
+ "backend/internal/repository/cindy_group_identity.go",
+ "backend/internal/repository/cindy_health_repo_test.go",
+ "backend/internal/repository/cindy_health_repo.go",
+ "backend/internal/repository/gateway_cache_cindy_balance_test.go",
+ "backend/internal/repository/group_repo_cindy_health_test.go",
+ "backend/internal/repository/group_repo_cindy_split_integration_test.go",
+ "backend/internal/repository/group_repo_cindy_split_test.go",
+ "backend/internal/repository/group_repo_cindy_split.go",
+ "backend/internal/repository/minimax_cindy_quota_migration_integration_test.go",
+ "backend/internal/service/admin_account_cindy_probe_test.go",
+ "backend/internal/service/admin_account_cindy_probe.go",
+ "backend/internal/service/admin_cindy_balance_test.go",
+ "backend/internal/service/admin_cindy_balance.go",
+ "backend/internal/service/admin_cindy_duplicate_inventory.go",
+ "backend/internal/service/admin_cindy_group_split_test.go",
+ "backend/internal/service/admin_cindy_group_split.go",
+ "backend/internal/service/channel_cindy_managed_crud_test.go",
+ "backend/internal/service/cindy_balance_native_messages_test.go",
+ "backend/internal/service/cindy_balance_pending_test.go",
+ "backend/internal/service/cindy_balance_probe_service_test.go",
+ "backend/internal/service/cindy_balance_probe_service.go",
+ "backend/internal/service/cindy_balance_probe_types.go",
+ "backend/internal/service/cindy_balance_recheck_test.go",
+ "backend/internal/service/cindy_balance_recheck.go",
+ "backend/internal/service/cindy_balance_response_test.go",
+ "backend/internal/service/cindy_balance_shared_test.go",
+ "backend/internal/service/cindy_balance.go",
+ "backend/internal/service/cindy_capability_catalog.go",
+ "backend/internal/service/cindy_channel_catalog_test.go",
+ "backend/internal/service/cindy_channel_catalog.go",
+ "backend/internal/service/cindy_codex_client_version.go",
+ "backend/internal/service/cindy_duplicate_inventory_test.go",
+ "backend/internal/service/cindy_duplicate_inventory.go",
+ "backend/internal/service/cindy_endpoint_scheduling_test.go",
+ "backend/internal/service/cindy_feature_flags_independence_test.go",
+ "backend/internal/service/cindy_feature_flags_test.go",
+ "backend/internal/service/cindy_feature_flags.go",
+ "backend/internal/service/cindy_group_identity_test.go",
+ "backend/internal/service/cindy_group_materialized_test.go",
+ "backend/internal/service/cindy_group.go",
+ "backend/internal/service/cindy_health_service_test.go",
+ "backend/internal/service/cindy_health.go",
+ "backend/internal/service/cindy_identity_test.go",
+ "backend/internal/service/cindy_identity.go",
+ "backend/internal/service/cindy_native_count_tokens_test.go",
+ "backend/internal/service/cindy_native_messages_test.go",
+ "backend/internal/service/cindy_rollout_testmain_test.go",
+ "backend/internal/service/cindy_terminal_scheduling_isolation_test.go",
+ "backend/internal/service/cindy_text_billing_test.go",
+ "backend/internal/service/cindy_text_billing.go",
+ "backend/internal/service/cindy_v4_catalog_contract_test.go",
+ "backend/internal/service/cindy_v4_catalog_fixture_test.go",
+ "backend/internal/service/cindy_v4_catalog.go",
+ "backend/internal/service/openai_alpha_search_cindy_native_first_test.go",
+ "backend/internal/service/openai_cindy_continuation_ingress_test.go",
+ "backend/internal/service/openai_cindy_continuation_test.go",
+ "backend/internal/service/openai_cindy_continuation.go",
+ "backend/internal/service/openai_cindy_http_wsv2_test.go",
+ "backend/internal/service/openai_cindy_http_wsv2.go",
+ "backend/internal/service/openai_cindy_opaque_binding_test.go",
+ "backend/internal/service/openai_cindy_opaque_binding.go",
+ "backend/internal/service/ratelimit_service_cindy_balance_test.go",
+ "backend/internal/service/testdata/cindy_free_catalog_2026-08-29.json",
+ "backend/internal/service/testdata/cindy_free_catalog_2026-09-11.json",
+ "backend/migrations/202_add_cindy_balance_insufficient_at.sql",
+ "backend/migrations/207_codexrip_restore_cindy_openai_defaults.sql",
+ "backend/migrations/208_codexrip_use_cindy_prompt_cache_hash.sql",
+ "backend/migrations/209_codexrip_restore_cindy_alpha_search_bridge.sql",
+ "backend/migrations/210_codexrip_reset_legacy_cindy_balance_markers.sql",
+ "backend/migrations/211_codexrip_restore_exact_cindy_budget_markers.sql",
+ "backend/migrations/212_codexrip_materialize_cindy_group_identity.sql",
+ "backend/migrations/213_codexrip_reset_unconfirmed_cindy_balance_markers.sql",
+ "backend/migrations/224_cindy_balance_probe_jobs.sql",
+ "backend/migrations/229_cindy_platform_wire_identity.sql",
+ "backend/migrations/231_cindy_health_state.sql",
+ "backend/migrations/234_fix_cindy_platform_projection_round_trip.sql",
+ "backend/migrations/235_preserve_mixed_openai_cindy_groups.sql",
+ "backend/migrations/236_bind_strict_cindy_groups_to_catalog_channel.sql",
+ "backend/migrations/237_cindy_terminal_health_and_cleanup.sql",
+ "backend/migrations/237_user_platform_quotas_add_cindy.sql",
+ "backend/migrations/238_cindy_account_stats_reset_at.sql",
+ "backend/migrations/241_minimax_cindy_platform_quota_compat.sql",
+ "backend/migrations/cindy_account_stats_reset_migration_test.go",
+ "backend/migrations/cindy_alpha_search_bridge_migration_integration_test.go",
+ "backend/migrations/cindy_alpha_search_bridge_migration_test.go",
+ "backend/migrations/cindy_balance_confirmation_reset_migration_test.go",
+ "backend/migrations/cindy_balance_marker_reset_migration_integration_test.go",
+ "backend/migrations/cindy_balance_marker_reset_migration_test.go",
+ "backend/migrations/cindy_balance_marker_restore_migration_test.go",
+ "backend/migrations/cindy_balance_probe_jobs_migration_test.go",
+ "backend/migrations/cindy_channel_binding_migration_integration_test.go",
+ "backend/migrations/cindy_group_identity_auth_cache_invalidation_migration_test.go",
+ "backend/migrations/cindy_health_state_migration_test.go",
+ "backend/migrations/cindy_openai_defaults_migration_integration_test.go",
+ "backend/migrations/cindy_openai_defaults_migration_test.go",
+ "backend/migrations/cindy_platform_v1_migration_integration_test.go",
+ "backend/migrations/cindy_platform_v1_migration_test.go",
+ "backend/migrations/cindy_prompt_cache_hash_migration_integration_test.go",
+ "backend/migrations/cindy_prompt_cache_hash_migration_test.go",
+ "backend/migrations/user_platform_quota_cindy_migration_test.go",
+ "deploy/tests/cindy-rollout-defaults-test.sh",
+ "frontend/src/api/__tests__/admin.groups.cindy-split.spec.ts",
+ "frontend/src/api/__tests__/cindyBalanceProbe.spec.ts",
+ "frontend/src/api/admin/cindyBalanceProbe.ts",
+ "frontend/src/features/cindy-balance-probe/__tests__/CindyBalanceProbePanel.spec.ts",
+ "frontend/src/features/cindy-balance-probe/__tests__/CindyBalanceProbeSummary.spec.ts",
+ "frontend/src/features/cindy-balance-probe/CindyBalanceProbePanel.vue",
+ "frontend/src/features/cindy-balance-probe/CindyBalanceProbeSummary.vue",
+ "frontend/src/features/cindy-duplicate-inventory/__tests__/CindyDuplicateInventoryPanel.spec.ts",
+ "frontend/src/features/cindy-duplicate-inventory/CindyDuplicateInventoryPanel.vue",
+ "frontend/src/features/cindy-group-split/__tests__/CindyGroupAuditDialog.spec.ts",
+ "frontend/src/features/cindy-group-split/CindyGroupAuditDialog.vue",
+ "frontend/src/i18n/__tests__/cindyProbeLocales.spec.ts",
+ "frontend/src/utils/__tests__/cindyOpenAIDefaults.spec.ts",
+ "frontend/src/utils/cindyOpenAIDefaults.ts",
+ "frontend/src/views/admin/CindyAccountsView.vue"
+ ],
+ "documentation-and-history": [
+ ".downstream/implementation-v0.2.7.md",
+ ".downstream/README.md",
+ ".downstream/upstream-0.2.0-overlap-review.md",
+ ".downstream/upstream-base",
+ ".downstream/upstream-review-v0.2.1.md",
+ ".downstream/upstream-review-v0.2.5.md",
+ ".downstream/upstream-review-v0.2.6.md",
+ ".downstream/upstream-risk.json",
+ "docs/channel-monitor-v2-safe-defaults.md"
+ ],
+ "mixed-requires-review": [
+ ".gitattributes",
+ "backend/.golangci.yml",
+ "backend/cmd/cleanup-ingress-reject-logs/main.go",
+ "backend/cmd/profit-preview/main_test.go",
+ "backend/cmd/profit-preview/main.go",
+ "backend/cmd/server/main_test.go",
+ "backend/cmd/server/main.go",
+ "backend/cmd/server/VERSION",
+ "backend/cmd/server/wire_gen_test.go",
+ "backend/cmd/server/wire_gen.go",
+ "backend/cmd/server/wire.go",
+ "backend/go.mod",
+ "backend/internal/config/config_test.go",
+ "backend/internal/config/config.go",
+ "backend/internal/domain/constants.go",
+ "backend/internal/handler/admin/account_api_key_visibility_test.go",
+ "backend/internal/handler/admin/account_api_key_visibility.go",
+ "backend/internal/handler/admin/account_codex_import_test.go",
+ "backend/internal/handler/admin/account_codex_import.go",
+ "backend/internal/handler/admin/account_data_handler_test.go",
+ "backend/internal/handler/admin/account_data_identity.go",
+ "backend/internal/handler/admin/account_data_import_decision.go",
+ "backend/internal/handler/admin/account_data_import_job.go",
+ "backend/internal/handler/admin/account_data_import_preview_test.go",
+ "backend/internal/handler/admin/account_data_import_v2.go",
+ "backend/internal/handler/admin/account_data_proxy_compat_test.go",
+ "backend/internal/handler/admin/account_data_v2_handler_test.go",
+ "backend/internal/handler/admin/account_data.go",
+ "backend/internal/handler/admin/account_duplicate_jobs.go",
+ "backend/internal/handler/admin/account_handler_available_models_test.go",
+ "backend/internal/handler/admin/account_handler_batch_delete_test.go",
+ "backend/internal/handler/admin/account_handler_configured_models_test.go",
+ "backend/internal/handler/admin/account_handler_list_test.go",
+ "backend/internal/handler/admin/account_handler_long_context_billing_test.go",
+ "backend/internal/handler/admin/account_handler_mixed_channel_test.go",
+ "backend/internal/handler/admin/account_handler_model_context_test.go",
+ "backend/internal/handler/admin/account_handler_traffic_telemetry_test.go",
+ "backend/internal/handler/admin/account_handler.go",
+ "backend/internal/handler/admin/account_reasoning_policy_test.go",
+ "backend/internal/handler/admin/account_reasoning_policy.go",
+ "backend/internal/handler/admin/account_today_stats_cache.go",
+ "backend/internal/handler/admin/admin_basic_handlers_test.go",
+ "backend/internal/handler/admin/admin_service_stub_test.go",
+ "backend/internal/handler/admin/batch_update_credentials_test.go",
+ "backend/internal/handler/admin/channel_handler_test.go",
+ "backend/internal/handler/admin/grok_import_probe.go",
+ "backend/internal/handler/admin/group_handler_platform_test.go",
+ "backend/internal/handler/admin/group_handler_simple_mode_test.go",
+ "backend/internal/handler/admin/group_handler.go",
+ "backend/internal/handler/admin/openai_oauth_handler.go",
+ "backend/internal/handler/admin/payment_handler_test.go",
+ "backend/internal/handler/admin/provider_identity_validation_test.go",
+ "backend/internal/handler/admin/setting_handler_audit.go",
+ "backend/internal/handler/admin/setting_handler_partial_payload_test.go",
+ "backend/internal/handler/admin/setting_handler_refusal_recovery_test.go",
+ "backend/internal/handler/admin/setting_handler_update.go",
+ "backend/internal/handler/admin/setting_handler.go",
+ "backend/internal/handler/admin/snapshot_cache.go",
+ "backend/internal/handler/admin/system_prompt_handler_test.go",
+ "backend/internal/handler/admin/system_prompt_handler.go",
+ "backend/internal/handler/admin/user_platform_quota_admin_test.go",
+ "backend/internal/handler/continuation_repair_live_support_test.go",
+ "backend/internal/handler/continuation_repair_live_test.go",
+ "backend/internal/handler/dto/account_mapper_redact_test.go",
+ "backend/internal/handler/dto/credentials_redact_test.go",
+ "backend/internal/handler/dto/credentials_redact.go",
+ "backend/internal/handler/dto/group_mapper_profit_visibility_test.go",
+ "backend/internal/handler/dto/mappers.go",
+ "backend/internal/handler/dto/provider_identity_mapper_test.go",
+ "backend/internal/handler/dto/settings.go",
+ "backend/internal/handler/dto/types.go",
+ "backend/internal/handler/endpoint_test.go",
+ "backend/internal/handler/endpoint.go",
+ "backend/internal/handler/failover_loop_profit_veto_test.go",
+ "backend/internal/handler/failover_loop_test.go",
+ "backend/internal/handler/gateway_handler_chat_completions.go",
+ "backend/internal/handler/gateway_handler_responses.go",
+ "backend/internal/handler/gateway_handler.go",
+ "backend/internal/handler/gateway_model_capacity_test.go",
+ "backend/internal/handler/gateway_models_test.go",
+ "backend/internal/handler/grok_media.go",
+ "backend/internal/handler/handler.go",
+ "backend/internal/handler/no_account_error_test.go",
+ "backend/internal/handler/no_account_error.go",
+ "backend/internal/handler/openai_alpha_search_bridge_error_test.go",
+ "backend/internal/handler/openai_alpha_search.go",
+ "backend/internal/handler/openai_automation_bootstrap_test.go",
+ "backend/internal/handler/openai_body_limit_failover_test.go",
+ "backend/internal/handler/openai_chat_completions.go",
+ "backend/internal/handler/openai_codex_models_handler_test.go",
+ "backend/internal/handler/openai_codex_models_handler.go",
+ "backend/internal/handler/openai_count_tokens_model_not_supported_test.go",
+ "backend/internal/handler/openai_embeddings.go",
+ "backend/internal/handler/openai_failover_retry_test.go",
+ "backend/internal/handler/openai_failover_retry.go",
+ "backend/internal/handler/openai_gateway_count_tokens.go",
+ "backend/internal/handler/openai_gateway_credential_failover_loop_test.go",
+ "backend/internal/handler/openai_gateway_first_output_timeout_test.go",
+ "backend/internal/handler/openai_gateway_handler_test.go",
+ "backend/internal/handler/openai_gateway_handler.go",
+ "backend/internal/handler/openai_gateway_native_usage_test.go",
+ "backend/internal/handler/openai_gateway_reasoning_failover_test.go",
+ "backend/internal/handler/openai_gateway_reasoning_failover.go",
+ "backend/internal/handler/openai_gateway_usage_context_test.go",
+ "backend/internal/handler/openai_images_failover_test.go",
+ "backend/internal/handler/openai_images.go",
+ "backend/internal/handler/openai_management_isolation_test.go",
+ "backend/internal/handler/openai_models_handler_test.go",
+ "backend/internal/handler/openai_models_handler.go",
+ "backend/internal/handler/openai_official_http_failover_integration_test.go",
+ "backend/internal/handler/openai_official_http_failover.go",
+ "backend/internal/handler/openai_official_http_retry_test.go",
+ "backend/internal/handler/openai_profit_veto_budget_test.go",
+ "backend/internal/handler/openai_reasoning_recovery_terminal_handler_test.go",
+ "backend/internal/handler/openai_refusal_recovery_handler_test.go",
+ "backend/internal/handler/openai_replayable_endpoints_failover_test.go",
+ "backend/internal/handler/openai_request_rejected_test.go",
+ "backend/internal/handler/openai_responses_failover_cancel_test.go",
+ "backend/internal/handler/openai_same_account_reacquire_slot_test.go",
+ "backend/internal/handler/openai_ws_failover_policy_test.go",
+ "backend/internal/handler/openai_ws_failover_policy.go",
+ "backend/internal/handler/ops_error_logger_continuation_test.go",
+ "backend/internal/handler/ops_error_logger_test.go",
+ "backend/internal/handler/ops_error_logger.go",
+ "backend/internal/handler/request_body_limit_test.go",
+ "backend/internal/handler/request_body_limit.go",
+ "backend/internal/handler/setting_handler.go",
+ "backend/internal/handler/stream_error_event.go",
+ "backend/internal/handler/wire.go",
+ "backend/internal/payment/provider/alipay_test.go",
+ "backend/internal/payment/provider/easypay_refund_test.go",
+ "backend/internal/payment/provider/wxpay_test.go",
+ "backend/internal/pkg/antigravity/client_test.go",
+ "backend/internal/pkg/antigravity/oauth_test.go",
+ "backend/internal/pkg/antigravity/request_transformer_test.go",
+ "backend/internal/pkg/apicompat/agent_stream_terminal_test.go",
+ "backend/internal/pkg/apicompat/agent_tool_history_test.go",
+ "backend/internal/pkg/apicompat/anthropic_responses_fidelity_test.go",
+ "backend/internal/pkg/apicompat/anthropic_responses_test.go",
+ "backend/internal/pkg/apicompat/anthropic_to_responses.go",
+ "backend/internal/pkg/apicompat/chat_terminal.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_request_invariants_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_responses_tool_output_media_test.go",
+ "backend/internal/pkg/apicompat/chatcompletions_to_responses.go",
+ "backend/internal/pkg/apicompat/responses_capabilities.go",
+ "backend/internal/pkg/apicompat/responses_created_at_wire_test.go",
+ "backend/internal/pkg/apicompat/responses_reasoning_test.go",
+ "backend/internal/pkg/apicompat/responses_reasoning.go",
+ "backend/internal/pkg/apicompat/responses_to_anthropic.go",
+ "backend/internal/pkg/apicompat/responses_to_chat_function_state_test.go",
+ "backend/internal/pkg/apicompat/responses_to_chat_function_state.go",
+ "backend/internal/pkg/apicompat/responses_to_chatcompletions.go",
+ "backend/internal/pkg/apicompat/service_tier_passthrough_test.go",
+ "backend/internal/pkg/apicompat/types.go",
+ "backend/internal/pkg/httpclient/pool.go",
+ "backend/internal/pkg/httputil/body_lenient_json_test.go",
+ "backend/internal/pkg/httputil/body_test.go",
+ "backend/internal/pkg/httputil/body.go",
+ "backend/internal/pkg/openai_compat/upstream_capability_test.go",
+ "backend/internal/pkg/openai_compat/upstream_capability.go",
+ "backend/internal/pkg/openai/constants.go",
+ "backend/internal/pkg/proxyurl/parse_test.go",
+ "backend/internal/pkg/proxyutil/dialer.go",
+ "backend/internal/pkg/tlsfingerprint/dialer_deadline_test.go",
+ "backend/internal/pkg/tlsfingerprint/dialer_test.go",
+ "backend/internal/pkg/tlsfingerprint/dialer.go",
+ "backend/internal/repository/account_credential_identity_repo_test.go",
+ "backend/internal/repository/account_credential_identity_repo.go",
+ "backend/internal/repository/account_repo_codex_fingerprint_seed_test.go",
+ "backend/internal/repository/account_repo_codex_lifecycle.go",
+ "backend/internal/repository/account_repo_integration_test.go",
+ "backend/internal/repository/account_repo_model_availability_test.go",
+ "backend/internal/repository/account_repo_model_context_test.go",
+ "backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go",
+ "backend/internal/repository/account_repo_ollama_cloud_usage_test.go",
+ "backend/internal/repository/account_repo_ollama_cloud_usage.go",
+ "backend/internal/repository/account_repo_schedulable_projection_integration_test.go",
+ "backend/internal/repository/account_repo_schedulable_projection_test.go",
+ "backend/internal/repository/account_repo_temp_unsched_test.go",
+ "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go",
+ "backend/internal/repository/account_repo.go",
+ "backend/internal/repository/account_traffic_observe_cache.go",
+ "backend/internal/repository/affiliate_repo.go",
+ "backend/internal/repository/api_key_repo_integration_test.go",
+ "backend/internal/repository/api_key_repo.go",
+ "backend/internal/repository/audit_log_repo.go",
+ "backend/internal/repository/batch_image_repo_integration_test.go",
+ "backend/internal/repository/channel_monitor_repo.go",
+ "backend/internal/repository/channel_monitor_template_duplicate_metadata_unit_test.go",
+ "backend/internal/repository/channel_monitor_template_repo.go",
+ "backend/internal/repository/channel_monitor_v2_repo_test.go",
+ "backend/internal/repository/channel_monitor_v2_repo.go",
+ "backend/internal/repository/channel_repo_account_stats_pricing.go",
+ "backend/internal/repository/channel_repo_pricing_time_test.go",
+ "backend/internal/repository/channel_repo_pricing.go",
+ "backend/internal/repository/channel_repo_test.go",
+ "backend/internal/repository/channel_repo.go",
+ "backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go",
+ "backend/internal/repository/concurrency_cache_live_test.go",
+ "backend/internal/repository/dashboard_aggregation_repo.go",
+ "backend/internal/repository/db_pool_test.go",
+ "backend/internal/repository/db_pool.go",
+ "backend/internal/repository/ent.go",
+ "backend/internal/repository/error_translate.go",
+ "backend/internal/repository/fixtures_integration_test.go",
+ "backend/internal/repository/gateway_cache_live_test.go",
+ "backend/internal/repository/gateway_cache_reasoning_state_test.go",
+ "backend/internal/repository/gateway_cache_reasoning_state.go",
+ "backend/internal/repository/gateway_cache.go",
+ "backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go",
+ "backend/internal/repository/group_repo.go",
+ "backend/internal/repository/group_usage_rollup_trigger_integration_test.go",
+ "backend/internal/repository/http_upstream_compression_test.go",
+ "backend/internal/repository/http_upstream_test.go",
+ "backend/internal/repository/http_upstream.go",
+ "backend/internal/repository/integration_harness_test.go",
+ "backend/internal/repository/openai_oauth_service.go",
+ "backend/internal/repository/ops_repo.go",
+ "backend/internal/repository/ops_write_pressure_integration_test.go",
+ "backend/internal/repository/passkey_repo.go",
+ "backend/internal/repository/pgx_sqlmock_test.go",
+ "backend/internal/repository/postgres_batch_insert_test.go",
+ "backend/internal/repository/postgres_batch_insert.go",
+ "backend/internal/repository/postgres_driver_test.go",
+ "backend/internal/repository/postgres_driver.go",
+ "backend/internal/repository/public_model_retirement_migration_integration_test.go",
+ "backend/internal/repository/scheduler_cache_reasoning_policy_test.go",
+ "backend/internal/repository/scheduler_cache_unit_test.go",
+ "backend/internal/repository/scheduler_cache.go",
+ "backend/internal/repository/temp_unsched_cache_health_test.go",
+ "backend/internal/repository/temp_unsched_cache.go",
+ "backend/internal/repository/usage_billing_owner_test.go",
+ "backend/internal/repository/usage_billing_repo.go",
+ "backend/internal/repository/usage_log_repo_stats.go",
+ "backend/internal/repository/user_group_rate_repo.go",
+ "backend/internal/repository/user_platform_quota_repo_integration_test.go",
+ "backend/internal/repository/user_platform_quota_repo.go",
+ "backend/internal/repository/user_repo.go",
+ "backend/internal/repository/wire.go",
+ "backend/internal/server/api_contract_test.go",
+ "backend/internal/server/middleware/audit_log_test.go",
+ "backend/internal/server/middleware/audit_log.go",
+ "backend/internal/server/middleware/group_model_allowlist_body_compat_test.go",
+ "backend/internal/server/middleware/group_model_allowlist_test.go",
+ "backend/internal/server/middleware/group_model_allowlist.go",
+ "backend/internal/server/middleware/openai_fast_policy_forwarding_test.go",
+ "backend/internal/server/router.go",
+ "backend/internal/server/routes/admin.go",
+ "backend/internal/server/routes/composite_platform_test.go",
+ "backend/internal/server/routes/gateway_codex_models_test.go",
+ "backend/internal/server/routes/gateway_group_fixture_test.go",
+ "backend/internal/server/routes/gateway_model_allowlist_test.go",
+ "backend/internal/server/routes/gateway_models_pinned_test.go",
+ "backend/internal/server/routes/gateway_test.go",
+ "backend/internal/server/routes/gateway.go",
+ "backend/internal/server/routes/system_prompt_routes_test.go",
+ "backend/internal/server/routes/user.go",
+ "backend/internal/service/account_available_models_test.go",
+ "backend/internal/service/account_base_url_input_test.go",
+ "backend/internal/service/account_base_url.go",
+ "backend/internal/service/account_business_errors_test.go",
+ "backend/internal/service/account_business_errors.go",
+ "backend/internal/service/account_credential_identity_test.go",
+ "backend/internal/service/account_credential_identity.go",
+ "backend/internal/service/account_reasoning_policy_test.go",
+ "backend/internal/service/account_scheduling_threshold_eval_test.go",
+ "backend/internal/service/account_scheduling_threshold_eval.go",
+ "backend/internal/service/account_service.go",
+ "backend/internal/service/account_traffic_observe.go",
+ "backend/internal/service/account_usage_service_test.go",
+ "backend/internal/service/account_usage_service.go",
+ "backend/internal/service/account_usage_session_window_test.go",
+ "backend/internal/service/account_wildcard_test.go",
+ "backend/internal/service/account.go",
+ "backend/internal/service/admin_account_model_context_test.go",
+ "backend/internal/service/admin_account.go",
+ "backend/internal/service/admin_group_duplicate.go",
+ "backend/internal/service/admin_group_platform_cache_test.go",
+ "backend/internal/service/admin_group.go",
+ "backend/internal/service/admin_service_bulk_update_test.go",
+ "backend/internal/service/admin_service_composite_group_test.go",
+ "backend/internal/service/admin_service_group_test.go",
+ "backend/internal/service/admin_service_spark_shadow_test.go",
+ "backend/internal/service/admin_service.go",
+ "backend/internal/service/agent_cc_stream_contract_test.go",
+ "backend/internal/service/agent_raw_stream_contract_test.go",
+ "backend/internal/service/agent_request_capabilities_test.go",
+ "backend/internal/service/agent_selection_diagnostics_test.go",
+ "backend/internal/service/agent_upstream_error_scope_test.go",
+ "backend/internal/service/anthropic_apikey_auth.go",
+ "backend/internal/service/antigravity_gateway_service.go",
+ "backend/internal/service/api_key_auth_cache_impl.go",
+ "backend/internal/service/api_key_auth_cache_profit_test.go",
+ "backend/internal/service/api_key_auth_cache_version_test.go",
+ "backend/internal/service/api_key_auth_cache.go",
+ "backend/internal/service/api_key_service.go",
+ "backend/internal/service/api_key.go",
+ "backend/internal/service/auth_service_platform_quota_test.go",
+ "backend/internal/service/billing_cache_service_user_platform_quota_test.go",
+ "backend/internal/service/billing_context_schedule.go",
+ "backend/internal/service/billing_service.go",
+ "backend/internal/service/billing_token_cost_request.go",
+ "backend/internal/service/channel_service.go",
+ "backend/internal/service/cn_provider_balance_check_service_test.go",
+ "backend/internal/service/cn_provider_balance_check_service.go",
+ "backend/internal/service/composite_platform_test.go",
+ "backend/internal/service/content_moderation_runtime_cache_test.go",
+ "backend/internal/service/deepseek_pricing_test.go",
+ "backend/internal/service/deepseek_pricing.go",
+ "backend/internal/service/domain_constants_test.go",
+ "backend/internal/service/domain_constants.go",
+ "backend/internal/service/email_service_smtp_test.go",
+ "backend/internal/service/gateway_anthropic_apikey_passthrough_test.go",
+ "backend/internal/service/gateway_anthropic_passthrough.go",
+ "backend/internal/service/gateway_count_tokens.go",
+ "backend/internal/service/gateway_model_availability_test.go",
+ "backend/internal/service/gateway_model_availability.go",
+ "backend/internal/service/gateway_multiplatform_test.go",
+ "backend/internal/service/gateway_scheduling.go",
+ "backend/internal/service/gateway_service_bedrock_beta_test.go",
+ "backend/internal/service/gateway_service_subscription_billing_test.go",
+ "backend/internal/service/gateway_service.go",
+ "backend/internal/service/gateway_usage_billing.go",
+ "backend/internal/service/gemini_messages_compat_service_test.go",
+ "backend/internal/service/gemini_multiplatform_test.go",
+ "backend/internal/service/gemini_oauth_service_test.go",
+ "backend/internal/service/gpt56_prompt_source_test.go",
+ "backend/internal/service/gpt56_prompt_source.go",
+ "backend/internal/service/grok_credential_failure.go",
+ "backend/internal/service/grok_free_quota_gate_test.go",
+ "backend/internal/service/grok_quota_service_test.go",
+ "backend/internal/service/grok_upstream_errors_test.go",
+ "backend/internal/service/group_model_allowlist_test.go",
+ "backend/internal/service/group_model_allowlist.go",
+ "backend/internal/service/group_model_capacity_test.go",
+ "backend/internal/service/group_model_capacity.go",
+ "backend/internal/service/group_peak_rate_test.go",
+ "backend/internal/service/group_profit_platform_test.go",
+ "backend/internal/service/group.go",
+ "backend/internal/service/http_upstream_profile_test.go",
+ "backend/internal/service/http_upstream_profile.go",
+ "backend/internal/service/kimi_anthropic_usage_test.go",
+ "backend/internal/service/model_pricing_resolver_test.go",
+ "backend/internal/service/model_pricing_resolver.go",
+ "backend/internal/service/namespace_roundtrip_control_test.go",
+ "backend/internal/service/namespace_roundtrip_diagnostic_support_test.go",
+ "backend/internal/service/namespace_roundtrip_diagnostic_test.go",
+ "backend/internal/service/namespace_roundtrip_live_test.go",
+ "backend/internal/service/oauth_refresh_api_test.go",
+ "backend/internal/service/oauth_refresh_api.go",
+ "backend/internal/service/oauth_service_test.go",
+ "backend/internal/service/oauth_service.go",
+ "backend/internal/service/openai_account_model_transient.go",
+ "backend/internal/service/openai_account_runtime_block_fastpath_test.go",
+ "backend/internal/service/openai_account_runtime_block_fastpath.go",
+ "backend/internal/service/openai_account_runtime_transient_test.go",
+ "backend/internal/service/openai_account_scheduler_test.go",
+ "backend/internal/service/openai_account_scheduler.go",
+ "backend/internal/service/openai_agent_identity_compat_test.go",
+ "backend/internal/service/openai_alpha_search_billing_test.go",
+ "backend/internal/service/openai_alpha_search_test.go",
+ "backend/internal/service/openai_alpha_search.go",
+ "backend/internal/service/openai_apikey_compat_test.go",
+ "backend/internal/service/openai_apikey_compat.go",
+ "backend/internal/service/openai_apikey_responses_probe_test.go",
+ "backend/internal/service/openai_apikey_responses_probe.go",
+ "backend/internal/service/openai_astra_pro_mode_test.go",
+ "backend/internal/service/openai_astra_pro_retry_test.go",
+ "backend/internal/service/openai_bulk_account_settings.go",
+ "backend/internal/service/openai_chat_reasoning_replay_test.go",
+ "backend/internal/service/openai_chat_reasoning_replay.go",
+ "backend/internal/service/openai_codex_account_identity_test.go",
+ "backend/internal/service/openai_codex_account_identity.go",
+ "backend/internal/service/openai_codex_client_identity_test.go",
+ "backend/internal/service/openai_codex_client_identity.go",
+ "backend/internal/service/openai_codex_context_contract_test.go",
+ "backend/internal/service/openai_codex_context_contract.go",
+ "backend/internal/service/openai_codex_context_window_test.go",
+ "backend/internal/service/openai_codex_fingerprint_test.go",
+ "backend/internal/service/openai_codex_fingerprint.go",
+ "backend/internal/service/openai_codex_identity_snapshot.go",
+ "backend/internal/service/openai_codex_identity.go",
+ "backend/internal/service/openai_codex_model_metadata_test.go",
+ "backend/internal/service/openai_codex_models_pinned.go",
+ "backend/internal/service/openai_codex_models_service_test.go",
+ "backend/internal/service/openai_codex_models_service.go",
+ "backend/internal/service/openai_codex_request_compression_test.go",
+ "backend/internal/service/openai_codex_request_compression.go",
+ "backend/internal/service/openai_codex_transform_additions_test.go",
+ "backend/internal/service/openai_codex_transform_test.go",
+ "backend/internal/service/openai_codex_transform.go",
+ "backend/internal/service/openai_codex_turn_state_test.go",
+ "backend/internal/service/openai_codex_turn_state.go",
+ "backend/internal/service/openai_compact_fallback_test.go",
+ "backend/internal/service/openai_compact_model_mapping_test.go",
+ "backend/internal/service/openai_compact_stream_bridge.go",
+ "backend/internal/service/openai_compat_model_test.go",
+ "backend/internal/service/openai_compat_model.go",
+ "backend/internal/service/openai_compat_reasoning_fidelity_test.go",
+ "backend/internal/service/openai_continuation_diagnostic_forward_test.go",
+ "backend/internal/service/openai_continuation_diagnostic_test.go",
+ "backend/internal/service/openai_continuation_diagnostic.go",
+ "backend/internal/service/openai_cyber_policy_test.go",
+ "backend/internal/service/openai_cyber_session_block_test.go",
+ "backend/internal/service/openai_cyber_session_block.go",
+ "backend/internal/service/openai_embeddings_test.go",
+ "backend/internal/service/openai_embeddings.go",
+ "backend/internal/service/openai_encrypted_content_lineage_legacy_test.go",
+ "backend/internal/service/openai_failover_cached_body_test.go",
+ "backend/internal/service/openai_fast_policy_test.go",
+ "backend/internal/service/openai_gateway_403_reset_test.go",
+ "backend/internal/service/openai_gateway_anthropic_native_pump_test.go",
+ "backend/internal/service/openai_gateway_apikey_item_id_test.go",
+ "backend/internal/service/openai_gateway_cc_pipeline.go",
+ "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go",
+ "backend/internal/service/openai_gateway_chat_completions_cancellation_test.go",
+ "backend/internal/service/openai_gateway_chat_completions_raw_test.go",
+ "backend/internal/service/openai_gateway_chat_completions_raw.go",
+ "backend/internal/service/openai_gateway_chat_completions.go",
+ "backend/internal/service/openai_gateway_cn_fixes_test.go",
+ "backend/internal/service/openai_gateway_count_tokens_test.go",
+ "backend/internal/service/openai_gateway_count_tokens.go",
+ "backend/internal/service/openai_gateway_forward.go",
+ "backend/internal/service/openai_gateway_grok_405_test.go",
+ "backend/internal/service/openai_gateway_grok_chat_bridge.go",
+ "backend/internal/service/openai_gateway_grok_test.go",
+ "backend/internal/service/openai_gateway_grok.go",
+ "backend/internal/service/openai_gateway_merge_contract_test.go",
+ "backend/internal/service/openai_gateway_messages_anthropic_native.go",
+ "backend/internal/service/openai_gateway_messages_chat_fallback_test.go",
+ "backend/internal/service/openai_gateway_messages_chat_fallback.go",
+ "backend/internal/service/openai_gateway_messages.go",
+ "backend/internal/service/openai_gateway_model_availability.go",
+ "backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go",
+ "backend/internal/service/openai_gateway_passthrough_flush_test.go",
+ "backend/internal/service/openai_gateway_passthrough.go",
+ "backend/internal/service/openai_gateway_record_usage_test.go",
+ "backend/internal/service/openai_gateway_request_body_reasoning_test.go",
+ "backend/internal/service/openai_gateway_request_body.go",
+ "backend/internal/service/openai_gateway_response_flush_test.go",
+ "backend/internal/service/openai_gateway_response_handling.go",
+ "backend/internal/service/openai_gateway_responses_anthropic_native.go",
+ "backend/internal/service/openai_gateway_responses_chat_fallback_test.go",
+ "backend/internal/service/openai_gateway_responses_chat_fallback.go",
+ "backend/internal/service/openai_gateway_responses_client_tools_test.go",
+ "backend/internal/service/openai_gateway_scheduling.go",
+ "backend/internal/service/openai_gateway_search_surcharge_test.go",
+ "backend/internal/service/openai_gateway_service_codex_cli_only_test.go",
+ "backend/internal/service/openai_gateway_service_codex_snapshot_test.go",
+ "backend/internal/service/openai_gateway_service_hotpath_test.go",
+ "backend/internal/service/openai_gateway_service_test.go",
+ "backend/internal/service/openai_gateway_service_tool_correction_test.go",
+ "backend/internal/service/openai_gateway_service.go",
+ "backend/internal/service/openai_gateway_turn_state_commit_test.go",
+ "backend/internal/service/openai_gateway_upstream_errors_test.go",
+ "backend/internal/service/openai_gateway_upstream_errors.go",
+ "backend/internal/service/openai_gateway_usage.go",
+ "backend/internal/service/openai_guardian_affinity_test.go",
+ "backend/internal/service/openai_http_terminal_boundary_test.go",
+ "backend/internal/service/openai_image_generation_controls_test.go",
+ "backend/internal/service/openai_images_incomplete_test.go",
+ "backend/internal/service/openai_images_responses.go",
+ "backend/internal/service/openai_images_test.go",
+ "backend/internal/service/openai_images.go",
+ "backend/internal/service/openai_lineage_regression_helpers_test.go",
+ "backend/internal/service/openai_live.go",
+ "backend/internal/service/openai_model_mapping_test.go",
+ "backend/internal/service/openai_model_mapping.go",
+ "backend/internal/service/openai_model_not_supported_test.go",
+ "backend/internal/service/openai_model_not_supported.go",
+ "backend/internal/service/openai_model_reasoning_alias_test.go",
+ "backend/internal/service/openai_models_list.go",
+ "backend/internal/service/openai_named_output_namespace_test.go",
+ "backend/internal/service/openai_namespace_roundtrip_test.go",
+ "backend/internal/service/openai_native_fidelity_test.go",
+ "backend/internal/service/openai_oauth_429_classifier_test.go",
+ "backend/internal/service/openai_oauth_429_classifier.go",
+ "backend/internal/service/openai_oauth_passthrough_test.go",
+ "backend/internal/service/openai_oauth_service.go",
+ "backend/internal/service/openai_official_http_failover_test.go",
+ "backend/internal/service/openai_official_http_failover.go",
+ "backend/internal/service/openai_official_http_state_test.go",
+ "backend/internal/service/openai_ollama_continuation_test.go",
+ "backend/internal/service/openai_passthrough_normalization_test.go",
+ "backend/internal/service/openai_previous_response_id_test.go",
+ "backend/internal/service/openai_previous_response_id.go",
+ "backend/internal/service/openai_quota_auto_reset.go",
+ "backend/internal/service/openai_quota_service.go",
+ "backend/internal/service/openai_raw_stream_truncation.go",
+ "backend/internal/service/openai_reasoning_configuration_test.go",
+ "backend/internal/service/openai_reasoning_effort_candidates_test.go",
+ "backend/internal/service/openai_reasoning_effort_policy.go",
+ "backend/internal/service/openai_reasoning_recovery_integration_test.go",
+ "backend/internal/service/openai_reasoning_recovery_terminal_test.go",
+ "backend/internal/service/openai_reasoning_recovery_test.go",
+ "backend/internal/service/openai_reasoning_recovery.go",
+ "backend/internal/service/openai_reasoning_state_cache_test.go",
+ "backend/internal/service/openai_reasoning_state_cache.go",
+ "backend/internal/service/openai_reasoning_summary_compat_test.go",
+ "backend/internal/service/openai_refusal_recovery_json.go",
+ "backend/internal/service/openai_refusal_recovery_pipeline_test.go",
+ "backend/internal/service/openai_refusal_recovery_request_test.go",
+ "backend/internal/service/openai_refusal_recovery_request.go",
+ "backend/internal/service/openai_refusal_recovery_stream_test.go",
+ "backend/internal/service/openai_refusal_recovery_stream.go",
+ "backend/internal/service/openai_refusal_recovery_test.go",
+ "backend/internal/service/openai_refusal_recovery_ws_test.go",
+ "backend/internal/service/openai_refusal_recovery_ws.go",
+ "backend/internal/service/openai_refusal_recovery.go",
+ "backend/internal/service/openai_request_capabilities.go",
+ "backend/internal/service/openai_request_integrity_semantics.go",
+ "backend/internal/service/openai_request_integrity.go",
+ "backend/internal/service/openai_responses_ingress_compat_test.go",
+ "backend/internal/service/openai_responses_ingress_compat.go",
+ "backend/internal/service/openai_responses_input_compat_test.go",
+ "backend/internal/service/openai_responses_input_compat.go",
+ "backend/internal/service/openai_responses_namespace_forward_test.go",
+ "backend/internal/service/openai_responses_namespace.go",
+ "backend/internal/service/openai_responses_rejected_field_retry_test.go",
+ "backend/internal/service/openai_responses_tool_schema_test.go",
+ "backend/internal/service/openai_satellite_policy_test.go",
+ "backend/internal/service/openai_setup_token_compat_test.go",
+ "backend/internal/service/openai_sse_data.go",
+ "backend/internal/service/openai_sticky_compat_test.go",
+ "backend/internal/service/openai_sticky_compat.go",
+ "backend/internal/service/openai_token_provider.go",
+ "backend/internal/service/openai_tool_continuation_test.go",
+ "backend/internal/service/openai_tool_continuation.go",
+ "backend/internal/service/openai_upstream_client_error_test.go",
+ "backend/internal/service/openai_upstream_transport_error_handle_test.go",
+ "backend/internal/service/openai_upstream_transport_error.go",
+ "backend/internal/service/openai_v183_compat.go",
+ "backend/internal/service/openai_ws_account_sticky_test.go",
+ "backend/internal/service/openai_ws_client.go",
+ "backend/internal/service/openai_ws_fallback_test.go",
+ "backend/internal/service/openai_ws_forwarder_client_cancel_test.go",
+ "backend/internal/service/openai_ws_forwarder_ingress_session_test.go",
+ "backend/internal/service/openai_ws_forwarder_ingress_test.go",
+ "backend/internal/service/openai_ws_forwarder_ingress.go",
+ "backend/internal/service/openai_ws_forwarder_logutil.go",
+ "backend/internal/service/openai_ws_forwarder_payload.go",
+ "backend/internal/service/openai_ws_forwarder_retry_payload_test.go",
+ "backend/internal/service/openai_ws_forwarder_success_test.go",
+ "backend/internal/service/openai_ws_forwarder_support.go",
+ "backend/internal/service/openai_ws_forwarder_v2.go",
+ "backend/internal/service/openai_ws_forwarder.go",
+ "backend/internal/service/openai_ws_http_bridge_isolation_test.go",
+ "backend/internal/service/openai_ws_http_bridge_resume_test.go",
+ "backend/internal/service/openai_ws_http_bridge_test.go",
+ "backend/internal/service/openai_ws_http_bridge.go",
+ "backend/internal/service/openai_ws_protocol_forward_test.go",
+ "backend/internal/service/openai_ws_reasoning_mode_model_test.go",
+ "backend/internal/service/openai_ws_state_fidelity_test.go",
+ "backend/internal/service/openai_ws_state_store_test.go",
+ "backend/internal/service/openai_ws_state_store.go",
+ "backend/internal/service/openai_ws_v2_passthrough_adapter_effort_test.go",
+ "backend/internal/service/openai_ws_v2_passthrough_adapter.go",
+ "backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go",
+ "backend/internal/service/ops_concurrency.go",
+ "backend/internal/service/ops_log_runtime_test.go",
+ "backend/internal/service/ops_metrics_collector_projection_test.go",
+ "backend/internal/service/ops_metrics_collector_test.go",
+ "backend/internal/service/ops_metrics_collector.go",
+ "backend/internal/service/ops_queue_sanitize_test.go",
+ "backend/internal/service/ops_service_user_error_test.go",
+ "backend/internal/service/ops_service.go",
+ "backend/internal/service/ops_system_log_service_test.go",
+ "backend/internal/service/ops_upstream_context.go",
+ "backend/internal/service/ops_user_error_test.go",
+ "backend/internal/service/payment_order_result_test.go",
+ "backend/internal/service/pricing_named_family_test.go",
+ "backend/internal/service/pricing_service.go",
+ "backend/internal/service/profit_preview_test.go",
+ "backend/internal/service/profit_preview.go",
+ "backend/internal/service/provider_identity_admin_test.go",
+ "backend/internal/service/provider_identity_test.go",
+ "backend/internal/service/provider_identity.go",
+ "backend/internal/service/reasoning_fidelity_control_test.go",
+ "backend/internal/service/reasoning_fidelity_live_test.go",
+ "backend/internal/service/reasoning_fidelity_parse_test.go",
+ "backend/internal/service/reasoning_fidelity_support_test.go",
+ "backend/internal/service/reasoning_replay_diagnostic_control_test.go",
+ "backend/internal/service/reasoning_replay_diagnostic_live_test.go",
+ "backend/internal/service/reasoning_replay_diagnostic_parse_test.go",
+ "backend/internal/service/reasoning_replay_diagnostic_support_test.go",
+ "backend/internal/service/scheduled_test_port.go",
+ "backend/internal/service/scheduler_snapshot_bulk_event_test.go",
+ "backend/internal/service/scheduler_snapshot_group_lifecycle_test.go",
+ "backend/internal/service/scheduler_snapshot_service.go",
+ "backend/internal/service/setting_gateway_runtime_refusal_test.go",
+ "backend/internal/service/setting_gateway_runtime.go",
+ "backend/internal/service/setting_parse.go",
+ "backend/internal/service/setting_public.go",
+ "backend/internal/service/setting_service_platform_quota_test.go",
+ "backend/internal/service/setting_service.go",
+ "backend/internal/service/setting_update.go",
+ "backend/internal/service/settings_view.go",
+ "backend/internal/service/temp_unsched.go",
+ "backend/internal/service/testdata/account_available_models_contract.json",
+ "backend/internal/service/testdata/codexrip_reverse_skill_system_prompt_legacy_dual_marker.txt",
+ "backend/internal/service/timing_wheel_service_test.go",
+ "backend/internal/service/token_refresh_service_candidates_test.go",
+ "backend/internal/service/token_refresh_service_test.go",
+ "backend/internal/service/token_refresh_service.go",
+ "backend/internal/service/update_service_test.go",
+ "backend/internal/service/update_service.go",
+ "backend/internal/service/upstream_models_capacity_sync_test.go",
+ "backend/internal/service/upstream_models_test.go",
+ "backend/internal/service/upstream_models.go",
+ "backend/internal/service/usage_billing.go",
+ "backend/internal/service/wire_test.go",
+ "backend/internal/service/wire.go",
+ "backend/internal/testutil/stubs.go",
+ "backend/internal/web/agent_route_ownership_test.go",
+ "backend/internal/web/embed_on.go",
+ "backend/internal/web/embed_test.go",
+ "backend/internal/web/frontend_routes.go",
+ "backend/internal/web/testdata/agent_cache_fixture.js",
+ "backend/resources/model-pricing/model_prices_and_context_window.json",
+ "DOWNSTREAM.md",
+ "frontend/index.html",
+ "frontend/package.json",
+ "frontend/pnpm-lock.yaml",
+ "frontend/src/__tests__/integration/data-import.spec.ts",
+ "frontend/src/api/__tests__/accountOperationIdempotency.spec.ts",
+ "frontend/src/api/__tests__/admin.accounts.contextCapacity.spec.ts",
+ "frontend/src/api/__tests__/admin.accounts.jobs.spec.ts",
+ "frontend/src/api/__tests__/client.spec.ts",
+ "frontend/src/api/__tests__/downstreamStatus.spec.ts",
+ "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts",
+ "frontend/src/api/accountOperationIdempotency.ts",
+ "frontend/src/api/admin/accounts.ts",
+ "frontend/src/api/admin/codexTickets.ts",
+ "frontend/src/api/admin/groups.ts",
+ "frontend/src/api/admin/index.ts",
+ "frontend/src/api/admin/settings.ts",
+ "frontend/src/api/admin/system.ts",
+ "frontend/src/api/admin/users.ts",
+ "frontend/src/api/client.ts",
+ "frontend/src/api/downstreamStatus.ts",
+ "frontend/src/App.vue",
+ "frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts",
+ "frontend/src/components/account/__tests__/AccountUsageCell.list.spec.ts",
+ "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts",
+ "frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts",
+ "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts",
+ "frontend/src/components/account/__tests__/credentialsBuilder.spec.ts",
+ "frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts",
+ "frontend/src/components/account/__tests__/EditAccountModal.spec.ts",
+ "frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts",
+ "frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts",
+ "frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts",
+ "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts",
+ "frontend/src/components/account/AccountCapacityCell.vue",
+ "frontend/src/components/account/AccountGroupsCell.vue",
+ "frontend/src/components/account/AccountIdentityBadges.vue",
+ "frontend/src/components/account/AccountQuotaInfo.vue",
+ "frontend/src/components/account/AccountSelectionCheckbox.vue",
+ "frontend/src/components/account/AccountStatsModal.vue",
+ "frontend/src/components/account/AccountStatusIndicator.vue",
+ "frontend/src/components/account/AccountTodayStatsCell.vue",
+ "frontend/src/components/account/AccountUsageCell.vue",
+ "frontend/src/components/account/BulkEditAccountModal.vue",
+ "frontend/src/components/account/CapacityBadge.vue",
+ "frontend/src/components/account/CnBaseUrlPresets.vue",
+ "frontend/src/components/account/CNProviderBalanceCell.vue",
+ "frontend/src/components/account/CNProviderQuotaCell.vue",
+ "frontend/src/components/account/CreateAccountModal.vue",
+ "frontend/src/components/account/credentialsBuilder.ts",
+ "frontend/src/components/account/EditAccountModal.vue",
+ "frontend/src/components/account/GrokBaseUrlPresets.vue",
+ "frontend/src/components/account/GrokQuotaProbeCell.vue",
+ "frontend/src/components/account/HeaderOverrideEditor.vue",
+ "frontend/src/components/account/HeaderOverrideJsonTools.vue",
+ "frontend/src/components/account/ModelContextCapacityField.vue",
+ "frontend/src/components/account/ModelWhitelistSelector.vue",
+ "frontend/src/components/account/OAuthAuthorizationFlow.vue",
+ "frontend/src/components/account/OllamaCloudUsageCell.vue",
+ "frontend/src/components/account/OllamaCloudUsageSettings.vue",
+ "frontend/src/components/account/OpenAIQuotaResetCell.vue",
+ "frontend/src/components/account/OpenAIReasoningPolicyFields.vue",
+ "frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue",
+ "frontend/src/components/account/QuotaBadge.vue",
+ "frontend/src/components/account/QuotaLimitCard.vue",
+ "frontend/src/components/account/QuotaNotifyToggle.vue",
+ "frontend/src/components/account/ReAuthAccountModal.vue",
+ "frontend/src/components/account/SyncFromCrsModal.vue",
+ "frontend/src/components/account/TempUnschedStatusModal.vue",
+ "frontend/src/components/account/UpstreamBillingRateCell.vue",
+ "frontend/src/components/account/UpstreamRequestIdHeaderField.vue",
+ "frontend/src/components/account/UsageProgressBar.vue",
+ "frontend/src/components/admin/account/__tests__/AccountActionMenu.position.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountDetailsDrawer.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountUsageViews.spec.ts",
+ "frontend/src/components/admin/account/__tests__/ImportDataModal.spec.ts",
+ "frontend/src/components/admin/account/AccountActionMenu.vue",
+ "frontend/src/components/admin/account/AccountBulkActionsBar.vue",
+ "frontend/src/components/admin/account/AccountCardGrid.vue",
+ "frontend/src/components/admin/account/AccountCompactList.vue",
+ "frontend/src/components/admin/account/AccountDetailsDrawer.vue",
+ "frontend/src/components/admin/account/AccountImportSettingsEditor.vue",
+ "frontend/src/components/admin/account/AccountStatsModal.vue",
+ "frontend/src/components/admin/account/AccountTableFilters.vue",
+ "frontend/src/components/admin/account/AccountTextTestPrompt.vue",
+ "frontend/src/components/admin/account/AccountViewModeSwitcher.vue",
+ "frontend/src/components/admin/account/ImportDataModal.vue",
+ "frontend/src/components/admin/account/ReAuthAccountModal.vue",
+ "frontend/src/components/admin/account/ScheduledTestsPanel.vue",
+ "frontend/src/components/admin/AdminComplianceDialog.vue",
+ "frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue",
+ "frontend/src/components/admin/channel/IntervalRow.vue",
+ "frontend/src/components/admin/channel/ModelTagInput.vue",
+ "frontend/src/components/admin/channel/PricingEntryCard.vue",
+ "frontend/src/components/admin/channel/TimePricingSection.vue",
+ "frontend/src/components/admin/ErrorPassthroughRulesModal.vue",
+ "frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts",
+ "frontend/src/components/admin/group/CodexManifestAccountsField.vue",
+ "frontend/src/components/admin/group/GroupRateMultipliersModal.vue",
+ "frontend/src/components/admin/group/GroupRPMOverridesModal.vue",
+ "frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue",
+ "frontend/src/components/admin/monitor/MonitorActionsCell.vue",
+ "frontend/src/components/admin/monitor/MonitorAdvancedRequestConfig.vue",
+ "frontend/src/components/admin/monitor/MonitorFiltersBar.vue",
+ "frontend/src/components/admin/monitor/MonitorFormDialog.vue",
+ "frontend/src/components/admin/monitor/MonitorKeyPickerDialog.vue",
+ "frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue",
+ "frontend/src/components/admin/monitor/MonitorRunResultDialog.vue",
+ "frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue",
+ "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue",
+ "frontend/src/components/admin/payment/AdminOrderDetail.vue",
+ "frontend/src/components/admin/payment/AdminOrderTable.vue",
+ "frontend/src/components/admin/payment/AdminRefundDialog.vue",
+ "frontend/src/components/admin/payment/OrderStatsCards.vue",
+ "frontend/src/components/admin/payment/TopUsersLeaderboard.vue",
+ "frontend/src/components/admin/proxy/ImportDataModal.vue",
+ "frontend/src/components/admin/subscription/BulkSubscriptionActionDialog.vue",
+ "frontend/src/components/admin/TLSFingerprintProfilesModal.vue",
+ "frontend/src/components/admin/usage/UsageCleanupDialog.vue",
+ "frontend/src/components/admin/usage/UsageFilters.vue",
+ "frontend/src/components/admin/usage/UsageStatsCards.vue",
+ "frontend/src/components/admin/usage/UsageTable.vue",
+ "frontend/src/components/admin/usage/UserTokenRanking.vue",
+ "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts",
+ "frontend/src/components/admin/user/GroupReplaceModal.vue",
+ "frontend/src/components/admin/user/UserAllowedGroupsModal.vue",
+ "frontend/src/components/admin/user/UserApiKeysModal.vue",
+ "frontend/src/components/admin/user/UserBalanceHistoryModal.vue",
+ "frontend/src/components/admin/user/UserBalanceModal.vue",
+ "frontend/src/components/admin/user/UserEditModal.vue",
+ "frontend/src/components/admin/user/UserPlatformQuotaModal.vue",
+ "frontend/src/components/AliyunCaptchaWidget.vue",
+ "frontend/src/components/auth/LoginAgreementPrompt.vue",
+ "frontend/src/components/auth/TotpLoginModal.vue",
+ "frontend/src/components/auth/TotpStepUpDialog.vue",
+ "frontend/src/components/channels/AvailableChannelsTable.vue",
+ "frontend/src/components/channels/SupportedModelChip.vue",
+ "frontend/src/components/charts/EndpointDistributionChart.vue",
+ "frontend/src/components/charts/GroupDistributionChart.vue",
+ "frontend/src/components/charts/ModelDistributionChart.vue",
+ "frontend/src/components/charts/TokenUsageTrend.vue",
+ "frontend/src/components/charts/UserBreakdownSubTable.vue",
+ "frontend/src/components/common/__tests__/BaseDialog.spec.ts",
+ "frontend/src/components/common/__tests__/DataTable.spec.ts",
+ "frontend/src/components/common/__tests__/PlatformIcon.spec.ts",
+ "frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts",
+ "frontend/src/components/common/__tests__/VersionBadge.downstream.spec.ts",
+ "frontend/src/components/common/AnnouncementBell.vue",
+ "frontend/src/components/common/AnnouncementPopup.vue",
+ "frontend/src/components/common/AutoRefreshButton.vue",
+ "frontend/src/components/common/BaseDialog.vue",
+ "frontend/src/components/common/ConfirmDialog.vue",
+ "frontend/src/components/common/DataTable.vue",
+ "frontend/src/components/common/DateRangePicker.vue",
+ "frontend/src/components/common/dialogStack.ts",
+ "frontend/src/components/common/EmptyState.vue",
+ "frontend/src/components/common/ExportProgressDialog.vue",
+ "frontend/src/components/common/GroupBadge.vue",
+ "frontend/src/components/common/GroupCapacityBadge.vue",
+ "frontend/src/components/common/GroupOptionItem.vue",
+ "frontend/src/components/common/GroupSelector.vue",
+ "frontend/src/components/common/HelpTooltip.vue",
+ "frontend/src/components/common/ImageUpload.vue",
+ "frontend/src/components/common/Input.vue",
+ "frontend/src/components/common/IpGeoBatchToolbar.vue",
+ "frontend/src/components/common/IpGeoCell.vue",
+ "frontend/src/components/common/LoadingSpinner.vue",
+ "frontend/src/components/common/LocaleSwitcher.vue",
+ "frontend/src/components/common/ModelIcon.vue",
+ "frontend/src/components/common/MonitorQuotaView.vue",
+ "frontend/src/components/common/NavigationProgress.vue",
+ "frontend/src/components/common/Pagination.vue",
+ "frontend/src/components/common/PlatformIcon.vue",
+ "frontend/src/components/common/PlatformTypeBadge.vue",
+ "frontend/src/components/common/ProxySelector.vue",
+ "frontend/src/components/common/Select.vue",
+ "frontend/src/components/common/Skeleton.vue",
+ "frontend/src/components/common/SubscriptionProgressMini.vue",
+ "frontend/src/components/common/Toast.vue",
+ "frontend/src/components/common/Toggle.vue",
+ "frontend/src/components/common/VersionBadge.vue",
+ "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts",
+ "frontend/src/components/keys/EndpointPopover.vue",
+ "frontend/src/components/keys/UseKeyModal.vue",
+ "frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts",
+ "frontend/src/components/layout/__tests__/AppSidebar.spec.ts",
+ "frontend/src/components/layout/AppHeader.vue",
+ "frontend/src/components/layout/AppLayout.vue",
+ "frontend/src/components/layout/AppSidebar.vue",
+ "frontend/src/components/layout/AuthLayout.vue",
+ "frontend/src/components/layout/TablePageLayout.vue",
+ "frontend/src/components/modelPlaza/ModelPlazaContent.vue",
+ "frontend/src/components/modelPlaza/PlazaFilterBar.vue",
+ "frontend/src/components/modelPlaza/PlazaGroupSection.vue",
+ "frontend/src/components/modelPlaza/PlazaModelPricingTable.vue",
+ "frontend/src/components/modelPlaza/PlazaNavBar.vue",
+ "frontend/src/components/payment/AmountInput.vue",
+ "frontend/src/components/payment/OrderStatusBadge.vue",
+ "frontend/src/components/payment/PaymentMethodSelector.vue",
+ "frontend/src/components/payment/PaymentProviderDialog.vue",
+ "frontend/src/components/payment/PaymentProviderList.vue",
+ "frontend/src/components/payment/PaymentQRDialog.vue",
+ "frontend/src/components/payment/PaymentStatusPanel.vue",
+ "frontend/src/components/payment/ProviderCard.vue",
+ "frontend/src/components/payment/StripePaymentInline.vue",
+ "frontend/src/components/payment/SubscriptionPlanCard.vue",
+ "frontend/src/components/payment/ToggleSwitch.vue",
+ "frontend/src/components/TurnstileWidget.vue",
+ "frontend/src/components/user/dashboard/UserDashboardCharts.vue",
+ "frontend/src/components/user/dashboard/UserDashboardQuickActions.vue",
+ "frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue",
+ "frontend/src/components/user/dashboard/UserDashboardStats.vue",
+ "frontend/src/components/user/monitor/MonitorCard.vue",
+ "frontend/src/components/user/monitor/MonitorCardGrid.vue",
+ "frontend/src/components/user/monitor/MonitorHero.vue",
+ "frontend/src/components/user/monitor/MonitorMetricPair.vue",
+ "frontend/src/components/user/monitor/MonitorTimeline.vue",
+ "frontend/src/components/user/MonitorDetailDialog.vue",
+ "frontend/src/components/user/PlatformCostCell.vue",
+ "frontend/src/components/user/PlatformUsageBreakdown.vue",
+ "frontend/src/components/user/profile/ProfileAvatarCard.vue",
+ "frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue",
+ "frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue",
+ "frontend/src/components/user/profile/ProfileInfoCard.vue",
+ "frontend/src/components/user/profile/ProfilePasskeyCard.vue",
+ "frontend/src/components/user/profile/TotpDisableDialog.vue",
+ "frontend/src/components/user/profile/TotpSetupModal.vue",
+ "frontend/src/components/user/UserAttributeForm.vue",
+ "frontend/src/components/user/UserAttributesConfigModal.vue",
+ "frontend/src/components/user/UserConcurrencyCell.vue",
+ "frontend/src/components/user/UserErrorDetailModal.vue",
+ "frontend/src/components/user/UserErrorRequestsTable.vue",
+ "frontend/src/components/user/UserPlatformQuotaCell.vue",
+ "frontend/src/composables/__tests__/useModelContextCapacities.spec.ts",
+ "frontend/src/composables/useModelContextCapacities.ts",
+ "frontend/src/composables/useModelWhitelist.ts",
+ "frontend/src/constants/__tests__/platforms.spec.ts",
+ "frontend/src/constants/platforms.ts",
+ "frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue",
+ "frontend/src/features/channel-monitor-v2/MetricCell.vue",
+ "frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue",
+ "frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue",
+ "frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue",
+ "frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts",
+ "frontend/src/i18n/locales/en/admin/accounts.ts",
+ "frontend/src/i18n/locales/en/admin/accountTasks.ts",
+ "frontend/src/i18n/locales/en/admin/index.ts",
+ "frontend/src/i18n/locales/en/admin/overview.ts",
+ "frontend/src/i18n/locales/en/admin/settings.ts",
+ "frontend/src/i18n/locales/en/common.ts",
+ "frontend/src/i18n/locales/en/index.ts",
+ "frontend/src/i18n/locales/en/misc.ts",
+ "frontend/src/i18n/locales/zh/admin/accounts.ts",
+ "frontend/src/i18n/locales/zh/admin/accountTasks.ts",
+ "frontend/src/i18n/locales/zh/admin/index.ts",
+ "frontend/src/i18n/locales/zh/admin/overview.ts",
+ "frontend/src/i18n/locales/zh/admin/settings.ts",
+ "frontend/src/i18n/locales/zh/common.ts",
+ "frontend/src/i18n/locales/zh/index.ts",
+ "frontend/src/i18n/locales/zh/misc.ts",
+ "frontend/src/router/__tests__/feature-access.spec.ts",
+ "frontend/src/router/index.ts",
+ "frontend/src/router/meta.d.ts",
+ "frontend/src/stores/__tests__/app.spec.ts",
+ "frontend/src/stores/app.ts",
+ "frontend/src/stores/index.ts",
+ "frontend/src/types/index.ts",
+ "frontend/src/utils/__tests__/modelContextCapacity.spec.ts",
+ "frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts",
+ "frontend/src/utils/accountImportParser.ts",
+ "frontend/src/utils/accountImportWorker.ts",
+ "frontend/src/utils/accountPresentation.ts",
+ "frontend/src/utils/accountQuotaWindows.ts",
+ "frontend/src/utils/accountTestModels.ts",
+ "frontend/src/utils/featureFlags.ts",
+ "frontend/src/utils/keyGroupProviders.ts",
+ "frontend/src/utils/modelContextCapacity.ts",
+ "frontend/src/utils/openaiReasoningPolicy.ts",
+ "frontend/src/utils/platformColors.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.console.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts",
+ "frontend/src/views/admin/__tests__/AccountTasksView.spec.ts",
+ "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts",
+ "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts",
+ "frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts",
+ "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts",
+ "frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts",
+ "frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts",
+ "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts",
+ "frontend/src/views/admin/__tests__/SettingsView.spec.ts",
+ "frontend/src/views/admin/AccountsView.vue",
+ "frontend/src/views/admin/AccountTasksView.vue",
+ "frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue",
+ "frontend/src/views/admin/AnnouncementsView.vue",
+ "frontend/src/views/admin/AuditLogView.vue",
+ "frontend/src/views/admin/BackupView.vue",
+ "frontend/src/views/admin/ChannelMonitorView.vue",
+ "frontend/src/views/admin/ChannelsView.vue",
+ "frontend/src/views/admin/DashboardView.vue",
+ "frontend/src/views/admin/groupModelAllowlist.ts",
+ "frontend/src/views/admin/groupsProfitControl.ts",
+ "frontend/src/views/admin/GroupsView.vue",
+ "frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue",
+ "frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue",
+ "frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue",
+ "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue",
+ "frontend/src/views/admin/ops/components/OpsDashboardSkeleton.vue",
+ "frontend/src/views/admin/ops/components/OpsEmailNotificationCard.vue",
+ "frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue",
+ "frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue",
+ "frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue",
+ "frontend/src/views/admin/ops/components/OpsErrorLogTable.vue",
+ "frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue",
+ "frontend/src/views/admin/ops/components/OpsLatencyChart.vue",
+ "frontend/src/views/admin/ops/components/OpsOpenAITokenStatsCard.vue",
+ "frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue",
+ "frontend/src/views/admin/ops/components/OpsRuntimeSettingsCard.vue",
+ "frontend/src/views/admin/ops/components/OpsSettingsDialog.vue",
+ "frontend/src/views/admin/ops/components/OpsSwitchRateTrendChart.vue",
+ "frontend/src/views/admin/ops/components/OpsSystemLogTable.vue",
+ "frontend/src/views/admin/ops/components/OpsThroughputTrendChart.vue",
+ "frontend/src/views/admin/ops/OpsDashboard.vue",
+ "frontend/src/views/admin/orders/AdminOrdersView.vue",
+ "frontend/src/views/admin/orders/AdminPaymentDashboardView.vue",
+ "frontend/src/views/admin/orders/AdminPaymentPlansView.vue",
+ "frontend/src/views/admin/orders/PlanEditDialog.vue",
+ "frontend/src/views/admin/PluginsView.vue",
+ "frontend/src/views/admin/PromoCodesView.vue",
+ "frontend/src/views/admin/ProxiesView.vue",
+ "frontend/src/views/admin/RedeemView.vue",
+ "frontend/src/views/admin/RiskControlView.vue",
+ "frontend/src/views/admin/settings/EmailTemplateEditor.vue",
+ "frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue",
+ "frontend/src/views/admin/SettingsView.vue",
+ "frontend/src/views/admin/SubscriptionsView.vue",
+ "frontend/src/views/admin/UsageView.vue",
+ "frontend/src/views/admin/UsersView.vue",
+ "frontend/src/views/auth/DingTalkCallbackView.vue",
+ "frontend/src/views/auth/EmailVerifyView.vue",
+ "frontend/src/views/auth/ForgotPasswordView.vue",
+ "frontend/src/views/auth/LinuxDoCallbackView.vue",
+ "frontend/src/views/auth/LoginView.vue",
+ "frontend/src/views/auth/OidcCallbackView.vue",
+ "frontend/src/views/auth/RegisterView.vue",
+ "frontend/src/views/auth/ResetPasswordView.vue",
+ "frontend/src/views/auth/WechatCallbackView.vue",
+ "frontend/src/views/auth/WechatPaymentCallbackView.vue",
+ "frontend/src/views/HomeView.vue",
+ "frontend/src/views/KeyUsageView.vue",
+ "frontend/src/views/NotFoundView.vue",
+ "frontend/src/views/public/LegalDocumentView.vue",
+ "frontend/src/views/setup/SetupWizardView.vue",
+ "frontend/src/views/user/AffiliateView.vue",
+ "frontend/src/views/user/AvailableChannelsView.vue",
+ "frontend/src/views/user/BatchImageGuideView.vue",
+ "frontend/src/views/user/ChannelStatusV2View.vue",
+ "frontend/src/views/user/CustomPageView.vue",
+ "frontend/src/views/user/KeysView.vue",
+ "frontend/src/views/user/PaymentQRCodeView.vue",
+ "frontend/src/views/user/PaymentResultView.vue",
+ "frontend/src/views/user/PaymentView.vue",
+ "frontend/src/views/user/ProfileView.vue",
+ "frontend/src/views/user/RedeemView.vue",
+ "frontend/src/views/user/StripePaymentView.vue",
+ "frontend/src/views/user/StripePopupView.vue",
+ "frontend/src/views/user/SubscriptionsView.vue",
+ "frontend/src/views/user/UsageView.vue",
+ "frontend/src/views/user/UserOrdersView.vue",
+ "frontend/src/workers/accountImport.worker.ts",
+ "frontend/tailwind.config.js",
+ "frontend/vite.config.ts",
+ "Makefile",
+ "README_CN.md",
+ "README_JA.md",
+ "README.md",
+ "THIRD_PARTY_NOTICES.md"
+ ],
+ "core-extension-and-job-host": [
+ ".github/scripts/test_first_party_plugins.sh",
+ "backend/cmd/package-plugin/main.go",
+ "backend/internal/handler/admin/account_job_execution_test.go",
+ "backend/internal/handler/admin/account_job_execution.go",
+ "backend/internal/handler/admin/account_job_handler.go",
+ "backend/internal/handler/admin/account_job_submit_test.go",
+ "backend/internal/handler/admin/plugin_handler.go",
+ "backend/internal/repository/account_job_repo_test.go",
+ "backend/internal/repository/account_job_repo.go",
+ "backend/internal/repository/account_plugin_projection.go",
+ "backend/internal/repository/plugin_bundle_integration_test.go",
+ "backend/internal/repository/plugin_bundle.go",
+ "backend/internal/repository/plugin_extension_state_integration_test.go",
+ "backend/internal/repository/plugin_extension_state.go",
+ "backend/internal/repository/plugin_legacy_import.go",
+ "backend/internal/repository/plugin_repo.go",
+ "backend/internal/server/routes/account_job_routes_test.go",
+ "backend/internal/service/account_job_test.go",
+ "backend/internal/service/account_job.go",
+ "backend/internal/service/openai_plugin_transport.go",
+ "backend/internal/service/plugin_bundle.go",
+ "backend/internal/service/plugin_catalog_test.go",
+ "backend/internal/service/plugin_compatibility.go",
+ "backend/internal/service/plugin_extension_accounts.go",
+ "backend/internal/service/plugin_extension_package_test.go",
+ "backend/internal/service/plugin_extension_runtime_test.go",
+ "backend/internal/service/plugin_extension_state.go",
+ "backend/internal/service/plugin_extensions_test.go",
+ "backend/internal/service/plugin_extensions.go",
+ "backend/internal/service/plugin_host_services.go",
+ "backend/internal/service/plugin_jobs.go",
+ "backend/internal/service/plugin_manager.go",
+ "backend/internal/service/plugin_manifest.go",
+ "backend/internal/service/plugin_package.go",
+ "backend/internal/service/plugin_provider.go",
+ "backend/internal/service/plugin_request.go",
+ "backend/internal/service/plugin_runtime.go",
+ "backend/internal/service/plugin_scheduling.go",
+ "backend/migrations/232_admin_account_jobs.sql",
+ "backend/migrations/246_plugin_extension_state.sql",
+ "backend/migrations/248_plugin_bundle_bootstrap.sql",
+ "backend/migrations/account_jobs_slim_migration_test.go",
+ "backend/pkg/extensionapi/proxy/address.go",
+ "backend/pkg/extensionapi/proxy/transport.go",
+ "backend/pkg/extensionapi/ui/bridge.js",
+ "backend/pkg/extensionapi/v1/api.go",
+ "backend/pkg/extensionapi/v1/catalog.go",
+ "backend/pkg/extensionapi/v1/rpc_test.go",
+ "backend/pkg/extensionapi/v1/rpc.go",
+ "backend/pkg/extensionapi/v1/runtime.go",
+ "backend/pkg/pluginapi/v1/manifest.schema.json",
+ "backend/pkg/pluginapi/v1/runtime.go",
+ "frontend/src/api/admin/__tests__/accountJobs.spec.ts",
+ "frontend/src/api/admin/accountJobs.ts",
+ "frontend/src/api/admin/plugins.ts",
+ "frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts",
+ "frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue",
+ "frontend/src/components/admin/account-jobs/AccountOperationDialog.vue",
+ "frontend/src/components/admin/account-jobs/AccountOperationProgress.vue",
+ "frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue",
+ "frontend/src/components/plugins/__tests__/ExtensionSlot.spec.ts",
+ "frontend/src/components/plugins/ExtensionDialog.vue",
+ "frontend/src/components/plugins/ExtensionSlot.vue",
+ "frontend/src/components/plugins/PluginFrame.vue",
+ "frontend/src/composables/__tests__/useAccountSelectionMetadata.spec.ts",
+ "frontend/src/composables/useAccountSelectionMetadata.ts",
+ "frontend/src/features/account-jobs/__tests__/integrationSurface.spec.ts",
+ "frontend/src/i18n/locales/en/admin/plugins.ts",
+ "frontend/src/i18n/locales/zh/admin/plugins.ts",
+ "frontend/src/stores/__tests__/accountJobs.spec.ts",
+ "frontend/src/stores/accountJobs.ts",
+ "frontend/src/stores/pluginExtensions.ts"
+ ],
+ "model-policy": [
+ ".github/scripts/test_model_context_capacity.sh",
+ "backend/internal/service/model_context_capacity_catalog_test_fixture_test.go",
+ "backend/internal/service/model_context_capacity_catalog_test.go",
+ "backend/internal/service/model_context_capacity_catalog.go",
+ "backend/internal/service/model_context_capacity_parser.go",
+ "backend/internal/service/model_context_capacity_reference_test.go",
+ "backend/internal/service/model_context_capacity_test.go",
+ "backend/internal/service/model_context_capacity.go",
+ "backend/internal/service/model_context_identity_test.go",
+ "backend/internal/service/model_context_identity.go",
+ "plugins/model-policy/catalog/catalog_data.go",
+ "plugins/model-policy/catalog/module_test.go",
+ "plugins/model-policy/catalog/module.go",
+ "plugins/model-policy/cmd/plugin/main.go",
+ "plugins/model-policy/go.mod",
+ "plugins/model-policy/manifest.source.json",
+ "plugins/model-policy/ui/assets/app.js",
+ "plugins/model-policy/ui/assets/styles.css",
+ "plugins/model-policy/ui/index.html"
+ ],
+ "core-data-and-migrations": [
+ "backend/ent/schema/account_folder.go",
+ "backend/ent/schema/account_tag_binding.go",
+ "backend/ent/schema/account_tag.go",
+ "backend/ent/schema/account.go",
+ "backend/ent/schema/api_key.go",
+ "backend/ent/schema/group.go",
+ "backend/ent/schema/system_prompt_runtime.go",
+ "backend/ent/schema/system_prompt_template_version.go",
+ "backend/ent/schema/system_prompt_template.go",
+ "backend/ent/schema/user_platform_quota.go",
+ "backend/migrations/230_account_credential_identities.sql",
+ "backend/migrations/237_add_minimax_platform.sql",
+ "backend/migrations/238_opencode_go_platform.sql",
+ "backend/migrations/239_codexrip_release_acceptance_api_key_leases.sql",
+ "backend/migrations/240_admin_account_capabilities.sql",
+ "backend/migrations/242_retire_public_model_manager.sql",
+ "backend/migrations/243_codex_fingerprint_device_default.sql",
+ "backend/migrations/account_credential_identity_migration_test.go",
+ "backend/migrations/minimax_platform_migration_test.go",
+ "backend/migrations/opencode_go_platform_migration_test.go",
+ "backend/migrations/release_acceptance_api_key_lease_migration_test.go"
+ ],
+ "image-tools": [
+ "backend/internal/config/image_studio_feature_flags.go",
+ "backend/internal/handler/image_studio_executor_test.go",
+ "backend/internal/handler/image_studio_executor.go",
+ "backend/internal/handler/image_studio_job_handler_test.go",
+ "backend/internal/handler/image_studio_job_handler.go",
+ "backend/internal/handler/openai_gateway_cindy_image_validation_test.go",
+ "backend/internal/repository/image_studio_job_repo_test.go",
+ "backend/internal/repository/image_studio_job_repo.go",
+ "backend/internal/service/cindy_image_pricing_test.go",
+ "backend/internal/service/image_studio_artifact_test.go",
+ "backend/internal/service/image_studio_artifact.go",
+ "backend/internal/service/image_studio_job_service_test.go",
+ "backend/internal/service/image_studio_job_service.go",
+ "backend/internal/service/image_studio_job_test.go",
+ "backend/internal/service/image_studio_job.go",
+ "backend/internal/service/image_studio_runtime_test.go",
+ "backend/internal/service/image_studio_runtime.go",
+ "backend/migrations/233_image_studio_jobs.sql",
+ "backend/migrations/image_studio_jobs_migration_test.go",
+ "frontend/src/api/__tests__/imageStudio.spec.ts",
+ "frontend/src/api/imageStudio.ts",
+ "frontend/src/features/image-studio/history.spec.ts",
+ "frontend/src/features/image-studio/history.ts",
+ "frontend/src/i18n/locales/en/imageStudio.ts",
+ "frontend/src/i18n/locales/zh/imageStudio.ts",
+ "frontend/src/views/user/__tests__/ImageStudioView.spec.ts",
+ "frontend/src/views/user/ImageStudioView.vue"
+ ],
+ "account-tools": [
+ "backend/internal/handler/admin/account_batch_test_test.go",
+ "backend/internal/handler/admin/account_batch_testing.go",
+ "backend/internal/handler/admin/account_taxonomy_handler_test.go",
+ "backend/internal/handler/admin/account_taxonomy.go",
+ "backend/internal/repository/account_taxonomy_integration_test.go",
+ "backend/internal/service/account_job_batch_test_runtime.go",
+ "backend/internal/service/account_test_models_test.go",
+ "backend/internal/service/account_test_prompt_test.go",
+ "backend/internal/service/account_test_prompt.go",
+ "backend/internal/service/account_test_reasoning_test.go",
+ "backend/internal/service/account_test_reasoning_wire_test.go",
+ "backend/internal/service/account_test_reasoning.go",
+ "backend/internal/service/account_test_service_cn_adaptive.go",
+ "backend/internal/service/account_test_service_openai_compact_test.go",
+ "backend/internal/service/account_test_service_openai_image_test.go",
+ "backend/internal/service/account_test_service_openai_test.go",
+ "backend/internal/service/account_test_service_opencode_go.go",
+ "backend/internal/service/account_test_service.go",
+ "backend/internal/service/admin_account_taxonomy_test.go",
+ "backend/internal/service/admin_account_taxonomy.go",
+ "backend/migrations/192_codexrip_account_taxonomy.sql",
+ "frontend/src/components/account/__tests__/AccountTestModal.spec.ts",
+ "frontend/src/components/account/AccountTestModal.vue",
+ "frontend/src/components/admin/account/__tests__/AccountBulkTaxonomyModal.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountFolderBar.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountTaxonomyManager.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts",
+ "frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts",
+ "frontend/src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts",
+ "frontend/src/components/admin/account/AccountBulkTaxonomyModal.vue",
+ "frontend/src/components/admin/account/AccountConsoleFilters.vue",
+ "frontend/src/components/admin/account/AccountFolderBar.vue",
+ "frontend/src/components/admin/account/AccountTaxonomyManager.vue",
+ "frontend/src/components/admin/account/AccountTestModal.vue",
+ "frontend/src/components/admin/account/AccountTestModelSelect.vue",
+ "frontend/src/components/admin/account/AccountTestReasoningSelect.vue",
+ "frontend/src/components/admin/account/BatchTestAccountModal.vue",
+ "frontend/src/composables/__tests__/useAccountTestPrompt.spec.ts",
+ "frontend/src/composables/useAccountTestPrompt.ts"
+ ],
+ "codex-runtime": [
+ "backend/internal/handler/admin/account_codex_ticket_test.go",
+ "backend/internal/handler/admin/account_codex_ticket.go",
+ "backend/internal/handler/admin/setting_codex_ticket_proxy.go",
+ "backend/internal/handler/admin/setting_handler_codex_ticket_test.go",
+ "backend/internal/repository/account_repo_codex_ticket_test.go",
+ "backend/internal/service/admin_account_codex_ticket_test.go",
+ "backend/internal/service/codex_ticket_lifecycle.go",
+ "backend/internal/service/codex_ticket_manual_test.go",
+ "backend/internal/service/codex_ticket_manual.go",
+ "backend/internal/service/codex_ticket_plugin.go",
+ "backend/internal/service/codex_ticket_proxy_test.go",
+ "backend/internal/service/codex_ticket_proxy.go",
+ "backend/internal/service/openai_codex_ticket_scheduler_test.go",
+ "backend/internal/service/openai_codex_ticket_test.go",
+ "backend/internal/service/openai_codex_ticket.go",
+ "backend/internal/service/setting_codex_ticket_test.go",
+ "backend/migrations/244_codex_ticket_lifecycle.sql",
+ "backend/migrations/245_codex_ticket_account_status_independent.sql",
+ "plugins/codex-runtime/cmd/plugin/main.go",
+ "plugins/codex-runtime/go.mod",
+ "plugins/codex-runtime/internal/tickets/cutover_test.go",
+ "plugins/codex-runtime/internal/tickets/identity_test.go",
+ "plugins/codex-runtime/internal/tickets/identity.go",
+ "plugins/codex-runtime/internal/tickets/module_test.go",
+ "plugins/codex-runtime/internal/tickets/module.go",
+ "plugins/codex-runtime/internal/tickets/outcome.go",
+ "plugins/codex-runtime/internal/tickets/proxy_test.go",
+ "plugins/codex-runtime/internal/tickets/proxy.go",
+ "plugins/codex-runtime/internal/tickets/state_test.go",
+ "plugins/codex-runtime/internal/tickets/state.go",
+ "plugins/codex-runtime/manifest.source.json",
+ "plugins/codex-runtime/ui/assets/app.js",
+ "plugins/codex-runtime/ui/assets/styles.css",
+ "plugins/codex-runtime/ui/index.html"
+ ],
+ "prompt-skills": [
+ "backend/internal/handler/remote_skill_handler_test.go",
+ "backend/internal/handler/remote_skill_handler.go",
+ "backend/internal/repository/business_system_prompt_repository_test.go",
+ "backend/internal/repository/business_system_prompt_repository.go",
+ "backend/internal/repository/business_system_prompt_revision_bus_test.go",
+ "backend/internal/repository/business_system_prompt_revision_bus.go",
+ "backend/internal/repository/remote_skill_registry_repository_test.go",
+ "backend/internal/repository/remote_skill_registry_repository.go",
+ "backend/internal/repository/remote_skill_registry_revision_bus.go",
+ "backend/internal/service/business_system_prompt_bundle_test.go",
+ "backend/internal/service/business_system_prompt_bundle.go",
+ "backend/internal/service/business_system_prompt_cache_identity_test.go",
+ "backend/internal/service/business_system_prompt_composition_test.go",
+ "backend/internal/service/business_system_prompt_composition.go",
+ "backend/internal/service/business_system_prompt_gateway_test.go",
+ "backend/internal/service/business_system_prompt_offline_integration_test.go",
+ "backend/internal/service/business_system_prompt_policy.go",
+ "backend/internal/service/business_system_prompt_service_test.go",
+ "backend/internal/service/business_system_prompt_service.go",
+ "backend/internal/service/business_system_prompt_test.go",
+ "backend/internal/service/openai_business_system_prompt_observability_test.go",
+ "backend/internal/service/openai_business_system_prompt_observability.go",
+ "backend/internal/service/openai_business_system_prompt.go",
+ "backend/internal/service/prompts/codexrip_reverse_skill_system_prompt.txt",
+ "backend/internal/service/prompts/gpt_5_6_instruct_v45.md",
+ "backend/internal/service/remote_skill_prompt_test.go",
+ "backend/internal/service/remote_skill_prompt.go",
+ "backend/internal/service/remote_skill_publication_test.go",
+ "backend/internal/service/remote_skill_publication.go",
+ "backend/internal/service/remote_skill_registry_filesystem_test.go",
+ "backend/internal/service/remote_skill_registry_filesystem.go",
+ "backend/internal/service/remote_skill_registry_manifest_test.go",
+ "backend/internal/service/remote_skill_registry_manifest.go",
+ "backend/internal/service/remote_skill_registry_source_provenance_test.go",
+ "backend/internal/service/remote_skill_registry_source_test.go",
+ "backend/internal/service/remote_skill_registry_source.go",
+ "backend/internal/service/remote_skill_registry_test.go",
+ "backend/internal/service/remote_skill_registry_validation.go",
+ "backend/internal/service/remote_skill_registry.go",
+ "backend/migrations/194_business_system_prompts.sql",
+ "backend/migrations/195_business_system_prompt_skill_bundles.sql",
+ "backend/migrations/196_business_system_prompt_remote_skill.sql",
+ "backend/migrations/197_remote_skill_registry.sql",
+ "backend/migrations/198_business_system_prompt_codex_skill_hybrid.sql",
+ "backend/migrations/199_business_system_prompt_managed_source.sql",
+ "backend/migrations/200_remote_skill_sources_and_legacy_cleanup_test.go",
+ "backend/migrations/200_remote_skill_sources_and_legacy_cleanup.sql",
+ "backend/migrations/201_remote_skill_paired_candidates_test.go",
+ "backend/migrations/201_remote_skill_paired_candidates.sql",
+ "backend/migrations/business_system_prompt_codex_skill_hybrid_migration_test.go",
+ "backend/migrations/business_system_prompt_managed_source_migration_test.go",
+ "backend/migrations/business_system_prompt_remote_skill_migration_test.go",
+ "backend/migrations/business_system_prompt_skill_bundles_migration_test.go",
+ "backend/migrations/business_system_prompts_migration_test.go",
+ "backend/migrations/remote_skill_registry_migration_test.go",
+ "backend/migrations/remote_skill_sources_migration_integration_test.go",
+ "deploy/tests/test_verify_business_system_prompt_bundle.py",
+ "frontend/src/api/admin/__tests__/systemPrompts.spec.ts",
+ "frontend/src/api/admin/systemPrompts.ts",
+ "frontend/src/components/admin/systemPrompt/SystemPromptAdvancedDrawer.vue",
+ "frontend/src/i18n/locales/en/admin/systemPrompts.ts",
+ "frontend/src/i18n/locales/zh/admin/systemPrompts.ts",
+ "frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts",
+ "frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts",
+ "frontend/src/views/admin/SystemPromptsView.vue",
+ "tools/sync_modelgang_remote_skill_seed.ps1",
+ "tools/verify_business_system_prompt_bundle.py"
+ ],
+ "core-quota-and-protocol": [
+ "backend/internal/repository/account_quota_estimate_integration_test.go",
+ "backend/internal/repository/account_quota_estimate.go",
+ "backend/internal/repository/account_quota_snapshot_order_integration_test.go",
+ "backend/internal/repository/quota_activity_test.go",
+ "backend/internal/repository/quota_activity.go",
+ "backend/internal/service/account_quota_schedulable_test.go",
+ "backend/internal/service/account_quota_windows_test.go",
+ "backend/internal/service/account_quota_windows.go",
+ "backend/internal/service/model_rate_limit.go",
+ "backend/internal/service/openai_ws_ratelimit_signal_test.go",
+ "backend/internal/service/quota_activity_test.go",
+ "backend/internal/service/quota_activity.go",
+ "backend/internal/service/quota_estimate_test.go",
+ "backend/internal/service/quota_estimate.go",
+ "backend/internal/service/quota_measurement_test.go",
+ "backend/internal/service/ratelimit_service_403_test.go",
+ "backend/internal/service/ratelimit_service_anthropic_test.go",
+ "backend/internal/service/ratelimit_service_openai_image_test.go",
+ "backend/internal/service/ratelimit_service_openai_test.go",
+ "backend/internal/service/ratelimit_service.go",
+ "backend/internal/service/ratelimit_session_window_test.go",
+ "backend/migrations/247_account_quota_estimates.sql"
+ ],
+ "admin-observability": [
+ "backend/internal/securityaudit/prompt_config_integration_test.go",
+ "backend/internal/securityaudit/prompt_event_repository.go",
+ "backend/internal/securityaudit/prompt_repository_integration_test.go",
+ "frontend/public/fonts/misans/MiSans-Font-Intellectual-Property-License.pdf",
+ "frontend/public/fonts/misans/NOTICE.md",
+ "frontend/src/features/prompt-audit/components/EndpointPool.vue",
+ "frontend/src/features/prompt-audit/components/EventDetailDialog.vue",
+ "frontend/src/features/prompt-audit/components/EventWorkspace.vue",
+ "frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue",
+ "frontend/src/features/prompt-audit/components/PolicyPanel.vue",
+ "frontend/src/features/prompt-audit/components/RuntimeOverview.vue",
+ "frontend/src/features/prompt-audit/PromptAuditView.vue",
+ "frontend/src/style.css",
+ "frontend/src/styles/announcement-markdown.css",
+ "frontend/src/styles/onboarding.css",
+ "frontend/src/styles/theme.css"
+ ]
+ }
+}
diff --git a/.downstream/coverage-v0.2.7.md b/.downstream/coverage-v0.2.7.md
new file mode 100644
index 000000000000..d7d48778b536
--- /dev/null
+++ b/.downstream/coverage-v0.2.7.md
@@ -0,0 +1,43 @@
+# 下游归属与审查覆盖
+
+当前清单尚未完成审查,不能用文件已分类代替功能迁移完成。生产仍按根项目手册运行;发布完成状态只记录在[实施状态](implementation-v0.2.7.md)。
+
+## 范围
+
+以官方精确提交 `aea725f2ea644d5592d0bbb1d63b607efa7e200a` 对比当前代码树,并纳入未跟踪的新源码,共 2061 个差异路径。533 个生成文件、固定素材及依赖锁文件按来源处理;其余 1528 个手写代码、测试和文档路径列在[机器可读清单](coverage-v0.2.7.json)。候选归属只用于找到审查入口,混合文件必须按实际调用链判定,不能整文件判作已迁移。
+
+| 候选归属 | 路径数 |
+|---|---:|
++| operations | 34 |
+| cindy-provider | 158 |
+| documentation-and-history | 9 |
+| mixed-requires-review | 1009 |
+| core-extension-and-job-host | 72 |
+| model-policy | 19 |
+| core-data-and-migrations | 21 |
+| image-tools | 27 |
+| account-tools | 40 |
+| codex-runtime | 34 |
+| prompt-skills | 67 |
+| core-quota-and-protocol | 22 |
+| admin-observability | 16 |
+
+## 已建立证据的决议
+
+| 证据入口 | 根因或归属 | 实现路径 |
+|---|---|---|
+| `TestQuotaWindowObservationsKeepIndependentDeadlinesAndClearRemovedSlots`、`TestQuotaSnapshotOrderingAndLegacyWindowClock` | 不同窗口不能共享新的采样起点;旧快照必须在锁内判定时序 | 核心统一窗口表示、窗口独立时钟、数据库单调写入 |
+| `TestQuotaRecoveryCannotReactivateOldUnknownErrorOrClearOtherState`、`TestOAuthQuota429UsesRecoverableQuotaSource` | 硬额度与普通 429 需要不同状态来源 | 核心额度读模型,独立恢复;保留管理员状态和普通冷却 |
+| `TestQuotaMeasurementUsesShadowWindowAndParentBillingIdentity`、`TestQuotaEstimateRepositoryRecalibratesAfterCostHistoryShrinks` | 费用必须对应同一主体、计费倍率、额度范围和连续费用历史 | 核心配对估值与可归因观测边界 |
+| `TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol`、`TestBatchTestReasoningPersistsPerAccountForRetry` | 选项必须写入实际出站协议,批量选择按账号身份持久化 | 核心协议校验和序列化;增强界面的最终插件归属仍待完成 |
+| `TestCodexTicketFacadeUsesPluginWithoutLegacyFallback`、`TestRestartRecoversAbandonedFirstAttemptWithoutSendingAnotherRequest` | 292 是领域业务,不能保留两个采集/续期执行器 | `plugins/codex-runtime` 独立执行;宿主只留协议、凭据刷新、任务及一次数据迁移 |
+| `TestSettingsCodexTicketConfigurationHasOnlyPluginWritePath` | 全局设置与插件配置不能同时写同一业务开关 | 旧写入口返回迁移提示,插件配置唯一写入 |
+| `TestCatalogProcessContractPreservesProductScope`、`TestPluginCatalogCacheHonorsActivationHealthAndConfiguration` | 固定目录与供应商匹配是可独立策略;观测/自定义值及金额提交仍属核心 | `plugins/model-policy` 独立目录,宿主通用能力接口及有界缓存 |
+| `TestFirstPartyBundleContainsMatchingSignedDomainPackages` | 每个插件及界面资源必须对应锁定签名包 | 当前两个域的签名和独立进程已核验;最终插件集合尚未完成 |
+| `plugins/codex-runtime/scripts/check-ui.py` | 协议标签与控件不能一起居中对齐按钮 | 插件界面采用底部对齐与自然换行,桌面/窄屏及深浅色 DOM 检查通过 |
+
+## 继续审查
+
+尚未决定保留或迁移的路径保持待审查。下一步按账号工具、提示词/技能、Cindy、图像、界面/观测五域逐项收敛,同时继续核查 Codex 与模型域的剩余混合调用。
+
+根项目运维、独立服务和 Worker 不属于此 Sub2API Git 清单,必须单独检查。当前已确认 `deploy-session-converter.ps1`、`deploy-2fauth.ps1` 使用固定入口;若干 s-ui、备份、清理和旧安装入口仍有私钥路径,尚未统一修正。历史 worktree、在线数据、凭据和账本均保留。
diff --git a/.downstream/implementation-v0.2.7.md b/.downstream/implementation-v0.2.7.md
index 7f57def778f0..48c880983f91 100644
--- a/.downstream/implementation-v0.2.7.md
+++ b/.downstream/implementation-v0.2.7.md
@@ -1,57 +1,58 @@
# v0.2.7 实施状态
-本分支尚未发布。用户要求所有明确问题、下游插件化和全项目审查完成后统一交付。
-
-## 固定基线
-
-- 生产和 origin/main:`65a82d9a8f079f389f08e092d318a663123095e9`。
-- 官方:`v0.2.7` / `aea725f2ea644d5592d0bbb1d63b607efa7e200a`。
-- 隔离工作目录:`work/sub2api-v027-plugins`,分支 `feat/v027-plugin-architecture`。
-- 官方合并提交:`5a4b26a1d`。七处文本冲突已解决;保留账号重试/释放、空 context 保护、对话框栈及路由测试依赖,同时引入官方 Seedance 路由与移动端模型广场入口。
-
-## 用户已经确定的边界
-
-- 开发等价 292 插件,同时尽量迁移全部自建能力;采用通用宿主扩展层和功能域插件,通用正确性修复、认证、事务、计费提交仍在核心。
-- 插件域:Codex 运行扩展、Cindy Provider、账号工具、提示词/技能、模型目录/策略、图像工具、界面/观测。插件独立模块,仅引用公开 SDK。
-- 保留日常操作入口,启用时才显示插件贡献的功能;已启用但故障时保留入口并显示不可用原因。后端同步检查。没有旧实现的隐式回退。
-- 同仓构建签名 `.s2plugin`,统一版本镜像携带锁定插件;迁移继承既有配置、任务、租约和启停状态。
-- 292 保持 OAuth/Setup Token 非影子账号范围、停用/错误账号支持、手动首次、严格292、3600秒TTL、到期前60秒续期/失败后到期后60秒重试一次、停止状态不自动恢复。
-- 费用按账号 A 口径,配对同一窗口的费用/使用率增量,显示完整窗口总额和剩余额度价值。无有效配对显示待估算。5h/7d/30d使用真实周期。
-- 推理强度覆盖单个和逐项批量连接测试,复用模型能力,不改纯额度查询和自动采样,不默认真实模型验收。
-- 代理编辑区:标签在上,下拉框和按钮底部对齐,窄屏换行。
-- 审查整个项目与运维。保留资源、网络、手动路由和映射,CPA停机,图像关闭,公开管理退役。
-- 按既有固定digest直发规则统一交付,不恢复取消的发布保护;最小定向测试复用同树结果。
-
-## 当前实现与未完成项
-
-- 合并后的定向后端命令已完成:`go test ./internal/handler ./internal/service -run '^(TestGrokMedia.*Slot|TestSeedance.*|TestOpenAIGateway.*DeepSeek.*)$' -count=1`。handler 匹配0例(仅完成编译),service匹配用例通过。
-- 正在实现统一额度窗口:新增 `account_quota_windows.go`,`Account.QuotaState` 接入 `IsSchedulable` 和两种管理DTO;用量API增加 `quota_windows` 并按实际时长取费用。前端改读窗口列表、移除累计费用除累计比例的旧公式、添加额度阻止状态。
-- 额度增量估值第一版已加入 `quota_estimate.go`、`account_quota_estimate.go`、迁移247。仅复用现有 `/wham/usage` 查询;查询前后须账号并发为0、计费池已完成且费用记录指纹与队列计数稳定。持久化同一账号身份/倍率、周期的基线和最近观测;UI按配对增量显示。`/wham`绝对reset_at已保留。仍需真实PostgreSQL集成测试、collector并发/迟到用例,以及审查跨实例/异步提交间隙保证,不能只凭公式单元测试宣布整个估值完成。
-- 当前额度改动还需:规范响应头/错误体/查询写入和恢复源、短/长期策略复用、处理原始字段重置时刻、配对估值、前端回归测试更新、倒计时刷新与数据层定向测试。
-- 新额度窗口单元测试已通过;类型命名冲突已修正为 `UpstreamQuotaState`。管理DTO精简投影映射已修正并编译通过;前端新增字段类型漏接已修正,`pnpm typecheck` 已通过。
-- 代理布局已在现组件修改,最终需随 Codex 插件UI迁移并验收。
-- 新增公开 `backend/pkg/extensionapi/v1`:版本化领域调用、宿主操作、状态CAS、租约、调度请求、界面贡献;基于官方进程握手并增补独立gRPC服务。SDK的RPC/取消测试通过。插件manifest/schema增加能力、依赖、UI槽声明,manager已开始多插件重构;声明启用来自持久bindings而非健康状态。新 `246_plugin_extension_state.sql` 和对应PostgreSQL CAS/带generation租约实现尚未运行真实数据库验收。
-- 单个/逐项批量连接测试已接入 `reasoning_effort`,共享模型元数据解析与校验,Responses/Chat/Grok出站序列化、SSE和任务结果字段已接入;前端选择器已加。需要实际HTTP载荷测试、批量幂等/重试测试、补齐其他已支持平台的模型元数据,以及最终账号工具插件迁移。
-- 各域真正业务迁移、构建签名锁定、配置/数据迁移、费用配对持久化、全项目覆盖清单与运维修正均未完成。不存在可发布的完整结果,禁止部署。
-- `plugins/codex-runtime` 已创建独立Go模块及可编译入口,包含票据状态机、准入/注入、手动采集/续期/停止操作的第一版;模块测试通过。它尚未接入宿主业务链路,也没有签名包、锁清单或正式UI。代理证书信任/诊断、持久自动任务调度、存量状态迁移、真实离线端到端采集测试仍需补齐,现有宿主292逻辑尚未切除。
-- SDK Host新增受manifest范围约束的账号目录及凭据准备接口,宿主通过现有OAuth凭据准备上下文支持Setup Token与停用账号;该接口最新修改待编译复验。
-- 后端Wire已从源provider重新生成;初次go generate缺工具依赖sum,改用 `go run -mod=mod github.com/google/wire/cmd/wire ./cmd/server` 成功,新增锁定工具间接依赖属于此次生成所需。
-- `plugins/codex-runtime` 已 `go mod tidy`,独立模块基础测试通过;最近修正自动续期不能因旧票仍有效而skip、operation ID去重、过期运行中阶段不自动重放、ticket.inject能力。新的采集网络逻辑尚未用假上游完整验证。
-- 插件通用后台操作已接入已有 AccountJobRuntime(`extension_operation`、PluginJobExecutor),复用持久任务、取消、失败重试与元数据约束;新增管理贡献/操作/任务端点,提交前和执行时都核验已启用能力/声明动作/账号范围。宿主状态表增加 `next_at` 及due查询,Codex模块可从持久ready/retry阶段续期,5个共享槽;尚未切换旧292执行器。
-- 新增真实压缩出站测试证明所选ultra经过模型映射/zstd后仍在最终请求内,Compact无效选择在发请求前拒绝;新增采样器用例验证查询期间的迟到费用和在途请求会丢弃样本,均已通过。
-- 最近一次后端定向命令覆盖 service/admin/repository/server 编译及新业务用例通过;repository/server当次匹配0例,仅完成编译,真实数据库验证未执行。最新前端typecheck通过。SDK和独立Codex模块仍需多进程/假上游集成测试后才能切换。
-- 前端五个定向文件66例通过(AccountUsageCell/AccountStatusIndicator/CodexTicketProxyEditor/单个测试/批量测试)。包含旧响应适配,但旧响应不再计算任何金额。新的强度交互及最新状态分支还需要对应新用例。
-- 不得把插件空壳、仅加开关、仍调用原业务实现当作迁移完成;每域转换后须删除旧业务执行路径。
-
-## 后续实现注意
-
-- 官方 `PluginManager` 实际仅支持一个启用的OAuth transport,不能仅新增manifest就声称多域插件可用。需要按能力注册/调度,并分离期望启用状态与运行健康。
-- 官方 HostService 只有KV/账号目录/身份解析,没有CAS、租约、业务数据访问、调度准入、后台任务、管理操作与UI扩展槽。新协议使用独立命名空间,兼容官方v1。
-- 现有 `account_usage_service` 的 `/usage` 自动探针会发送模型请求;线上调查只读 `/admin/accounts/:id`,不要为了验收随手请求 `/usage`。
-- 线上已读事实:16376 `codex_7d_used_percent=100`、明确未来reset,但 `rate_limit_reset_at=null`;16377 primary周期43200分钟。未改线上。
-- `usage_logs.CreatedAt` 当前在记录用量时设置为结束时间,Quota响应头在更早阶段采样。增量费用配对必须处理这个时序与并发/迟到提交,不能简单把当前累计费用绑到较早快照。
-- 新429分类器已改为真实窗口并删除猜测5h/7d期限;未知恢复时间的硬额度错误记录 `openai_quota_exhausted`,额度状态可在新鲜恢复证据后解除。该处最新改动仍需复验;有些原测试标有 `//go:build unit`,运行时须加 `-tags unit`。当前正在运行相关unit命令,记录结果后继续。
-- 调度缓存精简投影已补齐原始周期/重置字段和硬额度状态,避免30天周期在投影后重新被当作7天。
-- 新前端窗口结构使旧 `/usage` fixture及“12/40%=30”的错误估值断言过时,须改成验证服务端配对估值,不得删除仍需要的状态断言。
-- 已发现旧 `deploy-session-converter.ps1`、`deploy-2fauth.ps1` 等私钥路径/任意SSH封装,与现行入口规则不一致;仅读过,未修改或运行。
-- 主 checkout 有用户遗留未跟踪 `.worktrees/`、`WS`、`fc_`,不得清理。根目录不是Git仓库。
+本分支尚未发布。全部明确问题、功能域插件迁移及全项目审查完成后统一交付;当前禁止部署部分成果。
+
+## 固定基线与授权
+
+- 生产源码:`65a82d9a8f079f389f08e092d318a663123095e9`;官方 `v0.2.7`:`aea725f2ea644d5592d0bbb1d63b607efa7e200a`。
+- 工作目录:`work/sub2api-v027-plugins`;分支:`feat/v027-plugin-architecture`;官方合并提交:`5a4b26a1d`;前序基础契约提交:`27e6d8f17`。当前未提交修改均属于本任务,必须保留。
+- 插件域:Codex 运行扩展、Cindy Provider、账号工具、提示词/技能、模型目录/策略、图像工具、界面/观测。插件独立模块,仅引用公开 SDK;不能把空壳、开关或调用旧宿主业务算作迁移。
+- 宿主保留认证授权、账号基础数据、凭据刷新、事务/计费、通用调度与流式生命周期、通用正确性修复。
+- 插件停用撤下贡献入口并停止能力;启用但故障保留入口并显示不可用原因,后端同样执行。数据、已停止续期状态及持久账本保留。
+- 保留美国目标、现有网络/资源/映射、图像关闭、公开管理退役及 CPA 停机。统一固定 digest、自然排空、runtime=preserve 发布,不恢复已取消保护。
+
+## 已实现的工作
+
+- 官方 0.2.7 已合并。上游新能力与下游冲突决议沿用合并提交。
+- 额度统一读模型:保留真实 5h/7d/30d,窗口各自的观测时间与绝对期限;完整查询清除已撤销窗口,普通与 Spark 查询使用同一转换。旧字段仅兼容。已知未来期限不会因样本变旧而被推迟或解除。
+- 硬额度错误由额度状态独立拥有,未知期限保持未知,不写成普通 429 或运行时冷却;恢复证据不会在之后的窗口到期时重新激活旧错误。普通 429、管理员禁用与其他错误独立保留。
+- 快照数据库写入对账号行加锁后比较采样时间,修复并发旧观察覆盖新观察;首次更新为未变更的旧窗口保留原观测时刻。
+- 费用校准按配对的费用增量/使用率增量持久化;观测跨 HTTP/WS 请求、异步账单提交及丢弃路径,Redis epoch/gap 使不可归因观察失效。Spark 采用自己的窗口及父账号的主体/计费倍率。费用历史缩减也重新建立基线。
+- 单个和逐项批量连接测试保存 reasoning_effort。出站 Responses/Chat/Grok/OpenCode Go 已接入;不支持序列化该参数的协议不提供选项、不静默忽略。批量初始记录及重试保留各账号选择。
+- 通用扩展 SDK、独立进程 RPC、CAS/执行租约、同事务调度摘要、后台任务、贡献注册和 iframe UI 已实现。
+- Codex 插件实际拥有 292 采集、验证、注入、有限续期、代理诊断。宿主旧采集循环、票据内存缓存、旧续期数据库执行器及旧全局设置读写已移除;旧数据只供一次迁移和保护性读适配。
+- 取消与配置切换会丢弃迟到票据;中断的首次采集在执行租约过期后转为已中断,不重新请求。有效旧票跳过、停用/错误账号支持、两个有限续期阶段不变。
+- 插件设置为唯一配置写入来源,旧全局票据写入返回明确迁移提示,不暴露遗留代理凭据。账号页保留原操作位置,跨页资格采用通用账号元数据查询。
+- 签名打包命令、镜像 bundle 引导、持久迁移账本由前序实现保留。CI 已新增独立插件模块和签名进程检查,删除测试的引用改为迁移后的等价入口,未取消必需检查。
+- 模型目录静态数据和供应商/产品范围匹配已移入独立 `plugins/model-policy` 模块,公开 SDK 传输容量参考。宿主保留上游观测、自定义值和来源优先级等正确性规则,目录通过通用能力接口读取;有界缓存按配置版本隔离,每次先核验插件启停与健康。无内置目录暗中回退。原静态完整性、精确别名/原始键和容量参考回归通过。
+- 两个独立程序及签名 bundle 的本地验证通过:`TestExtensionRuntimeUsesOfficialProcessAndHostBroker`、`TestCatalogExtensionRuntimeUsesIndependentProcess`、`TestFirstPartyBundleContainsMatchingSignedDomainPackages`。构建目录为项目根 `artifacts/tmp/v027-plugin-check-0a4b635d31844b1f93e04e44b0513c47`,仅离线程序和合成数据;私钥为该目录内新生成的临时测试签名密钥,不可提交,任务完成后清理。
+
+## 可复用验证
+
+以下均在本地合成数据或本地 PostgreSQL 完成,未调用真实模型、未访问生产做验收。
+
+- 前端类型检查及全部本次修改 Vue/TS 文件的 ESLint 通过。账号选择/批量菜单/单账号菜单 20 例通过;插件槽与插件页 4 例通过;全局设置改走插件的定向保存测试通过。
+- 宿主插件接管:无旧票据回退、RPC 故障阻止、脱敏摘要、Compact 最终模型、两种调度器与停用凭据刷新边界通过。真实协议进程与签名包前序曾通过;最新二进制需在发布前由正常 CI 构建验证,不能沿用旧构建物。
+- 新增插件取消、主体/模型/账号/有效期校验及中断恢复用例通过。原状态机、私有代理证书固定与假上游唯一请求用例沿用前序结果;正常 CI 将覆盖最终模块。
+- 额度真实周期、多窗口/未知期限、独立时钟、撤销窗口、旧错误恢复及不污染普通冷却通过;修改所影响的快照写入、Spark 转换和 OpenAI 提前暂停用例通过。
+- 两个原 429 用例断言的是已废弃行为,已改为验证独立额度状态和未知期限;仅重跑失败用例并通过,其他瞬时 429 用例及 server 清理编译/测试已通过。
+- 推理强度:已有映射/zstd 出站证明保留;新增不支持协议拒绝、OpenCode Go 两种最终出站载荷及批量选择持久化用例通过。
+- 真实本地 PostgreSQL:新并发快照排序/旧窗口时钟、费用历史缩减重新校准通过。前序的插件 CAS/租约/停用取消同事务、bundle 重入/停止状态迁移、费用基线持久化结果保留。
+- 离线浏览器脚本:`plugins/codex-runtime/scripts/check-ui.py`。900px/390px、深浅色、HTTP405 反馈、清除并关闭、批量四目标验证通过,页面错误0;DOM 边界验证对齐且无横向溢出。截图位于项目根 `artifacts/evidence/v027-plugin-ui`;当前模型工具未提供可读图像输入,未声称完成逐像素视觉复核。
+- `git diff --check` 最近通过。后续修改仍需最终静态检查。
+
+## 未完成
+
+- 其余五域尚未迁移;Codex 的其他下游身份/传输/恢复策略及模型目录之外的能力策略仍需按领域策略与通用协议正确性划分,不能把当前两个插件视为七域完成。
+- 全下游差异覆盖清单尚未完成;全部运维脚本、独立服务及 Worker 的根因审查尚未完成。当前 deploy-session-converter.ps1 / deploy-2fauth.ps1 已使用具名密码 SSH 入口,与最初交接描述不同,仍需验证其固定远端实现;s-ui 与若干旧维护入口仍有私钥路径。
+- 所有额度模型作用范围及协议边界仍须最终检查;完整查询仅返回 allowed/limit_reached 的情况已实现并通过定向验证。
+- 通用插件任务/配置/能力范围及多域依赖仍需随其余域接入核验;构建锁清单当前含 Codex 和模型目录两个插件,需扩展到最终集合。
+- 当前仍是本地开发阶段,未创建 PR、未构建发布最终镜像、未部署。生产手册仍指向旧版本,必须等实际统一交付后更新。
+
+## 续做入口与边界
+
+- 本地 PostgreSQL 正在监听 127.0.0.1:55437;专用测试 DSN 为 `postgresql://codex_test@127.0.0.1:55437/sub2api_test_v027?sslmode=disable&TimeZone=UTC`,仅定向测试设置 `SUB2API_TEST_POSTGRES_ONLY_DSN`,不连接生产。
+- PowerShell 的原生 rg 配置会影响结果,使用 `rg --no-config`;通配路径改为目录加 `-g`,不要向 Windows 传字面星号路径。
+- 根项目不是 Git 仓库;源码 Git 在当前 worktree,主 checkout 和历史 worktree 不清理。
+- `/admin/accounts/:id/usage` 可能触发模型探针;不要为了验收直接访问线上该接口。
+- 内部未提交文件很多;不得重置或整体清空。旧测试的职责已迁到插件模块、宿主桥接及通用数据库测试,后续不能恢复双执行器来满足旧断言。
diff --git a/.github/scripts/test_first_party_plugins.sh b/.github/scripts/test_first_party_plugins.sh
new file mode 100644
index 000000000000..ca14cdbf652a
--- /dev/null
+++ b/.github/scripts/test_first_party_plugins.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root=$(git rev-parse --show-toplevel)
+cd "$repo_root"
+plugin_build_dir=$(mktemp -d)
+
+for module in plugins/*; do
+ [[ -f "$module/go.mod" ]] || continue
+ module_name=$(basename "$module")
+ (
+ cd "$module"
+ go test ./... -count=1
+ go build -trimpath -o "$plugin_build_dir/$module_name" ./cmd/plugin
+ )
+done
+
+cd backend
+go run ./cmd/package-plugin -generate-key "$plugin_build_dir/publisher.key" > "$plugin_build_dir/publisher.json"
+go run ./cmd/package-plugin \
+ -source ../plugins/codex-runtime \
+ -binary "$plugin_build_dir/codex-runtime" \
+ -platform linux-amd64 \
+ -tested-host-version 0.2.7 \
+ -output "$plugin_build_dir/codex-runtime.s2plugin" \
+ -bundle-lock "$plugin_build_dir/lock.json" \
+ -signing-key-file "$plugin_build_dir/publisher.key" \
+ -key-id codexrip-plugins-test
+
+go run ./cmd/package-plugin \
+ -source ../plugins/model-policy \
+ -binary "$plugin_build_dir/model-policy" \
+ -platform linux-amd64 \
+ -tested-host-version 0.2.7 \
+ -output "$plugin_build_dir/model-policy.s2plugin" \
+ -bundle-lock "$plugin_build_dir/lock.json" \
+ -default-enabled \
+ -signing-key-file "$plugin_build_dir/publisher.key" \
+ -key-id codexrip-plugins-test
+
+export SUB2API_EXTENSION_TEST_BINARY="$plugin_build_dir/codex-runtime"
+export SUB2API_MODEL_POLICY_TEST_BINARY="$plugin_build_dir/model-policy"
+export SUB2API_EXTENSION_TEST_BUNDLE="$plugin_build_dir/lock.json"
+export SUB2API_EXTENSION_TEST_PUBLIC_KEY
+SUB2API_EXTENSION_TEST_PUBLIC_KEY=$(jq -r .public_key "$plugin_build_dir/publisher.json")
+go test ./internal/service -run '^(TestExtensionRuntimeUsesOfficialProcessAndHostBroker|TestCatalogExtensionRuntimeUsesIndependentProcess|TestFirstPartyBundleContainsMatchingSignedDomainPackages)$' -count=1
diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml
index be465c5ec61d..cab473507256 100644
--- a/.github/workflows/downstream-verify.yml
+++ b/.github/workflows/downstream-verify.yml
@@ -31,6 +31,8 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Agent protocol contracts
run: bash .github/scripts/test_agent_protocol.sh
+ - name: First-party plugin contracts and signed process package
+ run: bash .github/scripts/test_first_party_plugins.sh
- name: Reasoning replay and recovery contracts
run: bash .github/scripts/test_reasoning_replay.sh
- name: Model context capacity contracts
@@ -131,9 +133,9 @@ jobs:
src/__tests__/integration/data-import.spec.ts \
src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \
src/stores/__tests__/accountJobs.spec.ts \
- src/composables/__tests__/useTicketSelection.spec.ts \
+ src/composables/__tests__/useAccountSelectionMetadata.spec.ts \
src/api/__tests__/accountOperationIdempotency.spec.ts \
- src/components/admin/account/__tests__/CodexTicketHarvestModal.spec.ts \
+ src/components/plugins/__tests__/ExtensionSlot.spec.ts \
src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \
src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts \
src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts \
diff --git a/.gitignore b/.gitignore
index fe0ec7542aa7..c364e4b20aea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -166,12 +166,16 @@ output/
# third-party projects remain outside the source tree.
/plugins/*
!/plugins/codex-runtime/
+!/plugins/codex-runtime/scripts/
+!/plugins/codex-runtime/scripts/check-ui.py
!/plugins/cindy-provider/
!/plugins/account-tools/
!/plugins/prompt-skills/
!/plugins/model-catalog/
+!/plugins/model-policy/
!/plugins/image-tools/
!/plugins/admin-observability/
/plugins/*/dist/
/plugins/*/node_modules/
/plugins/*/*.s2plugin
+/plugins/**/*.key
diff --git a/Dockerfile b/Dockerfile
index 0f02abe4a5e6..da1586e0d6e8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -74,30 +74,67 @@ WORKDIR /app/backend
# Copy go mod files first (better caching)
COPY backend/go.mod backend/go.sum ./
+COPY plugins/codex-runtime/go.mod plugins/codex-runtime/go.sum /app/plugins/codex-runtime/
+COPY plugins/model-policy/go.mod plugins/model-policy/go.sum /app/plugins/model-policy/
# Cache mount keeps the module cache across builds so a transient CDN blip on
# retry resumes instead of re-fetching every zip from scratch.
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
- go mod download
+ go mod download && \
+ cd /app/plugins/codex-runtime && go mod download && \
+ cd /app/plugins/model-policy && go mod download
# Copy backend source first
COPY backend/ ./
+COPY plugins/codex-runtime/ /app/plugins/codex-runtime/
+COPY plugins/model-policy/ /app/plugins/model-policy/
# Copy frontend dist from previous stage (must be after backend copy to avoid being overwritten)
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
-# Build the binary (BuildType=release for CI builds, embed frontend)
+# Build the host and its independent first-party plugin. The signing key exists
+# only inside this build step and is removed before the immutable bundle is
+# copied into the final image; OCI provenance remains the publisher trust root.
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
RUN --mount=type=cache,id=sub2api-gomod,target=/go/pkg/mod \
--mount=type=cache,id=sub2api-gobuild,target=/root/.cache/go-build \
VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
- CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
+ TARGET_OS="${TARGETOS:-linux}" && TARGET_ARCH="${TARGETARCH:-amd64}" && \
+ CGO_ENABLED=0 GOOS=${TARGET_OS} GOARCH=${TARGET_ARCH} go build \
-tags embed \
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-trimpath \
-o /app/sub2api \
- ./cmd/server
+ ./cmd/server && \
+ cd /app/plugins/codex-runtime && \
+ CGO_ENABLED=0 GOOS=${TARGET_OS} GOARCH=${TARGET_ARCH} go build -trimpath -o /tmp/codex-runtime-plugin ./cmd/plugin && \
+ cd /app/plugins/model-policy && \
+ CGO_ENABLED=0 GOOS=${TARGET_OS} GOARCH=${TARGET_ARCH} go build -trimpath -o /tmp/model-policy-plugin ./cmd/plugin && \
+ cd /app/backend && \
+ go run ./cmd/package-plugin -generate-key /tmp/codex-runtime-publisher.key >/tmp/codex-runtime-publisher.json && \
+ mkdir -p /app/bundled-plugins && \
+ go run ./cmd/package-plugin \
+ -source /app/plugins/codex-runtime \
+ -binary /tmp/codex-runtime-plugin \
+ -platform "${TARGET_OS}-${TARGET_ARCH}" \
+ -tested-host-version "${VERSION_VALUE}" \
+ -output /app/bundled-plugins/codex-runtime.s2plugin \
+ -bundle-lock /app/bundled-plugins/lock.json \
+ -migration codex-tickets-v1 \
+ -signing-key-file /tmp/codex-runtime-publisher.key \
+ -key-id codexrip-image-plugins-v1 && \
+ go run ./cmd/package-plugin \
+ -source /app/plugins/model-policy \
+ -binary /tmp/model-policy-plugin \
+ -platform "${TARGET_OS}-${TARGET_ARCH}" \
+ -tested-host-version "${VERSION_VALUE}" \
+ -output /app/bundled-plugins/model-policy.s2plugin \
+ -bundle-lock /app/bundled-plugins/lock.json \
+ -default-enabled \
+ -signing-key-file /tmp/codex-runtime-publisher.key \
+ -key-id codexrip-image-plugins-v1 && \
+ rm -f /tmp/codex-runtime-plugin /tmp/model-policy-plugin /tmp/codex-runtime-publisher.key /tmp/codex-runtime-publisher.json
# -----------------------------------------------------------------------------
# Stage 3: PostgreSQL Client (version-matched with docker-compose)
@@ -146,6 +183,7 @@ WORKDIR /app
# Copy binary/resources with ownership to avoid extra full-layer chown copy
COPY --from=backend-builder --chown=sub2api:sub2api /app/sub2api /app/sub2api
+COPY --from=backend-builder --chown=sub2api:sub2api /app/bundled-plugins /app/bundled-plugins
COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources
COPY --chown=sub2api:sub2api THIRD_PARTY_NOTICES.md /app/THIRD_PARTY_NOTICES.md
diff --git a/backend/cmd/package-plugin/main.go b/backend/cmd/package-plugin/main.go
new file mode 100644
index 000000000000..c728111c91e5
--- /dev/null
+++ b/backend/cmd/package-plugin/main.go
@@ -0,0 +1,261 @@
+// package-plugin creates an immutable signed package from a first-party module.
+// Publisher private keys are read only by this build command, never embedded.
+package main
+
+import (
+ "archive/zip"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+type packageOptions struct {
+ defaultEnabled bool
+ source, binary, platform, hostVersion, output, keyFile, keyID, sdkUI string
+ bundleLock, migration string
+}
+
+func main() {
+ var options packageOptions
+ flag.StringVar(&options.source, "source", "", "plugin module directory")
+ flag.StringVar(&options.binary, "binary", "", "compiled plugin program")
+ flag.StringVar(&options.platform, "platform", "linux-amd64", "runtime platform")
+ flag.StringVar(&options.hostVersion, "tested-host-version", "", "exact host version whose contract tests passed")
+ flag.StringVar(&options.output, "output", "", "new .s2plugin archive path")
+ flag.StringVar(&options.keyFile, "signing-key-file", "", "base64 Ed25519 private key file (or use SUB2API_PLUGIN_SIGNING_KEY)")
+ flag.StringVar(&options.keyID, "key-id", "codexrip-plugins-v1", "publisher key identifier")
+ flag.StringVar(&options.sdkUI, "ui-sdk", "pkg/extensionapi/ui", "public UI SDK directory")
+ flag.StringVar(&options.bundleLock, "bundle-lock", "", "write an immutable first-party bundle lock beside the package")
+ flag.StringVar(&options.migration, "migration", "", "one-time migration profile recorded in the bundle lock")
+ flag.BoolVar(&options.defaultEnabled, "default-enabled", false, "initial activation when no saved plugin state exists")
+ generate := flag.String("generate-key", "", "create a new private key file; prints only its public key")
+ flag.Parse()
+ var err error
+ if *generate != "" {
+ err = generateKey(*generate)
+ } else {
+ err = packagePlugin(options)
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func generateKey(path string) error {
+ public, private, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return errors.New("could not generate publisher key")
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ _, writeErr := file.WriteString(base64.StdEncoding.EncodeToString(private) + "\n")
+ closeErr := file.Close()
+ if writeErr != nil {
+ return writeErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ return json.NewEncoder(os.Stdout).Encode(map[string]string{"public_key": base64.StdEncoding.EncodeToString(public), "private_key_file": path})
+}
+
+func packagePlugin(options packageOptions) error {
+ if options.source == "" || options.binary == "" || options.output == "" || options.keyID == "" {
+ return errors.New("source, binary, output and key-id are required")
+ }
+ raw, err := os.ReadFile(filepath.Join(options.source, "manifest.source.json"))
+ if err != nil {
+ return err
+ }
+ var manifest service.PluginManifest
+ if err := json.Unmarshal(raw, &manifest); err != nil {
+ return err
+ }
+ assets := make(map[string][]byte)
+ if err := filepath.WalkDir(filepath.Join(options.source, "ui"), func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.Type()&os.ModeSymlink != 0 {
+ return errors.New("plugin assets cannot be symbolic links")
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ if !entry.Type().IsRegular() {
+ return errors.New("plugin assets must be regular files")
+ }
+ relative, err := filepath.Rel(options.source, path)
+ if err != nil {
+ return err
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ assets[filepath.ToSlash(relative)] = content
+ return nil
+ }); err != nil {
+ return err
+ }
+ bridge, err := os.ReadFile(filepath.Join(options.sdkUI, "bridge.js"))
+ if err != nil {
+ return err
+ }
+ assets["ui/assets/bridge.js"] = bridge
+ binary, err := os.ReadFile(options.binary)
+ if err != nil {
+ return err
+ }
+ binaryName := "plugin"
+ if strings.HasPrefix(options.platform, "windows-") {
+ binaryName += ".exe"
+ }
+ binaryPath := "runtimes/" + options.platform + "/" + binaryName
+ assets[binaryPath] = binary
+ manifest.Runtimes = map[string]service.PluginRuntime{options.platform: {Path: binaryPath}}
+ manifest.Files = make(map[string]string, len(assets))
+ for name, content := range assets {
+ digest := sha256.Sum256(content)
+ manifest.Files[name] = hex.EncodeToString(digest[:])
+ }
+ if options.hostVersion != "" {
+ manifest.Requires.TestedSub2APIVersions = []string{options.hostVersion}
+ manifest.Requires.RecommendedSub2APIVersion = options.hostVersion
+ }
+ if err := manifest.ValidateForRuntime(options.platform); err != nil {
+ return err
+ }
+ manifestRaw, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+ encodedKey := strings.TrimSpace(os.Getenv("SUB2API_PLUGIN_SIGNING_KEY"))
+ if options.keyFile != "" {
+ if encodedKey != "" {
+ return errors.New("choose a signing-key file or environment value, not both")
+ }
+ value, err := os.ReadFile(options.keyFile)
+ if err != nil {
+ return err
+ }
+ encodedKey = strings.TrimSpace(string(value))
+ }
+ key, err := base64.StdEncoding.DecodeString(encodedKey)
+ if err != nil || len(key) != ed25519.PrivateKeySize {
+ return errors.New("a valid Ed25519 private signing key is required")
+ }
+ private := ed25519.PrivateKey(key)
+ signature := service.PluginSignature{Algorithm: "ed25519", KeyID: options.keyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(private, manifestRaw))}
+ signatureRaw, err := json.Marshal(signature)
+ if err != nil {
+ return err
+ }
+ assets["manifest.json"] = manifestRaw
+ assets["signature.json"] = signatureRaw
+ if err := os.MkdirAll(filepath.Dir(options.output), 0755); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(options.output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ archive := zip.NewWriter(file)
+ names := make([]string, 0, len(assets))
+ for name := range assets {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ header := &zip.FileHeader{Name: name, Method: zip.Deflate}
+ header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC))
+ header.SetMode(0644)
+ if name == binaryPath {
+ header.SetMode(0755)
+ }
+ writer, writeErr := archive.CreateHeader(header)
+ if writeErr == nil {
+ _, writeErr = writer.Write(assets[name])
+ }
+ if writeErr != nil {
+ _ = archive.Close()
+ _ = file.Close()
+ return writeErr
+ }
+ }
+ if err := archive.Close(); err != nil {
+ _ = file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ content, err := os.ReadFile(options.output)
+ if err != nil {
+ return err
+ }
+ digest := sha256.Sum256(content)
+ packageDigest := hex.EncodeToString(digest[:])
+ publicKey := base64.StdEncoding.EncodeToString(private.Public().(ed25519.PublicKey))
+ if options.bundleLock != "" {
+ if strings.TrimSpace(options.hostVersion) == "" {
+ return errors.New("tested-host-version is required when writing a bundle lock")
+ }
+ if filepath.Dir(options.bundleLock) != filepath.Dir(options.output) {
+ return errors.New("bundle lock and plugin package must share a directory")
+ }
+ bundle := service.PluginBundle{
+ SchemaVersion: 1,
+ HostVersion: options.hostVersion,
+ PublisherKeyID: options.keyID,
+ PublisherPublicKey: publicKey,
+ Plugins: []service.BundledPlugin{{
+ ID: manifest.ID, Version: manifest.Version, File: filepath.Base(options.output),
+ SHA256: packageDigest, Migration: options.migration, DefaultEnabled: options.defaultEnabled,
+ }},
+ }
+ if previous, readErr := os.ReadFile(options.bundleLock); readErr == nil {
+ var existing service.PluginBundle
+ if json.Unmarshal(previous, &existing) != nil || existing.SchemaVersion != bundle.SchemaVersion || existing.HostVersion != bundle.HostVersion || existing.PublisherKeyID != bundle.PublisherKeyID || existing.PublisherPublicKey != bundle.PublisherPublicKey {
+ return errors.New("existing bundle identity does not match this build")
+ }
+ for _, entry := range existing.Plugins {
+ if entry.ID == manifest.ID {
+ return errors.New("plugin already exists in bundle lock")
+ }
+ }
+ bundle.Plugins = append(existing.Plugins, bundle.Plugins...)
+ } else if !errors.Is(readErr, os.ErrNotExist) {
+ return readErr
+ }
+ lock, err := json.MarshalIndent(bundle, "", " ")
+ if err != nil {
+ return err
+ }
+ lock = append(lock, '\n')
+ if err := os.WriteFile(options.bundleLock, lock, 0600); err != nil {
+ return err
+ }
+ }
+ return json.NewEncoder(os.Stdout).Encode(map[string]string{"plugin_id": manifest.ID, "version": manifest.Version, "sha256": packageDigest, "public_key": publicKey, "path": options.output})
+}
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 08b22ce20f20..eb1d52f90f8a 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -222,6 +222,13 @@ func runMainServer() {
log.Printf("Prompt Audit started in degraded state: %v", err)
}
}
+ if app.AccountJobs != nil {
+ if err := app.AccountJobs.Start(context.Background()); err != nil {
+ log.Printf("Account jobs could not start: %v", err)
+ app.Cleanup()
+ os.Exit(1)
+ }
+ }
// 启动服务器
go func() {
diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go
index cba8765c5973..66b5c8fa9fe6 100644
--- a/backend/cmd/server/wire.go
+++ b/backend/cmd/server/wire.go
@@ -29,6 +29,7 @@ type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
+ AccountJobs *service.AccountJobRuntime
Cleanup func()
}
@@ -60,7 +61,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
provideCleanup,
// Application struct
- wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"),
+ wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "AccountJobs", "Cleanup"),
)
return nil, nil
}
@@ -142,6 +143,7 @@ func provideCleanup(
cindyHealth *service.CindyHealthService,
cindyBalanceProbe *service.CindyBalanceProbeService,
imageStudioRuntime *service.ImageStudioRuntime,
+ pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -374,12 +376,6 @@ func provideCleanup(
}
return nil
}},
- {"OpenAICodexTicketHarvester", func() error {
- if openAIGateway != nil {
- openAIGateway.StopOpenAICodexTicketHarvester()
- }
- return nil
- }},
{"ScheduledTestRunnerService", func() error {
if scheduledTestRunner != nil {
scheduledTestRunner.Stop()
@@ -474,6 +470,9 @@ func provideCleanup(
}
runParallel(parallelSteps)
+ if pluginManager != nil {
+ pluginManager.Stop()
+ }
runSequential(infraSteps)
// Check if context timed out
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index ce27e21cb520..cde5d9d544e8 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -182,7 +182,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
authCacheInvalidationOutboxRepository := repository.NewAuthCacheInvalidationOutboxRepository(db)
authCacheInvalidationWorker := service.ProvideAuthCacheInvalidationWorker(authCacheInvalidationOutboxRepository, apiKeyCache, apiKeyService)
- opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService, authCacheInvalidationWorker, apiKeyService)
+ quotaActivityStore := repository.NewQuotaActivityStore(redisClient)
+ quotaActivityService := service.NewQuotaActivityService(quotaActivityStore)
+ opsService := service.ProvideOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink, settingService, authCacheInvalidationWorker, apiKeyService, quotaActivityService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService, opsService, settingService)
redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
@@ -216,16 +218,15 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
- usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
- openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService, concurrencyService, usageRecordWorkerPool)
+ openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService, quotaActivityService)
usageCache := service.NewUsageCache()
usageCommitObserver := provideUsageCommitObserver()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, gatewayService, openAIGatewayService, usageCommitObserver)
- pluginRepository := repository.NewPluginRepository(db)
+ pluginRepository := repository.ProvidePluginRepository(db, accountRepository)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginKVStore := repository.NewPluginKVStore(redisClient)
- pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore)
- accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
+ pluginManager := service.ProvidePluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore, quotaActivityService, openAIGatewayService)
+ accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager, quotaActivityService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService, accountJobCindyMutationRunner)
accountJobRepository := repository.NewAccountJobRepository(db)
@@ -318,6 +319,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig)
accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache, configConfig)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, cindyBalanceProbeHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, settingService, openAIGatewayService)
+ usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
@@ -392,11 +394,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
if err != nil {
return nil, err
}
- v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime)
+ v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, businessSystemPromptService, remoteSkillRegistryService, accountJobRuntime, cindyHealthService, cindyBalanceProbeService, imageStudioRuntime, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
PluginManager: pluginManager,
+ AccountJobs: accountJobRuntime,
Cleanup: v,
}
return application, nil
@@ -408,6 +411,7 @@ type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
+ AccountJobs *service.AccountJobRuntime
Cleanup func()
}
@@ -488,6 +492,7 @@ func provideCleanup(
cindyHealth *service.CindyHealthService,
cindyBalanceProbe *service.CindyBalanceProbeService,
imageStudioRuntime *service.ImageStudioRuntime,
+ pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -719,12 +724,6 @@ func provideCleanup(
}
return nil
}},
- {"OpenAICodexTicketHarvester", func() error {
- if openAIGateway != nil {
- openAIGateway.StopOpenAICodexTicketHarvester()
- }
- return nil
- }},
{"ScheduledTestRunnerService", func() error {
if scheduledTestRunner != nil {
scheduledTestRunner.Stop()
@@ -819,6 +818,9 @@ func provideCleanup(
}
runParallel(parallelSteps)
+ if pluginManager != nil {
+ pluginManager.Stop()
+ }
runSequential(infraSteps)
select {
diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go
index f6393a9a3ac4..8e85baa50e0d 100644
--- a/backend/cmd/server/wire_gen_test.go
+++ b/backend/cmd/server/wire_gen_test.go
@@ -103,6 +103,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // cindyHealth
nil, // cindyBalanceProbe
nil, // imageStudioRuntime
+ nil, // pluginManager
)
require.NotPanics(t, func() {
diff --git a/backend/go.mod b/backend/go.mod
index 1f6067256ce3..0ec4cd373206 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -2,7 +2,10 @@ module github.com/Wei-Shaw/sub2api
go 1.27.0
+replace github.com/HTExplicit/sub2api-plugins/modelpolicy => ../plugins/model-policy
+
require (
+ github.com/HTExplicit/sub2api-plugins/modelpolicy v0.0.0
entgo.io/ent v0.14.5
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alibabacloud-go/captcha-20230305 v1.1.3
diff --git a/backend/go.sum b/backend/go.sum
index be6667a0eb0d..7e445c134f87 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -652,6 +652,8 @@ golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
+golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
diff --git a/backend/internal/handler/admin/account_batch_test_test.go b/backend/internal/handler/admin/account_batch_test_test.go
index 3cdf2c88e50b..a0cd369ac3ed 100644
--- a/backend/internal/handler/admin/account_batch_test_test.go
+++ b/backend/internal/handler/admin/account_batch_test_test.go
@@ -70,6 +70,37 @@ func TestBatchTestRejectsAmbiguousSelections(t *testing.T) {
}
}
+func TestBatchTestReasoningPersistsPerAccountForRetry(t *testing.T) {
+ h := &AccountHandler{}
+ router := gin.New()
+ repo := attachAccountJobSubmitter(router, h)
+ router.POST("/batch-test", h.BatchTest)
+ req := httptest.NewRequest(http.MethodPost, "/batch-test", strings.NewReader(`{"items":[{"account_id":9,"model_id":"alias-a","reasoning_effort":"ultra"},{"account_id":3,"model_id":"other","reasoning_effort":"low"}]}`))
+ req.Header.Set("Content-Type", "application/json")
+ setAccountJobTestIdempotencyKey(req)
+ out := httptest.NewRecorder()
+ router.ServeHTTP(out, req)
+ require.Equal(t, http.StatusAccepted, out.Code, out.Body.String())
+ var payload batchTestJobPayload
+ params := requireSubmittedAccountJob(t, repo, service.AccountJobKindBatchTest, &payload)
+ want := map[int64]string{9: "ultra", 3: "low"}
+ for _, item := range payload.Items {
+ require.Equal(t, want[item.AccountID], item.ReasoningEffort)
+ }
+ for _, seed := range params.Items {
+ var metadata map[string]any
+ require.NoError(t, json.Unmarshal(seed.Metadata, &metadata))
+ require.Equal(t, want[*seed.TargetAccountID], metadata["reasoning_effort"])
+ }
+ // Restore and normalize the persisted payload as the failed-item retry path
+ // does. Choices stay attached to account IDs despite changed item ordinals.
+ _, _, err := payload.normalize()
+ require.NoError(t, err)
+ for _, item := range payload.Items {
+ require.Equal(t, want[item.AccountID], item.ReasoningEffort)
+ }
+}
+
type batchCatalogAdmin struct {
service.AdminService
calls atomic.Int32
diff --git a/backend/internal/handler/admin/account_batch_testing.go b/backend/internal/handler/admin/account_batch_testing.go
index 1c83149116ef..5c1e530930d9 100644
--- a/backend/internal/handler/admin/account_batch_testing.go
+++ b/backend/internal/handler/admin/account_batch_testing.go
@@ -107,8 +107,16 @@ func (h *AccountHandler) BatchTest(c *gin.Context) {
return
}
seeds := accountJobSeeds(ids)
+ efforts := make(map[int64]string, len(req.Items))
+ for _, item := range req.Items {
+ efforts[item.AccountID] = item.ReasoningEffort
+ }
for i, id := range ids {
- seeds[i].Metadata, _ = json.Marshal(map[string]any{"model_id": models[id]})
+ metadata := map[string]any{"model_id": models[id]}
+ if effort := efforts[id]; effort != "" {
+ metadata["reasoning_effort"] = effort
+ }
+ seeds[i].Metadata, _ = json.Marshal(metadata)
}
h.submitAccountJob(c, service.AccountJobKindBatchTest, req, seeds)
}
diff --git a/backend/internal/handler/admin/account_codex_ticket_test.go b/backend/internal/handler/admin/account_codex_ticket_test.go
index 2b1d7c193588..8aa0dd8465bf 100644
--- a/backend/internal/handler/admin/account_codex_ticket_test.go
+++ b/backend/internal/handler/admin/account_codex_ticket_test.go
@@ -1,36 +1,20 @@
package admin
import (
+ "testing"
+
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
- "testing"
)
-func TestAccountResponseCodexTicketsUsesConfiguredPolicy(t *testing.T) {
- account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}
- h := &AccountHandler{cfg: &config.Config{}}
- require.Empty(t, h.accountResponseFromService(account).CodexTurnTickets)
- require.Empty(t, h.accountListResponseFromService(account).CodexTurnTickets)
- h.cfg.Gateway.OpenAICodexTicket = config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"configured-model"}, FailClosed: false}
- status := h.accountListResponseFromService(account).CodexTurnTickets
- require.Len(t, status, 1)
- require.Equal(t, "configured-model", status[0].Model)
- require.False(t, status[0].Blocked)
- h.cfg.Gateway.OpenAICodexTicket.FailClosed = true
- require.True(t, h.accountResponseFromService(account).CodexTurnTickets[0].Blocked)
-}
-
-func TestAccountResponseCodexTicketsReadsLiveSettingsAfterRestart(t *testing.T) {
+func TestAccountResponseCodexTicketsDoesNotUseLegacySettings(t *testing.T) {
cfg := &config.Config{}
- repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyOpenAICodexTicketEnabled: "true"}}
- settings := service.NewSettingService(repo, cfg)
+ cfg.Gateway.OpenAICodexTicket = config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"legacy-model"}}
h := &AccountHandler{cfg: cfg}
- h.SetCodexTicketSettings(settings)
- account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeSetupToken}
- require.Len(t, h.accountListResponseFromService(account).CodexTurnTickets, 2)
- require.False(t, cfg.Gateway.OpenAICodexTicket.Enabled)
- repo.values[service.SettingKeyOpenAICodexTicketEnabled] = "false"
- settings.InvalidateOpenAICodexTicketEnabledCache()
- require.Empty(t, h.accountResponseFromService(account).CodexTurnTickets)
+ for _, kind := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken} {
+ account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: kind}
+ require.Empty(t, h.accountResponseFromService(account).CodexTurnTickets)
+ require.Empty(t, h.accountListResponseFromService(account).CodexTurnTickets)
+ }
}
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 04b8cf766caf..5f1fe7bd0bdd 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -68,7 +68,6 @@ type AccountHandler struct {
trafficObserver *service.AccountTrafficObserver
accountJobs *service.AccountJobService
cindyJobMutations service.AccountJobCindyMutationRunner
- codexTicketSettings *service.SettingService
codexTicketGateway *service.OpenAIGatewayService
cfg *config.Config
@@ -90,11 +89,6 @@ func (h *AccountHandler) SetAccountTrafficObserver(observer *service.AccountTraf
h.trafficObserver = observer
}
-// SetCodexTicketSettings supplies the live policy without mutating shared config.
-func (h *AccountHandler) SetCodexTicketSettings(settings *service.SettingService) {
- h.codexTicketSettings = settings
-}
-
// NewAccountHandler creates a new admin account handler
func NewAccountHandler(
adminService service.AdminService,
@@ -402,12 +396,8 @@ func (h *AccountHandler) accountListResponseFromService(account *service.Account
}
func (h *AccountHandler) enrichCodexTicketStatus(account *service.Account, out *dto.Account) {
- if h != nil && h.cfg != nil && out != nil {
- cfg := h.cfg.Gateway.OpenAICodexTicket
- if h.codexTicketSettings != nil {
- cfg.Enabled = h.codexTicketSettings.GetOpenAICodexTicketEnabled(context.Background(), cfg.Enabled)
- }
- out.CodexTurnTickets = service.OpenAICodexTicketStatuses(account, cfg, time.Now())
+ if h != nil && h.codexTicketGateway != nil && out != nil {
+ out.CodexTurnTickets = h.codexTicketGateway.CodexTicketStatuses(account)
}
}
diff --git a/backend/internal/handler/admin/plugin_handler.go b/backend/internal/handler/admin/plugin_handler.go
index 624594dc6eba..21a6cad9787f 100644
--- a/backend/internal/handler/admin/plugin_handler.go
+++ b/backend/internal/handler/admin/plugin_handler.go
@@ -2,6 +2,7 @@ package admin
import (
"crypto/rand"
+ "crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -11,7 +12,6 @@ import (
"net/http"
"os"
"path/filepath"
- "sort"
"strconv"
"strings"
"time"
@@ -43,29 +43,47 @@ func (h *PluginHandler) SubmitJob(c *gin.Context) {
return
}
var req struct {
- Operation string `json:"operation"`
- Items map[int64]json.RawMessage `json:"items"`
+ Operation string `json:"operation"`
+ Items []extensionv1.JobTarget `json:"items"`
}
- if c.ShouldBindJSON(&req) != nil || len(req.Items) == 0 || len(req.Items) > 100 || req.Operation == "" || h.jobs == nil {
+ if c.ShouldBindJSON(&req) != nil || len(req.Items) == 0 || len(req.Items) > 3200 || req.Operation == "" || h.jobs == nil {
response.BadRequest(c, "invalid plugin job")
return
}
- var ids []int64
- for accountID, payload := range req.Items {
- if accountID <= 0 || !json.Valid(payload) {
+ accounts := make(map[int64]bool)
+ payloads := make(map[string]json.RawMessage)
+ var seeds []service.AccountJobItemSeed
+ for _, target := range req.Items {
+ accountID, payload := target.AccountID, target.Payload
+ var fields map[string]json.RawMessage
+ if accountID <= 0 || json.Unmarshal(payload, &fields) != nil || fields == nil || len([]rune(target.Label)) > 256 {
response.BadRequest(c, "invalid plugin job target")
return
}
- if err := h.manager.ValidateAdminExtension(c.Request.Context(), id, accountID, req.Operation); err != nil {
- response.Error(c, http.StatusConflict, "Plugin operation is unavailable")
- return
+ if !accounts[accountID] {
+ if err := h.manager.ValidateAdminExtension(c.Request.Context(), id, accountID, req.Operation); err != nil {
+ response.Error(c, http.StatusConflict, "Plugin operation is unavailable")
+ return
+ }
+ accounts[accountID] = true
+ if len(accounts) > 100 {
+ response.BadRequest(c, "plugin jobs support at most 100 accounts")
+ return
+ }
+ }
+ payload, _ = json.Marshal(fields)
+ fingerprint := sha256.Sum256(append([]byte(strconv.FormatInt(accountID, 10)+"\x00"), payload...))
+ action := hex.EncodeToString(fingerprint[:])
+ if _, exists := payloads[action]; exists {
+ continue
}
- ids = append(ids, accountID)
+ payloads[action] = payload
+ itemMeta, _ := json.Marshal(map[string]any{"label": target.Label})
+ seeds = append(seeds, service.AccountJobItemSeed{Ordinal: len(seeds) + 1, Action: action, TargetAccountID: &accountID, Metadata: itemMeta})
}
- sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
- raw, _ := json.Marshal(service.PluginJobPayload{PluginID: id, Operation: req.Operation, Items: req.Items})
- metadata, _ := json.Marshal(map[string]any{"plugin_id": id, "operation": req.Operation, "target_count": len(ids)})
- job, replayed, err := h.jobs.Submit(c.Request.Context(), actor, service.AccountJobKindExtensionOperation, c.GetHeader("Idempotency-Key"), raw, metadata, accountJobSeeds(ids))
+ raw, _ := json.Marshal(service.PluginJobPayload{PluginID: id, Operation: req.Operation, Items: payloads})
+ metadata, _ := json.Marshal(map[string]any{"plugin_id": id, "operation": req.Operation, "target_count": len(seeds), "account_count": len(accounts)})
+ job, replayed, err := h.jobs.Submit(c.Request.Context(), actor, service.AccountJobKindExtensionOperation, c.GetHeader("Idempotency-Key"), raw, metadata, seeds)
if err != nil {
response.ErrorFrom(c, accountJobHTTPError(err))
return
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index b3c57d8090f9..a0563cd8d11f 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -311,9 +311,6 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
- OpenAICodexTicketEnabled: settings.OpenAICodexTicketEnabled,
- OpenAICodexTicketHarvestProxyURL: settings.OpenAICodexTicketHarvestProxyURL,
- OpenAICodexTicketHarvestProxyConfigured: strings.TrimSpace(settings.OpenAICodexTicketHarvestProxyURL) != "",
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go
index 12369560d691..81ac7b8c6fc5 100644
--- a/backend/internal/handler/admin/setting_handler_audit.go
+++ b/backend/internal/handler/admin/setting_handler_audit.go
@@ -483,12 +483,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
- if before.OpenAICodexTicketEnabled != after.OpenAICodexTicketEnabled {
- changed = append(changed, "openai_codex_ticket_enabled")
- }
- if before.OpenAICodexTicketHarvestProxyURL != after.OpenAICodexTicketHarvestProxyURL {
- changed = append(changed, "openai_codex_ticket_harvest_proxy_url")
- }
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
diff --git a/backend/internal/handler/admin/setting_handler_codex_ticket_test.go b/backend/internal/handler/admin/setting_handler_codex_ticket_test.go
index 579d4b2eb56f..67d6a59d262d 100644
--- a/backend/internal/handler/admin/setting_handler_codex_ticket_test.go
+++ b/backend/internal/handler/admin/setting_handler_codex_ticket_test.go
@@ -1,7 +1,6 @@
package admin
import (
- "context"
"net/http"
"net/http/httptest"
"testing"
@@ -11,44 +10,32 @@ import (
"github.com/stretchr/testify/require"
)
-func TestSettingsCodexTicketProxyWriteReadAndHotReload(t *testing.T) {
+func TestSettingsCodexTicketConfigurationHasOnlyPluginWritePath(t *testing.T) {
key := service.SettingKeyOpenAICodexTicketHarvestProxyURL
- oldProxy := "http://user:old-secret@old.example.com:8080"
- newProxy := "socks5h://user:new-secret@new.example.com:1080"
- h, repo := newStepUpSwitchTestHandler(t, map[string]string{key: oldProxy})
- require.Equal(t, oldProxy, h.settingService.GetOpenAICodexTicketHarvestProxyURL(context.Background()))
- rec := doUpdateSettings(t, h, map[string]any{key: newProxy}, nil)
- require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
- require.Equal(t, newProxy, repo.values[key])
- require.Equal(t, newProxy, h.settingService.GetOpenAICodexTicketHarvestProxyURL(context.Background()))
- require.Contains(t, rec.Body.String(), "new-secret")
- require.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
- require.Contains(t, rec.Body.String(), `"openai_codex_ticket_harvest_proxy_configured":true`)
- // Omission, empty input and the masked GET value all preserve the real secret.
- for _, body := range []map[string]any{{"site_name": "updated"}, {key: ""}, {key: service.MaskProxyURL(newProxy)}} {
- rec = doUpdateSettings(t, h, body, nil)
- require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
- require.Equal(t, newProxy, repo.values[key])
+ oldProxy := "http://user:fixture-old-secret@old.example.com:8080"
+ h, repo := newStepUpSwitchTestHandler(t, map[string]string{key: oldProxy, service.SettingKeyOpenAICodexTicketEnabled: "true"})
+ for _, body := range []map[string]any{
+ {key: "ftp://user:fixture-new-secret@new.example.com:21"},
+ {"openai_codex_ticket_enabled": false},
+ {"openai_codex_ticket_clear_proxy": true},
+ } {
+ rec := doUpdateSettings(t, h, body, nil)
+ require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String())
+ require.NotContains(t, rec.Body.String(), "fixture-new-secret")
+ require.Equal(t, oldProxy, repo.values[key])
+ require.Equal(t, "true", repo.values[service.SettingKeyOpenAICodexTicketEnabled])
}
+ rec := doUpdateSettings(t, h, map[string]any{"site_name": "updated"}, nil)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ require.Equal(t, oldProxy, repo.values[key], "legacy seed must remain intact and read-only")
+ require.NotContains(t, rec.Body.String(), "openai_codex_ticket")
+ require.NotContains(t, rec.Body.String(), "fixture-old-secret")
get := httptest.NewRecorder()
c, _ := gin.CreateTestContext(get)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
h.GetSettings(c)
require.Equal(t, http.StatusOK, get.Code)
- require.Contains(t, get.Body.String(), "new-secret")
require.Equal(t, "no-store", get.Header().Get("Cache-Control"))
- require.Contains(t, get.Body.String(), "new.example.com")
- rec = doUpdateSettings(t, h, map[string]any{"openai_codex_ticket_clear_proxy": true}, nil)
- require.Equal(t, http.StatusOK, rec.Code)
- require.Empty(t, repo.values[key])
- require.Equal(t, "false", repo.values[service.SettingKeyOpenAICodexTicketEnabled])
-}
-
-func TestSettingsCodexTicketRejectInvalidProxyWithoutLeakingPassword(t *testing.T) {
- key := service.SettingKeyOpenAICodexTicketHarvestProxyURL
- h, repo := newStepUpSwitchTestHandler(t, map[string]string{key: "http://previous.example.com:8080"})
- rec := doUpdateSettings(t, h, map[string]any{key: "ftp://user:invalid-secret@proxy.example.com:21"}, nil)
- require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
- require.NotContains(t, rec.Body.String(), "invalid-secret")
- require.Equal(t, "http://previous.example.com:8080", repo.values[key])
+ require.NotContains(t, get.Body.String(), "fixture-old-secret")
+ require.NotContains(t, get.Body.String(), "openai_codex_ticket")
}
diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go
index 741763ca5bff..982e284314d2 100644
--- a/backend/internal/handler/admin/setting_handler_update.go
+++ b/backend/internal/handler/admin/setting_handler_update.go
@@ -257,9 +257,6 @@ type UpdateSettingsRequest struct {
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
- OpenAICodexTicketEnabled *bool `json:"openai_codex_ticket_enabled"`
- OpenAICodexTicketHarvestProxyURL string `json:"openai_codex_ticket_harvest_proxy_url"`
- OpenAICodexTicketClearProxy bool `json:"openai_codex_ticket_clear_proxy"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
@@ -498,6 +495,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
+ for _, key := range []string{"openai_codex_ticket_enabled", "openai_codex_ticket_harvest_proxy_url", "openai_codex_ticket_clear_proxy"} {
+ if _, sent := sentFields[key]; sent {
+ response.Error(c, 409, "票据配置已迁移,请在 Codex 票据设置中修改")
+ return
+ }
+ }
var req UpdateSettingsRequest
if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
@@ -505,20 +508,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
auditReq := settingsAuditRequest(req)
omitted := omittedSettingKeys(sentFields)
- if req.OpenAICodexTicketClearProxy {
- disabled := false
- req.OpenAICodexTicketEnabled = &disabled
- req.OpenAICodexTicketHarvestProxyURL = ""
- delete(omitted, service.SettingKeyOpenAICodexTicketEnabled)
- delete(omitted, service.SettingKeyOpenAICodexTicketHarvestProxyURL)
- } else if raw := strings.TrimSpace(req.OpenAICodexTicketHarvestProxyURL); raw != "" && !service.IsMaskedProxyURL(raw) {
- normalized, err := service.NormalizeCodexTicketProxy(raw)
- if err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- req.OpenAICodexTicketHarvestProxyURL = normalized
- }
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
if err != nil {
@@ -1794,22 +1783,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexVersionAutoSyncEnabled
}(),
- OpenAICodexTicketEnabled: func() bool {
- if req.OpenAICodexTicketEnabled != nil {
- return *req.OpenAICodexTicketEnabled
- }
- return previousSettings.OpenAICodexTicketEnabled
- }(),
- OpenAICodexTicketHarvestProxyURL: func() string {
- if req.OpenAICodexTicketClearProxy {
- return ""
- }
- next := strings.TrimSpace(req.OpenAICodexTicketHarvestProxyURL)
- if service.IsMaskedProxyURL(next) {
- return previousSettings.OpenAICodexTicketHarvestProxyURL
- }
- return next
- }(),
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
@@ -2370,9 +2343,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
- OpenAICodexTicketEnabled: updatedSettings.OpenAICodexTicketEnabled,
- OpenAICodexTicketHarvestProxyURL: updatedSettings.OpenAICodexTicketHarvestProxyURL,
- OpenAICodexTicketHarvestProxyConfigured: strings.TrimSpace(updatedSettings.OpenAICodexTicketHarvestProxyURL) != "",
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 5988c2d96d92..4295a478985a 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -222,24 +222,21 @@ type SystemSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
- OpenAITTFTMode string `json:"openai_ttft_mode"`
- EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
- EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
- EnableCCHSigning bool `json:"enable_cch_signing"`
- EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"`
- ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"`
- ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
- EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
- RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
- EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
- AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
- OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
- OpenAICodexClientVersion string `json:"openai_codex_client_version"`
- OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
- OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
- OpenAICodexTicketEnabled bool `json:"openai_codex_ticket_enabled"`
- OpenAICodexTicketHarvestProxyURL string `json:"openai_codex_ticket_harvest_proxy_url"`
- OpenAICodexTicketHarvestProxyConfigured bool `json:"openai_codex_ticket_harvest_proxy_configured"`
+ OpenAITTFTMode string `json:"openai_ttft_mode"`
+ EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
+ EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
+ EnableCCHSigning bool `json:"enable_cch_signing"`
+ EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"`
+ ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"`
+ ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
+ EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
+ RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
+ EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
+ AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
+ OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
+ OpenAICodexClientVersion string `json:"openai_codex_client_version"`
+ OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
+ OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 0200be267ccd..338aece548a7 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -2679,8 +2679,12 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
return
}
task = wrapUsageRecordTaskContext(parent, task)
+ task, abandon := service.TrackQuotaUsageTask(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
+ if mode.Dropped() {
+ abandon()
+ }
return
}
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
@@ -2709,6 +2713,7 @@ func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context,
return
}
task = wrapUsageRecordTaskContext(parent, task)
+ task, _ = service.TrackQuotaUsageTask(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
return
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 114b94f24354..80e367ac65a9 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -324,7 +324,7 @@ func usageRecordContext(parent context.Context, base context.Context) context.Co
if requestID, _ := parent.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
base = context.WithValue(base, ctxkey.RequestID, strings.TrimSpace(requestID))
}
- return base
+ return service.CopyQuotaActivityContext(parent, base)
}
func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) service.UsageRecordTask {
@@ -3444,6 +3444,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.String("schedule_layer", scheduleDecision.Layer),
zap.Int("candidate_count", scheduleDecision.CandidateCount),
)
+ quotaUsageCtx := context.WithValue(ctx, ctxkey.AccountID, account.ID)
+ service.ObserveQuotaAccount(quotaUsageCtx, account.ID)
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
var requestPayloadHash string
@@ -3669,7 +3671,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
turnUsageInput := turnUsageSnapshot.Input(result, h.apiKeyService, turnRecordPricingAt)
accountID := account.ID
requestID := result.RequestID
- h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
+ h.submitOpenAIUsageRecordTask(quotaUsageCtx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, turnUsageInput); err != nil {
reqLog.Error("openai.websocket_record_usage_failed",
zap.Int64("account_id", accountID),
@@ -3928,8 +3930,12 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
return
}
task = wrapUsageRecordTaskContext(parent, task)
+ task, abandon := service.TrackQuotaUsageTask(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
+ if mode.Dropped() {
+ abandon()
+ }
return
}
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
@@ -4230,6 +4236,7 @@ func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Con
return
}
task = wrapUsageRecordTaskContext(parent, task)
+ task, _ = service.TrackQuotaUsageTask(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
return
diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go
index 33fdafb0cdc2..65500a975118 100644
--- a/backend/internal/handler/ops_error_logger.go
+++ b/backend/internal/handler/ops_error_logger.go
@@ -498,6 +498,7 @@ func setOpsSelectedAccount(c *gin.Context, accountID int64, platform ...string)
c.Set(opsAccountIDKey, accountID)
if c.Request != nil {
ctx := context.WithValue(c.Request.Context(), ctxkey.AccountID, accountID)
+ service.ObserveQuotaAccount(ctx, accountID)
if len(platform) > 0 {
p := strings.TrimSpace(platform[0])
if p != "" {
@@ -1108,6 +1109,11 @@ func (state *opsCaptureWriterState) shouldCapture() bool {
// - Streaming errors after the response has started (SSE) may still need explicit logging.
func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
return func(c *gin.Context) {
+ if c.Request != nil {
+ ctx, finish := ops.AttachQuotaActivity(c.Request.Context())
+ c.Request = c.Request.WithContext(ctx)
+ defer finish()
+ }
originalWriter := c.Writer
w := acquireOpsCaptureWriter(originalWriter)
w.setContext(c)
diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go
index 08176f4150e5..6139200c1d71 100644
--- a/backend/internal/handler/wire.go
+++ b/backend/internal/handler/wire.go
@@ -64,7 +64,6 @@ func ProvideAdminHandlers(
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
accountHandler.SetAccountTrafficObserver(accountTrafficObserver)
- accountHandler.SetCodexTicketSettings(settingService)
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
@@ -324,9 +323,6 @@ var ProviderSet = wire.NewSet(
func ProvideAccountJobRuntime(jobs *service.AccountJobService, accountHandler *admin.AccountHandler, pluginManager *service.PluginManager) (*service.AccountJobRuntime, error) {
accountHandler.SetAccountJobService(jobs)
runtime := service.NewAccountJobRuntime(jobs, service.NewPluginJobExecutor(pluginManager, accountHandler))
- if err := runtime.Start(context.Background()); err != nil {
- return nil, err
- }
return runtime, nil
}
diff --git a/backend/internal/pkg/proxyutil/dialer.go b/backend/internal/pkg/proxyutil/dialer.go
index 3e135e3eb9a8..5f1b607c8aba 100644
--- a/backend/internal/pkg/proxyutil/dialer.go
+++ b/backend/internal/pkg/proxyutil/dialer.go
@@ -10,22 +10,17 @@
package proxyutil
import (
- "context"
- "fmt"
+ proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy"
"net"
"net/http"
"net/url"
- "strings"
- "time"
-
- "golang.org/x/net/proxy"
)
const (
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
- socks5DialTimeout = 10 * time.Second
+ socks5DialTimeout = proxytransport.SOCKSDialTimeout
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
- socks5DialKeepAlive = 30 * time.Second
+ socks5DialKeepAlive = proxytransport.SOCKSKeepAlive
)
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
@@ -53,34 +48,5 @@ var socks5ForwardDialer = &net.Dialer{
// 返回:
// - error: 代理配置错误(协议不支持或 dialer 创建失败)
func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error {
- if proxyURL == nil {
- return nil
- }
-
- scheme := strings.ToLower(proxyURL.Scheme)
- switch scheme {
- case "http", "https":
- transport.Proxy = http.ProxyURL(proxyURL)
- return nil
-
- case "socks5", "socks5h":
- dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer)
- if err != nil {
- return fmt.Errorf("create socks5 dialer: %w", err)
- }
- // 优先使用支持 context 的 DialContext,以支持请求取消和超时
- if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
- transport.DialContext = contextDialer.DialContext
- } else {
- // 回退路径:如果 dialer 不支持 ContextDialer,则包装为简单的 DialContext
- // 注意:此回退不支持请求取消和超时控制
- transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
- return dialer.Dial(network, addr)
- }
- }
- return nil
-
- default:
- return fmt.Errorf("unsupported proxy scheme: %s", scheme)
- }
+ return proxytransport.Configure(transport, proxyURL, socks5ForwardDialer)
}
diff --git a/backend/internal/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go
index 9b4eea1e15b0..594aa4c6f0cf 100644
--- a/backend/internal/repository/account_job_repo.go
+++ b/backend/internal/repository/account_job_repo.go
@@ -553,6 +553,19 @@ func (r *accountJobRepository) Cancel(ctx context.Context, jobID, createdBy int6
if err != nil {
return nil, err
}
+ job, err = cancelLockedAccountJob(ctx, tx, job)
+ if err != nil {
+ return nil, err
+ }
+ if err = tx.Commit(); err != nil {
+ return nil, err
+ }
+ return job, nil
+}
+
+func cancelLockedAccountJob(ctx context.Context, tx *sql.Tx, job *service.AccountJob) (*service.AccountJob, error) {
+ jobID := job.ID
+ var err error
switch job.Status {
case service.AccountJobStatusPending:
if _, err = tx.ExecContext(ctx, `UPDATE admin_account_job_items SET status='canceled', finished_at=NOW(), updated_at=NOW()
@@ -571,9 +584,6 @@ func (r *accountJobRepository) Cancel(ctx context.Context, jobID, createdBy int6
if err != nil {
return nil, err
}
- if err = tx.Commit(); err != nil {
- return nil, err
- }
return job, nil
}
diff --git a/backend/internal/repository/account_plugin_projection.go b/backend/internal/repository/account_plugin_projection.go
new file mode 100644
index 000000000000..a921693c6c17
--- /dev/null
+++ b/backend/internal/repository/account_plugin_projection.go
@@ -0,0 +1,27 @@
+package repository
+
+import (
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "maps"
+)
+
+func withoutPluginAccountProjection(extra map[string]any) map[string]any {
+ if _, exists := extra[service.PluginAccountProjectionKey]; !exists {
+ return extra
+ }
+ copy := maps.Clone(extra)
+ delete(copy, service.PluginAccountProjectionKey)
+ return copy
+}
+
+func preservePluginAccountProjection(incoming, current map[string]any) map[string]any {
+ extra := withoutPluginAccountProjection(incoming)
+ if value, exists := current[service.PluginAccountProjectionKey]; exists {
+ extra = maps.Clone(extra)
+ if extra == nil {
+ extra = make(map[string]any)
+ }
+ extra[service.PluginAccountProjectionKey] = value
+ }
+ return extra
+}
diff --git a/backend/internal/repository/account_quota_estimate.go b/backend/internal/repository/account_quota_estimate.go
index 6515ec708bcb..23daa980d813 100644
--- a/backend/internal/repository/account_quota_estimate.go
+++ b/backend/internal/repository/account_quota_estimate.go
@@ -19,9 +19,9 @@ func (r *accountRepository) ObserveQuotaEstimate(ctx context.Context, point serv
_, err := r.sql.ExecContext(ctx, `INSERT INTO account_quota_estimates(account_id,identity_hash,period_key,resets_at,baseline_at,baseline_utilization,baseline_cost,observed_at,utilization,account_cost)
VALUES($1,$2,$3,$4,$5,$6,$7,$5,$6,$7)
ON CONFLICT(account_id,identity_hash,period_key) DO UPDATE SET
- baseline_at=CASE WHEN EXCLUDED.utilizationaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.observed_at ELSE account_quota_estimates.baseline_at END,
- baseline_utilization=CASE WHEN EXCLUDED.utilizationaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.utilization ELSE account_quota_estimates.baseline_utilization END,
- baseline_cost=CASE WHEN EXCLUDED.utilizationaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.account_cost ELSE account_quota_estimates.baseline_cost END,
+ baseline_at=CASE WHEN EXCLUDED.account_costaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.observed_at ELSE account_quota_estimates.baseline_at END,
+ baseline_utilization=CASE WHEN EXCLUDED.account_costaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.utilization ELSE account_quota_estimates.baseline_utilization END,
+ baseline_cost=CASE WHEN EXCLUDED.account_costaccount_quota_estimates.utilization AND EXCLUDED.account_cost<=account_quota_estimates.account_cost) THEN EXCLUDED.account_cost ELSE account_quota_estimates.baseline_cost END,
observed_at=EXCLUDED.observed_at,utilization=EXCLUDED.utilization,account_cost=EXCLUDED.account_cost
WHERE account_quota_estimates.observed_at'codex_primary_observed_at', extra->'codex_usage_updated_at'),
+ 'codex_secondary_observed_at', COALESCE(extra->'codex_secondary_observed_at', extra->'codex_usage_updated_at')))`
+ }
+ extraExpression := baseExtraExpression + " || $1::jsonb"
if clearProbeSnapshot {
extraExpression = "(" + extraExpression + ") - 'upstream_billing_probe'"
}
@@ -2868,13 +2879,13 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
extraExpression = ensureCodexFingerprintSeedSQL(extraExpression)
}
affected := int64(0)
- candidateUpdatedAt, monotonicSnapshot := codexUsageUpdatedAtFromExtraUpdates(updates)
if monotonicSnapshot {
rows, queryErr := client.QueryContext(ctx, `
WITH target AS MATERIALIZED (
SELECT id, extra ->> 'codex_usage_updated_at' AS current_updated_at
FROM accounts
WHERE id = $2 AND deleted_at IS NULL
+ FOR NO KEY UPDATE
), updated AS (
UPDATE accounts AS a
SET extra = `+extraExpression+`, updated_at = NOW()
@@ -3177,6 +3188,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
return 0, nil
}
updates.Extra = stripCodexFingerprintSeedFromExtraUpdate(updates.Extra)
+ updates.Extra = withoutPluginAccountProjection(updates.Extra)
setClauses := make([]string, 0, 8)
args := make([]any, 0, 8)
diff --git a/backend/internal/repository/account_repo_codex_lifecycle.go b/backend/internal/repository/account_repo_codex_lifecycle.go
index d6a02e9bebbc..e3b8f4dd5569 100644
--- a/backend/internal/repository/account_repo_codex_lifecycle.go
+++ b/backend/internal/repository/account_repo_codex_lifecycle.go
@@ -1,42 +1,12 @@
package repository
import (
- "context"
"database/sql"
"encoding/json"
- "errors"
- "time"
"github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/google/uuid"
)
-var _ service.CodexTicketRepository = (*accountRepository)(nil)
-
-func (r *accountRepository) ticketTx(ctx context.Context) (*sql.Tx, error) {
- db, ok := r.sql.(interface {
- BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
- })
- if !ok {
- return nil, errors.New("ticket transactional store unavailable")
- }
- return db.BeginTx(ctx, nil)
-}
-
-func lockTicketAccount(ctx context.Context, tx *sql.Tx, id int64) (*service.Account, error) {
- a := &service.Account{ID: id}
- var credentials, extra []byte
- err := tx.QueryRowContext(ctx, `SELECT platform,type,status,credentials,extra,parent_account_id FROM accounts WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, id).Scan(&a.Platform, &a.Type, &a.Status, &credentials, &extra, &a.ParentAccountID)
- if err != nil {
- return nil, err
- }
- if json.Unmarshal(credentials, &a.Credentials) != nil {
- return nil, service.ErrCodexTicketInactive
- }
- _ = json.Unmarshal(extra, &a.Extra)
- return a, nil
-}
-
const ticketStateColumns = `account_id,model,identity,phase,next_at,expires_at,lease_id,lease_until,operation,job_id,manual_was_enrolled,last_attempt_at,last_result`
func scanTicketState(row interface{ Scan(...any) error }) (*service.CodexTicketLifecycle, error) {
@@ -79,300 +49,3 @@ func ticketFromAccount(a *service.Account, model string) *service.CodexTicketRec
}
return &t
}
-
-func validStoredTicket(a *service.Account, model string, length int, now time.Time) *service.CodexTicketRecord {
- t := ticketFromAccount(a, model)
- if t == nil || len(t.State) != length || len(t.State) < 6 || t.State[:6] != "gAAAAA" || !now.Before(t.ExpiresAt) {
- return nil
- }
- if t.Identity != "" && t.Identity != service.CodexTicketAccountIdentity(a) {
- return nil
- }
- return t
-}
-
-func saveTicketState(ctx context.Context, tx *sql.Tx, s *service.CodexTicketLifecycle, ticket *service.CodexTicketRecord) error {
- result, err := json.Marshal(s.LastResult)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `UPDATE openai_codex_ticket_runtime SET identity=$3,phase=$4,next_at=$5,expires_at=$6,lease_id=$7,lease_until=$8,operation=$9,job_id=$10,manual_was_enrolled=$11,last_attempt_at=$12,last_result=$13::jsonb,updated_at=NOW() WHERE account_id=$1 AND model=$2`, s.AccountID, s.Model, s.Identity, s.Phase, s.NextAt, s.ExpiresAt, s.LeaseID, s.LeaseUntil, s.Operation, s.JobID, s.ManualWasEnrolled, s.LastAttemptAt, string(result))
- if err != nil {
- return err
- }
- updates := map[string]any{service.CodexTicketRuntimeExtraPrefix + s.Model: s}
- if ticket != nil {
- updates["codex_turn_ticket:"+s.Model] = ticket
- }
- raw, err := json.Marshal(updates)
- if err != nil {
- return err
- }
- _, err = tx.ExecContext(ctx, `UPDATE accounts SET extra=(CASE WHEN jsonb_typeof(extra)='object' THEN extra ELSE '{}'::jsonb END)||$2::jsonb WHERE id=$1`, s.AccountID, string(raw))
- return err
-}
-
-func (r *accountRepository) SeedCodexTicketRenewals(ctx context.Context, models []string, length int, now time.Time) error {
- accounts, err := r.ListByPlatform(ctx, service.PlatformOpenAI)
- if err != nil {
- return err
- }
- // The scheduler metadata projection intentionally omits ticket material, so
- // the authoritative full-account snapshot must be refreshed after a restart.
- // Otherwise a valid durable ticket can exist while the cached account still
- // appears ticketless to the full-account gate.
- syncAccountIDs := make([]int64, 0, len(accounts))
- for i := range accounts {
- if !service.CodexTicketAccountEligible(&accounts[i]) {
- continue
- }
- for _, model := range models {
- if validStoredTicket(&accounts[i], model, length, now) == nil {
- continue
- }
- if err := r.seedCodexTicket(ctx, accounts[i].ID, model, length, now); err != nil {
- return err
- }
- syncAccountIDs = append(syncAccountIDs, accounts[i].ID)
- }
- }
- if len(syncAccountIDs) > 0 {
- r.syncSchedulerAccountSnapshots(ctx, syncAccountIDs)
- }
- return nil
-}
-
-func (r *accountRepository) seedCodexTicket(ctx context.Context, id int64, model string, length int, now time.Time) error {
- tx, err := r.ticketTx(ctx)
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback() }()
- a, err := lockTicketAccount(ctx, tx, id)
- if errors.Is(err, sql.ErrNoRows) {
- return nil
- }
- if err != nil {
- return err
- }
- ticket := validStoredTicket(a, model, length, now)
- if ticket == nil || !service.CodexTicketAccountEligible(a) {
- return nil
- }
- identity := service.CodexTicketAccountIdentity(a)
- res, err := tx.ExecContext(ctx, `INSERT INTO openai_codex_ticket_runtime(account_id,model,identity) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, model, identity)
- if err != nil {
- return err
- }
- n, err := res.RowsAffected()
- if err != nil {
- return err
- }
- if n == 0 {
- return nil
- }
- ticket.Identity = identity
- s := &service.CodexTicketLifecycle{AccountID: id, Model: model, Identity: identity}
- s.Complete(now, ticket, service.CodexTicketResult{Success: true, Code: "ticket_imported", ExpiresAt: &ticket.ExpiresAt})
- if err = saveTicketState(ctx, tx, s, ticket); err != nil {
- return err
- }
- return tx.Commit()
-}
-
-func (r *accountRepository) DueCodexTickets(ctx context.Context, models []string, now time.Time, limit int) ([]service.CodexTicketLifecycle, error) {
- if limit < 1 || limit > 5 {
- limit = 5
- }
- raw, err := json.Marshal(models)
- if err != nil {
- return nil, err
- }
- rows, err := r.sql.QueryContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE model IN (SELECT jsonb_array_elements_text($1::jsonb)) AND ((phase IN ('ready','retry') AND next_at<=$2) OR (phase IN ('pre_running','post_running','manual_running') AND lease_until<=$2)) ORDER BY COALESCE(next_at,lease_until),account_id LIMIT $3`, string(raw), now, limit)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- var out []service.CodexTicketLifecycle
- for rows.Next() {
- s, err := scanTicketState(rows)
- if err != nil {
- return nil, err
- }
- out = append(out, *s)
- }
- return out, rows.Err()
-}
-
-func (r *accountRepository) ClaimCodexTicket(ctx context.Context, id int64, model, operation string, jobID int64, manual, force bool, now time.Time) (*service.CodexTicketLifecycle, error) {
- tx, err := r.ticketTx(ctx)
- if err != nil {
- return nil, err
- }
- defer func() { _ = tx.Rollback() }()
- var disabled bool
- if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM settings WHERE key='openai_codex_ticket_enabled' AND value='false')`).Scan(&disabled); err != nil {
- return nil, err
- }
- if disabled {
- return nil, service.ErrCodexTicketDisabled
- }
- a, err := lockTicketAccount(ctx, tx, id)
- if errors.Is(err, sql.ErrNoRows) {
- return nil, service.ErrCodexTicketInactive
- }
- if err != nil {
- return nil, err
- }
- identity := service.CodexTicketAccountIdentity(a)
- _, err = tx.ExecContext(ctx, `INSERT INTO openai_codex_ticket_runtime(account_id,model,identity) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, model, identity)
- if err != nil {
- return nil, err
- }
- s, err := scanTicketState(tx.QueryRowContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2 FOR UPDATE`, id, model))
- if err != nil {
- return nil, err
- }
- finishWithoutIO := func(reason error) (*service.CodexTicketLifecycle, error) {
- if e := saveTicketState(ctx, tx, s, nil); e != nil {
- return nil, e
- }
- if e := tx.Commit(); e != nil {
- return nil, e
- }
- return nil, reason
- }
- if !service.CodexTicketAccountEligible(a) || s.Identity != identity {
- s.Phase = "stopped"
- s.NextAt = nil
- s.LeaseID = ""
- s.LeaseUntil = nil
- if s.Identity != identity {
- s.Identity = identity
- s.ExpiresAt = nil
- delete(a.Extra, "codex_turn_ticket:"+model)
- _, err = tx.ExecContext(ctx, `UPDATE accounts SET extra=extra-$2 WHERE id=$1`, id, "codex_turn_ticket:"+model)
- if err != nil {
- return nil, err
- }
- }
- if !manual || !service.CodexTicketAccountEligible(a) {
- return finishWithoutIO(service.ErrCodexTicketInactive)
- }
- }
- if s.LeaseUntil != nil && now.Before(*s.LeaseUntil) && s.LeaseID != "" {
- return nil, service.ErrCodexTicketBusy
- }
- if s.LeaseID != "" {
- s.Complete(now, nil, service.CodexTicketFailure("ticket_interrupted"))
- }
- if manual && s.Operation == operation {
- return finishWithoutIO(service.ErrCodexTicketAlreadyAttempted)
- }
- if manual && !force && validStoredTicket(a, model, 292, now) != nil {
- return finishWithoutIO(service.ErrCodexTicketValid)
- }
- if jobID > 0 {
- var canceled bool
- if err = tx.QueryRowContext(ctx, `SELECT cancel_requested_at IS NOT NULL OR status<>'running' FROM admin_account_jobs WHERE id=$1`, jobID).Scan(&canceled); err != nil {
- return nil, err
- }
- if canceled {
- return nil, context.Canceled
- }
- }
- if err = s.Begin(now, manual); err != nil {
- return finishWithoutIO(err)
- }
- expires := now.Add(45 * time.Second)
- s.LeaseID = uuid.NewString()
- s.LeaseUntil = &expires
- s.Operation = operation
- s.JobID = jobID
- s.LastAttemptAt = &now
- if err = saveTicketState(ctx, tx, s, nil); err != nil {
- return nil, err
- }
- if err = tx.Commit(); err != nil {
- return nil, err
- }
- return s, nil
-}
-
-func (r *accountRepository) FinishCodexTicket(ctx context.Context, claim *service.CodexTicketLifecycle, ticket *service.CodexTicketRecord, result service.CodexTicketResult, now time.Time) (bool, error) {
- tx, err := r.ticketTx(ctx)
- if err != nil {
- return false, err
- }
- defer func() { _ = tx.Rollback() }()
- a, err := lockTicketAccount(ctx, tx, claim.AccountID)
- if errors.Is(err, sql.ErrNoRows) {
- return false, nil
- }
- if err != nil {
- return false, err
- }
- s, err := scanTicketState(tx.QueryRowContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2 FOR UPDATE`, claim.AccountID, claim.Model))
- if err != nil {
- return false, err
- }
- if s.LeaseID != claim.LeaseID || s.LeaseID == "" {
- return false, nil
- }
- var disabled bool
- if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM settings WHERE key='openai_codex_ticket_enabled' AND value='false')`).Scan(&disabled); err != nil {
- return false, err
- }
- canceled := false
- if s.JobID > 0 {
- if err = tx.QueryRowContext(ctx, `SELECT cancel_requested_at IS NOT NULL OR status<>'running' FROM admin_account_jobs WHERE id=$1`, s.JobID).Scan(&canceled); err != nil {
- return false, err
- }
- }
- if canceled || disabled || !service.CodexTicketAccountEligible(a) || s.Identity != service.CodexTicketAccountIdentity(a) {
- ticket = nil
- result = service.CodexTicketFailure("ticket_stale")
- s.Phase = "stopped"
- s.NextAt = nil
- s.ManualWasEnrolled = false
- }
- s.Complete(now, ticket, result)
- if err = saveTicketState(ctx, tx, s, ticket); err != nil {
- return false, err
- }
- if err = tx.Commit(); err != nil {
- return false, err
- }
- if ticket != nil {
- r.syncSchedulerAccountSnapshotDetached(ctx, a.ID)
- }
- return ticket != nil, nil
-}
-
-func (r *accountRepository) StopCodexTicket(ctx context.Context, id int64, models []string, now time.Time) error {
- tx, err := r.ticketTx(ctx)
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback() }()
- a, err := lockTicketAccount(ctx, tx, id)
- if err != nil {
- return err
- }
- for _, model := range models {
- _, err = tx.ExecContext(ctx, `INSERT INTO openai_codex_ticket_runtime(account_id,model,identity) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, id, model, service.CodexTicketAccountIdentity(a))
- if err != nil {
- return err
- }
- s, err := scanTicketState(tx.QueryRowContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2 FOR UPDATE`, id, model))
- if err != nil {
- return err
- }
- s.Phase = "stopped"
- s.Complete(now, nil, service.CodexTicketFailure("ticket_stopped"))
- if err = saveTicketState(ctx, tx, s, nil); err != nil {
- return err
- }
- }
- return tx.Commit()
-}
diff --git a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go b/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
deleted file mode 100644
index e1f5e54cd44a..000000000000
--- a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go
+++ /dev/null
@@ -1,252 +0,0 @@
-//go:build integration
-
-package repository
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
-)
-
-func newTicketIntegrationAccount(t *testing.T) (*accountRepository, *service.Account, time.Time) {
- t.Helper()
- ctx := context.Background()
- client := testEntClient(t)
- var oldSetting string
- settingErr := integrationDB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='openai_codex_ticket_enabled'`).Scan(&oldSetting)
- require.True(t, settingErr == nil || settingErr == sql.ErrNoRows)
- _, err := integrationDB.ExecContext(ctx, `INSERT INTO settings(key,value) VALUES('openai_codex_ticket_enabled','true') ON CONFLICT(key) DO UPDATE SET value='true'`)
- require.NoError(t, err)
- t.Cleanup(func() {
- if settingErr == sql.ErrNoRows {
- _, _ = integrationDB.ExecContext(ctx, `DELETE FROM settings WHERE key='openai_codex_ticket_enabled'`)
- } else {
- _, _ = integrationDB.ExecContext(ctx, `UPDATE settings SET value=$1 WHERE key='openai_codex_ticket_enabled'`, oldSetting)
- }
- })
- account := mustCreateAccount(t, client, &service.Account{Name: t.Name(), Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusActive, Credentials: map[string]any{"access_token": "fixture", "chatgpt_account_id": "principal-one"}, Extra: map[string]any{"model_context_windows": map[string]any{"gpt-5.6-sol": 200000}}})
- t.Cleanup(func() { _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID) })
- return newAccountRepositoryWithSQL(client, integrationDB, nil), account, time.Now().UTC().Truncate(time.Second)
-}
-
-func successfulTicket(a *service.Account, model string, now time.Time) *service.CodexTicketRecord {
- return &service.CodexTicketRecord{AccountID: a.ID, Model: model, Identity: service.CodexTicketAccountIdentity(a), State: "gAAAAA" + strings.Repeat("B", 286), Length: 292, CapturedAt: now, ExpiresAt: now.Add(time.Hour)}
-}
-
-func TestCodexTicketRepositoryClaimsAtomicTicketsAndStoppedImport(t *testing.T) {
- ctx := context.Background()
- r, a, now := newTicketIntegrationAccount(t)
- model := "gpt-5.6-sol"
- due, err := r.DueCodexTickets(ctx, []string{model}, now, 5)
- require.NoError(t, err)
- for _, item := range due {
- require.NotEqual(t, a.ID, item.AccountID)
- }
- claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual-one", 0, true, false, now)
- require.NoError(t, err)
- _, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-two", 0, true, false, now)
- require.ErrorIs(t, err, service.ErrCodexTicketBusy)
- ticket := successfulTicket(a, model, now)
- applied, err := r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now)
- require.NoError(t, err)
- require.True(t, applied)
- loaded, err := r.GetByID(ctx, a.ID)
- require.NoError(t, err)
- require.Contains(t, loaded.Extra, "model_context_windows")
- require.Contains(t, loaded.Extra, "codex_turn_ticket:"+model)
- _, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-two", 0, true, false, now)
- require.ErrorIs(t, err, service.ErrCodexTicketValid)
- forced, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual-force", 0, true, true, now)
- require.NoError(t, err)
- applied, err = r.FinishCodexTicket(ctx, forced, nil, service.CodexTicketFailure("ticket_length"), now)
- require.NoError(t, err)
- require.False(t, applied)
- loaded, err = r.GetByID(ctx, a.ID)
- require.NoError(t, err)
- raw, _ := json.Marshal(loaded.Extra["codex_turn_ticket:"+model])
- require.Contains(t, string(raw), ticket.State)
- require.NoError(t, r.StopCodexTicket(ctx, a.ID, []string{model}, now))
- require.NoError(t, r.SeedCodexTicketRenewals(ctx, []string{model}, 292, now))
- var phase string
- require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT phase FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&phase))
- require.Equal(t, "stopped", phase)
-}
-
-func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T) {
- ctx := context.Background()
- r, a, now := newTicketIntegrationAccount(t)
- model := "gpt-6-astra"
- results := make(chan *service.CodexTicketLifecycle, 2)
- var wg sync.WaitGroup
- for _, op := range []string{"one", "two"} {
- wg.Add(1)
- go func(op string) {
- defer wg.Done()
- claim, err := r.ClaimCodexTicket(ctx, a.ID, model, op, 0, true, false, now)
- if err == nil {
- results <- claim
- } else {
- require.ErrorIs(t, err, service.ErrCodexTicketBusy)
- }
- }(op)
- }
- wg.Wait()
- close(results)
- var claims []*service.CodexTicketLifecycle
- for c := range results {
- claims = append(claims, c)
- }
- require.Len(t, claims, 1)
- // OAuth refresh preserves the owner and active claim.
- _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET credentials=credentials||'{"access_token":"refreshed"}'::jsonb WHERE id=$1`, a.ID)
- require.NoError(t, err)
- var lease string
- require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT lease_id FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&lease))
- require.Equal(t, claims[0].LeaseID, lease)
- // Business status changes do not revoke a credential owner's ticket claim.
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='disabled' WHERE id=$1`, a.ID)
- require.NoError(t, err)
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='active' WHERE id=$1`, a.ID)
- require.NoError(t, err)
- applied, err := r.FinishCodexTicket(ctx, claims[0], successfulTicket(a, model, now), service.CodexTicketResult{Success: true}, now)
- require.NoError(t, err)
- require.True(t, applied)
- // A legacy ticket without an identity stamp cannot survive changing owner.
- raw, _ := json.Marshal(map[string]any{"codex_turn_ticket:" + model: map[string]any{"state": "gAAAAA" + strings.Repeat("B", 286), "length": 292, "expires_at": now.Add(time.Hour)}})
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=extra||$2::jsonb WHERE id=$1`, a.ID, string(raw))
- require.NoError(t, err)
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET credentials=credentials||'{"chatgpt_account_id":"principal-two"}'::jsonb WHERE id=$1`, a.ID)
- require.NoError(t, err)
- loaded, err := r.GetByID(ctx, a.ID)
- require.NoError(t, err)
- require.NotContains(t, loaded.Extra, "codex_turn_ticket:"+model)
-}
-
-func TestCodexTicketInactiveAccountsHarvestAndRenewWithoutEnabling(t *testing.T) {
- for _, accountType := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken} {
- for _, status := range []string{"disabled", "error"} {
- t.Run(accountType+"/"+status, func(t *testing.T) {
- ctx := context.Background()
- r, a, now := newTicketIntegrationAccount(t)
- _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET type=$2,status=$3,schedulable=false,error_message='keep diagnostic' WHERE id=$1`, a.ID, accountType, status)
- require.NoError(t, err)
- a, err = r.GetByID(ctx, a.ID)
- require.NoError(t, err)
- const model = "gpt-6-astra"
- claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual", 0, true, false, now)
- require.NoError(t, err)
- ticket := successfulTicket(a, model, now)
- applied, err := r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now)
- require.NoError(t, err)
- require.True(t, applied)
- renewAt := ticket.ExpiresAt.Add(-time.Minute)
- renewal, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, renewAt)
- require.NoError(t, err)
- applied, err = r.FinishCodexTicket(ctx, renewal, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
- require.NoError(t, err)
- require.True(t, applied)
- loaded, err := r.GetByID(ctx, a.ID)
- require.NoError(t, err)
- require.Equal(t, status, loaded.Status)
- require.False(t, loaded.Schedulable)
- require.Equal(t, "keep diagnostic", loaded.ErrorMessage)
- // Deletion still revokes a live claim and rejects the late result.
- claim, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-again", 0, true, true, renewAt)
- require.NoError(t, err)
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET deleted_at=NOW() WHERE id=$1`, a.ID)
- require.NoError(t, err)
- applied, err = r.FinishCodexTicket(ctx, claim, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt)
- require.NoError(t, err)
- require.False(t, applied)
- })
- }
- }
-}
-
-func TestCodexTicketRepositoryInterruptedRenewalIsConsumed(t *testing.T) {
- ctx := context.Background()
- r, a, now := newTicketIntegrationAccount(t)
- model := "gpt-5.6-sol"
- claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual", 0, true, false, now)
- require.NoError(t, err)
- ticket := successfulTicket(a, model, now)
- _, err = r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now)
- require.NoError(t, err)
- before := ticket.ExpiresAt.Add(-time.Minute)
- pre, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, before)
- require.NoError(t, err)
- require.Equal(t, "pre_running", pre.Phase)
- // Process crashed. Expired lease consumes the pre-stage; it cannot replay it.
- _, err = r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, before.Add(46*time.Second))
- require.ErrorIs(t, err, service.ErrCodexTicketNotDue)
- post, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, ticket.ExpiresAt.Add(time.Minute))
- require.NoError(t, err)
- require.Equal(t, "post_running", post.Phase)
- _, err = r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, ticket.ExpiresAt.Add(2*time.Minute))
- require.ErrorIs(t, err, service.ErrCodexTicketNotDue)
- var phase string
- require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT phase FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&phase))
- require.Equal(t, "stopped", phase)
-}
-
-type codexTicketSnapshotRecorder struct {
- service.SchedulerCache
- mu sync.Mutex
- accountIDs []int64
-}
-
-func (r *codexTicketSnapshotRecorder) SetAccount(_ context.Context, account *service.Account) error {
- if r == nil || account == nil {
- return nil
- }
- r.mu.Lock()
- defer r.mu.Unlock()
- r.accountIDs = append(r.accountIDs, account.ID)
- return nil
-}
-
-func (r *codexTicketSnapshotRecorder) contains(accountID int64) bool {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, id := range r.accountIDs {
- if id == accountID {
- return true
- }
- }
- return false
-}
-
-func TestCodexTicketSeedRefreshesAuthoritativeSchedulerSnapshot(t *testing.T) {
- ctx := context.Background()
- client := testEntClient(t)
- now := time.Now().UTC().Truncate(time.Second)
- model := "gpt-6-astra"
- account := mustCreateAccount(t, client, &service.Account{
- Name: t.Name(),
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Credentials: map[string]any{"access_token": "seed-refresh-token", "chatgpt_account_id": "seed-refresh-principal"},
- Extra: map[string]any{},
- })
- t.Cleanup(func() {
- _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID)
- })
- account.Extra["codex_turn_ticket:"+model] = successfulTicket(account, model, now)
- raw, err := json.Marshal(account.Extra)
- require.NoError(t, err)
- _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=$2::jsonb WHERE id=$1`, account.ID, string(raw))
- require.NoError(t, err)
-
- recorder := &codexTicketSnapshotRecorder{}
- repo := newAccountRepositoryWithSQL(client, integrationDB, recorder)
- require.NoError(t, repo.SeedCodexTicketRenewals(ctx, []string{model}, 292, now))
- require.True(t, recorder.contains(account.ID), "startup seeding must refresh the full authoritative scheduler snapshot")
-}
diff --git a/backend/internal/repository/codex_ticket_proxy_trust.go b/backend/internal/repository/codex_ticket_proxy_trust.go
deleted file mode 100644
index c3983a60025d..000000000000
--- a/backend/internal/repository/codex_ticket_proxy_trust.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package repository
-
-import (
- "context"
- "encoding/json"
- "errors"
- "strconv"
-
- "github.com/Wei-Shaw/sub2api/internal/service"
-)
-
-func (r *accountRepository) CodexTicketProxyTrustGeneration(ctx context.Context) (string, error) {
- rows, err := r.sql.QueryContext(ctx, `SELECT generation FROM openai_codex_ticket_proxy_generation WHERE id=1`)
- if err != nil {
- return "", err
- }
- defer func() { _ = rows.Close() }()
- if !rows.Next() {
- return "", errors.New("ticket proxy generation unavailable")
- }
- var generation int64
- if err = rows.Scan(&generation); err != nil {
- return "", err
- }
- return strconv.FormatInt(generation, 10), nil
-}
-
-func (r *accountRepository) LoadCodexTicketProxyTrust(ctx context.Context, key string) (*service.CodexTicketProxyTrust, error) {
- rows, err := r.sql.QueryContext(ctx, `SELECT certificates FROM openai_codex_ticket_proxy_trust WHERE proxy_key=$1`, key)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- if !rows.Next() {
- return nil, rows.Err()
- }
- var raw []byte
- if err = rows.Scan(&raw); err != nil {
- return nil, err
- }
- var trust service.CodexTicketProxyTrust
- if err = json.Unmarshal(raw, &trust); err != nil {
- return nil, err
- }
- return &trust, nil
-}
-
-func (r *accountRepository) SaveCodexTicketProxyTrust(ctx context.Context, key string, trust *service.CodexTicketProxyTrust) error {
- if len(key) != 64 || trust == nil || len(trust.Certificates) == 0 {
- return errors.New("invalid ticket proxy trust")
- }
- raw, err := json.Marshal(trust)
- if err != nil {
- return err
- }
- _, err = r.sql.ExecContext(ctx, `INSERT INTO openai_codex_ticket_proxy_trust(proxy_key,certificates) VALUES($1,$2::jsonb) ON CONFLICT(proxy_key) DO UPDATE SET certificates=EXCLUDED.certificates,updated_at=NOW()`, key, string(raw))
- return err
-}
diff --git a/backend/internal/repository/integration_harness_test.go b/backend/internal/repository/integration_harness_test.go
index cd1b3a37bc19..b72bb63e7954 100644
--- a/backend/internal/repository/integration_harness_test.go
+++ b/backend/internal/repository/integration_harness_test.go
@@ -7,8 +7,10 @@ import (
"database/sql"
"fmt"
"log"
+ "net/url"
"os"
"os/exec"
+ "regexp"
"strconv"
"strings"
"sync/atomic"
@@ -44,6 +46,9 @@ var (
func TestMain(m *testing.M) {
ctx := context.Background()
+ if dsn := strings.TrimSpace(os.Getenv("SUB2API_TEST_POSTGRES_ONLY_DSN")); dsn != "" {
+ os.Exit(runLocalPostgresTests(m, dsn))
+ }
if err := timezone.Init("UTC"); err != nil {
log.Printf("failed to init timezone: %v", err)
@@ -138,6 +143,40 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
+// Explicit local-only mode exercises the same migrations against a disposable
+// native PostgreSQL when Docker is unavailable. Redis-dependent cases are
+// reported skipped, never emulated as successful integration tests.
+func runLocalPostgresTests(m *testing.M, dsn string) int {
+ parsed, err := url.Parse(dsn)
+ if err != nil || parsed.Scheme != "postgresql" || parsed.Hostname() != "127.0.0.1" || parsed.Port() == "" || parsed.Port() == "5432" || parsed.User == nil || parsed.User.Username() != "codex_test" || !regexp.MustCompile(`^/sub2api_test_[a-z0-9_]+$`).MatchString(parsed.Path) {
+ log.Print("local PostgreSQL test DSN must name a disposable loopback database on a nondefault port")
+ return 1
+ }
+ for key := range parsed.Query() {
+ if key != "sslmode" && key != "TimeZone" {
+ log.Print("unsupported local test connection option")
+ return 1
+ }
+ }
+ ctx := context.Background()
+ if err = timezone.Init("UTC"); err != nil {
+ return 1
+ }
+ integrationDB, err = openSQLWithRetry(ctx, dsn, 15*time.Second)
+ if err != nil {
+ log.Print("local PostgreSQL test connection failed")
+ return 1
+ }
+ defer integrationDB.Close()
+ if err = ApplyMigrations(ctx, integrationDB); err != nil {
+ log.Printf("local test migrations failed: %v", err)
+ return 1
+ }
+ integrationEntClient = dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, integrationDB)))
+ defer integrationEntClient.Close()
+ return m.Run()
+}
+
func dockerIsAvailable(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "docker", "info")
cmd.Env = os.Environ()
@@ -251,6 +290,9 @@ func testEntSQLTx(t *testing.T) (*dbent.Client, *sql.Tx) {
func testRedis(t *testing.T) *redisclient.Client {
t.Helper()
+ if integrationRedis == nil {
+ t.Skip("Redis integration is not available in explicit PostgreSQL-only mode")
+ }
prefix := fmt.Sprintf(
"it:%s:%d:%d:",
diff --git a/backend/internal/repository/plugin_bundle.go b/backend/internal/repository/plugin_bundle.go
new file mode 100644
index 000000000000..25edc1479467
--- /dev/null
+++ b/backend/internal/repository/plugin_bundle.go
@@ -0,0 +1,169 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "strconv"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func (r *pluginRepository) BundleApplied(ctx context.Context, key, bundle string) (bool, error) {
+ var applied bool
+ err := r.db.QueryRowContext(ctx, `SELECT user_removed OR (completed AND bundle_sha256=$2) FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, key, bundle).Scan(&applied)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return applied, err
+}
+
+// Package replacement is staged with the previous activation intent recorded
+// durably. A restart during migration cannot silently reset an enabled plugin.
+func (r *pluginRepository) PrepareBundledPlugin(ctx context.Context, plugin *service.PluginInstallation, bundle, profile string, enabled bool, config string) (*service.PluginInstallation, error) {
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+ if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, "plugin-bundle:"+plugin.PluginKey); err != nil {
+ return nil, err
+ }
+ var journalBundle string
+ var journalEnabled bool
+ var imported, complete, removed bool
+ var oldProfile string
+ err = tx.QueryRowContext(ctx, `SELECT bundle_sha256,desired_enabled,migration_profile,state_imported,completed,user_removed FROM sub2api_plugin_bootstrap WHERE plugin_key=$1 FOR UPDATE`, plugin.PluginKey).Scan(&journalBundle, &journalEnabled, &oldProfile, &imported, &complete, &removed)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, err
+ }
+ if removed || (journalBundle == bundle && complete) {
+ return nil, nil
+ }
+ if oldProfile != profile {
+ imported = false
+ }
+ if journalBundle == bundle {
+ enabled = journalEnabled
+ } else {
+ var oldID int64
+ var oldConfig string
+ err = tx.QueryRowContext(ctx, `SELECT id,config_encrypted FROM sub2api_plugin_installations WHERE plugin_key=$1 FOR UPDATE`, plugin.PluginKey).Scan(&oldID, &oldConfig)
+ if err == nil {
+ if oldConfig != "" {
+ config = oldConfig
+ }
+ if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM sub2api_plugin_bindings WHERE plugin_id=$1 AND enabled)`, oldID).Scan(&enabled); err != nil {
+ return nil, err
+ }
+ } else if !errors.Is(err, sql.ErrNoRows) {
+ return nil, err
+ }
+ }
+ manifest, err := json.Marshal(plugin.Manifest)
+ if err != nil {
+ return nil, err
+ }
+ var id int64
+ err = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_installations(plugin_key,name,version,description,author,manifest,artifact_data,artifact_path,install_path,binary_path,binary_sha256,signature_status,state,config_encrypted,last_error,installed_at,updated_at)
+ VALUES($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12,'disabled',$13,'',NOW(),NOW())
+ ON CONFLICT(plugin_key) DO UPDATE SET name=EXCLUDED.name,version=EXCLUDED.version,description=EXCLUDED.description,author=EXCLUDED.author,manifest=EXCLUDED.manifest,
+ artifact_data=EXCLUDED.artifact_data,artifact_path=EXCLUDED.artifact_path,install_path=EXCLUDED.install_path,binary_path=EXCLUDED.binary_path,binary_sha256=EXCLUDED.binary_sha256,
+ signature_status=EXCLUDED.signature_status,state='disabled',config_encrypted=CASE WHEN sub2api_plugin_installations.config_encrypted='' THEN EXCLUDED.config_encrypted ELSE sub2api_plugin_installations.config_encrypted END,last_error='',updated_at=NOW()
+ RETURNING id`, plugin.PluginKey, plugin.Name, plugin.Version, plugin.Description, plugin.Author, manifest, plugin.ArtifactData, plugin.ArtifactPath, plugin.InstallPath, plugin.BinaryPath, plugin.BinarySHA256, plugin.SignatureStatus, config).Scan(&id)
+ if err != nil {
+ return nil, err
+ }
+ bindings := make([]service.PluginBinding, 0, len(plugin.Manifest.Capabilities))
+ for _, capability := range plugin.Manifest.Capabilities {
+ bindings = append(bindings, service.PluginBinding{Capability: capability.ID, Platform: capability.Platform, AccountType: capability.AccountType, RolloutPercent: 100})
+ }
+ if err = replacePluginBindings(ctx, tx, id, bindings); err != nil {
+ return nil, err
+ }
+ _, err = tx.ExecContext(ctx, `INSERT INTO sub2api_plugin_bootstrap(plugin_key,bundle_sha256,migration_profile,desired_enabled,completed,state_imported) VALUES($1,$2,$3,$4,false,$5)
+ ON CONFLICT(plugin_key) DO UPDATE SET bundle_sha256=EXCLUDED.bundle_sha256,migration_profile=EXCLUDED.migration_profile,desired_enabled=EXCLUDED.desired_enabled,completed=false,state_imported=EXCLUDED.state_imported,updated_at=NOW()`, plugin.PluginKey, bundle, profile, enabled, imported)
+ if err != nil {
+ return nil, err
+ }
+ if err = tx.Commit(); err != nil {
+ return nil, err
+ }
+ return r.GetByID(ctx, id)
+}
+
+func (r *pluginRepository) CompleteBundledPlugin(ctx context.Context, id int64, bundle string) error {
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ var key string
+ var enabled bool
+ err = tx.QueryRowContext(ctx, `SELECT p.plugin_key,b.desired_enabled FROM sub2api_plugin_installations p JOIN sub2api_plugin_bootstrap b ON b.plugin_key=p.plugin_key WHERE p.id=$1 AND b.bundle_sha256=$2 FOR UPDATE OF p,b`, id, bundle).Scan(&key, &enabled)
+ if err != nil {
+ return err
+ }
+ state := service.PluginStateDisabled
+ if enabled {
+ state = service.PluginStateEnabled
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bindings SET enabled=$2,updated_at=NOW() WHERE plugin_id=$1`, id, enabled); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_installations SET state=$2,updated_at=NOW() WHERE id=$1`, id, state); err != nil {
+ return err
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET completed=true,state_imported=true,updated_at=NOW() WHERE plugin_key=$1 AND bundle_sha256=$2`, key, bundle); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (r *pluginRepository) LegacyBundleSeed(ctx context.Context, key, profile string, fallback service.PluginBundleSeed) (service.PluginBundleSeed, error) {
+ var imported bool
+ err := r.db.QueryRowContext(ctx, `SELECT state_imported FROM sub2api_plugin_bootstrap WHERE plugin_key=$1 AND migration_profile=$2`, key, profile).Scan(&imported)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return fallback, err
+ }
+ if imported {
+ return fallback, nil
+ }
+ if profile == "" {
+ return fallback, nil
+ }
+ if profile != "codex-tickets-v1" {
+ return fallback, errors.New("unsupported legacy plugin migration")
+ }
+ var config map[string]any
+ if err := json.Unmarshal(fallback.Config, &config); err != nil {
+ return fallback, err
+ }
+ rows, err := r.db.QueryContext(ctx, `SELECT key,value FROM settings WHERE key IN ('openai_codex_ticket_enabled','openai_codex_ticket_harvest_proxy_url')`)
+ if err != nil {
+ return fallback, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return fallback, err
+ }
+ switch key {
+ case "openai_codex_ticket_enabled":
+ v, err := strconv.ParseBool(value)
+ if err != nil {
+ return fallback, errors.New("invalid legacy ticket switch")
+ }
+ config["enabled"] = v
+ case "openai_codex_ticket_harvest_proxy_url":
+ config["proxy_url"] = value
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return fallback, err
+ }
+ fallback.Config, err = json.Marshal(config)
+ return fallback, err
+}
diff --git a/backend/internal/repository/plugin_bundle_integration_test.go b/backend/internal/repository/plugin_bundle_integration_test.go
new file mode 100644
index 000000000000..3acaef6c9f68
--- /dev/null
+++ b/backend/internal/repository/plugin_bundle_integration_test.go
@@ -0,0 +1,96 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func bundleFixture(t *testing.T, repo *pluginRepository) *service.PluginInstallation {
+ t.Helper()
+ key := "codexrip.test-" + uuid.NewString()
+ t.Cleanup(func() {
+ for _, table := range []string{"sub2api_plugin_state", "sub2api_plugin_bootstrap", "sub2api_plugin_installations"} {
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM "+table+" WHERE plugin_key=$1", key)
+ }
+ })
+ return &service.PluginInstallation{PluginKey: key, Name: "bundle fixture", Version: "1.0.0", BinarySHA256: strings.Repeat("a", 64), SignatureStatus: service.PluginSignatureTrusted, Manifest: service.PluginManifest{ID: key, Capabilities: []service.PluginCapability{{ID: extensionv1.CapabilityAdmin, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth}}}, ArtifactData: []byte("fixture")}
+}
+
+func TestPluginBundleResumesWithoutResettingActivationOrReinstallingRemovedPlugin(t *testing.T) {
+ ctx := context.Background()
+ repo := &pluginRepository{db: integrationDB}
+ artifact := bundleFixture(t, repo)
+ first, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-one", "", true, "first-config")
+ require.NoError(t, err)
+ require.Equal(t, service.PluginStateDisabled, first.State)
+ resumed, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-one", "", false, "replacement-config")
+ require.NoError(t, err)
+ require.Equal(t, "first-config", resumed.ConfigEncrypted)
+ require.NoError(t, repo.CompleteBundledPlugin(ctx, first.ID, "bundle-one"))
+ current, err := repo.GetByID(ctx, first.ID)
+ require.NoError(t, err)
+ require.True(t, current.Bindings[0].Enabled)
+ current.Bindings[0].Enabled = false
+ require.NoError(t, repo.UpdateBindingsAndState(ctx, current.ID, current.Bindings, service.PluginStateDisabled, "", nil, service.PluginStateEnabled, current.BinarySHA256))
+ artifact.Version = "1.0.1"
+ artifact.BinarySHA256 = strings.Repeat("b", 64)
+ updated, err := repo.PrepareBundledPlugin(ctx, artifact, "bundle-two", "", true, "new-defaults")
+ require.NoError(t, err)
+ require.Equal(t, "first-config", updated.ConfigEncrypted)
+ require.NoError(t, repo.CompleteBundledPlugin(ctx, updated.ID, "bundle-two"))
+ updated, err = repo.GetByID(ctx, updated.ID)
+ require.NoError(t, err)
+ require.False(t, updated.Bindings[0].Enabled)
+ require.NoError(t, repo.Delete(ctx, updated.ID, updated.BinarySHA256))
+ applied, err := repo.BundleApplied(ctx, artifact.PluginKey, "a-future-bundle")
+ require.NoError(t, err)
+ require.True(t, applied, "explicit removal must survive later bundle upgrades")
+}
+
+func TestPluginBundleDoesNotResurrectStoppedTicketWhenLegacyRowsChange(t *testing.T) {
+ ctx := context.Background()
+ repo := &pluginRepository{db: integrationDB}
+ artifact := bundleFixture(t, repo)
+ now := time.Now().UTC().Truncate(time.Second)
+ expiry := now.Add(time.Hour)
+ account := mustCreateAccount(t, testEntClient(t), &service.Account{Name: "migration-" + uuid.NewString(), Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "owner"}})
+ identity := service.CodexTicketAccountIdentity(account)
+ ticket := service.CodexTicketRecord{State: "gAAAAA" + strings.Repeat("x", 286), AccountID: account.ID, Identity: identity, Model: "gpt-6-astra", CapturedAt: now, ExpiresAt: expiry, Length: 292}
+ extra, _ := json.Marshal(map[string]any{"codex_turn_ticket:gpt-6-astra": ticket})
+ _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=$2::jsonb WHERE id=$1`, account.ID, extra)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, `INSERT INTO openai_codex_ticket_runtime(account_id,model,identity,phase,expires_at) VALUES($1,'gpt-6-astra',$2,'stopped',$3)`, account.ID, identity, expiry)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM scheduler_outbox WHERE account_id=$1`, account.ID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID)
+ })
+ installation, err := repo.PrepareBundledPlugin(ctx, artifact, "migration-bundle", "codex-tickets-v1", true, "config")
+ require.NoError(t, err)
+ configuration := json.RawMessage(`{"models":["gpt-6-astra"],"proxy_url":""}`)
+ require.NoError(t, repo.ImportLegacyPluginState(ctx, installation, "codex-tickets-v1", configuration))
+ require.NoError(t, repo.CompleteBundledPlugin(ctx, installation.ID, "migration-bundle"))
+ _, err = integrationDB.ExecContext(ctx, `UPDATE openai_codex_ticket_runtime SET phase='ready',next_at=NOW() WHERE account_id=$1`, account.ID)
+ require.NoError(t, err)
+ require.NoError(t, repo.ImportLegacyPluginState(ctx, installation, "codex-tickets-v1", configuration))
+ modelHash := sha256.Sum256([]byte("gpt-6-astra"))
+ stored, err := repo.ReadExtensionState(ctx, artifact.PluginKey, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(modelHash[:])})
+ require.NoError(t, err)
+ var state map[string]any
+ require.NoError(t, json.Unmarshal(stored.Value, &state))
+ require.Equal(t, "stopped", state["phase"])
+ require.Nil(t, state["next_attempt_at"])
+}
diff --git a/backend/internal/repository/plugin_extension_state.go b/backend/internal/repository/plugin_extension_state.go
index bed6f7c62489..67b076b5029c 100644
--- a/backend/internal/repository/plugin_extension_state.go
+++ b/backend/internal/repository/plugin_extension_state.go
@@ -3,7 +3,9 @@ package repository
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
+ "github.com/Wei-Shaw/sub2api/internal/service"
extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
)
@@ -20,22 +22,93 @@ func (r *pluginRepository) ReadExtensionState(ctx context.Context, plugin string
func (r *pluginRepository) CompareSwapExtensionState(ctx context.Context, plugin string, req extensionv1.StateRequest) (extensionv1.StateResult, error) {
var out extensionv1.StateResult
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return out, err
+ }
+ defer tx.Rollback()
+ var projectionData map[string]any
+ if req.Projection != nil {
+ projectionData, err = lockPluginAccountProjection(ctx, tx, plugin, req.Projection)
+ if err != nil {
+ return out, err
+ }
+ }
var row *sql.Row
if req.ExpectedRevision == 0 {
- row = r.db.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value,next_at)
+ row = tx.QueryRowContext(ctx, `INSERT INTO sub2api_plugin_state(plugin_key,namespace,state_key,value,next_at)
VALUES($1,$2,$3,$4::jsonb,$5) ON CONFLICT DO NOTHING RETURNING revision,value`, plugin, req.Namespace, req.Key, []byte(req.Value), req.NextAt)
} else {
- row = r.db.QueryRowContext(ctx, `UPDATE sub2api_plugin_state SET value=$4::jsonb,revision=revision+1,updated_at=NOW(),next_at=$6
+ row = tx.QueryRowContext(ctx, `UPDATE sub2api_plugin_state SET value=$4::jsonb,revision=revision+1,updated_at=NOW(),next_at=$6
WHERE plugin_key=$1 AND namespace=$2 AND state_key=$3 AND revision=$5 RETURNING revision,value`, plugin, req.Namespace, req.Key, []byte(req.Value), req.ExpectedRevision, req.NextAt)
}
- err := row.Scan(&out.Revision, &out.Value)
+ err = row.Scan(&out.Revision, &out.Value)
if errors.Is(err, sql.ErrNoRows) {
+ _ = tx.Rollback()
return r.ReadExtensionState(ctx, plugin, req)
}
+ if err != nil {
+ return out, err
+ }
+ if req.Projection != nil {
+ raw, marshalErr := json.Marshal(projectionData)
+ if marshalErr != nil {
+ return out, marshalErr
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE accounts SET extra=jsonb_set(COALESCE(NULLIF(extra,'null'::jsonb),'{}'::jsonb),ARRAY[$2],$3::jsonb,true),updated_at=NOW() WHERE id=$1`, req.Projection.AccountID, service.PluginAccountProjectionKey, raw); err != nil {
+ return out, err
+ }
+ if err = enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventAccountChanged, &req.Projection.AccountID, nil, nil); err != nil {
+ return out, err
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return out, err
+ }
out.Found, out.Applied = err == nil, err == nil
+ if req.Projection != nil && r.refreshAccount != nil {
+ r.refreshAccount(ctx, req.Projection.AccountID)
+ }
return out, err
}
+func lockPluginAccountProjection(ctx context.Context, tx *sql.Tx, plugin string, projection *extensionv1.AccountProjection) (map[string]any, error) {
+ var platform, accountType string
+ var credentials, extra []byte
+ if err := tx.QueryRowContext(ctx, `SELECT platform,type,credentials,extra FROM accounts WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, projection.AccountID).Scan(&platform, &accountType, &credentials, &extra); err != nil {
+ return nil, err
+ }
+ a := &service.Account{ID: projection.AccountID, Platform: platform, Type: accountType}
+ if json.Unmarshal(credentials, &a.Credentials) != nil || json.Unmarshal(extra, &a.Extra) != nil {
+ return nil, errors.New("invalid account data for projection")
+ }
+ if service.CodexTicketAccountIdentity(a) != projection.Identity {
+ return nil, errors.New("credential owner changed")
+ }
+ values, _ := a.Extra[service.PluginAccountProjectionKey].(map[string]any)
+ if values == nil {
+ values = make(map[string]any)
+ }
+ current := service.PluginAccountProjection{Identity: projection.Identity, Scheduling: make(map[string]extensionv1.SchedulingConstraint)}
+ if raw, err := json.Marshal(values[plugin]); err == nil {
+ var previous service.PluginAccountProjection
+ if json.Unmarshal(raw, &previous) == nil && previous.Identity == projection.Identity && previous.Scheduling != nil {
+ current = previous
+ }
+ }
+ for _, constraint := range projection.Scheduling {
+ current.Scheduling[constraint.Model] = constraint
+ }
+ if current.Observations == nil {
+ current.Observations = make(map[string]extensionv1.AccountObservation)
+ }
+ for _, observation := range projection.Observations {
+ current.Observations[observation.Key] = observation
+ }
+ values[plugin] = current
+ return values, nil
+}
+
func (r *pluginRepository) DueExtensionStates(ctx context.Context, plugin string, req extensionv1.DueStateRequest) ([]extensionv1.DueState, error) {
rows, err := r.db.QueryContext(ctx, `SELECT state_key,revision,value FROM sub2api_plugin_state WHERE plugin_key=$1 AND namespace=$2 AND next_at<=NOW() ORDER BY next_at,state_key LIMIT $3`, plugin, req.Namespace, req.Limit)
if err != nil {
diff --git a/backend/internal/repository/plugin_extension_state_integration_test.go b/backend/internal/repository/plugin_extension_state_integration_test.go
new file mode 100644
index 000000000000..a12d4af29932
--- /dev/null
+++ b/backend/internal/repository/plugin_extension_state_integration_test.go
@@ -0,0 +1,129 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPluginExtensionStateAndSchedulingProjectionCommitTogether(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ account := mustCreateAccount(t, client, &service.Account{Name: "projection-" + uuid.NewString(), Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusError, Schedulable: false, ErrorMessage: "operator note", Credentials: map[string]any{"chatgpt_account_id": "subject"}, Extra: map[string]any{"user_setting": "preserve"}})
+ // The shared fixture helper defaults accounts to schedulable. Establish the
+ // operator-disabled state explicitly before exercising the plugin write.
+ _, setupErr := integrationDB.ExecContext(ctx, `UPDATE accounts SET schedulable=false WHERE id=$1`, account.ID)
+ require.NoError(t, setupErr)
+ plugin := "local.test.projection-" + uuid.NewString()
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_state WHERE plugin_key=$1`, plugin)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM scheduler_outbox WHERE account_id=$1`, account.ID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id=$1`, account.ID)
+ })
+ refreshed := 0
+ repo := &pluginRepository{db: integrationDB, refreshAccount: func(context.Context, int64) { refreshed++ }}
+ until := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
+ request := extensionv1.StateRequest{Namespace: "tickets", Key: "one", Value: json.RawMessage(`{"phase":"ready","private_material":"test-only-secret"}`), Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: service.CodexTicketAccountIdentity(account), Scheduling: []extensionv1.SchedulingConstraint{{Model: "gpt-6-astra", Effect: "allow", Until: &until, Reason: "ticket_ready"}}}}
+ written, err := repo.CompareSwapExtensionState(ctx, plugin, request)
+ require.NoError(t, err)
+ require.True(t, written.Applied)
+ require.EqualValues(t, 1, written.Revision)
+ require.Equal(t, 1, refreshed)
+ var extra []byte
+ var state, note string
+ var schedulable bool
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT extra,status,error_message,schedulable FROM accounts WHERE id=$1`, account.ID).Scan(&extra, &state, ¬e, &schedulable))
+ require.Contains(t, string(extra), "ticket_ready")
+ require.Contains(t, string(extra), "user_setting")
+ require.NotContains(t, string(extra), "test-only-secret")
+ require.Equal(t, service.StatusError, state)
+ require.Equal(t, "operator note", note)
+ require.False(t, schedulable)
+ request.Value = json.RawMessage(`{"phase":"stopped"}`)
+ request.Projection.Scheduling[0].Effect = "deny"
+ stale, err := repo.CompareSwapExtensionState(ctx, plugin, request)
+ require.NoError(t, err)
+ require.False(t, stale.Applied)
+ require.Equal(t, 1, refreshed)
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT extra FROM accounts WHERE id=$1`, account.ID).Scan(&extra))
+ require.Contains(t, string(extra), `"effect": "allow"`)
+ _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET credentials='{"chatgpt_account_id":"another-subject"}'::jsonb WHERE id=$1`, account.ID)
+ require.NoError(t, err)
+ request.ExpectedRevision = 1
+ _, err = repo.CompareSwapExtensionState(ctx, plugin, request)
+ require.Error(t, err)
+ stored, err := repo.ReadExtensionState(ctx, plugin, request)
+ require.NoError(t, err)
+ require.EqualValues(t, 1, stored.Revision)
+}
+
+func TestPluginExtensionLeaseGenerationPreventsStaleRelease(t *testing.T) {
+ ctx := context.Background()
+ plugin := "local.test.lease-" + uuid.NewString()
+ repo := &pluginRepository{db: integrationDB}
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_leases WHERE plugin_key=$1`, plugin)
+ })
+ req := extensionv1.LeaseRequest{Namespace: "tickets", Key: "one", Owner: "worker", TTLSeconds: 30}
+ first, err := repo.AcquireExtensionLease(ctx, plugin, req)
+ require.NoError(t, err)
+ require.True(t, first.Acquired)
+ req.Generation = first.Generation
+ _, err = repo.ReleaseExtensionLease(ctx, plugin, req)
+ require.NoError(t, err)
+ second, err := repo.AcquireExtensionLease(ctx, plugin, req)
+ require.NoError(t, err)
+ require.True(t, second.Acquired)
+ require.Greater(t, second.Generation, first.Generation)
+ _, err = repo.ReleaseExtensionLease(ctx, plugin, req)
+ require.NoError(t, err)
+ third, err := repo.AcquireExtensionLease(ctx, plugin, req)
+ require.NoError(t, err)
+ require.False(t, third.Acquired)
+}
+
+func TestDisablingPluginCancelsOwnedJobsInSameTransaction(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{Email: "plugin-job-" + uuid.NewString() + "@example.com", PasswordHash: "test-hash"})
+ plugins := &pluginRepository{db: integrationDB}
+ plugin, err := plugins.Install(ctx, &service.PluginInstallation{PluginKey: "local.test.cancel-" + uuid.NewString(), Name: "test", Version: "1.0.0", Manifest: service.PluginManifest{ID: "test.plugin"}, BinarySHA256: strings.Repeat("c", 64), SignatureStatus: service.PluginSignatureTrusted, State: service.PluginStateEnabled}, []service.PluginBinding{{Capability: extensionv1.CapabilityAdmin, Platform: service.PlatformOpenAI, AccountType: service.AccountTypeOAuth, Enabled: true, RolloutPercent: 100}})
+ require.NoError(t, err)
+ jobs := NewAccountJobRepository(integrationDB)
+ var ids []int64
+ t.Cleanup(func() {
+ for _, id := range ids {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM admin_account_jobs WHERE id=$1`, id)
+ }
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM sub2api_plugin_installations WHERE id=$1`, plugin.ID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, user.ID)
+ })
+ for range 2 {
+ job, _, err := jobs.Create(ctx, service.CreateAccountJobParams{CreatedBy: user.ID, Kind: service.AccountJobKindExtensionOperation, IdempotencyKey: uuid.NewString(), RequestHash: strings.Repeat("a", 64), PayloadCipher: "test-payload", PayloadExpires: time.Now().Add(time.Hour), Metadata: json.RawMessage(fmt.Sprintf(`{"plugin_id":%d}`, plugin.ID)), Items: []service.AccountJobItemSeed{{Ordinal: 1, Metadata: json.RawMessage(`{}`)}}, Attempt: 1})
+ require.NoError(t, err)
+ ids = append(ids, job.ID)
+ }
+ _, err = integrationDB.ExecContext(ctx, `UPDATE admin_account_jobs SET status='running',started_at=NOW() WHERE id=$1`, ids[1])
+ require.NoError(t, err)
+ for i := range plugin.Bindings {
+ plugin.Bindings[i].Enabled = false
+ }
+ require.NoError(t, plugins.UpdateBindingsAndState(ctx, plugin.ID, plugin.Bindings, service.PluginStateDisabled, "", nil, service.PluginStateEnabled, plugin.BinarySHA256))
+ pending, err := jobs.Get(ctx, ids[0])
+ require.NoError(t, err)
+ require.Equal(t, service.AccountJobStatusCanceled, pending.Status)
+ require.Equal(t, pending.TargetCount, pending.CanceledCount)
+ running, err := jobs.Get(ctx, ids[1])
+ require.NoError(t, err)
+ require.NotNil(t, running.CancelRequestedAt)
+}
diff --git a/backend/internal/repository/plugin_legacy_import.go b/backend/internal/repository/plugin_legacy_import.go
new file mode 100644
index 000000000000..5ca35154ad1f
--- /dev/null
+++ b/backend/internal/repository/plugin_legacy_import.go
@@ -0,0 +1,174 @@
+package repository
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+)
+
+// This is a one-time representation adapter. Lifecycle decisions run in the
+// plugin after cutover; completed bundle journals never read these old tables.
+func (r *pluginRepository) ImportLegacyPluginState(ctx context.Context, plugin *service.PluginInstallation, profile string, configuration json.RawMessage) error {
+ var imported bool
+ if err := r.db.QueryRowContext(ctx, `SELECT state_imported FROM sub2api_plugin_bootstrap WHERE plugin_key=$1`, plugin.PluginKey).Scan(&imported); err != nil {
+ return err
+ }
+ if imported {
+ return nil
+ }
+ if profile == "" {
+ return nil
+ }
+ if profile != "codex-tickets-v1" {
+ return errors.New("unknown legacy state profile")
+ }
+ var config struct {
+ Models []string `json:"models"`
+ ProxyURL string `json:"proxy_url"`
+ }
+ if err := json.Unmarshal(configuration, &config); err != nil {
+ return err
+ }
+ rows, err := r.db.QueryContext(ctx, `SELECT id,platform,type,credentials,extra FROM accounts WHERE deleted_at IS NULL AND platform='openai' AND type IN ('oauth','setup-token') AND parent_account_id IS NULL ORDER BY id`)
+ if err != nil {
+ return err
+ }
+ var accounts []*service.Account
+ for rows.Next() {
+ a := &service.Account{}
+ var credentials, extra []byte
+ if err := rows.Scan(&a.ID, &a.Platform, &a.Type, &credentials, &extra); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ if json.Unmarshal(credentials, &a.Credentials) != nil || json.Unmarshal(extra, &a.Extra) != nil {
+ _ = rows.Close()
+ return fmt.Errorf("invalid legacy account JSON for %d", a.ID)
+ }
+ accounts = append(accounts, a)
+ }
+ readErr := rows.Err()
+ closeErr := rows.Close()
+ if readErr != nil {
+ return readErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ for _, account := range accounts {
+ for _, model := range config.Models {
+ if err := r.importLegacyTicket(ctx, plugin.PluginKey, account, model); err != nil {
+ return err
+ }
+ }
+ }
+ if config.ProxyURL != "" {
+ return r.importLegacyProxyTrust(ctx, plugin.PluginKey, config.ProxyURL)
+ }
+ return nil
+}
+
+func (r *pluginRepository) importLegacyTicket(ctx context.Context, plugin string, account *service.Account, model string) error {
+ legacy, err := scanTicketState(r.db.QueryRowContext(ctx, `SELECT `+ticketStateColumns+` FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, account.ID, model))
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ ticket := ticketFromAccount(account, model)
+ if legacy == nil && ticket == nil {
+ return nil
+ }
+ identity := service.CodexTicketAccountIdentity(account)
+ phase := "stopped"
+ var next, expiry, attempt *time.Time
+ var lastCode, operation string
+ manualEnrolled := false
+ if legacy != nil {
+ phase, next, expiry, attempt, manualEnrolled = legacy.Phase, legacy.NextAt, legacy.ExpiresAt, legacy.LastAttemptAt, legacy.ManualWasEnrolled
+ if legacy.LastResult != nil {
+ lastCode = legacy.LastResult.Code
+ }
+ if legacy.JobID > 0 {
+ operation = fmt.Sprintf("job-%d", legacy.JobID)
+ } else if parts := strings.Split(legacy.Operation, ":"); len(parts) == 4 && parts[0] == "job" {
+ operation = "job-" + parts[1]
+ }
+ if (legacy.Identity != "" && legacy.Identity != identity) || (phase != "ready" && phase != "retry" && phase != "stopped") {
+ phase = "stopped"
+ next = nil
+ lastCode = "ticket_interrupted"
+ }
+ }
+ if ticket != nil && (ticket.AccountID != 0 && ticket.AccountID != account.ID || ticket.Model != "" && ticket.Model != model || len(ticket.State) != 292 || !strings.HasPrefix(ticket.State, "gAAAAA") || ticket.Identity != "" && ticket.Identity != identity) {
+ ticket = nil
+ phase = "stopped"
+ next = nil
+ }
+ var rawTicket any
+ if ticket != nil {
+ captured := ticket.CapturedAt
+ if captured.IsZero() {
+ captured = ticket.ExpiresAt.Add(-time.Hour)
+ }
+ rawTicket = map[string]any{"state": ticket.State, "account_id": account.ID, "identity": identity, "model": model, "captured_at": captured, "expires_at": ticket.ExpiresAt}
+ if legacy == nil && time.Now().Before(ticket.ExpiresAt) {
+ phase = "ready"
+ expiry = &ticket.ExpiresAt
+ due := ticket.ExpiresAt.Add(-time.Minute)
+ next = &due
+ }
+ }
+ if ticket == nil || phase == "stopped" {
+ next = nil
+ }
+ value, err := json.Marshal(map[string]any{"phase": phase, "identity": identity, "operation_id": operation, "next_attempt_at": next, "expires_at": expiry, "manual_was_enrolled": manualEnrolled, "last_code": lastCode, "last_attempt_at": attempt, "ticket": rawTicket})
+ if err != nil {
+ return err
+ }
+ constraint := extensionv1.SchedulingConstraint{Model: model, Effect: "deny", Reason: "ticket_missing"}
+ if ticket != nil && time.Now().Before(ticket.ExpiresAt) {
+ constraint.Effect = "allow"
+ constraint.Until = &ticket.ExpiresAt
+ constraint.Reason = "ticket_ready"
+ }
+ sum := sha256.Sum256([]byte(model))
+ observation := extensionv1.AccountObservation{Key: model, Kind: "codex_ticket", State: phase, ExpiresAt: expiry, NextAt: next, CheckedAt: attempt, Code: lastCode}
+ if ticket != nil {
+ observation.Count = len(ticket.State)
+ }
+ _, err = r.CompareSwapExtensionState(ctx, plugin, extensionv1.StateRequest{Namespace: "tickets", Key: strconv.FormatInt(account.ID, 10) + "." + hex.EncodeToString(sum[:]), Value: value, NextAt: next, Projection: &extensionv1.AccountProjection{AccountID: account.ID, Identity: identity, Scheduling: []extensionv1.SchedulingConstraint{constraint}, Observations: []extensionv1.AccountObservation{observation}}})
+ return err
+}
+
+func (r *pluginRepository) importLegacyProxyTrust(ctx context.Context, plugin, raw string) error {
+ normal, err := proxytransport.Normalize(raw)
+ if err != nil {
+ return errors.New("invalid legacy proxy configuration")
+ }
+ var generation int64
+ if err := r.db.QueryRowContext(ctx, `SELECT generation FROM openai_codex_ticket_proxy_generation WHERE id=1`).Scan(&generation); err != nil {
+ return err
+ }
+ oldKey := sha256.Sum256([]byte(normal + "\x00" + strconv.FormatInt(generation, 10) + "\x00chatgpt.com"))
+ var value json.RawMessage
+ err = r.db.QueryRowContext(ctx, `SELECT certificates FROM openai_codex_ticket_proxy_trust WHERE proxy_key=$1`, hex.EncodeToString(oldKey[:])).Scan(&value)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ key := sha256.Sum256([]byte(normal + "\x00chatgpt.com"))
+ _, err = r.CompareSwapExtensionState(ctx, plugin, extensionv1.StateRequest{Namespace: "proxy-trust", Key: hex.EncodeToString(key[:]), Value: value})
+ return err
+}
diff --git a/backend/internal/repository/plugin_repo.go b/backend/internal/repository/plugin_repo.go
index 4c942a10d900..ca417347d46e 100644
--- a/backend/internal/repository/plugin_repo.go
+++ b/backend/internal/repository/plugin_repo.go
@@ -11,7 +11,16 @@ import (
)
type pluginRepository struct {
- db *sql.DB
+ db *sql.DB
+ refreshAccount func(context.Context, int64)
+}
+
+func ProvidePluginRepository(db *sql.DB, accounts service.AccountRepository) service.PluginRepository {
+ repo := &pluginRepository{db: db}
+ if concrete, ok := accounts.(*accountRepository); ok {
+ repo.refreshAccount = concrete.syncSchedulerAccountSnapshotDetached
+ }
+ return repo
}
func NewPluginRepository(db *sql.DB) service.PluginRepository {
@@ -116,6 +125,9 @@ func (r *pluginRepository) Install(ctx context.Context, plugin *service.PluginIn
if err := replacePluginBindings(ctx, tx, id, bindings); err != nil {
return nil, err
}
+ if _, err := tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=false,desired_enabled=false,updated_at=NOW() WHERE plugin_key=$1`, plugin.PluginKey); err != nil {
+ return nil, err
+ }
if err := tx.Commit(); err != nil {
return nil, err
}
@@ -129,22 +141,28 @@ func (r *pluginRepository) GetArtifact(ctx context.Context, id int64) ([]byte, e
}
func (r *pluginRepository) Delete(ctx context.Context, id int64, expectedBinarySHA256 string) error {
- result, err := r.db.ExecContext(ctx, `
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ var key string
+ err = tx.QueryRowContext(ctx, `
DELETE FROM sub2api_plugin_installations p
WHERE p.id = $1 AND p.binary_sha256 = $2 AND p.state NOT IN ('starting', 'enabled')
AND NOT EXISTS (SELECT 1 FROM sub2api_plugin_bindings b WHERE b.plugin_id = p.id AND b.enabled = TRUE)
- `, id, expectedBinarySHA256)
- if err != nil {
- return err
+ RETURNING p.plugin_key
+ `, id, expectedBinarySHA256).Scan(&key)
+ if err == sql.ErrNoRows {
+ return service.ErrPluginStateChanged
}
- rows, err := result.RowsAffected()
if err != nil {
return err
}
- if rows != 1 {
- return service.ErrPluginStateChanged
+ if _, err = tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET user_removed=true,desired_enabled=false,updated_at=NOW() WHERE plugin_key=$1`, key); err != nil {
+ return err
}
- return nil
+ return tx.Commit()
}
func (r *pluginRepository) BeginEnable(ctx context.Context, id int64, binarySHA256, expectedState string) error {
@@ -257,9 +275,51 @@ func (r *pluginRepository) UpdateBindingsAndState(
if err := replacePluginBindings(ctx, tx, pluginID, bindings); err != nil {
return err
}
+ desired := false
+ for _, binding := range bindings {
+ desired = desired || binding.Enabled
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE sub2api_plugin_bootstrap SET desired_enabled=$2,updated_at=NOW() WHERE plugin_key=(SELECT plugin_key FROM sub2api_plugin_installations WHERE id=$1)`, pluginID, desired); err != nil {
+ return err
+ }
+ if state == service.PluginStateDisabled {
+ if err := cancelPluginAccountJobs(ctx, tx, pluginID); err != nil {
+ return err
+ }
+ }
return tx.Commit()
}
+func cancelPluginAccountJobs(ctx context.Context, tx *sql.Tx, pluginID int64) error {
+ rows, err := tx.QueryContext(ctx, `SELECT `+accountJobSelectColumns+` FROM admin_account_jobs WHERE kind=$1 AND metadata->>'plugin_id'=$2 AND status IN ('pending','running') ORDER BY id FOR UPDATE`, service.AccountJobKindExtensionOperation, fmt.Sprint(pluginID))
+ if err != nil {
+ return err
+ }
+ var jobs []*service.AccountJob
+ for rows.Next() {
+ job, scanErr := scanAccountJob(rows)
+ if scanErr != nil {
+ _ = rows.Close()
+ return scanErr
+ }
+ jobs = append(jobs, job)
+ }
+ readErr := rows.Err()
+ closeErr := rows.Close()
+ if readErr != nil {
+ return readErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ for _, job := range jobs {
+ if _, err := cancelLockedAccountJob(ctx, tx, job); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
type pluginBindingExecutor interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
diff --git a/backend/internal/repository/quota_activity.go b/backend/internal/repository/quota_activity.go
new file mode 100644
index 000000000000..48e706fcea4e
--- /dev/null
+++ b/backend/internal/repository/quota_activity.go
@@ -0,0 +1,81 @@
+package repository
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/redis/go-redis/v9"
+)
+
+type quotaActivityStore struct{ redis *redis.Client }
+
+func NewQuotaActivityStore(client *redis.Client) service.QuotaActivityStore {
+ return "aActivityStore{redis: client}
+}
+
+var quotaActivityScript = redis.NewScript(`
+redis.call('HSETNX',KEYS[2],'epoch',ARGV[4])
+local clock=redis.call('TIME')
+local now=tonumber(clock[1])*1000+math.floor(tonumber(clock[2])/1000)
+local expired=redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',now)
+if expired>0 then redis.call('HINCRBY',KEYS[2],'gaps',expired);redis.call('HINCRBY',KEYS[2],'revision',1) end
+local action=ARGV[1]
+local present=redis.call('ZSCORE',KEYS[1],ARGV[2])
+if action=='begin' or action=='refresh' then
+ if not present then
+ redis.call('HINCRBY',KEYS[2],'revision',1)
+ if action=='refresh' then redis.call('HINCRBY',KEYS[2],'gaps',1) end
+ end
+ redis.call('ZADD',KEYS[1],now+90000,ARGV[2])
+elseif action=='finish' then
+ redis.call('ZREM',KEYS[1],ARGV[2])
+ redis.call('HINCRBY',KEYS[2],'revision',1)
+ if ARGV[3]~='1' then redis.call('HINCRBY',KEYS[2],'gaps',1) end
+end
+return {redis.call('ZCARD',KEYS[1]),tonumber(redis.call('HGET',KEYS[2],'revision') or '0'),tonumber(redis.call('HGET',KEYS[2],'gaps') or '0'),redis.call('HGET',KEYS[2],'epoch')}
+`)
+
+func (s *quotaActivityStore) run(ctx context.Context, id int64, action, request string, complete bool) (service.QuotaActivityStamp, error) {
+ prefix := fmt.Sprintf("quota-observation:{%d}:", id)
+ settled := "0"
+ if complete {
+ settled = "1"
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return service.QuotaActivityStamp{}, err
+ }
+ values, err := quotaActivityScript.Run(ctx, s.redis, []string{prefix + "active", prefix + "clock"}, action, request, settled, hex.EncodeToString(nonce[:])).Slice()
+ if err != nil {
+ return service.QuotaActivityStamp{}, err
+ }
+ if len(values) != 4 {
+ return service.QuotaActivityStamp{}, fmt.Errorf("invalid quota activity stamp")
+ }
+ active, okActive := values[0].(int64)
+ revision, okRevision := values[1].(int64)
+ gaps, okGaps := values[2].(int64)
+ epoch, okEpoch := values[3].(string)
+ if !okActive || !okRevision || !okGaps || !okEpoch || epoch == "" {
+ return service.QuotaActivityStamp{}, fmt.Errorf("invalid quota activity types")
+ }
+ return service.QuotaActivityStamp{Active: active, Revision: revision, Gaps: gaps, Epoch: epoch}, nil
+}
+func (s *quotaActivityStore) Begin(ctx context.Context, id int64, request string) error {
+ _, err := s.run(ctx, id, "begin", request, false)
+ return err
+}
+func (s *quotaActivityStore) Refresh(ctx context.Context, id int64, request string) error {
+ _, err := s.run(ctx, id, "refresh", request, false)
+ return err
+}
+func (s *quotaActivityStore) Finish(ctx context.Context, id int64, request string, complete bool) error {
+ _, err := s.run(ctx, id, "finish", request, complete)
+ return err
+}
+func (s *quotaActivityStore) Read(ctx context.Context, id int64) (service.QuotaActivityStamp, error) {
+ return s.run(ctx, id, "read", "", false)
+}
diff --git a/backend/internal/repository/quota_activity_test.go b/backend/internal/repository/quota_activity_test.go
new file mode 100644
index 000000000000..dc9b7a1513ed
--- /dev/null
+++ b/backend/internal/repository/quota_activity_test.go
@@ -0,0 +1,43 @@
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/redis/go-redis/v9"
+ "github.com/stretchr/testify/require"
+)
+
+func TestQuotaActivityClockDetectsExpiredWorkAndRedisEpochChanges(t *testing.T) {
+ server := miniredis.RunT(t)
+ now := time.Now().UTC()
+ server.SetTime(now)
+ client := redis.NewClient(&redis.Options{Addr: server.Addr()})
+ t.Cleanup(func() { _ = client.Close() })
+ store := NewQuotaActivityStore(client)
+ ctx := context.Background()
+ require.NoError(t, store.Begin(ctx, 7, "request-a"))
+ start, err := store.Read(ctx, 7)
+ require.NoError(t, err)
+ require.EqualValues(t, 1, start.Active)
+ require.NotEmpty(t, start.Epoch)
+ require.NoError(t, store.Finish(ctx, 7, "request-a", true))
+ end, err := store.Read(ctx, 7)
+ require.NoError(t, err)
+ require.Zero(t, end.Active)
+ require.Zero(t, end.Gaps)
+ require.Greater(t, end.Revision, start.Revision)
+ require.NoError(t, store.Begin(ctx, 7, "abandoned"))
+ server.SetTime(now.Add(91 * time.Second))
+ expired, err := store.Read(ctx, 7)
+ require.NoError(t, err)
+ require.Zero(t, expired.Active)
+ require.EqualValues(t, 1, expired.Gaps)
+ require.Equal(t, start.Epoch, expired.Epoch)
+ server.FlushAll() // This is the isolated in-memory test Redis only.
+ fresh, err := store.Read(ctx, 7)
+ require.NoError(t, err)
+ require.NotEqual(t, start.Epoch, fresh.Epoch, "lost observation state must not reuse a persisted calibration")
+}
diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go
index 1e9f15782e95..b01085dc9bb9 100644
--- a/backend/internal/repository/scheduler_cache.go
+++ b/backend/internal/repository/scheduler_cache.go
@@ -1096,11 +1096,15 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
"codex_primary_window_minutes",
"codex_primary_reset_after_seconds",
"codex_primary_reset_at",
+ "codex_primary_observed_at",
"codex_secondary_used_percent",
"codex_secondary_window_minutes",
"codex_secondary_reset_after_seconds",
"codex_secondary_reset_at",
+ "codex_secondary_observed_at",
"openai_quota_exhausted",
+ "openai_quota_status",
+ service.PluginAccountProjectionKey,
"auto_pause_5h_threshold",
"auto_pause_7d_threshold",
"auto_pause_5h_disabled",
diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go
index 783a1078a162..dd25ff6c1d4c 100644
--- a/backend/internal/repository/wire.go
+++ b/backend/internal/repository/wire.go
@@ -102,7 +102,8 @@ var ProviderSet = wire.NewSet(
NewUserGroupRateRepository,
NewErrorPassthroughRepository,
NewTLSFingerprintProfileRepository,
- NewPluginRepository,
+ ProvidePluginRepository,
+ NewQuotaActivityStore,
NewPluginKVStore,
NewChannelRepository,
NewChannelMonitorRepository,
diff --git a/backend/internal/service/account_quota_windows.go b/backend/internal/service/account_quota_windows.go
index d40bc1b1432e..2898cdc7874e 100644
--- a/backend/internal/service/account_quota_windows.go
+++ b/backend/internal/service/account_quota_windows.go
@@ -75,9 +75,10 @@ func (s *OpenAICodexUsageSnapshot) Windows(observed time.Time) []AccountQuotaWin
id string
used *float64
minutes, seconds *int
+ reset *time.Time
}{
- {"primary", s.PrimaryUsedPercent, s.PrimaryWindowMinutes, s.PrimaryResetAfterSeconds},
- {"secondary", s.SecondaryUsedPercent, s.SecondaryWindowMinutes, s.SecondaryResetAfterSeconds},
+ {"primary", s.PrimaryUsedPercent, s.PrimaryWindowMinutes, s.PrimaryResetAfterSeconds, s.PrimaryResetAt},
+ {"secondary", s.SecondaryUsedPercent, s.SecondaryWindowMinutes, s.SecondaryResetAfterSeconds, s.SecondaryResetAt},
} {
if item.used == nil || math.IsNaN(*item.used) || math.IsInf(*item.used, 0) || *item.used < 0 {
continue
@@ -89,8 +90,8 @@ func (s *OpenAICodexUsageSnapshot) Windows(observed time.Time) []AccountQuotaWin
continue
}
}
- var reset *time.Time
- if item.seconds != nil && *item.seconds > 0 {
+ reset := cloneTimePtr(item.reset)
+ if reset == nil && item.seconds != nil && *item.seconds >= 0 && *item.seconds <= 366*24*3600 {
v := observed.Add(time.Duration(*item.seconds) * time.Second)
reset = &v
}
@@ -110,7 +111,9 @@ func OpenAIQuotaWindows(extra map[string]any, now time.Time) []AccountQuotaWindo
out = append(out, w)
}
}
- if len(out) == 0 {
+ _, hasPrimary := extra["codex_primary_used_percent"]
+ _, hasSecondary := extra["codex_secondary_used_percent"]
+ if !hasPrimary && !hasSecondary {
for _, item := range []struct {
name string
minutes int
@@ -126,6 +129,9 @@ func OpenAIQuotaWindows(extra map[string]any, now time.Time) []AccountQuotaWindo
func readQuotaWindow(extra map[string]any, name string, legacyMinutes int, observed *time.Time, now time.Time) (AccountQuotaWindow, bool) {
prefix := "codex_" + name + "_"
+ if local := quotaTime(extra[prefix+"observed_at"]); local != nil {
+ observed = local
+ }
used, ok := resolveAccountExtraNumber(extra, prefix+"used_percent")
if !ok || math.IsNaN(used) || math.IsInf(used, 0) || used < 0 {
return AccountQuotaWindow{}, false
@@ -141,12 +147,17 @@ func readQuotaWindow(extra map[string]any, name string, legacyMinutes int, obser
}
}
reset := quotaTime(extra[prefix+"reset_at"])
- if observed != nil && (reset == nil || !observed.Before(*reset)) {
- if seconds, found := resolveAccountExtraNumber(extra, prefix+"reset_after_seconds"); found && seconds > 0 && seconds <= 366*24*3600 {
+ if reset == nil && observed != nil {
+ if seconds, found := resolveAccountExtraNumber(extra, prefix+"reset_after_seconds"); found && seconds >= 0 && seconds <= 366*24*3600 {
x := observed.Add(time.Duration(seconds) * time.Second)
reset = &x
}
}
+ // A new observation after an old deadline cannot inherit that expired
+ // deadline as proof that the newly observed usage has already reset.
+ if reset != nil && observed != nil && reset.Before(*observed) {
+ reset = nil
+ }
return newQuotaWindow(name, minutes, used, reset, observed, now), true
}
@@ -168,15 +179,29 @@ func (a *Account) QuotaState(now time.Time) *UpstreamQuotaState {
state.Until = cloneTimePtr(w.ResetsAt)
}
}
+ var blocks []map[string]any
if block, ok := a.Extra["openai_quota_exhausted"].(map[string]any); ok {
+ blocks = append(blocks, block)
+ }
+ query, _ := a.Extra["openai_quota_status"].(map[string]any)
+ if reached, _ := query["limit_reached"].(bool); reached {
+ blocks = append(blocks, query)
+ }
+ for _, block := range blocks {
observed := quotaTime(block["observed_at"])
until := quotaTime(block["reset_at"])
recovered := len(windows) > 0
for _, window := range windows {
- if observed == nil || window.ObservedAt == nil || !window.ObservedAt.After(*observed) || window.Expired || window.Utilization >= 100 {
+ if observed == nil || window.ObservedAt == nil || !window.ObservedAt.After(*observed) || window.Utilization >= 100 {
recovered = false
}
}
+ queryAt := quotaTime(query["observed_at"])
+ allowed, _ := query["allowed"].(bool)
+ reached, _ := query["limit_reached"].(bool)
+ if allowed && !reached && queryAt != nil && observed != nil && queryAt.After(*observed) {
+ recovered = true
+ }
if !recovered && (until == nil || now.Before(*until)) {
state.Blocked = true
if until == nil {
@@ -196,7 +221,7 @@ func (a *Account) QuotaState(now time.Time) *UpstreamQuotaState {
}
func persistOpenAIQuotaClassification(ctx context.Context, repo AccountRepository, account *Account, classification openAIOAuth429Classification, now time.Time) error {
- if repo == nil || account == nil || classification.Disposition == openAIOAuth429Transient {
+ if account == nil || classification.Disposition == openAIOAuth429Transient {
return nil
}
block := map[string]any{"observed_at": now.UTC().Format(time.RFC3339Nano), "window": classification.Window}
@@ -205,6 +230,9 @@ func persistOpenAIQuotaClassification(ctx context.Context, repo AccountRepositor
}
updates := map[string]any{"openai_quota_exhausted": block}
mergeAccountExtra(account, updates)
+ if repo == nil {
+ return nil
+ }
return repo.UpdateExtra(ctx, account.ID, updates)
}
@@ -222,3 +250,58 @@ func (w AccountQuotaWindow) PeriodKey() string {
}
return fmt.Sprintf("%d/%s", w.WindowMinutes, w.ResetsAt.UTC().Format(time.RFC3339))
}
+
+// A /wham envelope is a complete observation of its own quota scope. Missing
+// slots explicitly replace old slots; another feature's envelope never enters
+// this conversion. The same representation is used by headers and by readers.
+func buildCodexRateLimitExtraUpdates(limits *OpenAIRateLimit, observed time.Time) map[string]any {
+ if limits == nil {
+ return nil
+ }
+ snapshot := &OpenAICodexUsageSnapshot{UpdatedAt: observed.UTC().Format(time.RFC3339Nano)}
+ apply := func(window *OpenAIRateLimitWindow, primary bool) {
+ used, minutes := 0.0, 0
+ var reset *time.Time
+ var seconds *int
+ if window != nil {
+ used, minutes = window.UsedPercent, int(window.LimitWindowSeconds/60)
+ if window.ResetAt > 0 {
+ absolute := time.Unix(window.ResetAt, 0).UTC()
+ reset = &absolute
+ remaining := int(absolute.Sub(observed).Seconds())
+ seconds = &remaining
+ } else if window.ResetAfterSeconds > 0 && window.ResetAfterSeconds <= 366*24*3600 {
+ remaining := int(window.ResetAfterSeconds)
+ seconds = &remaining
+ }
+ }
+ if primary {
+ snapshot.PrimaryUsedPercent, snapshot.PrimaryWindowMinutes = &used, &minutes
+ snapshot.PrimaryResetAt, snapshot.PrimaryResetAfterSeconds = reset, seconds
+ } else {
+ snapshot.SecondaryUsedPercent, snapshot.SecondaryWindowMinutes = &used, &minutes
+ snapshot.SecondaryResetAt, snapshot.SecondaryResetAfterSeconds = reset, seconds
+ }
+ }
+ apply(limits.PrimaryWindow, true)
+ apply(limits.SecondaryWindow, false)
+ updates := buildCodexUsageExtraUpdates(snapshot, observed)
+ status := map[string]any{"observed_at": observed.UTC().Format(time.RFC3339Nano), "allowed": limits.Allowed, "limit_reached": limits.LimitReached}
+ var until *time.Time
+ unknown := false
+ for _, window := range snapshot.Windows(observed) {
+ if window.Utilization < 100 || window.Expired {
+ continue
+ }
+ if window.ResetsAt == nil {
+ unknown = true
+ } else if until == nil || window.ResetsAt.After(*until) {
+ until = window.ResetsAt
+ }
+ }
+ if until != nil && !unknown {
+ status["reset_at"] = until.UTC().Format(time.RFC3339Nano)
+ }
+ updates["openai_quota_status"] = status
+ return updates
+}
diff --git a/backend/internal/service/account_quota_windows_test.go b/backend/internal/service/account_quota_windows_test.go
index 35f275d9d82b..089fb4379591 100644
--- a/backend/internal/service/account_quota_windows_test.go
+++ b/backend/internal/service/account_quota_windows_test.go
@@ -1,6 +1,8 @@
package service
import (
+ "context"
+ "net/http"
"testing"
"time"
@@ -27,6 +29,66 @@ func TestAccountQuotaWindowsActualPeriod(t *testing.T) {
require.Nil(t, account.QuotaState(now))
}
+func TestQuotaWindowObservationsKeepIndependentDeadlinesAndClearRemovedSlots(t *testing.T) {
+ now := time.Date(2026, 9, 19, 7, 0, 0, 0, time.UTC)
+ primaryUsed, secondaryUsed := 10.0, 100.0
+ short, long, hour := 300, 43200, 3600
+ extra := buildCodexUsageExtraUpdates(&OpenAICodexUsageSnapshot{PrimaryUsedPercent: &primaryUsed, PrimaryWindowMinutes: &short, PrimaryResetAfterSeconds: &hour, SecondaryUsedPercent: &secondaryUsed, SecondaryWindowMinutes: &long, SecondaryResetAfterSeconds: &hour}, now)
+ later := now.Add(30 * time.Minute)
+ for key, value := range buildCodexUsageExtraUpdates(&OpenAICodexUsageSnapshot{PrimaryUsedPercent: &primaryUsed, PrimaryWindowMinutes: &short}, later) {
+ extra[key] = value
+ }
+ windows := OpenAIQuotaWindows(extra, later)
+ require.Len(t, windows, 2)
+ require.Equal(t, now.Add(time.Hour), *windows[1].ResetsAt, "the untouched window keeps its original deadline")
+ require.Equal(t, now, *windows[1].ObservedAt)
+ account := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: extra}
+ require.True(t, account.QuotaState(later).Blocked)
+ require.False(t, account.QuotaState(now.Add(2*time.Hour)).Blocked)
+ absolute := now.Add(30 * 24 * time.Hour).Unix()
+ updates := buildCodexRateLimitExtraUpdates(&OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 30, LimitWindowSeconds: 30 * 86400, ResetAt: absolute, ResetAfterSeconds: 999}}, later)
+ for key, value := range updates {
+ extra[key] = value
+ }
+ windows = OpenAIQuotaWindows(extra, later)
+ require.Len(t, windows, 1, "an absent secondary slot must not revive old state")
+ require.Equal(t, 43200, windows[0].WindowMinutes)
+ require.Equal(t, time.Unix(absolute, 0).UTC(), *windows[0].ResetsAt)
+ require.Nil(t, openAIThresholdCandidate(extra, "7d", later), "a monthly window is not a weekly pause policy")
+}
+
+func TestQuotaRecoveryCannotReactivateOldUnknownErrorOrClearOtherState(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ account := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: "disabled", Schedulable: false, ErrorMessage: "operator note", Extra: map[string]any{}}
+ err := persistOpenAIQuotaClassification(context.Background(), nil, account, openAIOAuth429Classification{Disposition: openAIOAuth429QuotaReset}, now)
+ require.NoError(t, err)
+ require.True(t, account.QuotaState(now).Blocked)
+ for key, value := range buildCodexRateLimitExtraUpdates(&OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 0, LimitWindowSeconds: 18000, ResetAt: now.Add(time.Hour).Unix()}}, now.Add(time.Minute)) {
+ account.Extra[key] = value
+ }
+ require.False(t, account.QuotaState(now.Add(2*time.Minute)).Blocked)
+ require.False(t, account.QuotaState(now.Add(2*time.Hour)).Blocked, "expiry cannot resurrect a superseded unknown deadline")
+ require.Equal(t, "disabled", account.Status)
+ require.False(t, account.Schedulable)
+ require.Equal(t, "operator note", account.ErrorMessage)
+}
+
+func TestOAuthQuota429UsesRecoverableQuotaSource(t *testing.T) {
+ account := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}}
+ header := http.Header{}
+ header.Set("x-codex-primary-used-percent", "100")
+ header.Set("x-codex-primary-window-minutes", "10080")
+ header.Set("x-codex-primary-reset-after-seconds", "3600")
+ gateway := &OpenAIGatewayService{}
+ gateway.markOpenAIOAuth429RateLimited(context.Background(), account, header, nil)
+ require.True(t, gateway.isOpenAIAccountRuntimeBlocked(account))
+ require.Nil(t, account.RateLimitResetAt)
+ _, runtimeCooldown := gateway.openaiAccountRuntimeBlockUntil.Load(account.ID)
+ require.False(t, runtimeCooldown, "hard quota must not leave an unrelated runtime cooldown")
+ state := account.QuotaState(time.Now())
+ require.NotNil(t, state.Until)
+}
+
func TestAccountQuotaWindowsMultipleAndUnknown(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
a := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Extra: map[string]any{
@@ -41,3 +103,18 @@ func TestAccountQuotaWindowsMultipleAndUnknown(t *testing.T) {
require.Equal(t, now.Add(time.Hour), *a.QuotaState(now).Until)
require.False(t, a.QuotaState(now.Add(2*time.Hour)).Blocked)
}
+
+func TestQuotaQueryFlagsRemainEvidenceWhenWindowDetailsAreMissing(t *testing.T) {
+ now := time.Now().UTC()
+ account := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: buildCodexRateLimitExtraUpdates(&OpenAIRateLimit{LimitReached: true}, now)}
+ state := account.QuotaState(now)
+ require.NotNil(t, state)
+ require.True(t, state.Blocked)
+ require.Nil(t, state.Until)
+ require.NoError(t, persistOpenAIQuotaClassification(context.Background(), nil, account, openAIOAuth429Classification{Disposition: openAIOAuth429QuotaReset}, now))
+ for key, value := range buildCodexRateLimitExtraUpdates(&OpenAIRateLimit{Allowed: true}, now.Add(time.Minute)) {
+ account.Extra[key] = value
+ }
+ state = account.QuotaState(now.Add(time.Minute))
+ require.True(t, state == nil || !state.Blocked, "an explicit recovered query can supersede the old quota error without inventing a window")
+}
diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go
index 345f997a1534..7957a20ea061 100644
--- a/backend/internal/service/account_scheduling_threshold_eval.go
+++ b/backend/internal/service/account_scheduling_threshold_eval.go
@@ -248,37 +248,27 @@ func firstStringValue(values map[string]any, keys ...string) string {
}
func openAIThresholdCandidate(extra map[string]any, window string, now time.Time) *accountSchedulingThresholdCandidate {
- if len(extra) == 0 {
- return nil
- }
-
- var (
- usedPercentKey string
- resetAtKey string
- )
+ minutes := 0
switch window {
case "5h":
- usedPercentKey = "codex_5h_used_percent"
- resetAtKey = "codex_5h_reset_at"
+ minutes = 300
case "7d":
- usedPercentKey = "codex_7d_used_percent"
- resetAtKey = "codex_7d_reset_at"
+ minutes = 10080
default:
return nil
}
-
- usedPercent, ok := extra[usedPercentKey]
- if !ok {
- return nil
- }
- if openAIQuotaWindowReset(extra, window, now) || openAICodexSnapshotStaleForPause(extra, now) {
- return nil
- }
- return &accountSchedulingThresholdCandidate{
- window: window,
- usedPercent: schedulingPercentValue(usedPercent),
- until: parseSchedulingResetAt(extra[resetAtKey]),
+ for _, observed := range OpenAIQuotaWindows(extra, now) {
+ if observed.WindowMinutes != minutes || observed.Expired {
+ continue
+ }
+ // A known upstream deadline remains authoritative when a sample grows old.
+ // An advisory pause without a deadline still requires a recent observation.
+ if observed.ResetsAt == nil && openAICodexSnapshotStaleForPause(extra, now) {
+ continue
+ }
+ return &accountSchedulingThresholdCandidate{window: window, usedPercent: observed.Utilization, until: cloneTimePtr(observed.ResetsAt)}
}
+ return nil
}
func anthropicThresholdCandidates(account *Account) []*accountSchedulingThresholdCandidate {
diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go
index 5e7e9fa77e92..d249c3e8e7b3 100644
--- a/backend/internal/service/account_scheduling_threshold_eval_test.go
+++ b/backend/internal/service/account_scheduling_threshold_eval_test.go
@@ -163,7 +163,7 @@ func TestEvaluateAccountSchedulingThreshold_OpenAIPreservesPercentageSemantics(t
require.Equal(t, 91.0, openAIDecision.UsedPercent)
}
-func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.T) {
+func TestEvaluateAccountSchedulingThreshold_OpenAIKeepsKnownDeadlineFromStaleSnapshot(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
@@ -178,7 +178,10 @@ func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.
decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
- require.False(t, decision.ShouldPause)
+ require.True(t, decision.ShouldPause)
+ delete(account.Extra, "codex_5h_reset_at")
+ decision = EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformOpenAI: 90}, now)
+ require.False(t, decision.ShouldPause, "an advisory pause without a deadline still needs fresh evidence")
}
func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) {
diff --git a/backend/internal/service/account_test_prompt_test.go b/backend/internal/service/account_test_prompt_test.go
index 633ea6940f7b..ca94aff025ce 100644
--- a/backend/internal/service/account_test_prompt_test.go
+++ b/backend/internal/service/account_test_prompt_test.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
@@ -106,9 +107,20 @@ func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) {
a.Credentials["model_mapping"] = map[string]any{"alias": "gpt-5.6-sol"}
a.Credentials["header_override_enabled"] = true
a.Credentials["header_overrides"] = map[string]any{openAICodexTurnStateHeader: fakeCodexTicketState(312)}
- a.Extra = map[string]any{openAICodexTicketExtraKey("gpt-5.6-sol"): &openAICodexTicket{State: fakeCodexTicketState(292), Length: 292, AccountID: a.ID, Model: "gpt-5.6-sol", ExpiresAt: time.Now().Add(time.Hour)}}
+ a.Extra = map[string]any{openAICodexTicketExtraKey("gpt-5.6-sol"): &CodexTicketRecord{State: fakeCodexTicketState(292), Length: 292, AccountID: a.ID, Model: "gpt-5.6-sol", ExpiresAt: time.Now().Add(time.Hour)}}
upstream := &queuedHTTPUpstream{responses: []*http.Response{newJSONResponse(200, "data: {\"type\":\"response.completed\"}\n\n")}}
gateway := &OpenAIGatewayService{cfg: cfg}
+ available := true
+ gateway.pluginManager = ticketTestManager(t, cfg.Gateway.OpenAICodexTicket, func(in extensionv1.Invocation) (extensionv1.Result, error) {
+ var request extensionv1.SchedulingRequest
+ require.NoError(t, json.Unmarshal(in.Payload, &request))
+ require.Equal(t, "gpt-5.6-sol", request.Model)
+ if !available {
+ return extensionv1.Result{Code: "ticket_missing"}, nil
+ }
+ raw, _ := json.Marshal(map[string]any{"headers": map[string]string{openAICodexTurnStateHeader: fakeCodexTicketState(292)}})
+ return extensionv1.Result{Payload: raw}, nil
+ })
svc := &AccountTestService{cfg: cfg, httpUpstream: upstream, openAIGatewayService: gateway}
c, rec := newTestContext()
prompt := "你的知识库库截止日期是什么时间,直接回复不要联网"
@@ -124,8 +136,7 @@ func TestAccountTestPromptOAuthFinalTicketUsesMappedModel(t *testing.T) {
require.Contains(t, rec.Body.String(), "ticket_fingerprint")
require.NotContains(t, rec.Body.String(), fakeCodexTicketState(292))
c, rec = newTestContext()
- delete(a.Extra, openAICodexTicketExtraKey("gpt-5.6-sol"))
- svc.openAIGatewayService = &OpenAIGatewayService{cfg: cfg}
+ available = false // Legacy Extra still has a ticket; only the plugin may provide it.
require.Error(t, svc.testOpenAIAccountConnection(c, a, "alias", prompt, ""))
require.Len(t, upstream.requests, 1)
require.Contains(t, rec.Body.String(), "没有有效292")
diff --git a/backend/internal/service/account_test_reasoning.go b/backend/internal/service/account_test_reasoning.go
index 59f75839350b..ad37c2960f11 100644
--- a/backend/internal/service/account_test_reasoning.go
+++ b/backend/internal/service/account_test_reasoning.go
@@ -17,6 +17,9 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri
return nil, ""
}
model = account.GetMappedModel(strings.TrimSpace(model))
+ if !accountTestSupportsReasoningWire(account, model) || isOpenAIImageModel(model) || isGrokVideoGenerationModel(model) {
+ return nil, ""
+ }
if IsCindyAPIKeyAccount(account.Platform, account.Type, account.Credentials) {
if capability, ok := ResolveCindyCapability(model); ok {
return capability.CodexReasoningEfforts(), capability.DefaultReasoningEffort
@@ -43,6 +46,24 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri
return out, ""
}
+func accountTestSupportsReasoningWire(account *Account, model string) bool {
+ switch {
+ case account.IsOpenCodeGo():
+ protocol := openCodeGoNativeProtocol(account, model)
+ return protocol == APIProtocolResponses || protocol == APIProtocolChatCompletions
+ case account.IsCNProvider():
+ protocol := account.GetAPIProtocol()
+ // The adaptive test verifies all native endpoints, including Messages,
+ // whose effort contract differs. Never silently omit a chosen effort on
+ // one leg of that test.
+ return protocol == APIProtocolResponses || protocol == APIProtocolChatCompletions
+ case account.IsOpenAI(), account.Platform == PlatformGrok:
+ return true
+ default:
+ return false
+ }
+}
+
func ValidateAccountTestReasoning(account *Account, model, mode, effort string) error {
if effort == "" {
return nil
diff --git a/backend/internal/service/account_test_reasoning_test.go b/backend/internal/service/account_test_reasoning_test.go
index e536565dfd2c..5a51d6e7d446 100644
--- a/backend/internal/service/account_test_reasoning_test.go
+++ b/backend/internal/service/account_test_reasoning_test.go
@@ -35,3 +35,15 @@ func TestAccountTestReasoningFinalPayload(t *testing.T) {
applyAccountTestReasoning(c, defaultPayload, false)
require.NotContains(t, defaultPayload, "reasoning")
}
+
+func TestAccountTestReasoningDoesNotAdvertiseUnsupportedProtocol(t *testing.T) {
+ for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformDeepseek} {
+ account := &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}}
+ supported := true
+ account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"test-model": {Reasoning: &supported, SupportedReasoningLevels: []string{"high"}}}})
+ levels, _ := AccountTestReasoningOptions(account, "test-model")
+ require.Empty(t, levels)
+ require.Error(t, ValidateAccountTestReasoning(account, "test-model", "default", "high"))
+ require.NoError(t, ValidateAccountTestReasoning(account, "test-model", "default", ""))
+ }
+}
diff --git a/backend/internal/service/account_test_reasoning_wire_test.go b/backend/internal/service/account_test_reasoning_wire_test.go
index 2d0368468852..dba8b8c90321 100644
--- a/backend/internal/service/account_test_reasoning_wire_test.go
+++ b/backend/internal/service/account_test_reasoning_wire_test.go
@@ -39,3 +39,30 @@ func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) {
require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeCompact, AccountTestOptions{ReasoningEffort: "ultra"}))
require.Len(t, upstream.requests, 1, "unsupported mode must fail before any upstream request")
}
+
+func TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol(t *testing.T) {
+ for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions} {
+ t.Run(protocol, func(t *testing.T) {
+ account := openCodeGoTestAccount(44)
+ account.Credentials["api_protocol"] = protocol
+ supported := true
+ account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{"test-model": {Reasoning: &supported, SupportedReasoningLevels: []string{"high"}}}})
+ response := adaptiveCNChatTestResponse()
+ if protocol == APIProtocolResponses {
+ response = adaptiveCNResponsesTestResponse()
+ }
+ svc, upstream := adaptiveCNAccountTestService(account, response)
+ c, recorded := newTestContext()
+ require.NoError(t, svc.TestAccountConnection(c, account.ID, "test-model", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "high"}))
+ require.Len(t, upstream.requests, 1)
+ body, err := io.ReadAll(upstream.requests[0].Body)
+ require.NoError(t, err)
+ field := "reasoning_effort"
+ if protocol == APIProtocolResponses {
+ field = "reasoning.effort"
+ }
+ require.Equal(t, "high", gjson.GetBytes(body, field).String())
+ require.Contains(t, recorded.Body.String(), `"effective_reasoning_effort":"high"`)
+ })
+ }
+}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 0239c0eda758..d14ddd2606f5 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -146,6 +146,7 @@ func normalizeGrokAccountTestMode(mode string) string {
// AccountTestService handles account testing operations
type AccountTestService struct {
+ quotaActivity *QuotaActivityService
accountRepo AccountRepository
geminiTokenProvider *GeminiTokenProvider
claudeTokenProvider *ClaudeTokenProvider
@@ -385,6 +386,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Route to platform-specific test method
+ ctx, finishObservation := s.quotaActivity.Attach(ctx)
+ c.Request = c.Request.WithContext(ctx)
+ ObserveQuotaAccount(ctx, account.ID)
+ defer finishObservation()
if account.IsOpenCodeGo() {
return s.testOpenCodeGoConnection(c, account, modelID, prompt)
}
diff --git a/backend/internal/service/account_test_service_opencode_go.go b/backend/internal/service/account_test_service_opencode_go.go
index 2115c36ce326..abbdd7b573c3 100644
--- a/backend/internal/service/account_test_service_opencode_go.go
+++ b/backend/internal/service/account_test_service_opencode_go.go
@@ -51,10 +51,14 @@ func (s *AccountTestService) testOpenCodeGoConnection(c *gin.Context, account *A
payloadBytes, err = json.Marshal(payload)
case APIProtocolResponses:
apiURL = buildOpenAIResponsesURL(normalizedBaseURL)
- payloadBytes, err = json.Marshal(createOpenAITestPayload(testModelID, false, prompt))
+ payload := createOpenAITestPayload(testModelID, false, prompt)
+ applyAccountTestReasoning(c, payload, false)
+ payloadBytes, err = json.Marshal(payload)
default:
apiURL = buildOpenAIChatCompletionsURL(normalizedBaseURL)
- payloadBytes, err = json.Marshal(createOpenAIChatCompletionsTestPayload(testModelID, prompt))
+ payload := createOpenAIChatCompletionsTestPayload(testModelID, prompt)
+ applyAccountTestReasoning(c, payload, true)
+ payloadBytes, err = json.Marshal(payload)
protocol = APIProtocolChatCompletions
}
if err != nil {
diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go
index d16faeb5350d..68c18fbe7576 100644
--- a/backend/internal/service/account_usage_service.go
+++ b/backend/internal/service/account_usage_service.go
@@ -771,9 +771,11 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
for i := range usage.QuotaWindows {
window := &usage.QuotaWindows[i]
window.Estimate = &QuotaEstimate{Status: "insufficient_data"}
- if store, ok := s.accountRepo.(QuotaEstimateRepository); ok && window.PeriodKey() != "" {
- if base, latest, err := store.ReadQuotaEstimate(ctx, account.ID, quotaEstimateIdentity(account), window.PeriodKey()); err == nil {
- window.Estimate = EstimateQuotaValue(base, latest, *window)
+ if store, ok := s.accountRepo.(QuotaEstimateRepository); ok && s.openAIQuotaService != nil && window.PeriodKey() != "" {
+ if basis, known := s.openAIQuotaService.estimateBasis(ctx, account); known {
+ if base, latest, err := store.ReadQuotaEstimate(ctx, account.ID, basis, window.PeriodKey()); err == nil {
+ window.Estimate = EstimateQuotaValue(base, latest, *window)
+ }
}
}
if start := window.StatsStart(); start != nil && s.usageLogRepo != nil {
@@ -861,6 +863,12 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
if account == nil || !account.IsOAuth() {
return nil, nil
}
+ if s.openAIQuotaService != nil {
+ var finish func()
+ ctx, finish = s.openAIQuotaService.activity.Attach(ctx)
+ ObserveQuotaAccount(ctx, account.ID)
+ defer finish()
+ }
accessToken := ""
if !account.IsOpenAIAgentIdentity() {
accessToken = account.GetOpenAIAccessToken()
diff --git a/backend/internal/service/admin_account_codex_ticket_test.go b/backend/internal/service/admin_account_codex_ticket_test.go
index b6f925dde4e1..c18e9a9d63ab 100644
--- a/backend/internal/service/admin_account_codex_ticket_test.go
+++ b/backend/internal/service/admin_account_codex_ticket_test.go
@@ -9,7 +9,7 @@ import (
func TestUpdateAccountPreservesCodexTicketOnEdit(t *testing.T) {
account := ticketTestAccount(41)
- ticket := &openAICodexTicket{Model: "gpt-6-astra", State: fakeCodexTicketState(292), Length: 292, ExpiresAt: time.Now().Add(time.Hour)}
+ ticket := &CodexTicketRecord{Model: "gpt-6-astra", State: fakeCodexTicketState(292), Length: 292, ExpiresAt: time.Now().Add(time.Hour)}
key := openAICodexTicketExtraKey(ticket.Model)
account.Extra = map[string]any{key: ticket}
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{41: account}}
diff --git a/backend/internal/service/codex_ticket_lifecycle.go b/backend/internal/service/codex_ticket_lifecycle.go
index 7945cf927e72..d47c5cf78ac6 100644
--- a/backend/internal/service/codex_ticket_lifecycle.go
+++ b/backend/internal/service/codex_ticket_lifecycle.go
@@ -1,10 +1,8 @@
package service
import (
- "context"
"crypto/sha256"
"encoding/hex"
- "encoding/json"
"errors"
"net/http"
"strings"
@@ -54,16 +52,6 @@ type CodexTicketRecord struct {
Attempts int `json:"attempts"`
}
-// Optional capability on AccountRepository keeps the gateway's existing
-// constructor and test adapters intact. Production accountRepository owns SQL.
-type CodexTicketRepository interface {
- SeedCodexTicketRenewals(context.Context, []string, int, time.Time) error
- DueCodexTickets(context.Context, []string, time.Time, int) ([]CodexTicketLifecycle, error)
- ClaimCodexTicket(context.Context, int64, string, string, int64, bool, bool, time.Time) (*CodexTicketLifecycle, error)
- FinishCodexTicket(context.Context, *CodexTicketLifecycle, *CodexTicketRecord, CodexTicketResult, time.Time) (bool, error)
- StopCodexTicket(context.Context, int64, []string, time.Time) error
-}
-
var ErrCodexTicketBusy = errors.New("ticket operation already running")
var ErrCodexTicketAlreadyAttempted = errors.New("ticket operation already attempted")
var ErrCodexTicketInactive = errors.New("ticket account is unavailable")
@@ -93,30 +81,31 @@ func CodexTicketAccountEligible(a *Account) bool {
func CodexTicketFailure(code string) CodexTicketResult {
messages := map[string]string{
- "ticket_disabled": "票据总开关未开启",
- "ticket_proxy_missing": "未配置打票代理",
- "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票",
- "ticket_model_invalid": "模型不在票据配置范围内",
- "ticket_busy": "该账号和模型已有打票任务",
- "ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送",
- "ticket_token": "无法取得账号访问令牌",
- "ticket_timeout": "请求超时",
- "ticket_transport": "打票代理传输失败",
- "ticket_upstream": "上游拒绝打票请求",
- "ticket_length": "响应票据长度不符合292规则",
- "ticket_prefix": "响应票据格式不符合规则",
- "ticket_missing_header": "响应未包含票据头",
- "ticket_persist": "票据持久化失败,未加入自动续期",
- "ticket_stale": "账号身份、任务或续期状态已经变化",
- "ticket_canceled": "任务已取消",
- "ticket_stopped": "自动续期已停止",
- "ticket_proxy_auth": "代理鉴权失败",
- "ticket_proxy_protocol": "代理协议握手失败,请核对HTTP或SOCKS协议",
- "ticket_proxy_dns": "代理主机DNS解析失败",
- "ticket_proxy_connect": "代理连接被拒绝或CONNECT失败",
- "ticket_proxy_eof": "连接被对端提前关闭(EOF)",
- "ticket_proxy_reset": "连接被对端重置",
- "ticket_proxy_tls": "代理TLS证书验证失败,请测试代理连接",
+ "ticket_plugin_unavailable": "票据插件未启用或暂不可用",
+ "ticket_disabled": "票据总开关未开启",
+ "ticket_proxy_missing": "未配置打票代理",
+ "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票",
+ "ticket_model_invalid": "模型不在票据配置范围内",
+ "ticket_busy": "该账号和模型已有打票任务",
+ "ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送",
+ "ticket_token": "无法取得账号访问令牌",
+ "ticket_timeout": "请求超时",
+ "ticket_transport": "打票代理传输失败",
+ "ticket_upstream": "上游拒绝打票请求",
+ "ticket_length": "响应票据长度不符合292规则",
+ "ticket_prefix": "响应票据格式不符合规则",
+ "ticket_missing_header": "响应未包含票据头",
+ "ticket_persist": "票据持久化失败,未加入自动续期",
+ "ticket_stale": "账号身份、任务或续期状态已经变化",
+ "ticket_canceled": "任务已取消",
+ "ticket_stopped": "自动续期已停止",
+ "ticket_proxy_auth": "代理鉴权失败",
+ "ticket_proxy_protocol": "代理协议握手失败,请核对HTTP或SOCKS协议",
+ "ticket_proxy_dns": "代理主机DNS解析失败",
+ "ticket_proxy_connect": "代理连接被拒绝或CONNECT失败",
+ "ticket_proxy_eof": "连接被对端提前关闭(EOF)",
+ "ticket_proxy_reset": "连接被对端重置",
+ "ticket_proxy_tls": "代理TLS证书验证失败,请测试代理连接",
}
message, ok := messages[code]
if !ok {
@@ -151,93 +140,6 @@ func CodexTicketFailure(code string) CodexTicketResult {
return CodexTicketResult{Code: code, Message: message, Stage: stage}
}
-// Begin consumes an automatic stage before any network IO. An abandoned
-// attempt cannot be repeated after a process crash.
-func (s *CodexTicketLifecycle) Begin(now time.Time, manual bool) error {
- if manual {
- s.ManualWasEnrolled = s.Phase == "ready" || s.Phase == "retry"
- s.Phase = "manual_running"
- return nil
- }
- if s.NextAt == nil || now.Before(*s.NextAt) || s.ExpiresAt == nil {
- return ErrCodexTicketNotDue
- }
- switch s.Phase {
- case "ready":
- if !now.Before(*s.ExpiresAt) {
- due := s.ExpiresAt.Add(time.Minute)
- s.Phase = "retry"
- s.NextAt = &due
- if now.Before(due) {
- return ErrCodexTicketNotDue
- }
- s.Phase = "post_running"
- } else {
- s.Phase = "pre_running"
- }
- case "retry":
- s.Phase = "post_running"
- default:
- return ErrCodexTicketNotDue
- }
- return nil
-}
-
-func (s *CodexTicketLifecycle) Complete(now time.Time, ticket *CodexTicketRecord, result CodexTicketResult) {
- prior := s.Phase
- if s.LastAttemptAt == nil {
- s.LastAttemptAt = &now
- }
- s.LastResult = &result
- s.LeaseID = ""
- s.LeaseUntil = nil
- s.JobID = 0
- if ticket != nil && result.Success {
- due := ticket.ExpiresAt.Add(-time.Minute)
- s.ExpiresAt = &ticket.ExpiresAt
- s.NextAt = &due
- s.Phase = "ready"
- return
- }
- if (prior == "pre_running" || prior == "manual_running") && s.ExpiresAt != nil {
- // A failed forced refresh never discards a usable previous ticket.
- if prior == "manual_running" && s.ManualWasEnrolled && now.Before(*s.ExpiresAt) {
- due := s.ExpiresAt.Add(-time.Minute)
- if !now.Before(due) {
- due = s.ExpiresAt.Add(time.Minute)
- s.Phase = "retry"
- } else {
- s.Phase = "ready"
- }
- s.NextAt = &due
- return
- }
- if prior == "pre_running" {
- due := s.ExpiresAt.Add(time.Minute)
- s.NextAt = &due
- s.Phase = "retry"
- return
- }
- }
- s.Phase = "stopped"
- s.NextAt = nil
-}
-
-func codexTicketRuntimeFromExtra(account *Account, model string) *CodexTicketLifecycle {
- if account == nil || account.Extra == nil {
- return nil
- }
- raw, err := json.Marshal(account.Extra[CodexTicketRuntimeExtraPrefix+model])
- if err != nil {
- return nil
- }
- var state CodexTicketLifecycle
- if json.Unmarshal(raw, &state) != nil || state.Phase == "" {
- return nil
- }
- return &state
-}
-
func codexTicketWireSummary(headers http.Header) map[string]any {
state := strings.TrimSpace(headers.Get(openAICodexTurnStateHeader))
result := map[string]any{"ticket_present": state != "", "ticket_length": len(state)}
diff --git a/backend/internal/service/codex_ticket_manual.go b/backend/internal/service/codex_ticket_manual.go
index 6fa1199d7325..aabb1dbe8174 100644
--- a/backend/internal/service/codex_ticket_manual.go
+++ b/backend/internal/service/codex_ticket_manual.go
@@ -2,16 +2,8 @@ package service
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "strings"
- "time"
)
-type codexTicketUpstreamError struct{ message string }
-
// Ticket preparation can refresh credentials without enrolling the account in
// business scheduling or replacing an administrator's status/error decision.
type codexTicketCredentialContextKey struct{}
@@ -25,171 +17,9 @@ func isCodexTicketCredentialContext(ctx context.Context) bool {
return ticket
}
-func (e *codexTicketUpstreamError) Error() string { return e.message }
-
-// Shared by manual jobs and automatic renewal, across administrators.
-func (s *OpenAIGatewayService) codexTicketSlots() chan struct{} {
- s.openaiCodexTicketLifecycleMu.Lock()
- defer s.openaiCodexTicketLifecycleMu.Unlock()
- if s.openaiCodexTicketSlots == nil {
- s.openaiCodexTicketSlots = make(chan struct{}, 5)
- }
- return s.openaiCodexTicketSlots
-}
-
func (s *OpenAIGatewayService) CodexTicketModels() []string {
return append([]string(nil), s.openAICodexTicketConfig().Models...)
}
func (s *OpenAIGatewayService) CodexTicketsEnabled(ctx context.Context) bool {
return s.openAICodexTicketEnabledContext(ctx)
}
-
-func (s *OpenAIGatewayService) HarvestCodexTicket(ctx context.Context, id int64, model, operation string, jobID int64, force bool) CodexTicketResult {
- return s.runCodexTicketAttempt(ctx, id, model, operation, jobID, true, force)
-}
-
-func (s *OpenAIGatewayService) StopCodexTicketRenewal(ctx context.Context, id int64, models []string) error {
- store, ok := s.accountRepo.(CodexTicketRepository)
- if !ok {
- return errors.New("ticket store unavailable")
- }
- return store.StopCodexTicket(ctx, id, models, time.Now())
-}
-
-func (s *OpenAIGatewayService) runCodexTicketAttempt(parent context.Context, id int64, model, operation string, jobID int64, manual, force bool) CodexTicketResult {
- if s == nil || !s.openAICodexTicketEnabledContext(parent) {
- return CodexTicketFailure("ticket_disabled")
- }
- model = strings.TrimSpace(model)
- if parent.Err() != nil {
- return CodexTicketFailure("ticket_canceled")
- }
- if !s.openAICodexTicketConfiguredModel(model) {
- return CodexTicketFailure("ticket_model_invalid")
- }
- store, ok := s.accountRepo.(CodexTicketRepository)
- if !ok {
- return CodexTicketFailure("ticket_persist")
- }
- slots := s.codexTicketSlots()
- select {
- case slots <- struct{}{}:
- defer func() { <-slots }()
- case <-parent.Done():
- return CodexTicketFailure("ticket_canceled")
- }
- started := time.Now()
- claim, err := store.ClaimCodexTicket(parent, id, model, operation, jobID, manual, force, started)
- if err != nil {
- switch {
- case errors.Is(err, ErrCodexTicketValid):
- return CodexTicketResult{Success: true, Code: "ticket_skipped", Message: "已有有效票据,本次跳过"}
- case errors.Is(err, ErrCodexTicketBusy):
- return CodexTicketFailure("ticket_busy")
- case errors.Is(err, ErrCodexTicketAlreadyAttempted):
- return CodexTicketFailure("ticket_interrupted")
- case errors.Is(err, ErrCodexTicketInactive):
- return CodexTicketFailure("ticket_ineligible")
- case errors.Is(err, ErrCodexTicketDisabled):
- return CodexTicketFailure("ticket_disabled")
- case errors.Is(err, context.Canceled):
- return CodexTicketFailure("ticket_canceled")
- case errors.Is(err, ErrCodexTicketNotDue):
- return CodexTicketResult{Code: "ticket_not_due", Message: "未到续期时间"}
- default:
- return CodexTicketFailure("ticket_persist")
- }
- }
- ctx, cancel := context.WithTimeout(parent, 25*time.Second)
- defer cancel()
- var ticket *CodexTicketRecord
- result := func() CodexTicketResult {
- account, err := s.accountRepo.GetByID(ctx, id)
- if err != nil || !CodexTicketAccountEligible(account) {
- return CodexTicketFailure("ticket_ineligible")
- }
- if CodexTicketAccountIdentity(account) != claim.Identity {
- return CodexTicketFailure("ticket_stale")
- }
- proxyURL := s.openAICodexTicketHarvestProxyURLContext(ctx)
- if proxyURL == "" {
- return CodexTicketFailure("ticket_proxy_missing")
- }
- var prepared *codexTicketPreparedProxy
- if _, hasTrustStore := s.accountRepo.(CodexTicketProxyTrustRepository); hasTrustStore {
- var proxyResult *CodexTicketProxyTestResult
- prepared, proxyResult, err = s.prepareCodexTicketProxy(ctx, proxyURL, manual, true)
- if err != nil {
- if proxyResult != nil && strings.HasPrefix(proxyResult.Code, "ticket_") {
- return CodexTicketFailure(proxyResult.Code)
- }
- return CodexTicketFailure(codexTicketTransportFailureCode(err))
- }
- defer prepared.transport.CloseIdleConnections()
- }
- token, _, err := s.GetAccessToken(withCodexTicketCredentials(ctx), account)
- if err != nil || strings.TrimSpace(token) == "" {
- return CodexTicketFailure("ticket_token")
- }
- state, status, err := s.fireCodexTicketProbe(ctx, account, token, model, proxyURL, 25*time.Second, prepared)
- if err != nil {
- var upstream *codexTicketUpstreamError
- if errors.As(err, &upstream) {
- r := CodexTicketFailure("ticket_upstream")
- r.HTTPStatus = status
- r.ObservedLength = len(state)
- if upstream.message != "" {
- r.Message = upstream.message
- }
- return r
- }
- return CodexTicketFailure(codexTicketTransportFailureCode(err))
- }
- failure := CodexTicketResult{HTTPStatus: status, ObservedLength: len(state)}
- switch {
- case status != 200:
- failure = CodexTicketFailure("ticket_upstream")
- case state == "":
- failure = CodexTicketFailure("ticket_missing_header")
- case len(state) != 292:
- failure = CodexTicketFailure("ticket_length")
- case !strings.HasPrefix(state, openAICodexTicketStatePrefix):
- failure = CodexTicketFailure("ticket_prefix")
- default:
- now := time.Now()
- expiry := now.Add(time.Duration(s.openAICodexTicketConfig().TTLSeconds) * time.Second)
- ticket = &CodexTicketRecord{AccountID: id, Model: model, Identity: claim.Identity, State: state, Length: len(state), CapturedAt: now, ExpiresAt: expiry, Attempts: 1}
- sum := sha256.Sum256([]byte(state))
- return CodexTicketResult{Success: true, Code: "ticket_ready", Message: "292票据已取得", HTTPStatus: status, ObservedLength: len(state), ExpiresAt: &expiry, Fingerprint: hex.EncodeToString(sum[:8])}
- }
- failure.HTTPStatus = status
- failure.ObservedLength = len(state)
- return failure
- }()
- result.DurationMS = time.Since(started).Milliseconds()
- if parent.Err() != nil {
- ticket = nil
- result = CodexTicketFailure("ticket_canceled")
- }
- if !s.openAICodexTicketEnabledContext(parent) {
- ticket = nil
- result = CodexTicketFailure("ticket_disabled")
- }
- finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(parent), 8*time.Second)
- defer finishCancel()
- applied, err := store.FinishCodexTicket(finishCtx, claim, ticket, result, time.Now())
- if err != nil {
- return CodexTicketFailure("ticket_persist")
- }
- if ticket != nil && !applied {
- return CodexTicketFailure("ticket_stale")
- }
- if applied {
- // Publish in memory only after the database transaction committed.
- raw, _ := json.Marshal(ticket)
- var mem openAICodexTicket
- _ = json.Unmarshal(raw, &mem)
- s.openaiCodexTickets.Store(openAICodexTicketKey(id, model), &mem)
- }
- return result
-}
diff --git a/backend/internal/service/codex_ticket_manual_test.go b/backend/internal/service/codex_ticket_manual_test.go
index be34080266c3..e1669ae07a0b 100644
--- a/backend/internal/service/codex_ticket_manual_test.go
+++ b/backend/internal/service/codex_ticket_manual_test.go
@@ -2,262 +2,41 @@ package service
import (
"context"
- "errors"
- "net/http"
- "sync"
- "sync/atomic"
"testing"
"time"
- "github.com/Wei-Shaw/sub2api/internal/config"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
"github.com/stretchr/testify/require"
)
-// An in-memory durable-store adapter isolates gateway IO tests. SQL atomicity
-// and claims are covered separately by repository integration tests.
-type codexTicketMemoryStore struct {
- AccountRepository
- mu sync.Mutex
- account Account
- states map[string]*CodexTicketLifecycle
- updates map[string]any
- persistError error
-}
-
-func newCodexTicketMemoryStore(a *Account) *codexTicketMemoryStore {
- return &codexTicketMemoryStore{account: *a, states: map[string]*CodexTicketLifecycle{}, updates: map[string]any{}}
-}
-func (r *codexTicketMemoryStore) GetByID(context.Context, int64) (*Account, error) {
- a := r.account
- return &a, nil
-}
-func (r *codexTicketMemoryStore) SeedCodexTicketRenewals(context.Context, []string, int, time.Time) error {
- return nil
-}
-func (r *codexTicketMemoryStore) DueCodexTickets(_ context.Context, _ []string, now time.Time, _ int) ([]CodexTicketLifecycle, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- var out []CodexTicketLifecycle
- for _, s := range r.states {
- if (s.Phase == "ready" || s.Phase == "retry") && s.NextAt != nil && !now.Before(*s.NextAt) {
- out = append(out, *s)
- }
- }
- return out, nil
-}
-func (r *codexTicketMemoryStore) ClaimCodexTicket(_ context.Context, id int64, model, operation string, jobID int64, manual, force bool, now time.Time) (*CodexTicketLifecycle, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- s := r.states[model]
- if s == nil {
- s = &CodexTicketLifecycle{AccountID: id, Model: model, Identity: CodexTicketAccountIdentity(&r.account)}
- r.states[model] = s
- }
- if s.LeaseID != "" {
- return nil, ErrCodexTicketBusy
- }
- if s.Operation == operation && manual {
- return nil, ErrCodexTicketAlreadyAttempted
- }
- if manual && !force && s.ExpiresAt != nil && now.Before(*s.ExpiresAt) {
- return nil, ErrCodexTicketValid
- }
- if err := s.Begin(now, manual); err != nil {
- return nil, err
- }
- s.LeaseID = operation
- s.Operation = operation
- s.JobID = jobID
- copy := *s
- return ©, nil
-}
-func (r *codexTicketMemoryStore) FinishCodexTicket(_ context.Context, claim *CodexTicketLifecycle, ticket *CodexTicketRecord, result CodexTicketResult, now time.Time) (bool, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.persistError != nil {
- return false, r.persistError
- }
- s := r.states[claim.Model]
- if s.LeaseID != claim.LeaseID {
- return false, nil
- }
- s.Complete(now, ticket, result)
- if ticket != nil {
- r.updates[claim.Model] = ticket
- }
- return ticket != nil, nil
-}
-func (r *codexTicketMemoryStore) StopCodexTicket(_ context.Context, _ int64, models []string, now time.Time) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- for _, m := range models {
- if s := r.states[m]; s != nil {
- s.Phase = "stopped"
- s.Complete(now, nil, CodexTicketFailure("ticket_stopped"))
- }
- }
- return nil
-}
-
-func TestCodexTicketFiniteRenewalSchedule(t *testing.T) {
- now := time.Date(2026, 9, 19, 0, 0, 0, 0, time.UTC)
- expiry := now.Add(time.Hour)
- s := &CodexTicketLifecycle{}
- success := CodexTicketResult{Success: true}
- ticket := &CodexTicketRecord{ExpiresAt: expiry}
- require.NoError(t, s.Begin(now, true))
- s.Complete(now, ticket, success)
- require.Equal(t, expiry.Add(-time.Minute), *s.NextAt)
- require.ErrorIs(t, s.Begin(expiry.Add(-61*time.Second), false), ErrCodexTicketNotDue)
- require.NoError(t, s.Begin(expiry.Add(-time.Minute), false))
- require.Equal(t, "pre_running", s.Phase)
- s.Complete(expiry.Add(-50*time.Second), nil, CodexTicketFailure("ticket_length"))
- require.Equal(t, "retry", s.Phase)
- require.Equal(t, expiry.Add(time.Minute), *s.NextAt)
- require.ErrorIs(t, s.Begin(expiry, false), ErrCodexTicketNotDue)
- require.NoError(t, s.Begin(expiry.Add(time.Minute), false))
- s.Complete(expiry.Add(61*time.Second), nil, CodexTicketFailure("ticket_timeout"))
- require.Equal(t, "stopped", s.Phase)
- require.Nil(t, s.NextAt)
- require.ErrorIs(t, s.Begin(expiry.Add(24*time.Hour), false), ErrCodexTicketNotDue)
- // Manual success is the only transition out of stopped.
- require.NoError(t, s.Begin(expiry.Add(time.Hour), true))
- ticket.ExpiresAt = expiry.Add(2 * time.Hour)
- s.Complete(expiry.Add(time.Hour), ticket, success)
- require.Equal(t, "ready", s.Phase)
- require.Equal(t, ticket.ExpiresAt.Add(-time.Minute), *s.NextAt)
-}
-
-func TestCodexTicketMissedWindowAndInterruptedStage(t *testing.T) {
- now := time.Now()
- expiry := now.Add(-30 * time.Second)
- due := expiry.Add(-time.Minute)
- s := &CodexTicketLifecycle{Phase: "ready", ExpiresAt: &expiry, NextAt: &due}
- require.ErrorIs(t, s.Begin(now, false), ErrCodexTicketNotDue)
- require.Equal(t, "retry", s.Phase)
- require.NoError(t, s.Begin(now.Add(time.Hour), false))
- require.Equal(t, "post_running", s.Phase)
- s.Complete(now.Add(time.Hour), nil, CodexTicketFailure("ticket_interrupted"))
- require.Equal(t, "stopped", s.Phase)
- expiry = now.Add(time.Hour)
- due = expiry.Add(-time.Minute)
- s = &CodexTicketLifecycle{Phase: "ready", ExpiresAt: &expiry, NextAt: &due}
- require.NoError(t, s.Begin(now, true))
- s.Complete(now, nil, CodexTicketFailure("ticket_length"))
- require.Equal(t, "ready", s.Phase)
- require.Equal(t, due, *s.NextAt)
- s = &CodexTicketLifecycle{}
- require.NoError(t, s.Begin(now, true))
- s.Complete(now, nil, CodexTicketFailure("ticket_length"))
- require.Equal(t, "stopped", s.Phase)
-}
-
-func TestCodexTicketManualOnlyOneRequestAndPersistence(t *testing.T) {
- for _, tc := range []struct {
- name string
- length int
- persist bool
- success bool
- }{{"success", 292, false, true}, {"312", 312, false, false}, {"database failure", 292, true, false}} {
- t.Run(tc.name, func(t *testing.T) {
- a := ticketTestAccount(41)
- a.Status = StatusActive
- r := newCodexTicketMemoryStore(a)
- if tc.persist {
- r.persistError = errors.New("database unavailable")
- }
- var calls atomic.Int64
- upstream := &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
- calls.Add(1)
- resp := codexTicketResponse()
- resp.Header.Set(openAICodexTurnStateHeader, fakeCodexTicketState(tc.length))
- return resp, nil
- }}
- s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream)
- s.accountRepo = r
- s.refreshOpenAICodexTickets(context.Background())
- require.Zero(t, calls.Load(), "never probe unenrolled accounts")
- result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual1", 0, false)
- require.Equal(t, tc.success, result.Success)
- require.Equal(t, int64(1), calls.Load())
- require.Nil(t, r.states["gpt-5.6-sol"], "Astra success cannot enroll Sol")
- if tc.success {
- require.NotNil(t, s.lookupOpenAICodexTicket(a, "gpt-6-astra"))
- } else {
- require.Nil(t, s.lookupOpenAICodexTicket(a, "gpt-6-astra"))
- }
- s.refreshOpenAICodexTickets(context.Background())
- require.Equal(t, int64(1), calls.Load())
- })
- }
-}
-
-func TestCodexTicketEligibilityIndependentOfAccountStatus(t *testing.T) {
- for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
- for _, status := range []string{StatusActive, "disabled", "error"} {
- t.Run(accountType+"/"+status, func(t *testing.T) {
- a := ticketTestAccount(41)
- a.Type, a.Status, a.Schedulable, a.ErrorMessage = accountType, status, false, "preserve diagnostic"
- r := newCodexTicketMemoryStore(a)
- var calls atomic.Int64
- upstream := &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
- calls.Add(1)
- resp := codexTicketResponse()
- resp.Header.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
- return resp, nil
- }}
- s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream)
- s.accountRepo = r
- result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false)
- require.True(t, result.Success)
- require.Equal(t, int64(1), calls.Load())
- require.Equal(t, "ready", r.states["gpt-6-astra"].Phase)
- require.Equal(t, status, a.Status)
- require.False(t, a.Schedulable)
- require.Equal(t, "preserve diagnostic", a.ErrorMessage)
- })
- }
- }
- for _, a := range []*Account{
- {Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
- {Platform: PlatformAnthropic, Type: AccountTypeOAuth},
- {Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: func() *int64 { id := int64(1); return &id }()},
- nil,
- } {
- require.False(t, CodexTicketAccountEligible(a))
- }
-}
-
type ticketTokenFailureRepo struct {
- *codexTicketMemoryStore
+ AccountRepository
+ account *Account
statusWrites int
}
+func (r *ticketTokenFailureRepo) GetByID(context.Context, int64) (*Account, error) {
+ return r.account, nil
+}
func (r *ticketTokenFailureRepo) SetError(context.Context, int64, string) error {
r.statusWrites++
return nil
}
-
func TestCodexTicketExpiredCredentialFailurePreservesAccountStatus(t *testing.T) {
a := ticketTestAccount(41)
a.Status, a.Schedulable, a.ErrorMessage = "disabled", false, "original diagnostic"
a.Credentials["expires_at"] = time.Now().Add(-time.Minute).Format(time.RFC3339)
delete(a.Credentials, "refresh_token")
- r := &ticketTokenFailureRepo{codexTicketMemoryStore: newCodexTicketMemoryStore(a)}
- provider := NewOpenAITokenProvider(r, nil, nil)
- s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
- t.Fatal("invalid credentials must not reach the upstream")
- return nil, nil
- }})
- s.accountRepo, s.openAITokenProvider = r, provider
- result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false)
- require.False(t, result.Success)
- require.Equal(t, "ticket_token", result.Code)
- require.Zero(t, r.statusWrites)
- require.Equal(t, "disabled", r.account.Status)
- require.False(t, r.account.Schedulable)
- require.Equal(t, "original diagnostic", r.account.ErrorMessage)
- _, err := provider.GetAccessToken(context.Background(), a)
+ repo := &ticketTokenFailureRepo{account: a}
+ provider := NewOpenAITokenProvider(repo, nil, nil)
+ gateway := &OpenAIGatewayService{accountRepo: repo, openAITokenProvider: provider}
+ _, err := gateway.ResolveExtensionIdentity(context.Background(), extensionv1.AccountQuery{AccountID: a.ID, PrepareCredentials: true})
+ require.Error(t, err)
+ require.Zero(t, repo.statusWrites)
+ require.Equal(t, "disabled", a.Status)
+ require.False(t, a.Schedulable)
+ require.Equal(t, "original diagnostic", a.ErrorMessage)
+ _, err = provider.GetAccessToken(context.Background(), a)
require.Error(t, err)
- require.Equal(t, 1, r.statusWrites, "ordinary inference still quarantines unusable credentials")
+ require.Equal(t, 1, repo.statusWrites, "ordinary inference still quarantines unusable credentials")
}
diff --git a/backend/internal/service/codex_ticket_plugin.go b/backend/internal/service/codex_ticket_plugin.go
new file mode 100644
index 000000000000..a4f7b5132849
--- /dev/null
+++ b/backend/internal/service/codex_ticket_plugin.go
@@ -0,0 +1,173 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+// The compatibility facade is the only host location that identifies this
+// first-party domain. Acquisition, renewal and validation execute in its plugin.
+const codexRuntimePluginKey = "codexrip.codex-runtime"
+
+func (s *OpenAIGatewayService) CodexTicketStatuses(account *Account) []OpenAICodexTicketStatus {
+ return OpenAICodexTicketStatuses(account, s.openAICodexTicketConfig(), time.Now())
+}
+
+func (s *OpenAIGatewayService) openAICodexTicketConfig() config.OpenAICodexTicketConfig {
+ cfg := config.OpenAICodexTicketConfig{FailClosed: true, TargetLength: 292, TTLSeconds: 3600, RefreshBeforeSeconds: 60, Models: []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}}
+ if s == nil || s.pluginManager == nil {
+ return cfg
+ }
+ raw, active := s.pluginManager.activeConfig(codexRuntimePluginKey)
+ if !active {
+ return cfg
+ }
+ var value struct {
+ Enabled bool `json:"enabled"`
+ FailClosed bool `json:"fail_closed"`
+ ProxyURL string `json:"proxy_url"`
+ Models []string `json:"models"`
+ }
+ if json.Unmarshal(raw, &value) != nil {
+ return cfg
+ }
+ cfg.Enabled = value.Enabled
+ cfg.FailClosed = value.FailClosed
+ cfg.HarvestProxyURL = value.ProxyURL
+ if len(value.Models) > 0 {
+ cfg.Models = value.Models
+ }
+ return cfg
+}
+
+func (s *OpenAIGatewayService) openAICodexTicketEnabledContext(context.Context) bool {
+ return s.openAICodexTicketConfig().Enabled
+}
+func (s *OpenAIGatewayService) openAICodexTicketHarvestProxyURLContext(context.Context) string {
+ return s.openAICodexTicketConfig().HarvestProxyURL
+}
+
+func (s *OpenAIGatewayService) applyOpenAICodexTicket(ctx context.Context, account *Account, model string, headers http.Header) error {
+ if s == nil || s.pluginManager == nil {
+ return nil
+ }
+ if err := s.pluginManager.ApplyRequestHeaders(ctx, account, model, headers); err != nil {
+ return fmt.Errorf("%w: extension request prerequisites", ErrOpenAICodexTicketUnavailable)
+ }
+ return nil
+}
+
+func (s *OpenAIGatewayService) openAICodexTicketBlocksAccount(account *Account, model string) bool {
+ return s != nil && s.pluginManager != nil && !s.pluginManager.SchedulingDecision(account, model, time.Now()).Allowed
+}
+
+func (s *OpenAIGatewayService) HarvestCodexTicket(ctx context.Context, id int64, model, operation string, jobID int64, force bool) CodexTicketResult {
+ if s == nil || s.pluginManager == nil {
+ return CodexTicketFailure("ticket_plugin_unavailable")
+ }
+ installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey)
+ if installation == nil {
+ return CodexTicketFailure("ticket_plugin_unavailable")
+ }
+ if jobID > 0 {
+ operation = fmt.Sprintf("job-%d", jobID)
+ }
+ payload, _ := json.Marshal(map[string]any{"account_id": id, "model": model, "operation_id": operation, "force": force})
+ response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, id, "harvest", payload)
+ if err != nil {
+ return CodexTicketFailure("ticket_plugin_unavailable")
+ }
+ var result CodexTicketResult
+ if json.Unmarshal(response.Payload, &result) != nil {
+ return CodexTicketFailure("ticket_transport")
+ }
+ return result
+}
+
+func (s *OpenAIGatewayService) StopCodexTicketRenewal(ctx context.Context, id int64, models []string) error {
+ if s == nil || s.pluginManager == nil {
+ return errors.New("ticket plugin unavailable")
+ }
+ installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey)
+ if installation == nil {
+ return errors.New("ticket plugin unavailable")
+ }
+ for _, model := range models {
+ raw, _ := json.Marshal(map[string]any{"account_id": id, "model": model})
+ response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, id, "stop", raw)
+ if err != nil {
+ return err
+ }
+ var result CodexTicketResult
+ if json.Unmarshal(response.Payload, &result) != nil || !result.Success {
+ return errors.New("ticket stop failed")
+ }
+ }
+ return nil
+}
+
+func (s *OpenAIGatewayService) TestCodexTicketProxy(ctx context.Context, raw string) (*CodexTicketProxyTestResult, error) {
+ if s == nil || s.pluginManager == nil {
+ return nil, errors.New("ticket plugin unavailable")
+ }
+ installation, _ := s.pluginManager.installedByKey(codexRuntimePluginKey)
+ if installation == nil {
+ return nil, errors.New("ticket plugin unavailable")
+ }
+ payload, _ := json.Marshal(map[string]string{"proxy_url": raw})
+ response, err := s.pluginManager.InvokeAdminExtension(ctx, installation.ID, 0, "proxy.test", payload)
+ if err != nil {
+ return nil, err
+ }
+ var result CodexTicketProxyTestResult
+ if err := json.Unmarshal(response.Payload, &result); err != nil {
+ return nil, err
+ }
+ if result.NetworkReachable && (result.Code == "proxy_reachable" || result.Code == "target_http_status") {
+ result.Message = fmt.Sprintf("连接和证书验证正常,目标返回HTTP %d;未发送账号凭据", result.HTTPStatus)
+ } else {
+ result.Message = "代理连接或证书验证失败"
+ }
+ return &result, nil
+}
+
+func OpenAICodexTicketStatuses(account *Account, cfg config.OpenAICodexTicketConfig, now time.Time) []OpenAICodexTicketStatus {
+ if !cfg.Enabled || !isOpenAICodexTicketAccount(account) {
+ return nil
+ }
+ projection := accountPluginProjection(account, codexRuntimePluginKey)
+ out := make([]OpenAICodexTicketStatus, 0, len(cfg.Models))
+ for _, model := range cfg.Models {
+ entry := OpenAICodexTicketStatus{Model: model, RenewalState: "idle"}
+ if projection.Identity == CodexTicketAccountIdentity(account) {
+ if observation, ok := projection.Observations[model]; ok {
+ entry.RenewalState = observation.State
+ entry.NextAttemptAt = observation.NextAt
+ entry.LastAttemptAt = observation.CheckedAt
+ entry.ExpiresAt = observation.ExpiresAt
+ entry.Length = observation.Count
+ if observation.Code != "" {
+ result := CodexTicketFailure(observation.Code)
+ if observation.Code == "ticket_ready" || observation.Code == "ticket_skipped" {
+ result = CodexTicketResult{Code: observation.Code, Success: true, Message: "已有有效票据"}
+ }
+ entry.LastResult = &result
+ }
+ }
+ if grant, ok := projection.Scheduling[model]; ok && grant.Effect == "allow" && grant.Until != nil && now.Before(*grant.Until) {
+ entry.Ready = true
+ entry.ExpiresAt = grant.Until
+ entry.RemainingSeconds = int64(grant.Until.Sub(now).Seconds())
+ }
+ }
+ entry.Blocked = cfg.FailClosed && !entry.Ready
+ out = append(out, entry)
+ }
+ return out
+}
diff --git a/backend/internal/service/codex_ticket_proxy.go b/backend/internal/service/codex_ticket_proxy.go
index a9247f4fa566..1365aaad49a6 100644
--- a/backend/internal/service/codex_ticket_proxy.go
+++ b/backend/internal/service/codex_ticket_proxy.go
@@ -1,25 +1,7 @@
package service
import (
- "context"
- "crypto/sha256"
- "crypto/tls"
- "crypto/x509"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptrace"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
+ proxytransport "github.com/Wei-Shaw/sub2api/pkg/extensionapi/proxy"
)
const codexTicketProxyTarget = "https://chatgpt.com/backend-api/codex/responses"
@@ -29,12 +11,6 @@ type CodexTicketProxyTrust struct {
Fingerprint string `json:"fingerprint"`
}
-type CodexTicketProxyTrustRepository interface {
- CodexTicketProxyTrustGeneration(context.Context) (string, error)
- LoadCodexTicketProxyTrust(context.Context, string) (*CodexTicketProxyTrust, error)
- SaveCodexTicketProxyTrust(context.Context, string, *CodexTicketProxyTrust) error
-}
-
type CodexTicketProxyStage struct {
Name string `json:"name"`
Success bool `json:"success"`
@@ -59,413 +35,5 @@ type CodexTicketProxyTestResult struct {
// NormalizeCodexTicketProxy is shared by settings validation and the preview
// test. Parsing never performs IO or changes the configured proxy.
func NormalizeCodexTicketProxy(raw string) (string, error) {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return "", nil
- }
- if len(raw) > 16384 {
- return "", errors.New("proxy input is too long")
- }
- if strings.ContainsAny(raw, "\r\n") {
- fields := map[string]string{}
- for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || line == "(" || line == ")" {
- continue
- }
- i := strings.IndexAny(line, "::=")
- if i < 0 {
- return "", errors.New("each proxy field needs a label")
- }
- key := strings.ToLower(strings.TrimSpace(line[:i]))
- value := strings.TrimSpace(line[i+len(string([]rune(line[i:])[0])):])
- switch key {
- case "proxy server", "proxy_server", "server", "host", "hostname", "主机", "服务器", "节点", "代理服务器":
- key = "host"
- case "port", "端口":
- key = "port"
- case "user", "username", "用户名", "账号", "帐号":
- key = "username"
- case "pass", "password", "密码":
- key = "password"
- case "scheme", "protocol", "协议":
- key = "scheme"
- default:
- return "", errors.New("unrecognized proxy field label")
- }
- if _, exists := fields[key]; exists {
- return "", errors.New("duplicate proxy field")
- }
- fields[key] = value
- }
- if fields["host"] == "" || fields["port"] == "" {
- return "", errors.New("proxy host and port are required")
- }
- scheme := fields["scheme"]
- if scheme == "" {
- scheme = "http"
- }
- u := url.URL{Scheme: strings.ToLower(scheme), Host: net.JoinHostPort(strings.Trim(fields["host"], "[]"), fields["port"])}
- _, hasUser := fields["username"]
- _, hasPass := fields["password"]
- if hasUser != hasPass {
- return "", errors.New("both username and password are required")
- }
- if hasUser {
- u.User = url.UserPassword(fields["username"], fields["password"])
- }
- raw = u.String()
- } else if !strings.Contains(raw, "://") {
- if strings.Contains(raw, "@") {
- raw = "http://" + raw
- } else {
- parts := strings.SplitN(raw, ":", 4)
- if len(parts) == 4 && !strings.HasPrefix(raw, "[") {
- if _, err := strconv.Atoi(parts[1]); err != nil {
- return "", errors.New("ambiguous proxy fields; use labeled host, port, username and password")
- }
- u := url.URL{Scheme: "http", Host: net.JoinHostPort(parts[0], parts[1]), User: url.UserPassword(parts[2], parts[3])}
- raw = u.String()
- } else {
- raw = "http://" + raw
- }
- }
- }
- u, err := url.Parse(raw)
- if err != nil || u == nil {
- return "", errors.New("invalid proxy URL")
- }
- u.Scheme = strings.ToLower(u.Scheme)
- if err = ValidateOpenAICodexTicketHarvestProxyURL(u.String()); err != nil {
- return "", err
- }
- if u.Port() == "" {
- return "", errors.New("proxy port is required")
- }
- if strings.ContainsAny(u.Hostname(), " \t\r\n") {
- return "", errors.New("invalid proxy host")
- }
- u.Path = ""
- return u.String(), nil
-}
-
-func codexTicketProxyKey(raw string) string {
- sum := sha256.Sum256([]byte(raw + "\x00chatgpt.com"))
- return hex.EncodeToString(sum[:])
-}
-
-// Verify against system roots first, then only the certificates pinned to this
-// configured proxy and target. Learning is limited to unauthenticated preflight.
-// Hostname and validity checks always apply, including during first-use trust.
-func verifyCodexTicketProxyTLS(cs tls.ConnectionState, trust *CodexTicketProxyTrust, learn bool) (*CodexTicketProxyTrust, error) {
- if len(cs.PeerCertificates) == 0 {
- return nil, errors.New("no peer certificate")
- }
- leaf := cs.PeerCertificates[0]
- name := cs.ServerName
- if name == "" {
- name = "chatgpt.com"
- }
- intermediates := x509.NewCertPool()
- for _, cert := range cs.PeerCertificates[1:] {
- intermediates.AddCert(cert)
- }
- opts := x509.VerifyOptions{DNSName: name, Intermediates: intermediates}
- if _, err := leaf.Verify(opts); err == nil {
- return nil, nil
- }
- if name != "chatgpt.com" {
- return nil, errors.New("proxy TLS issuer is not trusted")
- }
- if trust != nil && len(trust.Certificates) > 0 {
- roots := x509.NewCertPool()
- for _, der := range trust.Certificates {
- cert, err := x509.ParseCertificate(der)
- if err != nil {
- return nil, err
- }
- roots.AddCert(cert)
- }
- opts.Roots = roots
- if _, err := leaf.Verify(opts); err == nil {
- return trust, nil
- }
- }
- if !learn {
- return nil, errors.New("ticket proxy certificate changed or is not trusted; test the proxy connection")
- }
- // Trust the provided chain anchor (or a pinned leaf if there is no chain).
- anchor := cs.PeerCertificates[len(cs.PeerCertificates)-1]
- if time.Now().Before(anchor.NotBefore) || !time.Now().Before(anchor.NotAfter) {
- return nil, errors.New("proxy certificate is expired or not yet valid")
- }
- roots := x509.NewCertPool()
- roots.AddCert(anchor)
- opts.Roots = roots
- if _, err := leaf.Verify(opts); err != nil {
- return nil, err
- }
- sum := sha256.Sum256(anchor.RawSubjectPublicKeyInfo)
- return &CodexTicketProxyTrust{Certificates: [][]byte{anchor.Raw}, Fingerprint: hex.EncodeToString(sum[:])}, nil
-}
-
-type codexTicketPreparedProxy struct {
- client *http.Client
- transport *http.Transport
- trust *CodexTicketProxyTrust
-}
-
-type codexTicketProxyObservation struct {
- mu sync.Mutex
- trust *CodexTicketProxyTrust
- stages []CodexTicketProxyStage
-}
-
-func (o *codexTicketProxyObservation) add(stage CodexTicketProxyStage) {
- if o != nil {
- o.mu.Lock()
- defer o.mu.Unlock()
- o.stages = append(o.stages, stage)
- }
-}
-func (o *codexTicketProxyObservation) snapshot() (*CodexTicketProxyTrust, []CodexTicketProxyStage) {
- o.mu.Lock()
- defer o.mu.Unlock()
- return o.trust, append([]CodexTicketProxyStage(nil), o.stages...)
-}
-
-func newCodexTicketProxyTransport(raw string, trust *CodexTicketProxyTrust, learn bool, observed *codexTicketProxyObservation) (*http.Transport, error) {
- u, err := url.Parse(raw)
- if err != nil {
- return nil, errors.New("invalid proxy")
- }
- tr := &http.Transport{DialContext: (&net.Dialer{Timeout: 8 * time.Second}).DialContext, TLSHandshakeTimeout: 8 * time.Second, ResponseHeaderTimeout: 12 * time.Second, DisableKeepAlives: true, DisableCompression: true, ForceAttemptHTTP2: false, TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}}
- // InsecureSkipVerify only disables Go's default verifier; VerifyConnection
- // below fully verifies hostname, time and system or per-proxy pinned roots.
- tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, VerifyConnection: func(cs tls.ConnectionState) error { // #nosec G402 -- custom constrained certificate verification below
- next, err := verifyCodexTicketProxyTLS(cs, trust, learn)
- if err == nil && observed != nil {
- observed.mu.Lock()
- observed.trust = next
- observed.mu.Unlock()
- }
- return err
- }}
- tr.OnProxyConnectResponse = func(ctx context.Context, proxyURL *url.URL, connectReq *http.Request, connectRes *http.Response) error {
- observed.add(CodexTicketProxyStage{Name: "auth", Success: connectRes.StatusCode != 407, Message: fmt.Sprintf("HTTP %d", connectRes.StatusCode)})
- observed.add(CodexTicketProxyStage{Name: "connect", Success: connectRes.StatusCode == 200, Message: fmt.Sprintf("HTTP %d", connectRes.StatusCode)})
- if connectRes.StatusCode == 407 {
- return errors.New("ticket proxy authentication failed (407)")
- }
- if connectRes.StatusCode != 200 {
- return errors.New("ticket proxy CONNECT rejected")
- }
- return nil
- }
- if err = proxyutil.ConfigureTransportProxy(tr, u); err != nil {
- return nil, err
- }
- return tr, nil
-}
-
-func (s *OpenAIGatewayService) prepareCodexTicketProxy(ctx context.Context, raw string, learn, persist bool) (*codexTicketPreparedProxy, *CodexTicketProxyTestResult, error) {
- result := &CodexTicketProxyTestResult{Stages: []CodexTicketProxyStage{}}
- normal, err := NormalizeCodexTicketProxy(raw)
- if err != nil || normal == "" {
- result.Code = "invalid_proxy"
- result.Message = "代理格式不完整或无法识别"
- return nil, result, errors.New(result.Message)
- }
- u, _ := url.Parse(normal)
- result.Protocol = u.Scheme
- result.Stages = append(result.Stages, CodexTicketProxyStage{Name: "parse", Success: true})
- var trust *CodexTicketProxyTrust
- store, _ := s.accountRepo.(CodexTicketProxyTrustRepository)
- trustKey := codexTicketProxyKey(normal)
- if store != nil {
- generation, generationErr := store.CodexTicketProxyTrustGeneration(ctx)
- if generationErr != nil {
- return nil, result, generationErr
- }
- trustKey = codexTicketProxyKey(normal + "\x00" + generation)
- trust, err = store.LoadCodexTicketProxyTrust(ctx, trustKey)
- if err != nil {
- return nil, result, err
- }
- }
- if !learn {
- tr, e := newCodexTicketProxyTransport(normal, trust, false, nil)
- if e != nil {
- return nil, result, e
- }
- return &codexTicketPreparedProxy{client: &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, transport: tr, trust: trust}, result, nil
- }
- observation := &codexTicketProxyObservation{}
- tr, err := newCodexTicketProxyTransport(normal, trust, true, observation)
- if err != nil {
- return nil, result, err
- }
- defer tr.CloseIdleConnections()
- client := &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
- stageStart := time.Now()
- trace := &httptrace.ClientTrace{
- DNSDone: func(i httptrace.DNSDoneInfo) {
- observation.add(CodexTicketProxyStage{Name: "dns", Success: i.Err == nil})
- },
- ConnectDone: func(network, addr string, e error) {
- observation.add(CodexTicketProxyStage{Name: "tcp", Success: e == nil, DurationMS: time.Since(stageStart).Milliseconds()})
- },
- TLSHandshakeDone: func(cs tls.ConnectionState, e error) {
- observation.add(CodexTicketProxyStage{Name: "tls", Success: e == nil, DurationMS: time.Since(stageStart).Milliseconds()})
- },
- }
- req, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodHead, codexTicketProxyTarget, nil)
- if err != nil {
- return nil, result, err
- }
- resp, err := client.Do(req)
- observed, stages := observation.snapshot()
- result.Stages = append(result.Stages, stages...)
- if err != nil {
- result.Code = codexTicketTransportFailureCode(err)
- result.Message = CodexTicketFailure(result.Code).Message
- result.FailureDetail = codexTicketProxyFailureDetail(err, normal)
- if strings.HasPrefix(u.Scheme, "socks") && errors.Is(err, context.DeadlineExceeded) {
- result.ProtocolSuggestion = "尝试确认该端口是否为HTTP CONNECT代理"
- }
- return nil, result, errors.New(result.Message)
- }
- _ = resp.Body.Close()
- result.HTTPStatus = resp.StatusCode
- result.NetworkReachable = true
- result.Success = resp.StatusCode >= 200 && resp.StatusCode < 400
- result.Code = "proxy_reachable"
- result.Message = "代理连接和TLS正常;连接测试不代表已取得292票据"
- if !result.Success {
- result.Code = "target_http_status"
- result.Message = fmt.Sprintf("连接和TLS正常,目标返回HTTP %d;未发送OAuth凭据,尚未验证打票能力", resp.StatusCode)
- }
- result.Stages = append(result.Stages, CodexTicketProxyStage{Name: "http", Success: result.Success, Message: fmt.Sprintf("HTTP %d", resp.StatusCode)})
- result.CertificateTrust = "system"
- if observed != nil && len(observed.Certificates) > 0 {
- result.CertificateTrust = "proxy_certificate_pinned"
- result.CertificateFingerprint = observed.Fingerprint
- if persist && store != nil {
- if err = store.SaveCodexTicketProxyTrust(ctx, trustKey, observed); err != nil {
- return nil, result, err
- }
- }
- }
- finalTr, err := newCodexTicketProxyTransport(normal, observed, false, nil)
- if err != nil {
- return nil, result, err
- }
- return &codexTicketPreparedProxy{client: &http.Client{Transport: finalTr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, transport: finalTr, trust: observed}, result, nil
-}
-
-func (s *OpenAIGatewayService) TestCodexTicketProxy(ctx context.Context, raw string) (*CodexTicketProxyTestResult, error) {
- ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
- defer cancel()
- normalized, _ := NormalizeCodexTicketProxy(raw)
- configured, _ := NormalizeCodexTicketProxy(s.openAICodexTicketHarvestProxyURLContext(ctx))
- prepared, result, err := s.prepareCodexTicketProxy(ctx, raw, true, normalized != "" && normalized == configured)
- if prepared != nil {
- defer prepared.transport.CloseIdleConnections()
- // Validate the next, pinned connection too. Harvesting uses this same
- // verifier after preflight, so a rotating certificate cannot falsely pass
- // a draft test that only exercised first-use learning.
- if err == nil {
- started := time.Now()
- req, requestErr := http.NewRequestWithContext(ctx, http.MethodHead, codexTicketProxyTarget, nil)
- if requestErr != nil {
- return nil, requestErr
- }
- resp, followErr := prepared.client.Do(req)
- if followErr != nil {
- result.Success = false
- result.Code = codexTicketTransportFailureCode(followErr)
- result.Message = CodexTicketFailure(result.Code).Message
- result.FailureDetail = codexTicketProxyFailureDetail(followErr, normalized)
- result.Stages = append(result.Stages, CodexTicketProxyStage{Name: "pinned_connection", Success: false, DurationMS: time.Since(started).Milliseconds(), Message: result.Message})
- return result, nil
- }
- _ = resp.Body.Close()
- result.HTTPStatus = resp.StatusCode
- result.Success = resp.StatusCode >= 200 && resp.StatusCode < 400
- result.Stages = append(result.Stages, CodexTicketProxyStage{Name: "pinned_connection", Success: true, DurationMS: time.Since(started).Milliseconds(), Message: fmt.Sprintf("HTTP %d", resp.StatusCode)})
- if !result.Success {
- result.Code = "target_http_status"
- result.Message = fmt.Sprintf("连接和固定证书验证正常,目标返回HTTP %d;未发送OAuth凭据,尚未验证打票能力", resp.StatusCode)
- } else {
- result.Code = "proxy_reachable"
- result.Message = "代理连接和固定证书验证正常;连接测试不代表已取得292票据"
- }
- }
- }
- if err != nil && result != nil && result.Code != "" {
- return result, nil
- }
- return result, err
-}
-
-func codexTicketTransportFailureCode(err error) string {
- if errors.Is(err, context.Canceled) {
- return "ticket_canceled"
- }
- var timeout net.Error
- if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &timeout) && timeout.Timeout() {
- return "ticket_timeout"
- }
- t := strings.ToLower(err.Error())
- if strings.Contains(t, "407") || strings.Contains(t, "authentication") {
- return "ticket_proxy_auth"
- }
- if strings.Contains(t, "certificate") || strings.Contains(t, "tls") || strings.Contains(t, "x509") {
- return "ticket_proxy_tls"
- }
- if strings.Contains(t, "no such host") || strings.Contains(t, "name resolution") {
- return "ticket_proxy_dns"
- }
- if strings.Contains(t, "malformed http response") || strings.Contains(t, "unexpected protocol") || strings.Contains(t, "socks version") {
- return "ticket_proxy_protocol"
- }
- if strings.Contains(t, "connection refused") || strings.Contains(t, "connect rejected") {
- return "ticket_proxy_connect"
- }
- if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
- return "ticket_proxy_eof"
- }
- if strings.Contains(t, "connection reset") || strings.Contains(t, "forcibly closed") {
- return "ticket_proxy_reset"
- }
- return "ticket_transport"
-}
-
-func codexTicketProxyFailureDetail(err error, proxyURL string) string {
- secrets := []string{proxyURL}
- if parsed, parseErr := url.Parse(proxyURL); parseErr == nil && parsed.User != nil {
- password, _ := parsed.User.Password()
- secrets = append(secrets, parsed.User.Username(), password)
- }
- text := []rune(codexTicketSafeUpstreamMessage(err.Error(), secrets...))
- if len(text) > 256 {
- text = text[:256]
- }
- return string(text)
-}
-
-var ticketSensitiveValuePattern = regexp.MustCompile(`(?i)(?:bearer\s+\S+|(?:https?|socks5h?)://\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]+|gAAAAA[A-Za-z0-9_=-]+)`)
-
-func codexTicketSafeUpstreamMessage(raw string, secretValues ...string) string {
- for _, v := range secretValues {
- if v != "" {
- raw = strings.ReplaceAll(raw, v, "[redacted]")
- }
- }
- raw = ticketSensitiveValuePattern.ReplaceAllString(sanitizeUpstreamErrorMessage(raw), "[redacted]")
- runes := []rune(raw)
- if len(runes) > 512 {
- runes = runes[:512]
- }
- return string(runes)
+ return proxytransport.Normalize(raw)
}
diff --git a/backend/internal/service/codex_ticket_proxy_test.go b/backend/internal/service/codex_ticket_proxy_test.go
index adbe005acf04..a9b5f8346149 100644
--- a/backend/internal/service/codex_ticket_proxy_test.go
+++ b/backend/internal/service/codex_ticket_proxy_test.go
@@ -1,23 +1,7 @@
package service
import (
- "context"
- "crypto/rand"
- "crypto/rsa"
- "crypto/tls"
- "crypto/x509"
- "crypto/x509/pkix"
- "errors"
- "io"
- "math/big"
- "net"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync"
"testing"
- "time"
"github.com/stretchr/testify/require"
)
@@ -42,170 +26,3 @@ func TestCodexTicketProxyInput(t *testing.T) {
require.Error(t, err)
}
}
-
-func ticketPrivateCertificate(t *testing.T, host string, expired bool) (tls.Certificate, *x509.Certificate) {
- t.Helper()
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
- now := time.Now()
- until := now.Add(time.Hour)
- if expired {
- until = now.Add(-time.Minute)
- }
- tmpl := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: host}, DNSNames: []string{host}, NotBefore: now.Add(-time.Hour), NotAfter: until, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
- der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- require.NoError(t, err)
- cert, err := x509.ParseCertificate(der)
- require.NoError(t, err)
- return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, cert
-}
-
-func TestCodexTicketProxyTrustScopeAndRotation(t *testing.T) {
- _, cert := ticketPrivateCertificate(t, "chatgpt.com", false)
- cs := tls.ConnectionState{ServerName: "chatgpt.com", PeerCertificates: []*x509.Certificate{cert}}
- _, err := verifyCodexTicketProxyTLS(cs, nil, false)
- require.Error(t, err)
- trust, err := verifyCodexTicketProxyTLS(cs, nil, true)
- require.NoError(t, err)
- require.NotEmpty(t, trust.Fingerprint)
- _, err = verifyCodexTicketProxyTLS(cs, trust, false)
- require.NoError(t, err)
- _, other := ticketPrivateCertificate(t, "chatgpt.com", false)
- cs.PeerCertificates = []*x509.Certificate{other}
- _, err = verifyCodexTicketProxyTLS(cs, trust, false)
- require.Error(t, err, "automatic renewal cannot learn a changed certificate")
- next, err := verifyCodexTicketProxyTLS(cs, trust, true)
- require.NoError(t, err)
- require.NotEqual(t, trust.Fingerprint, next.Fingerprint)
- _, wrong := ticketPrivateCertificate(t, "another.example.com", false)
- cs.PeerCertificates = []*x509.Certificate{wrong}
- _, err = verifyCodexTicketProxyTLS(cs, nil, true)
- require.Error(t, err)
- _, expired := ticketPrivateCertificate(t, "chatgpt.com", true)
- cs.PeerCertificates = []*x509.Certificate{expired}
- _, err = verifyCodexTicketProxyTLS(cs, nil, true)
- require.Error(t, err)
- require.NotEqual(t, codexTicketProxyKey("http://u:p@one:8080"), codexTicketProxyKey("http://u:p@two:8080"))
-}
-
-func TestCodexTicketProxyCONNECTPreflightAndHarvestShareTrust(t *testing.T) {
- certificate, _ := ticketPrivateCertificate(t, "chatgpt.com", false)
- var mu sync.Mutex
- var methods, auth []string
- target := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- mu.Lock()
- methods = append(methods, r.Method)
- auth = append(auth, r.Header.Get("Authorization"))
- mu.Unlock()
- require.Equal(t, "/backend-api/codex/responses", r.URL.Path)
- if r.Method == http.MethodHead {
- w.WriteHeader(401)
- return
- }
- w.Header().Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
- w.WriteHeader(200)
- }))
- target.TLS = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
- target.StartTLS()
- defer target.Close()
- addr := target.Listener.Addr().String()
- proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodConnect {
- w.WriteHeader(405)
- return
- }
- require.Equal(t, "chatgpt.com:443", r.Host)
- if r.Header.Get("Proxy-Authorization") == "" {
- w.WriteHeader(407)
- return
- }
- dst, err := net.Dial("tcp", addr)
- if err != nil {
- w.WriteHeader(502)
- return
- }
- hijacker, ok := w.(http.Hijacker)
- if !ok {
- _ = dst.Close()
- w.WriteHeader(500)
- return
- }
- conn, _, err := hijacker.Hijack()
- if err != nil {
- _ = dst.Close()
- return
- }
- _, _ = io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n")
- go func() {
- defer func() { _ = conn.Close() }()
- defer func() { _ = dst.Close() }()
- go func() { _, _ = io.Copy(dst, conn) }()
- _, _ = io.Copy(conn, dst)
- }()
- }))
- defer proxy.Close()
- proxyURL, _ := url.Parse(proxy.URL)
- proxyURL.User = url.UserPassword("demo", "synthetic")
- svc := &OpenAIGatewayService{}
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
- prepared, result, err := svc.prepareCodexTicketProxy(ctx, proxyURL.String(), true, false)
- require.NoError(t, err)
- defer prepared.transport.CloseIdleConnections()
- require.True(t, result.NetworkReachable)
- require.False(t, result.Success)
- require.Equal(t, 401, result.HTTPStatus)
- require.Equal(t, "proxy_certificate_pinned", result.CertificateTrust)
- a := ticketTestAccount(41)
- state, status, err := svc.fireCodexTicketProbe(ctx, a, "synthetic-oauth", "gpt-5.6-sol", proxyURL.String(), time.Second, prepared)
- require.NoError(t, err)
- require.Equal(t, 200, status)
- require.Len(t, state, 292)
- mu.Lock()
- require.Equal(t, []string{"HEAD", "POST"}, methods)
- require.Equal(t, []string{"", "Bearer synthetic-oauth"}, auth)
- mu.Unlock()
- // Draft testing also reconnects with the pinned verifier, without credentials.
- tested, testErr := svc.TestCodexTicketProxy(ctx, proxyURL.String())
- require.NoError(t, testErr)
- require.False(t, tested.Success)
- require.Equal(t, 401, tested.HTTPStatus)
- require.Equal(t, "pinned_connection", tested.Stages[len(tested.Stages)-1].Name)
- mu.Lock()
- require.Equal(t, []string{"HEAD", "POST", "HEAD", "HEAD"}, methods)
- require.Equal(t, []string{"", "Bearer synthetic-oauth", "", ""}, auth)
- mu.Unlock()
- // A regular system-verifying client remains unable to trust this certificate.
- tr := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
- defer tr.CloseIdleConnections()
- req, _ := http.NewRequestWithContext(ctx, http.MethodHead, codexTicketProxyTarget, nil)
- _, err = (&http.Client{Transport: tr}).Do(req)
- require.Error(t, err)
-}
-
-func TestCodexTicketProxyAuthenticationFailureAndErrorRedaction(t *testing.T) {
- proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(407) }))
- defer proxy.Close()
- svc := &OpenAIGatewayService{}
- result, err := svc.TestCodexTicketProxy(context.Background(), proxy.URL)
- require.NoError(t, err)
- require.False(t, result.Success)
- require.Equal(t, "ticket_proxy_auth", result.Code)
- msg := codexTicketSafeUpstreamMessage("Bearer sensitive http://u:p@proxy:1 "+fakeCodexTicketState(292)+" secret-value", "secret-value")
- require.NotContains(t, msg, "sensitive")
- require.NotContains(t, msg, "u:p")
- require.NotContains(t, msg, "secret-value")
- require.False(t, strings.Contains(msg, "gAAAAA"))
-}
-
-func TestCodexTicketProxyDisconnectCauseIsRedacted(t *testing.T) {
- proxyURL := "http://diagnostic-user:diagnostic-password@proxy.example.com:8080"
- err := &url.Error{Op: "Head", URL: proxyURL, Err: io.EOF}
- require.Equal(t, "ticket_proxy_eof", codexTicketTransportFailureCode(err))
- detail := codexTicketProxyFailureDetail(err, proxyURL)
- require.Contains(t, detail, "EOF")
- require.NotContains(t, detail, "diagnostic-user")
- require.NotContains(t, detail, "diagnostic-password")
- require.Equal(t, "ticket_proxy_reset", codexTicketTransportFailureCode(errors.New("read: connection reset by peer")))
- require.LessOrEqual(t, len([]rune(codexTicketProxyFailureDetail(errors.New(strings.Repeat("failure ", 100)), proxyURL))), 256)
-}
diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go
index 1ab1c0bc9c28..d3159fc7b9cb 100644
--- a/backend/internal/service/gateway_usage_billing.go
+++ b/backend/internal/service/gateway_usage_billing.go
@@ -586,13 +586,21 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
}
if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil {
logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr)
+ MarkQuotaLogFailed(ctx, usageLog.AccountID)
+ } else {
+ MarkQuotaLogPersisted(ctx, usageLog.AccountID)
}
+ } else {
+ MarkQuotaLogPersisted(ctx, usageLog.AccountID)
}
return
}
if _, err := repo.Create(usageCtx, usageLog); err != nil {
logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
+ MarkQuotaLogFailed(ctx, usageLog.AccountID)
+ } else {
+ MarkQuotaLogPersisted(ctx, usageLog.AccountID)
}
}
diff --git a/backend/internal/service/model_context_capacity.go b/backend/internal/service/model_context_capacity.go
index 35e020fd9aa2..690f314f7a53 100644
--- a/backend/internal/service/model_context_capacity.go
+++ b/backend/internal/service/model_context_capacity.go
@@ -9,6 +9,7 @@ import (
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
)
const (
@@ -24,14 +25,7 @@ const (
// ModelContextCapacity keeps the upstream's distinct limits intact. A zero is
// unknown, never a promise of an unlimited window. ObservedAt belongs to an
// actual upstream observation, not to a registry enrichment or account edit.
-type ModelContextCapacity struct {
- ContextWindow int64 `json:"context_window,omitempty"`
- MaxContextWindow int64 `json:"max_context_window,omitempty"`
- MaxInputTokens int64 `json:"max_input_tokens,omitempty"`
- MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
- CapacityBasis string `json:"capacity_basis,omitempty"`
- ObservedAt string `json:"observed_at,omitempty"`
-}
+type ModelContextCapacity = extensionv1.ModelContextCapacity
type UpstreamModelContextCapacitySnapshot struct {
ObservedAt string `json:"observed_at"`
@@ -41,33 +35,11 @@ type UpstreamModelContextCapacitySnapshot struct {
// ModelContextCapacityReference preserves the raw product reference separately
// from the planning capacity selected by this project.
-type ModelContextCapacityReference struct {
- Product string `json:"product"`
- SourceURL string `json:"source_url"`
- Release string `json:"release"`
- VerifiedAt string `json:"verified_at"`
- ContextWindow int64 `json:"context_window"`
- MaxContextWindow int64 `json:"max_context_window"`
-}
+type ModelContextCapacityReference = extensionv1.ModelContextCapacityReference
// OfficialModelContextCapacity is release-owned evidence. Match constraints and
// reference values are not client-writeable.
-type OfficialModelContextCapacity struct {
- ModelContextCapacity
- ModelID string `json:"model_id"`
- Aliases []string `json:"aliases,omitempty"`
- Provider string `json:"provider"`
- Product string `json:"product"`
- SourceURL string `json:"source_url"`
- SourceURLs []string `json:"source_urls,omitempty"`
- VerifiedAt string `json:"verified_at"`
- OriginalText string `json:"original_text"`
- NormalizationBasis string `json:"normalization_basis,omitempty"`
- Conditions string `json:"conditions,omitempty"`
- Reference *ModelContextCapacityReference `json:"reference,omitempty"`
- MatchHosts []string `json:"-"`
- MatchAccountModes []string `json:"-"`
-}
+type OfficialModelContextCapacity = extensionv1.OfficialModelContextCapacity
type ResolvedModelContextCapacity struct {
ModelContextCapacity
@@ -513,12 +485,12 @@ func LookupOfficialModelContextCapacity(account *Account, upstreamModelID string
if account == nil || !validModelContextID(upstreamModelID) {
return nil
}
- for _, candidate := range modelContextReferenceCandidates(upstreamModelID) {
- if found, matched := lookupExactOfficialModelContextCapacity(account, candidate); matched {
- return found
- }
+ query := extensionv1.CatalogQuery{Candidates: modelContextReferenceCandidates(upstreamModelID), Platform: account.Platform, AccountType: account.Type, AccountMode: account.GetAccountMode()}
+ if parsed, err := url.Parse(upstreamModelRegistryBaseURL(account)); err == nil {
+ query.Scheme, query.Host, query.Port, query.Path = parsed.Scheme, parsed.Hostname(), parsed.Port(), parsed.Path
+ query.HasURLCredentials = parsed.User != nil
}
- return nil
+ return lookupExtensionCatalog(query)
}
func modelContextReferenceCandidates(modelID string) []string {
@@ -544,79 +516,6 @@ func modelContextReferenceCandidates(modelID string) []string {
return candidates
}
-func lookupExactOfficialModelContextCapacity(account *Account, modelID string) (*OfficialModelContextCapacity, bool) {
- var found *OfficialModelContextCapacity
- for _, entry := range officialModelContextCapacityCatalog {
- if !strings.EqualFold(entry.ModelID, modelID) && !containsFoldModelContextString(entry.Aliases, modelID) {
- continue
- }
- if !officialModelContextCapacityApplies(account, entry) {
- continue
- }
- if _, ok := modelContextPlanningCapacity(entry.ModelContextCapacity); !ok {
- continue
- }
- if found != nil && found.ModelContextCapacity != entry.ModelContextCapacity {
- // No table order can resolve conflicting official records.
- return nil, true
- }
- copy := entry
- copy.Aliases = append([]string(nil), entry.Aliases...)
- copy.SourceURLs = append([]string(nil), entry.SourceURLs...)
- if entry.Reference != nil {
- reference := *entry.Reference
- copy.Reference = &reference
- }
- found = ©
- }
- return found, found != nil
-}
-
-func officialModelContextCapacityApplies(account *Account, entry OfficialModelContextCapacity) bool {
- baseURL := upstreamModelRegistryBaseURL(account)
- parsed, err := url.Parse(baseURL)
- isKimiCodingEndpoint := err == nil && parsed != nil && parsed.Scheme == "https" && parsed.User == nil &&
- strings.EqualFold(parsed.Hostname(), "api.kimi.com") && (parsed.Port() == "" || parsed.Port() == "443") &&
- (parsed.Path == "/coding" || strings.HasPrefix(parsed.Path, "/coding/"))
- // A short model ID such as k3 and a generic "coding" mode cannot identify
- // the Kimi product on another vendor. A real Kimi account or its exact
- // official Coding endpoint is required; custom relays remain overridable.
- if entry.Provider == "kimi" && entry.Product == "coding" && account.Platform != PlatformKimi && !isKimiCodingEndpoint {
- return false
- }
- if len(entry.MatchHosts) > 0 {
- if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" || (parsed.Port() != "" && parsed.Port() != "443") {
- return false
- }
- matched := false
- for _, host := range entry.MatchHosts {
- if strings.EqualFold(parsed.Hostname(), host) {
- matched = true
- break
- }
- }
- if !matched {
- return false
- }
- }
- if len(entry.MatchAccountModes) > 0 {
- mode := account.GetAccountMode()
- // Generic OpenAI-compatible Kimi accounts still have a distinguishable
- // official Coding endpoint. Never infer membership tier from a model ID.
- if mode == "" && err == nil && parsed != nil {
- if isKimiCodingEndpoint {
- mode = AccountModeCoding
- } else {
- mode = AccountModePayG
- }
- }
- if !containsExactModelContextString(entry.MatchAccountModes, mode) {
- return false
- }
- }
- return true
-}
-
func containsExactModelContextString(values []string, target string) bool {
for _, value := range values {
if value == target {
diff --git a/backend/internal/service/model_context_capacity_catalog.go b/backend/internal/service/model_context_capacity_catalog.go
index 762bfbfa663f..b0c90c4eac60 100644
--- a/backend/internal/service/model_context_capacity_catalog.go
+++ b/backend/internal/service/model_context_capacity_catalog.go
@@ -1,816 +1,85 @@
package service
-// This release-pinned catalog describes capacity, not model availability or routing.
-// Stored IDs/aliases are exact; matching-only namespace and GPT spelling variants
-// are handled by the resolver. MatchHosts and MatchAccountModes
-// constrain product-specific evidence; an equal self-hosted model ID is insufficient.
-// Zero input/output fields mean unknown, never a copy of the total context window.
-const officialModelContextCapacityVerifiedAt = "2026-09-07"
-
-const (
- GPTContextCapacityReferenceRelease = "rust-v0.153.4"
- gptContextCapacityReferenceSource = "https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/models-manager/models.json"
- gptContextCapacityReferenceVerifiedAt = "2026-09-08"
- gptLongContextSelection = "本项目将订阅参考最大 872000 的 GPT 档选用 API 规格 1050000 作为规划基准;订阅参考原值未改写,不表示订阅服务端已返回 1050000。"
- gptSubscriptionMaximumSelection = "本项目按官方 Codex 内置目录的最大窗口规划;不是单个订阅账号的实时容量或可用性承诺。"
- gptSnapshotAliasSelection = " 官方 API 文档列出的默认日期快照为同一型号的精确别名(2026-09-17 核实),沿用本行容量;其他日期后缀不推断。"
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ "sort"
+ "strconv"
+ "sync/atomic"
+ "time"
)
-const (
- claudeContextSource = "https://platform.claude.com/docs/en/build-with-claude/context-windows"
- claudeIntegerSource = "https://www-cdn.anthropic.com/78073f739564e986ff3e28522761a7a0b4484f84.pdf#page=30"
- claudeOutputSource = "https://platform.claude.com/docs/en/about-claude/models/optimizing-for-cost-and-intelligence#set-budgets-and-output-caps"
- claudeUnitsBasis = "按官方同平台单位归一:Sonnet 4.6 System Card 明确 1M=1,000,000;成本指南明确 Fable 5.1 的 128K=128,000;其他型号以各自规格及该单位体系归一,非逐型号整数原文。"
- claudeLegacyBasis = "官方上下文指南以 200000 说明 200K;64K 输出按同平台十进制单位归一为 64000,不宣称已找到逐型号 64000 的独立整数上限原文。"
- deepseekModelsSource = "https://api-docs.deepseek.com/quick_start/pricing/"
- deepseekIntegerSource = "https://api-docs.deepseek.com/quick_start/agent_integrations/pi_mono/"
- qwenEndpointSource = "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/base-url"
- kimiIntegerSource = "https://www.kimi.ai/help/kimi-api/api-troubleshooting"
- kimiCodingSource = "https://www.kimi.com/code/docs/third-party-tools/claude-code.html"
- glmOverviewSource = "https://docs.bigmodel.cn/cn/guide/start/model-overview"
- glmOutputSource = "https://docs.bigmodel.cn/cn/guide/start/concept-param"
- glmMillionSource = "https://docs.bigmodel.cn/cn/guide/develop/openclaw"
- glm200KSource = "https://docs.z.ai/devpack/using5.1"
- minimaxSource = "https://platform.minimax.io/docs/api-reference/api-overview"
- doubaoModelsSource = "https://www.volcengine.com/docs/82379/1330310?lang=zh"
- doubaoIntegerSource = "https://www.volcengine.com/docs/82379/1494384?lang=zh"
- doubaoEndpointSource = "https://www.volcengine.com/docs/82379/1399009?lang=zh"
- doubaoUnitsBasis = "跨官方页面单位推导:模型表默认回答 4k,官方 API 的 max_tokens 默认值 4096,据此按 k=1024 归一;1024k=1048576,256k=262144,224k=229376。不是上下文原文直接整数证据。"
-)
+type extensionCatalogProvider struct{ resolver extensionv1.CatalogResolver }
+
+var processExtensionCatalog atomic.Pointer[extensionCatalogProvider]
-func codexModelContextCapacityReference(window, maximum int64) *ModelContextCapacityReference {
- return &ModelContextCapacityReference{
- Product: "codex_subscription", SourceURL: gptContextCapacityReferenceSource,
- Release: GPTContextCapacityReferenceRelease, VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- ContextWindow: window, MaxContextWindow: maximum,
+func lookupExtensionCatalog(query extensionv1.CatalogQuery) *OfficialModelContextCapacity {
+ provider := processExtensionCatalog.Load()
+ if provider == nil {
+ return nil
}
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ result, err := provider.resolver.ResolveCatalog(ctx, query)
+ if err != nil {
+ return nil
+ }
+ return result.Entry
}
-
-// Host sets contain only the documented regional API hosts applicable to the
-// corresponding entries. They deliberately do not use suffix or wildcard rules.
-var (
- qwenBailianCapacityHosts = []string{"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com"}
- qwenBailianMainCapacityHosts = []string{"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com", "dashscope-us.aliyuncs.com", "cn-hongkong.dashscope.aliyuncs.com"}
- qwenBailianCoderCapacityHosts = []string{"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com", "dashscope-us.aliyuncs.com"}
- doubaoBeijingCapacityHosts = []string{"ark.cn-beijing.volces.com"}
-)
-
-var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{
- // Selected GPT planning baselines. API entries remain the fallback for models
- // not covered by the pinned Codex reference; no model availability is changed.
- {
- ModelID: "gpt-6-astra", Aliases: []string{"gpt-6"}, Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-6-astra",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "1,050,000 context window; 128,000 max output tokens",
- Reference: codexModelContextCapacityReference(272000, 872000),
- Conditions: gptLongContextSelection,
- },
- {
- ModelID: "gpt-5.6-sol", Aliases: []string{"gpt-5.6"}, Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-sol",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "1,050,000 context window; 128,000 max output tokens",
- Reference: codexModelContextCapacityReference(272000, 872000),
- Conditions: gptLongContextSelection + " gpt-5.6 是 Sol 的明确别名;不写回账号模型映射。",
- },
- {
- ModelID: "gpt-5.6-terra", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-terra",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "1,050,000 context window; 128,000 max output tokens",
- Reference: codexModelContextCapacityReference(272000, 872000),
- Conditions: gptLongContextSelection,
- },
- {
- ModelID: "gpt-5.6-luna", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-luna",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "1,050,000 context window; 128,000 max output tokens",
- Reference: codexModelContextCapacityReference(272000, 872000),
- Conditions: gptLongContextSelection,
- },
- {
- ModelID: "gpt-5.5", Aliases: []string{"gpt-5.5-2026-04-23"}, Provider: "openai", Product: "codex_subscription",
- ModelContextCapacity: ModelContextCapacity{MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisMaximum},
- SourceURL: gptContextCapacityReferenceSource,
- SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.5"},
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "context_window: 272000; max_context_window: 272000",
- Reference: codexModelContextCapacityReference(272000, 272000),
- Conditions: gptSubscriptionMaximumSelection + gptSnapshotAliasSelection,
- },
- {
- ModelID: "gpt-5.4", Aliases: []string{"gpt-5.4-2026-03-05"}, Provider: "openai", Product: "codex_subscription",
- ModelContextCapacity: ModelContextCapacity{MaxContextWindow: 1000000, CapacityBasis: ModelContextCapacityBasisMaximum},
- SourceURL: gptContextCapacityReferenceSource,
- SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.4"},
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "context_window: 272000; max_context_window: 1000000",
- Reference: codexModelContextCapacityReference(272000, 1000000),
- Conditions: gptSubscriptionMaximumSelection + gptSnapshotAliasSelection,
- },
- {
- ModelID: "gpt-5.4-mini", Aliases: []string{"gpt-5.4-mini-2026-03-17"}, Provider: "openai", Product: "codex_subscription",
- ModelContextCapacity: ModelContextCapacity{MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisMaximum},
- SourceURL: gptContextCapacityReferenceSource,
- SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.4-mini"},
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "context_window: 272000; max_context_window: 272000",
- Reference: codexModelContextCapacityReference(272000, 272000),
- Conditions: gptSubscriptionMaximumSelection + gptSnapshotAliasSelection,
- },
- {
- ModelID: "gpt-5.4-nano", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.4-nano",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "400,000 context window; 128,000 max output tokens",
- },
- {
- ModelID: "gpt-5.3-codex", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "400,000 context window; 128,000 max output tokens",
- },
- {
- ModelID: "gpt-5.2", Aliases: []string{"gpt-5.2-2025-12-11"}, Provider: "openai", Product: "codex_subscription",
- ModelContextCapacity: ModelContextCapacity{MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisMaximum},
- SourceURL: gptContextCapacityReferenceSource,
- SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.2"},
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "context_window: 272000; max_context_window: 272000",
- Reference: codexModelContextCapacityReference(272000, 272000),
- Conditions: gptSubscriptionMaximumSelection + gptSnapshotAliasSelection,
- },
- {
- ModelID: "gpt-5.2-pro", Aliases: []string{"gpt-5.2-pro-2025-12-11"}, Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.2-pro",
- VerifiedAt: "2026-09-17",
- OriginalText: "400,000 context window; 128,000 max output tokens",
- Conditions: "官方 API 规格;gpt-5.2-pro-2025-12-11 为文档列出的默认快照。Pro 型号不在 Codex 订阅参考目录内,无订阅参考。",
- },
- {
- ModelID: "gpt-daybreak-blue-latest", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-daybreak-blue-latest",
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "1,050,000 context window; 128,000 max output tokens",
- Reference: codexModelContextCapacityReference(272000, 872000),
- Conditions: gptLongContextSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。",
- },
- {
- ModelID: "gpt-daybreak-red-latest", Provider: "openai", Product: "codex_subscription",
- ModelContextCapacity: ModelContextCapacity{MaxContextWindow: 372000, CapacityBasis: ModelContextCapacityBasisMaximum},
- SourceURL: gptContextCapacityReferenceSource,
- VerifiedAt: gptContextCapacityReferenceVerifiedAt,
- OriginalText: "context_window: 372000; max_context_window: 372000",
- Reference: codexModelContextCapacityReference(372000, 372000),
- Conditions: gptSubscriptionMaximumSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。",
- },
- {
- ModelID: "gpt-4.1", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1047576, MaxOutputTokens: 32768, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-4.1",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "1,047,576 context window; 32,768 max output tokens",
- },
- {
- ModelID: "gpt-4o", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 128000, MaxOutputTokens: 16384, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-4o",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "128,000 context window; 16,384 max output tokens",
- },
- {
- ModelID: "gpt-4o-mini", Provider: "openai", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 128000, MaxOutputTokens: 16384, CapacityBasis: "total_context"},
- SourceURL: "https://developers.openai.com/api/docs/models/gpt-4o-mini",
- VerifiedAt: officialModelContextCapacityVerifiedAt,
- OriginalText: "128,000 context window; 16,384 max output tokens",
- },
-
- // Claude API: ordinary Messages limits, excluding Batch-only beta output caps.
- {
- ModelID: "claude-fable-5-1", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/fable-5-1/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-opus-5", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-sonnet-5", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-fable-5", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/fable-5/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-opus-4-8", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: claudeContextSource,
- SourceURLs: []string{"https://platform.claude.com/docs/en/about-claude/model-deprecations", claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-opus-4-7", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: claudeContextSource,
- SourceURLs: []string{"https://platform.claude.com/docs/en/about-claude/model-deprecations", claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- },
- {
- ModelID: "claude-opus-4-6", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/opus-4-6/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- Conditions: "普通 Messages 输出上限;不采用 Batch API beta 的 300K 输出上限。",
- },
- {
- ModelID: "claude-sonnet-4-6", Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/sonnet-4-6/overview",
- SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: claudeUnitsBasis,
- Conditions: "普通 Messages 输出上限;不采用 Batch API beta 的 300K 输出上限。",
- },
- {
- ModelID: "claude-opus-4-5-20251101", Aliases: []string{"claude-opus-4-5"}, Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000, MaxOutputTokens: 64000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/opus-4-5/overview",
- SourceURLs: []string{claudeContextSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "200K; 64K",
- NormalizationBasis: claudeLegacyBasis,
- },
- {
- ModelID: "claude-sonnet-4-5-20250929", Aliases: []string{"claude-sonnet-4-5"}, Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000, MaxOutputTokens: 64000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/zh-CN/models/sonnet-4-5/overview",
- SourceURLs: []string{claudeContextSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "200K; 64K",
- NormalizationBasis: claudeLegacyBasis,
- },
- {
- ModelID: "claude-haiku-4-5-20251001", Aliases: []string{"claude-haiku-4-5"}, Provider: "anthropic", Product: "messages_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 200000, MaxOutputTokens: 64000, CapacityBasis: "total_context"},
- SourceURL: "https://platform.claude.com/docs/en/models/haiku-4-5/overview",
- SourceURLs: []string{claudeContextSource, claudeOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "200K; 64K",
- NormalizationBasis: claudeLegacyBasis,
- },
-
- // Gemini publishes independent input/output limits, not an additive total.
- {
- ModelID: "gemini-3.8-flash", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- },
- {
- ModelID: "gemini-3.5-flash-lite", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- },
- {
- ModelID: "gemini-3.1-pro-preview", Aliases: []string{"gemini-3.1-pro-preview-customtools"}, Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- Conditions: "Preview;customtools 为官方列出的同规格版本。目录仅匹配容量,不新增模型或改变可用性。",
- },
- {
- ModelID: "gemini-3.5-flash", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- },
- {
- ModelID: "gemini-3-flash-preview", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3-flash-preview",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- Conditions: "Preview;容量记录不代表账号已获访问权限。",
- },
- {
- ModelID: "gemini-2.5-pro", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- },
- {
- ModelID: "gemini-2.5-flash", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- },
- {
- ModelID: "gemini-2.0-flash", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 8192, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-2.0-flash",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 8,192",
- Conditions: "历史规格,官方于 2026-06-01 关闭;只保留容量证据,不恢复可用性。",
- },
- {
- ModelID: "gemini-3-pro-preview", Provider: "gemini", Product: "gemini_api",
- ModelContextCapacity: ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536, CapacityBasis: "input_limit"},
- SourceURL: "https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-preview",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "Input token limit 1,048,576; Output token limit 65,536",
- Conditions: "历史规格,官方于 2026-03-09 关闭;只保留容量证据,不恢复可用性。",
- },
-
- // Grok independent input/output hard limits are not guessed from context size.
- {
- ModelID: "grok-4.6", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 500000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.6",
- SourceURLs: []string{"https://docs.x.ai/developers/grok-4-6"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "500,000",
- Conditions: "官方无独立文本输出上限;仍受总上下文约束。",
- },
- {
- ModelID: "grok-4.5", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 500000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.5",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "500,000",
- Conditions: "不把 grok-build-latest 登记为本行别名;沿项目真实目标解析,保留既有 grok-build-0.1 路由。",
- },
- {
- ModelID: "grok-4.3", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.3",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000",
- },
- {
- ModelID: "grok-build-0.1", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 256000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-build-0.1",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256,000",
- Conditions: "Early access;容量记录不代表账号已获访问权限。",
- },
- {
- ModelID: "grok-4.20-0309-reasoning", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.20-0309-reasoning",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000",
- },
- {
- ModelID: "grok-4.20-0309-non-reasoning", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.20-0309-non-reasoning",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000",
- },
- {
- ModelID: "grok-4.20-multi-agent-0309", Provider: "grok", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, CapacityBasis: "total_context"},
- SourceURL: "https://docs.x.ai/developers/models/grok-4.20-multi-agent-0309",
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000",
- Conditions: "Beta;容量记录不代表账号已获访问权限。",
- },
-
- // DeepSeek current exact IDs. Retired moving aliases are deliberately absent.
- {
- ModelID: "deepseek-v4-pro", Provider: "deepseek", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 384000, CapacityBasis: "total_context"},
- SourceURL: deepseekModelsSource, SourceURLs: []string{deepseekIntegerSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 384K",
- NormalizationBasis: "官方 Pi 接入配置直接给出 contextWindow=1000000、maxTokens=384000。",
- Conditions: "当前价目表标注版本 DeepSeek-V4-Pro-0813;不把 deepseek-chat/reasoner 自动映射为 V4。",
- },
- {
- ModelID: "deepseek-v4-flash", Provider: "deepseek", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 384000, CapacityBasis: "total_context"},
- SourceURL: deepseekModelsSource, SourceURLs: []string{deepseekIntegerSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 384K",
- NormalizationBasis: "官方 Pi 接入配置直接给出 contextWindow=1000000、maxTokens=384000。",
- Conditions: "当前价目表标注版本 DeepSeek-V4-Flash-0731;不把 deepseek-chat/reasoner 自动映射为 V4。",
- },
- {
- ModelID: "deepseek-v4-flash-vision-exp", Provider: "deepseek", Product: "api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 384000, CapacityBasis: "total_context"},
- SourceURL: deepseekModelsSource, SourceURLs: []string{deepseekIntegerSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 384K",
- NormalizationBasis: "官方模型表与 Pro/Flash 共用容量规格,按官方 Pi 接入配置的整数单位归一;不是该实验型号独立整数配置。",
- Conditions: "实验视觉版本;容量记录不修改模型可用性。",
- },
-
- // Qwen: official Bailian pay-as-you-go API only. Input caps shared by both
- // reasoning modes are the lower declared limit, with both originals retained.
- {
- ModelID: "qwen3.8-max", Aliases: []string{"qwen3.8-max-0902", "qwen3.8-max-2026-09-02"}, Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-8-max",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚、东京、香港。精确快照 ID 为官方明确列举,不推断滚动指向。",
- MatchHosts: qwenBailianMainCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3.8-flash", Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-8-flash",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚、东京、香港;不覆盖独立 Coding Plan/Token Plan 或自部署权重。",
- MatchHosts: qwenBailianMainCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3.7-plus", Aliases: []string{"qwen3.7-plus-2026-05-26"}, Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-7-plus",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚、东京、香港;不覆盖独立 Coding Plan/Token Plan 或自部署权重。",
- MatchHosts: qwenBailianMainCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3.7-flash", Aliases: []string{"qwen3.7-flash-2026-07-15"}, Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-7-flash",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚、东京、香港;不覆盖独立 Coding Plan/Token Plan 或自部署权重。",
- MatchHosts: qwenBailianMainCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3.8-27b", Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-8-27b",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "仅核实百炼 API 北京、新加坡;不套用于自部署同名权重。",
- MatchHosts: qwenBailianCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3.8-2.4t-a95b", Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 983616, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-8-2-4t-a95b",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 991,808 / 983,616; 131,072",
- NormalizationBasis: "官方直接整数;最大输入取非思考 991808 与思考 983616 的共同下限 983616。",
- Conditions: "仅核实百炼 API 北京、新加坡;不套用于自部署同名权重。",
- MatchHosts: qwenBailianCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3-coder-plus", Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 997952, MaxOutputTokens: 65536, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-coder-plus",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 997,952; 65,536",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚;不覆盖独立 Coding Plan/Token Plan。",
- MatchHosts: qwenBailianCoderCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3-coder-flash", Aliases: []string{"qwen3-coder-flash-2025-07-28"}, Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxInputTokens: 997952, MaxOutputTokens: 65536, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-coder-flash",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000; 997,952; 65,536",
- Conditions: "百炼 API;北京、新加坡、法兰克福、弗吉尼亚;不覆盖独立 Coding Plan/Token Plan。",
- MatchHosts: qwenBailianCoderCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "qwen3-coder-next", Provider: "qwen", Product: "bailian_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 204800, MaxOutputTokens: 65536, CapacityBasis: "total_context"},
- SourceURL: "https://docs.modelstudio.console.alibabacloud.com/en/model-studio/qwen3-coder-next",
- SourceURLs: []string{qwenEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "262,144; 204,800; 65,536",
- Conditions: "百炼 API;北京、新加坡、法兰克福;不覆盖自部署或独立 Coding Plan/Token Plan。",
- MatchHosts: qwenBailianCapacityHosts, MatchAccountModes: []string{"payg"},
- },
-
- // Kimi Open Platform and Coding are separate products. Defaults such as
- // max_completion_tokens=131072 or 32768 are not independent output hard caps.
- {
- ModelID: "kimi-k3", Provider: "kimi", Product: "open_platform",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1048576, CapacityBasis: "total_context"},
- SourceURL: kimiIntegerSource, SourceURLs: []string{"https://platform.kimi.com/docs/models"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1024*1024 - prompt_tokens",
- NormalizationBasis: "官方输出余额公式明确总窗口为 1024×1024=1048576;131072 是默认输出预算而非独立硬上限。",
- Conditions: "Kimi 开放平台,不套用于 Coding 产品 k3。",
- MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "kimi-k2.6", Provider: "kimi", Product: "open_platform",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: kimiIntegerSource, SourceURLs: []string{"https://platform.kimi.com/docs/models"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256*1024 - prompt_tokens",
- NormalizationBasis: "官方输出余额公式明确总窗口为 256×1024=262144;32768 是默认输出预算而非独立硬上限。",
- Conditions: "Kimi 开放平台;不将已停用 K2/K2.5/moonshot-v1 旧名自动映射为该模型。",
- MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "kimi-k2.7-code", Provider: "kimi", Product: "open_platform",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: "https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart", SourceURLs: []string{kimiIntegerSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256K",
- NormalizationBasis: "型号官方规格为 256K,按同平台官方 256×1024 公式归一;不宣称该型号有独立输出硬上限整数证据。",
- Conditions: "Kimi 开放平台;不同于 Coding 的 kimi-for-coding。",
- MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "kimi-k2.7-code-highspeed", Provider: "kimi", Product: "open_platform",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: "https://platform.kimi.ai/docs/guide/kimi-k2-7-code-quickstart", SourceURLs: []string{kimiIntegerSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256K",
- NormalizationBasis: "官方与 K2.7 Code 列为同模型、同 256K,按同平台官方 256×1024 公式归一;独立输出硬上限未知。",
- Conditions: "Kimi 开放平台高速版,不使用 Coding 套餐权益推断容量。",
- MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "kimi-for-coding", Provider: "kimi", Product: "coding",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: kimiCodingSource, SourceURLs: []string{"https://www.kimi.com/code/docs/kimi-code/models.html"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "262144",
- Conditions: "Coding 所有会员;当前对应 K2.7 Code。关闭 thinking 可路由至 K2.6,不改变账号现有路由。",
- MatchAccountModes: []string{"coding"},
- },
- {
- ModelID: "kimi-for-coding-highspeed", Provider: "kimi", Product: "coding",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: kimiCodingSource, SourceURLs: []string{"https://www.kimi.com/code/docs/kimi-code/models.html"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "262144",
- Conditions: "Coding Allegretto 及以上;容量记录不代表账号拥有该型号使用权。",
- MatchAccountModes: []string{"coding"},
- },
- {
- ModelID: "k3-256k", Provider: "kimi", Product: "coding",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: kimiCodingSource, SourceURLs: []string{"https://www.kimi.com/code/docs/kimi-code/models.html"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "262144",
- Conditions: "Coding Moderato 及以上;此 ID 在各可用套餐均固定 256K。",
- MatchAccountModes: []string{"coding"},
- },
- {
- ModelID: "k3", Provider: "kimi", Product: "coding",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, CapacityBasis: "total_context"},
- SourceURL: kimiCodingSource, SourceURLs: []string{"https://www.kimi.com/code/docs/kimi-code/models.html"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "262144; 1048576",
- NormalizationBasis: "官方套餐表直接整数:Moderato=262144;Allegretto 及以上思考路径=1048576。权益/请求路径未知时采用共同下限 262144。",
- Conditions: "仅 Coding;不把开放平台 kimi-k3 的 1M 套入;k3[1m] 是 Claude Code 客户端配置语法,不是 API model ID。",
- MatchAccountModes: []string{"coding"},
- },
-
- // GLM: keep exact official integers separate from same-platform normalization.
- {
- ModelID: "glm-5.3", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3", SourceURLs: []string{glmOverviewSource, glmMillionSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: "上下文按同平台 GLM-5.2 官方整数配置 1000000 归一 1M;不是 GLM-5.3 直接整数原文。输出 131072 来自官方逐型号参数表。",
- Conditions: "容量不代表任意端点均可用;Coding Plan 的协议限制保持原账号配置。",
- },
- {
- ModelID: "glm-5.3-flash", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: "https://docs.bigmodel.cn/cn/guide/models/vlm/glm-5.3-flash", SourceURLs: []string{glmOverviewSource, glmMillionSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1M; 128K",
- NormalizationBasis: "上下文按同平台 GLM-5.2 官方整数配置 1000000 归一 1M;不是 GLM-5.3-Flash 直接整数原文。输出 131072 来自官方逐型号参数表。",
- Conditions: "容量不代表任意端点均可用;不改原生多模态或 Coding Plan 协议能力。",
- },
- {
- ModelID: "glm-5.2", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glmMillionSource, SourceURLs: []string{glmOverviewSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1000000; 131072",
- NormalizationBasis: "官方 GLM-5.2 接入配置明确上下文整数,输出为逐型号参数表的硬上限。",
- },
- {
- ModelID: "glm-5.1", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glm200KSource, SourceURLs: []string{glmOverviewSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204800; 131072",
- NormalizationBasis: "官方按型号整数配置明确 204800;不采用旧客户端建议配置中的 200000。",
- },
- {
- ModelID: "glm-5", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glm200KSource, SourceURLs: []string{glmOverviewSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204800; 131072",
- NormalizationBasis: "官方按型号整数配置明确 204800;不采用旧客户端建议配置中的 200000。",
- },
- {
- ModelID: "glm-4.7", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glm200KSource, SourceURLs: []string{glmOverviewSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204800; 131072",
- NormalizationBasis: "官方按型号整数配置明确 204800;不采用旧客户端建议配置中的 200000。",
- },
- {
- ModelID: "glm-5-turbo", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glmOverviewSource, SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "200K; 131072",
- NormalizationBasis: "上下文 200K 按同平台官方 GLM-5/5.1/4.7 整数 204800 归一;不是该型号独立上下文整数原文。输出为逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.6", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, MaxOutputTokens: 131072, CapacityBasis: "total_context"},
- SourceURL: glmOverviewSource, SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "200K; 131072",
- NormalizationBasis: "上下文 200K 按同平台官方 GLM-5/5.1/4.7 整数 204800 归一;不是该型号独立上下文整数原文。输出为逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.5", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 131072, MaxOutputTokens: 98304, CapacityBasis: "total_context"},
- SourceURL: "https://docs.z.ai/guides/llm/glm-4.5", SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "128K; 98304",
- NormalizationBasis: "官方 128K 上下文按同平台 200K=204800 的单位体系归一为 131072;输出 98304 为逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.5-air", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 131072, MaxOutputTokens: 98304, CapacityBasis: "total_context"},
- SourceURL: "https://docs.z.ai/guides/llm/glm-4.5", SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "128K; 98304",
- NormalizationBasis: "官方 128K 上下文按同平台 200K=204800 的单位体系归一为 131072;输出 98304 为逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.5-x", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 131072, MaxOutputTokens: 98304, CapacityBasis: "total_context"},
- SourceURL: "https://docs.z.ai/guides/llm/glm-4.5", SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "128K; 98304",
- NormalizationBasis: "官方 128K 上下文按同平台 200K=204800 的单位体系归一为 131072;输出 98304 为逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.5-airx", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 131072, MaxOutputTokens: 98304, CapacityBasis: "total_context"},
- SourceURL: "https://docs.z.ai/guides/llm/glm-4.5", SourceURLs: []string{glm200KSource, "https://docs.z.ai/guides/overview/concept-param"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "128K; 98304",
- NormalizationBasis: "官方 128K 上下文按同平台 200K=204800 的单位体系归一为 131072;输出 98304 为国际版逐型号参数表整数。",
- },
- {
- ModelID: "glm-4.5-flash", Provider: "zhipu", Product: "model_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 131072, MaxOutputTokens: 98304, CapacityBasis: "total_context"},
- SourceURL: "https://docs.z.ai/guides/llm/glm-4.5", SourceURLs: []string{glm200KSource, glmOutputSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "128K; 98304",
- NormalizationBasis: "官方 128K 上下文按同平台 200K=204800 的单位体系归一为 131072;输出 98304 为逐型号参数表整数。",
- },
-
- // MiniMax model IDs deliberately preserve the official mixed case.
- {
- ModelID: "MiniMax-M3", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1,000,000",
- Conditions: "官方窗口为输入与输出总和;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.7", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方窗口为输入与输出总和;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.7-highspeed", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方高速版;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.5", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方窗口为输入与输出总和;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.5-highspeed", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方高速版;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.1", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方窗口为输入与输出总和;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2.1-highspeed", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "官方高速版;未公布的独立输入/输出硬上限留空。",
- },
- {
- ModelID: "MiniMax-M2", Provider: "minimax", Product: "text_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 204800, CapacityBasis: "total_context"},
- SourceURL: minimaxSource, VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "204,800",
- Conditions: "另页 128k 输出含 CoT 但无已核实整数依据;本行不从示例或第三方推定输出硬上限。",
- },
-
- // Doubao's accepted integers are explicitly labelled cross-page inference.
- // The answer-only and thinking limits are not collapsed into max_output_tokens.
- {
- ModelID: "doubao-seed-evolving", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 1048576, MaxInputTokens: 1048576, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource, "https://www.volcengine.com/docs/82379/2549861?lang=zh"},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "1024k; 1024k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "仅核实北京方舟公开 API;滚动升级型号。最大回答/思维链分别给出,不合并为独立总输出上限。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-1-pro-260628", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 262144, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 256k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "仅核实北京方舟公开 API;精确版本后缀为 260628。最大回答/思维链不合并为总输出上限。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-1-turbo-260628", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 262144, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 256k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "仅核实北京方舟公开 API;精确版本后缀为 260628。最大回答/思维链不合并为总输出上限。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-pro-260215", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-lite-260215", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-lite-260428", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-mini-260215", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-mini-260428", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
- {
- ModelID: "doubao-seed-2-0-code-preview-260215", Provider: "doubao", Product: "ark_beijing_api",
- ModelContextCapacity: ModelContextCapacity{ContextWindow: 262144, MaxInputTokens: 229376, CapacityBasis: "total_context"},
- SourceURL: doubaoModelsSource, SourceURLs: []string{doubaoIntegerSource, doubaoEndpointSource},
- VerifiedAt: officialModelContextCapacityVerifiedAt, OriginalText: "256k; 224k",
- NormalizationBasis: doubaoUnitsBasis,
- Conditions: "北京方舟往期 Preview 模型规格;仅记录容量,不恢复或推断模型可用性。",
- MatchHosts: doubaoBeijingCapacityHosts, MatchAccountModes: []string{"payg"},
- },
+func (m *PluginManager) ResolveCatalog(ctx context.Context, query extensionv1.CatalogQuery) (extensionv1.CatalogMatch, error) {
+ registry := m.extensions.Load()
+ if registry == nil || registry.unavailable != "" {
+ return extensionv1.CatalogMatch{}, errors.New("model catalog unavailable")
+ }
+ var ids []int64
+ for id, installation := range registry.installations {
+ if pluginHasCapability(installation, extensionv1.CapabilityCatalog, query.Platform, query.AccountType) {
+ ids = append(ids, id)
+ }
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ raw, err := json.Marshal(query)
+ if err != nil {
+ return extensionv1.CatalogMatch{}, err
+ }
+ var resolved extensionv1.CatalogMatch
+ for _, id := range ids {
+ runtime := registry.runtimes[id]
+ if runtime == nil || runtime.draining.Load() || runtime.client.Exited() || !pluginDependenciesHealthy(registry.installations[id], registry, map[int64]bool{}) {
+ return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable")
+ }
+ cacheKey := strconv.FormatUint(runtime.configRevision.Load(), 10) + ":" + string(raw)
+ var payload json.RawMessage
+ if cached, ok := runtime.catalogCache.Load(cacheKey); ok {
+ payload = cached.(json.RawMessage)
+ } else {
+ out, callErr := m.InvokeExtension(ctx, id, query.Platform, query.AccountType, extensionv1.Invocation{Capability: extensionv1.CapabilityCatalog, Operation: "resolve", Payload: raw})
+ if callErr != nil || out.Code != "" {
+ return extensionv1.CatalogMatch{}, errors.New("enabled model catalog unavailable")
+ }
+ payload = out.Payload
+ }
+ var match extensionv1.CatalogMatch
+ if json.Unmarshal(payload, &match) != nil {
+ return extensionv1.CatalogMatch{}, errors.New("invalid model catalog response")
+ }
+ if match.Entry != nil && runtime.catalogCacheSize.Load() < 2048 {
+ if _, loaded := runtime.catalogCache.LoadOrStore(cacheKey, payload); !loaded {
+ runtime.catalogCacheSize.Add(1)
+ }
+ }
+ if !match.Matched {
+ continue
+ }
+ if match.Entry == nil {
+ return match, nil
+ }
+ if resolved.Entry != nil && resolved.Entry.ModelContextCapacity != match.Entry.ModelContextCapacity {
+ return extensionv1.CatalogMatch{Matched: true}, nil
+ }
+ resolved = match
+ }
+ return resolved, nil
}
diff --git a/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go b/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go
new file mode 100644
index 000000000000..2b1e975ae06e
--- /dev/null
+++ b/backend/internal/service/model_context_capacity_catalog_test_fixture_test.go
@@ -0,0 +1,13 @@
+package service
+
+// Contract tests use the actual independent catalog module as their in-process
+// fixture. Production reaches it only through the enabled plugin process.
+import catalog "github.com/HTExplicit/sub2api-plugins/modelpolicy/catalog"
+
+var officialModelContextCapacityCatalog = catalog.Snapshot()
+
+const GPTContextCapacityReferenceRelease = catalog.ReferenceRelease
+const gptContextCapacityReferenceSource = catalog.ReferenceSource
+const gptContextCapacityReferenceVerifiedAt = catalog.ReferenceVerifiedAt
+
+func init() { processExtensionCatalog.Store(&extensionCatalogProvider{resolver: catalog.New()}) }
diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go
index 6f9a75587165..bd834609ab77 100644
--- a/backend/internal/service/openai_account_runtime_block_fastpath.go
+++ b/backend/internal/service/openai_account_runtime_block_fastpath.go
@@ -647,8 +647,8 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
}
s.recordOpenAIOAuth429()
classification := classifyOpenAIOAuth429At(headers, responseBody, time.Now())
- disposition, resetAt := classification.Disposition, classification.ResetAt
- if disposition != openAIOAuth429Transient && resetAt == nil {
+ disposition := classification.Disposition
+ if disposition != openAIOAuth429Transient {
if err := persistOpenAIQuotaClassification(ctx, s.accountRepo, account, classification, time.Now()); err != nil {
slog.Warn("quota_state_write_failed", "account_id", account.ID)
}
@@ -667,9 +667,7 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context
}
now := time.Now()
cooldownUntil := now.Add(openAIOAuth429FallbackCooldown)
- if resetAt != nil && resetAt.After(now) {
- cooldownUntil = *resetAt
- } else if s.rateLimitService != nil {
+ if s.rateLimitService != nil {
cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account)
if !ok || cooldown <= 0 {
s.openaiOAuth429RetryStartedAt.Delete(account.ID)
@@ -1313,6 +1311,9 @@ func (s *OpenAIGatewayService) isOpenAIAccountRuntimeBlockedContext(_ context.Co
if s == nil || account == nil || account.ID <= 0 {
return false
}
+ if quota := account.QuotaState(time.Now()); quota != nil && quota.Blocked {
+ return true
+ }
isOpenAI := isOpenAIAccount(account)
mu := s.openAIAccountRuntimeBlockLock(account.ID)
mu.Lock()
@@ -1754,6 +1755,12 @@ func (s *OpenAIGatewayService) CooldownOpenAIRetryExhausted(
return
case http.StatusTooManyRequests:
classification := classifyOpenAIOAuth429At(failoverErr.ResponseHeaders, failoverErr.ResponseBody, now)
+ if isOpenAIOAuthAccount(account) && classification.Disposition != openAIOAuth429Transient {
+ if err := persistOpenAIQuotaClassification(ctx, s.accountRepo, account, classification, now); err != nil {
+ slog.Warn("quota_state_write_failed", "account_id", account.ID)
+ }
+ return
+ }
until := now.Add(openAIOAuth429FallbackCooldown)
if classification.Disposition != openAIOAuth429Transient && classification.ResetAt != nil && classification.ResetAt.After(now) {
until = *classification.ResetAt
diff --git a/backend/internal/service/openai_codex_ticket.go b/backend/internal/service/openai_codex_ticket.go
index 3bc3fe4c8817..ba2493341e85 100644
--- a/backend/internal/service/openai_codex_ticket.go
+++ b/backend/internal/service/openai_codex_ticket.go
@@ -1,31 +1,18 @@
package service
import (
- "bytes"
- "context"
- "encoding/json"
"errors"
- "fmt"
- "io"
"maps"
- "net/http"
"net/url"
"strconv"
"strings"
- "sync"
"time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/google/uuid"
"github.com/tidwall/gjson"
- "go.uber.org/zap"
)
const (
openAICodexTicketExtraKeyPrefix = "codex_turn_ticket:"
- openAICodexAstraMinVersion = "0.153.4"
openAICodexTicketStatePrefix = "gAAAAA"
openAICodexTicketDefaultModel = "gpt-6-astra"
openAICodexTicketDefaultSolModel = "gpt-5.6-sol"
@@ -35,21 +22,6 @@ const (
// 且 fail_closed 禁止裸打业务请求。
var ErrOpenAICodexTicketUnavailable = errors.New("codex turn-state ticket unavailable")
-type openAICodexTicket struct {
- Identity string `json:"identity,omitempty"`
- AccountID int64 `json:"account_id"`
- Model string `json:"model"`
- State string `json:"state"`
- Length int `json:"length"`
- CapturedAt time.Time `json:"captured_at"`
- ExpiresAt time.Time `json:"expires_at"`
- Attempts int `json:"attempts"`
-}
-
-func openAICodexTicketKey(accountID int64, model string) string {
- return fmt.Sprintf("%d\x00%s", accountID, strings.TrimSpace(model))
-}
-
func openAICodexTicketExtraKey(model string) string {
return openAICodexTicketExtraKeyPrefix + strings.TrimSpace(model)
}
@@ -62,49 +34,6 @@ func extractOpenAICodexTicketModel(body []byte) string {
return normalizeOpenAICodexTicketModel(gjson.GetBytes(body, "model").String())
}
-func (s *OpenAIGatewayService) openAICodexTicketConfig() config.OpenAICodexTicketConfig {
- cfg := config.OpenAICodexTicketConfig{}
- if s != nil && s.cfg != nil {
- cfg = s.cfg.Gateway.OpenAICodexTicket
- }
- if cfg.TargetLength <= 0 {
- cfg.TargetLength = 292
- }
- if cfg.TTLSeconds <= 0 {
- cfg.TTLSeconds = 3600
- }
- if cfg.RefreshBeforeSeconds <= 0 {
- cfg.RefreshBeforeSeconds = 60
- }
- if cfg.HarvestProbeIntervalSeconds <= 0 {
- cfg.HarvestProbeIntervalSeconds = 6
- }
- if cfg.HarvestAttemptTimeoutSeconds <= 0 {
- cfg.HarvestAttemptTimeoutSeconds = 25
- }
- if len(cfg.Models) == 0 {
- cfg.Models = []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}
- }
- return cfg
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketGatedModel(model string) bool {
- return s.openAICodexTicketEnabled() && s.openAICodexTicketConfiguredModel(model)
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketConfiguredModel(model string) bool {
- model = normalizeOpenAICodexTicketModel(model)
- if model == "" {
- return false
- }
- for _, item := range s.openAICodexTicketConfig().Models {
- if normalizeOpenAICodexTicketModel(item) == model {
- return true
- }
- }
- return false
-}
-
// OpenAICodexTicketStatus 是给管理端看的门票摘要,不含 state blob。
type OpenAICodexTicketStatus struct {
Model string `json:"model"`
@@ -119,221 +48,6 @@ type OpenAICodexTicketStatus struct {
LastResult *CodexTicketResult `json:"last_result,omitempty"`
}
-func OpenAICodexTicketStatuses(account *Account, cfg config.OpenAICodexTicketConfig, now time.Time) []OpenAICodexTicketStatus {
- if !cfg.Enabled || !isOpenAICodexTicketAccount(account) {
- return nil
- }
- models, targetLen := cfg.Models, cfg.TargetLength
- if len(models) == 0 {
- models = []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel}
- }
- if targetLen <= 0 {
- targetLen = 292
- }
- out := make([]OpenAICodexTicketStatus, 0, len(models))
- for _, model := range models {
- model = normalizeOpenAICodexTicketModel(model)
- if model == "" {
- continue
- }
- status := OpenAICodexTicketStatus{Model: model, RenewalState: "idle"}
- if runtime := codexTicketRuntimeFromExtra(account, model); runtime != nil {
- status.RenewalState = runtime.Phase
- status.NextAttemptAt = runtime.NextAt
- status.LastAttemptAt = runtime.LastAttemptAt
- status.LastResult = runtime.LastResult
- }
- ticket := parseOpenAICodexTicketFromAny(0, model, nil)
- if account != nil && account.Extra != nil {
- ticket = parseOpenAICodexTicketFromAny(account.ID, model, account.Extra[openAICodexTicketExtraKey(model)])
- }
- if ticket.valid(now, targetLen) && (ticket.Identity == "" || ticket.Identity == CodexTicketAccountIdentity(account)) {
- status.Ready = true
- status.Length = ticket.Length
- remaining := int64(ticket.ExpiresAt.Sub(now) / time.Second)
- if remaining < 0 {
- remaining = 0
- }
- status.RemainingSeconds = remaining
- exp := ticket.ExpiresAt
- status.ExpiresAt = &exp
- }
- status.Blocked = cfg.FailClosed && !status.Ready
- out = append(out, status)
- }
- return out
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketEnabled() bool {
- return s.openAICodexTicketEnabledContext(context.Background())
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketEnabledContext(ctx context.Context) bool {
- if s == nil {
- return false
- }
- fallback := s.cfg != nil && s.cfg.Gateway.OpenAICodexTicket.Enabled
- if s.settingService != nil {
- return s.settingService.GetOpenAICodexTicketEnabled(ctx, fallback)
- }
- return fallback
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketHarvestProxyURL() string {
- return s.openAICodexTicketHarvestProxyURLContext(context.Background())
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketHarvestProxyURLContext(ctx context.Context) string {
- if s.settingService != nil {
- if proxy := s.settingService.GetOpenAICodexTicketHarvestProxyURL(ctx); proxy != "" {
- return proxy
- }
- if s.settingService.codexTicketProxySettingExists() {
- return ""
- }
- }
- return strings.TrimSpace(s.openAICodexTicketConfig().HarvestProxyURL)
-}
-
-func (t *openAICodexTicket) valid(now time.Time, targetLen int) bool {
- if t == nil {
- return false
- }
- state := strings.TrimSpace(t.State)
- if len(state) != targetLen || t.Length != targetLen || !strings.HasPrefix(state, openAICodexTicketStatePrefix) {
- return false
- }
- if t.ExpiresAt.IsZero() || !now.Before(t.ExpiresAt) {
- return false
- }
- return true
-}
-
-func (s *OpenAIGatewayService) lookupOpenAICodexTicket(account *Account, model string) *openAICodexTicket {
- if s == nil || account == nil || account.ID <= 0 {
- return nil
- }
- model = normalizeOpenAICodexTicketModel(model)
- if model == "" {
- return nil
- }
- key := openAICodexTicketKey(account.ID, model)
- targetLen := 292
- if s != nil {
- targetLen = s.openAICodexTicketConfig().TargetLength
- }
- now := time.Now()
- var mem *openAICodexTicket
- if raw, ok := s.openaiCodexTickets.Load(key); ok {
- mem, _ = raw.(*openAICodexTicket)
- }
- var extra *openAICodexTicket
- if account.Extra != nil {
- extra = parseOpenAICodexTicketFromAny(account.ID, model, account.Extra[openAICodexTicketExtraKey(model)])
- }
- identity := CodexTicketAccountIdentity(account)
- // Bind legacy blobs to the principal at the first read, before publishing
- // them in memory. A later principal change must not reuse an unstamped cache.
- if extra != nil && extra.Identity == "" {
- extra.Identity = identity
- }
- if mem != nil && mem.Identity != "" && mem.Identity != identity {
- mem = nil
- s.openaiCodexTickets.Delete(key)
- }
- if extra != nil && extra.Identity != "" && extra.Identity != identity {
- extra = nil
- }
- if extra.valid(now, targetLen) && (mem == nil || extra.CapturedAt.After(mem.CapturedAt)) {
- s.openaiCodexTickets.Store(key, extra)
- return extra
- }
- if mem.valid(now, targetLen) {
- return mem
- }
- if extra != nil {
- s.openaiCodexTickets.Store(key, extra)
- return extra
- }
- if mem != nil {
- s.openaiCodexTickets.Delete(key)
- }
- return nil
-}
-
-func parseOpenAICodexTicketFromAny(accountID int64, model string, raw any) *openAICodexTicket {
- if raw == nil {
- return nil
- }
- b, err := json.Marshal(raw)
- if err != nil {
- return nil
- }
- var ticket openAICodexTicket
- if err := json.Unmarshal(b, &ticket); err != nil {
- return nil
- }
- ticket.AccountID = accountID
- if strings.TrimSpace(model) != "" {
- ticket.Model = model
- }
- ticket.State = strings.TrimSpace(ticket.State)
- if ticket.Length == 0 {
- ticket.Length = len(ticket.State)
- }
- if ticket.State == "" {
- return nil
- }
- return &ticket
-}
-
-func (s *OpenAIGatewayService) storeOpenAICodexTicket(ctx context.Context, account *Account, ticket *openAICodexTicket) {
- if s == nil || account == nil || ticket == nil || account.ID <= 0 {
- return
- }
- model := normalizeOpenAICodexTicketModel(ticket.Model)
- ticket.Model = model
- ticket.AccountID = account.ID
- s.openaiCodexTickets.Store(openAICodexTicketKey(account.ID, model), ticket)
- if s.accountRepo == nil {
- return
- }
- ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
- openAICodexTicketExtraKey(model): ticket,
- }); err != nil {
- logger.L().Warn("openai_codex_ticket persist failed",
- zap.Int64("account_id", account.ID),
- zap.String("model", model),
- zap.Error(err),
- )
- }
-}
-
-// applyOpenAICodexTicket 在出站请求上覆盖 x-codex-turn-state。
-// 请求路径只注入已捕获的有效门票,不现场打票;无票则返回
-// ErrOpenAICodexTicketUnavailable。打票由后台 harvester 完成。
-func (s *OpenAIGatewayService) applyOpenAICodexTicket(ctx context.Context, account *Account, model string, h http.Header) error {
- if s == nil || h == nil || !isOpenAICodexTicketAccount(account) || !s.openAICodexTicketEnabledContext(ctx) {
- return nil
- }
- model = normalizeOpenAICodexTicketModel(model)
- if model == "" || !s.openAICodexTicketGatedModel(model) {
- return nil
- }
- cfg := s.openAICodexTicketConfig()
- ticket := s.lookupOpenAICodexTicket(account, model)
- if ticket.valid(time.Now(), cfg.TargetLength) {
- h.Set(openAICodexTurnStateHeader, ticket.State)
- return nil
- }
- if !cfg.FailClosed {
- return nil
- }
- return ErrOpenAICodexTicketUnavailable
-}
-
// openAICodexTicketOutboundModel 预测本请求真正出站的模型名,也就是
// applyOpenAICodexTicket 注入时读到的 body.model。
//
@@ -364,253 +78,6 @@ func (s *OpenAIGatewayService) openAICodexTicketOutboundModel(account *Account,
return model
}
-// outboundModel 必须是真正会发给上游的模型名(openAICodexTicketOutboundModel),
-// 不是客户端原始模型:注入侧读的是出站 body.model,两侧口径必须一致。
-func (s *OpenAIGatewayService) openAICodexTicketBlocksAccount(account *Account, outboundModel string) bool {
- if s == nil || !isOpenAICodexTicketAccount(account) || !s.openAICodexTicketEnabled() {
- return false
- }
- cfg := s.openAICodexTicketConfig()
- if !cfg.FailClosed {
- return false
- }
- model := normalizeOpenAICodexTicketModel(outboundModel)
- if !s.openAICodexTicketGatedModel(model) {
- return false
- }
- ticket := s.lookupOpenAICodexTicket(account, model)
- return !ticket.valid(time.Now(), cfg.TargetLength)
-}
-
-func (s *OpenAIGatewayService) fireOpenAICodexTicketProbe(ctx context.Context, account *Account, token, model, proxyURL string, attemptTimeout time.Duration) (state string, status int, err error) {
- return s.fireCodexTicketProbe(ctx, account, token, model, proxyURL, attemptTimeout, nil)
-}
-
-func (s *OpenAIGatewayService) fireCodexTicketProbe(ctx context.Context, account *Account, token, model, proxyURL string, attemptTimeout time.Duration, prepared *codexTicketPreparedProxy) (state string, status int, err error) {
- attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
- defer cancel()
-
- body := []byte(`{"model":` + jsonString(model) + `,"store":false,"stream":true,"instructions":"Reply with exactly: pong","input":[{"role":"user","content":[{"type":"input_text","text":"ping"}]}]}`)
- req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
- if err != nil {
- return "", 0, err
- }
- req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAIHarvest))
- req.Close = true
- req.Host = "chatgpt.com"
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("Accept", "text/event-stream")
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("OpenAI-Beta", "responses=experimental")
- req.Header.Set("session_id", uuid.NewString())
- if err := resolveAndSetOpenAIChatGPTAccountHeaders(attemptCtx, s.accountRepo, req.Header, account); err != nil {
- return "", 0, err
- }
- applyOpenAICodexTicketHarvestIdentity(req.Header, model)
-
- // Synthetic probes must use the dedicated no-reuse transport even when the
- // production account is bound to a plugin. This also avoids reading pluginManager
- // while handlers are still wiring it during gateway construction.
- var resp *http.Response
- if prepared != nil {
- resp, err = prepared.client.Do(req)
- } else {
- resp, err = s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
- }
- if err != nil {
- return "", 0, err
- }
- if resp == nil {
- return "", 0, errors.New("nil upstream response")
- }
- // Only the response header is needed; no connection will be reused.
- defer func() {
- if resp.Body != nil {
- _ = resp.Body.Close()
- }
- }()
- state = extractOpenAICodexTurnState(resp.Header)
- if resp.StatusCode != http.StatusOK {
- var detail string
- if resp.Body != nil {
- raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
- secrets := []string{token, proxyURL}
- if parsed, parseErr := url.Parse(proxyURL); parseErr == nil && parsed.User != nil {
- password, _ := parsed.User.Password()
- secrets = append(secrets, parsed.User.Username(), password)
- }
- detail = codexTicketSafeUpstreamMessage(extractUpstreamErrorMessage(raw), secrets...)
- }
- return state, resp.StatusCode, &codexTicketUpstreamError{message: detail}
- }
- return state, resp.StatusCode, nil
-}
-
-func jsonString(v string) string {
- b, err := json.Marshal(v)
- if err != nil {
- return `""`
- }
- return string(b)
-}
-
-func applyOpenAICodexTicketHarvestIdentity(h http.Header, model string) {
- ensureCodexIdentityHeaders(h)
- enforceCodexIdentityHeaders(h)
- version := strings.TrimSpace(h.Get("version"))
- if needsOpenAICodexAstraVersion(model) && (version == "" || CompareVersions(version, openAICodexAstraMinVersion) < 0) {
- h.Set("version", openAICodexAstraMinVersion)
- h.Set("user-agent", buildCodexCLIUserAgent(openAICodexAstraMinVersion))
- h.Set("originator", openai.CodexDefaultOriginator)
- }
-}
-
-func needsOpenAICodexAstraVersion(model string) bool {
- m := strings.ToLower(normalizeOpenAICodexTicketModel(model))
- return strings.Contains(m, "gpt-6") || strings.Contains(m, "astra")
-}
-
-func (s *OpenAIGatewayService) StartOpenAICodexTicketHarvester() {
- if s == nil {
- return
- }
- s.openaiCodexTicketLifecycleMu.Lock()
- defer s.openaiCodexTicketLifecycleMu.Unlock()
- if s.openaiCodexTicketStopped || s.openaiCodexTicketDone != nil {
- return
- }
- ctx, cancel := context.WithCancel(context.Background())
- done := make(chan struct{})
- s.openaiCodexTicketCancel = cancel
- s.openaiCodexTicketDone = done
- go func() {
- defer close(done)
- s.openAICodexTicketHarvestLoop(ctx)
- }()
- logger.L().Info("openai_codex_ticket harvester started",
- zap.Int("ttl_seconds", s.openAICodexTicketConfig().TTLSeconds),
- zap.Int("target_length", s.openAICodexTicketConfig().TargetLength),
- zap.Strings("models", s.openAICodexTicketConfig().Models),
- )
-}
-
-func (s *OpenAIGatewayService) StopOpenAICodexTicketHarvester() {
- if s == nil {
- return
- }
- s.openaiCodexTicketLifecycleMu.Lock()
- s.openaiCodexTicketStopped = true
- cancel, done := s.openaiCodexTicketCancel, s.openaiCodexTicketDone
- s.openaiCodexTicketLifecycleMu.Unlock()
- if cancel != nil {
- cancel()
- }
- if done != nil {
- <-done
- }
-}
-
-func (s *OpenAIGatewayService) openAICodexTicketHarvestLoop(ctx context.Context) {
- store, ok := s.accountRepo.(CodexTicketRepository)
- if !ok {
- return
- }
- // Import only still-valid legacy tickets. Existing runtime rows (including
- // stopped rows) are never re-enrolled by this startup step.
- if err := store.SeedCodexTicketRenewals(ctx, s.openAICodexTicketConfig().Models, s.openAICodexTicketConfig().TargetLength, time.Now()); err != nil {
- logger.L().Warn("codex_ticket_seed_failed")
- }
- timer := time.NewTimer(0)
- defer timer.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-timer.C:
- s.refreshOpenAICodexTickets(ctx)
- timer.Reset(time.Second)
- }
- }
-}
-
-// Only durable successful enrollments have due rows. Missing tickets are never
-// harvested by a background scan.
-func (s *OpenAIGatewayService) refreshOpenAICodexTickets(ctx context.Context) {
- if s == nil || s.accountRepo == nil || ctx.Err() != nil || !s.openAICodexTicketEnabledContext(ctx) {
- return
- }
- store, ok := s.accountRepo.(CodexTicketRepository)
- if !ok {
- return
- }
- items, err := store.DueCodexTickets(ctx, s.openAICodexTicketConfig().Models, time.Now(), 5)
- if err != nil {
- logger.L().Warn("codex_ticket_due_read_failed")
- return
- }
- var wg sync.WaitGroup
- for _, item := range items {
- wg.Add(1)
- go func(item CodexTicketLifecycle) {
- defer wg.Done()
- s.runCodexTicketAttempt(ctx, item.AccountID, item.Model, "renewal", 0, false, false)
- }(item)
- }
- wg.Wait()
-}
-
-// probeOnceOpenAICodexTicket 走打票代理打一发。命中合格 292(HTTP 200、长度==target、
-// gAAAAA 前缀)就落库;否则记 Info miss,交给下个周期重试。同一 key 并发去重,避免上一发还没
-// 回来又叠一发。
-func (s *OpenAIGatewayService) probeOnceOpenAICodexTicket(ctx context.Context, account *Account, model string) {
- if s == nil || !isOpenAICodexTicketAccount(account) || ctx.Err() != nil || !s.openAICodexTicketEnabledContext(ctx) {
- return
- }
- cfg := s.openAICodexTicketConfig()
- proxyURL := s.openAICodexTicketHarvestProxyURLContext(ctx)
- if proxyURL == "" || s.httpUpstream == nil || ctx.Err() != nil {
- return
- }
- key := openAICodexTicketKey(account.ID, model)
- _, _, _ = s.openaiCodexTicketFlight.Do(key, func() (any, error) {
- token, _, err := s.GetAccessToken(ctx, account)
- if err != nil || strings.TrimSpace(token) == "" {
- logger.L().Info("openai_codex_ticket probe miss",
- zap.Int64("account_id", account.ID), zap.String("model", model),
- zap.String("reason", "token"), zap.Error(err))
- return nil, nil
- }
- state, status, perr := s.fireOpenAICodexTicketProbe(ctx, account, token, model, proxyURL, time.Duration(cfg.HarvestAttemptTimeoutSeconds)*time.Second)
- if perr != nil {
- logger.L().Info("openai_codex_ticket probe miss",
- zap.Int64("account_id", account.ID), zap.String("model", model),
- zap.String("reason", "error"), zap.Error(perr))
- return nil, nil
- }
- if status != http.StatusOK || state == "" || len(state) != cfg.TargetLength || !strings.HasPrefix(state, openAICodexTicketStatePrefix) {
- logger.L().Info("openai_codex_ticket probe miss",
- zap.Int64("account_id", account.ID), zap.String("model", model),
- zap.Int("http", status), zap.Int("len", len(state)))
- return nil, nil
- }
- now := time.Now()
- ticket := &openAICodexTicket{
- AccountID: account.ID,
- Model: model,
- State: state,
- Length: len(state),
- CapturedAt: now,
- ExpiresAt: now.Add(time.Duration(cfg.TTLSeconds) * time.Second),
- Attempts: 1,
- }
- s.storeOpenAICodexTicket(ctx, account, ticket)
- logger.L().Info("openai_codex_ticket harvested",
- zap.Int64("account_id", account.ID), zap.String("model", model),
- zap.Int("length", ticket.Length), zap.String("mode", "continuous"))
- return nil, nil
- })
-}
-
// IsOpenAICodexTicketExtraKey identifies server-managed ticket material.
func IsOpenAICodexTicketExtraKey(key string) bool {
return strings.HasPrefix(key, openAICodexTicketExtraKeyPrefix) || strings.HasPrefix(key, CodexTicketRuntimeExtraPrefix)
diff --git a/backend/internal/service/openai_codex_ticket_lifecycle_test.go b/backend/internal/service/openai_codex_ticket_lifecycle_test.go
deleted file mode 100644
index b7c2de9805d5..000000000000
--- a/backend/internal/service/openai_codex_ticket_lifecycle_test.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "io"
- "net/http"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
-)
-
-type codexTicketFuncUpstream struct {
- HTTPUpstream
- do func(*http.Request) (*http.Response, error)
-}
-
-func (u *codexTicketFuncUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return u.do(req)
-}
-func codexTicketResponse() *http.Response {
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
- return &http.Response{StatusCode: http.StatusOK, Header: h, Body: io.NopCloser(strings.NewReader("data: {}\n\n"))}
-}
-
-func TestCodexTicketProbeBypassesPluginDuringWiring(t *testing.T) {
- manager := &PluginManager{}
- manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "plugin must not handle synthetic probes"})
- var calls atomic.Int64
- upstream := &codexTicketFuncUpstream{do: func(req *http.Request) (*http.Response, error) {
- calls.Add(1)
- if HTTPUpstreamProfileFromContext(req.Context()) != HTTPUpstreamProfileOpenAIHarvest || !req.Close {
- return nil, errors.New("missing no-reuse transport profile")
- }
- return codexTicketResponse(), nil
- }}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true}, upstream)
- svc.SetPluginManager(manager)
- account := ticketTestAccount(41)
- // This binding rejects ordinary traffic; harvesting still uses the dedicated transport.
- request, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
- _, err := svc.doOpenAIUpstream(request, "", account)
- require.Error(t, err)
- start := make(chan struct{})
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- <-start
- for i := 0; i < 200; i++ {
- svc.SetPluginManager(manager)
- }
- }()
- close(start)
- for i := 0; i < 20; i++ {
- state, status, err := svc.fireOpenAICodexTicketProbe(context.Background(), account, "test-token", "gpt-6-astra", "http://proxy.example.com:8080", time.Second)
- require.NoError(t, err)
- require.Equal(t, http.StatusOK, status)
- require.Len(t, state, 292)
- }
- wg.Wait()
- require.Equal(t, int64(20), calls.Load())
-}
-
-type codexTicketLifecycleRepo struct {
- AccountRepository
- account Account
- list func(context.Context) ([]Account, error)
- persist func(context.Context) error
-}
-
-func (r *codexTicketLifecycleRepo) SeedCodexTicketRenewals(ctx context.Context, _ []string, _ int, _ time.Time) error {
- _, err := r.ListByPlatform(ctx, PlatformOpenAI)
- return err
-}
-func (r *codexTicketLifecycleRepo) DueCodexTickets(context.Context, []string, time.Time, int) ([]CodexTicketLifecycle, error) {
- return []CodexTicketLifecycle{{AccountID: r.account.ID, Model: "gpt-6-astra"}}, nil
-}
-func (r *codexTicketLifecycleRepo) ClaimCodexTicket(_ context.Context, id int64, model, operation string, _ int64, _, _ bool, _ time.Time) (*CodexTicketLifecycle, error) {
- return &CodexTicketLifecycle{AccountID: id, Model: model, Identity: CodexTicketAccountIdentity(&r.account), Phase: "pre_running", LeaseID: operation}, nil
-}
-func (r *codexTicketLifecycleRepo) FinishCodexTicket(ctx context.Context, _ *CodexTicketLifecycle, ticket *CodexTicketRecord, _ CodexTicketResult, _ time.Time) (bool, error) {
- return ticket != nil, r.UpdateExtra(ctx, r.account.ID, nil)
-}
-func (r *codexTicketLifecycleRepo) StopCodexTicket(context.Context, int64, []string, time.Time) error {
- return nil
-}
-func (r *codexTicketLifecycleRepo) GetByID(context.Context, int64) (*Account, error) {
- a := r.account
- return &a, nil
-}
-
-func (r *codexTicketLifecycleRepo) ListByPlatform(ctx context.Context, _ string) ([]Account, error) {
- if r.list != nil {
- return r.list(ctx)
- }
- return []Account{r.account}, nil
-}
-func (r *codexTicketLifecycleRepo) UpdateExtra(ctx context.Context, _ int64, _ map[string]any) error {
- if r.persist != nil {
- return r.persist(ctx)
- }
- return nil
-}
-
-type codexTicketLifecycleSettings struct {
- SettingRepository
- get func(context.Context, string) (string, error)
-}
-
-func (r *codexTicketLifecycleSettings) GetValue(ctx context.Context, key string) (string, error) {
- return r.get(ctx, key)
-}
-
-func TestCodexTicketHarvesterStopCancelsInFlightWork(t *testing.T) {
- for _, stage := range []string{"settings-enabled", "settings-proxy", "accounts", "upstream"} {
- t.Run(stage, func(t *testing.T) {
- started := make(chan struct{})
- cancelled := make(chan struct{})
- var once sync.Once
- var canceledOnce sync.Once
- block := func(ctx context.Context) error {
- once.Do(func() { close(started) })
- <-ctx.Done()
- canceledOnce.Do(func() { close(cancelled) })
- return ctx.Err()
- }
- account := ticketTestAccount(41)
- account.Status = StatusActive
- repo := &codexTicketLifecycleRepo{account: *account}
- upstream := &codexTicketFuncUpstream{do: func(req *http.Request) (*http.Response, error) {
- if stage == "upstream" {
- return nil, block(req.Context())
- }
- return codexTicketResponse(), nil
- }}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080", HarvestAttemptTimeoutSeconds: 25, Models: []string{"gpt-6-astra"}}, upstream)
- svc.accountRepo = repo
- if stage == "accounts" {
- repo.list = func(ctx context.Context) ([]Account, error) { return nil, block(ctx) }
- }
- if stage == "persist" {
- repo.persist = block
- }
- if strings.HasPrefix(stage, "settings-") {
- svc.settingService = NewSettingService(&codexTicketLifecycleSettings{get: func(ctx context.Context, key string) (string, error) {
- if stage == "settings-enabled" && key == SettingKeyOpenAICodexTicketEnabled || stage == "settings-proxy" && key == SettingKeyOpenAICodexTicketHarvestProxyURL {
- return "", block(ctx)
- }
- if key == SettingKeyOpenAICodexTicketEnabled {
- return "true", nil
- }
- return "", ErrSettingNotFound
- }}, svc.cfg)
- }
- svc.StartOpenAICodexTicketHarvester()
- t.Cleanup(svc.StopOpenAICodexTicketHarvester)
- select {
- case <-started:
- case <-time.After(3 * time.Second):
- t.Fatal("harvester did not reach " + stage)
- }
- // Repeated start must not create a second loop or overwrite the cancellation state.
- svc.StartOpenAICodexTicketHarvester()
- stopped := make(chan struct{})
- go func() { svc.StopOpenAICodexTicketHarvester(); close(stopped) }()
- select {
- case <-stopped:
- case <-time.After(time.Second):
- t.Fatal("stop waited for the probe timeout")
- }
- select {
- case <-cancelled:
- case <-time.After(time.Second):
- t.Fatal("in-flight operation did not receive cancellation")
- }
- svc.StopOpenAICodexTicketHarvester()
- svc.StartOpenAICodexTicketHarvester()
- })
- }
-}
-
-type codexTicketHeaderOnlyBody struct{ reads, closes int }
-
-func (b *codexTicketHeaderOnlyBody) Read([]byte) (int, error) { b.reads++; return 0, io.EOF }
-func (b *codexTicketHeaderOnlyBody) Close() error { b.closes++; return nil }
-func TestCodexTicketProbeClosesStreamWithoutDraining(t *testing.T) {
- body := &codexTicketHeaderOnlyBody{}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{}, &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) {
- response := codexTicketResponse()
- response.Body = body
- return response, nil
- }})
- _, _, err := svc.fireOpenAICodexTicketProbe(context.Background(), ticketTestAccount(41), "test-token", "gpt-6-astra", "", time.Second)
- require.NoError(t, err)
- require.Zero(t, body.reads)
- require.Equal(t, 1, body.closes)
-}
-
-func TestCodexTicketPolicyExemptsCredentialShadows(t *testing.T) {
- parentID := int64(41)
- parent := ticketTestAccount(parentID)
- shadow := ticketTestAccount(42)
- shadow.ParentAccountID = &parentID
- shadow.Status = StatusActive
- cfg := config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, HarvestProxyURL: "http://proxy.example.com:8080"}
- upstream := &httpUpstreamRecorder{}
- svc := ticketTestService(t, cfg, upstream)
- svc.accountRepo = &codexTicketRefreshRepo{accounts: []Account{*shadow}}
- require.True(t, svc.openAICodexTicketBlocksAccount(parent, "gpt-6-astra"))
- for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
- shadow.Type = accountType
- require.False(t, svc.openAICodexTicketBlocksAccount(shadow, "gpt-6-astra"))
- headers := http.Header{}
- headers.Set(openAICodexTurnStateHeader, "client-state")
- require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), shadow, "gpt-6-astra", headers))
- require.Equal(t, "client-state", headers.Get(openAICodexTurnStateHeader))
- require.Empty(t, OpenAICodexTicketStatuses(shadow, cfg, time.Now()))
- svc.probeOnceOpenAICodexTicket(context.Background(), shadow, "gpt-6-astra")
- }
- svc.refreshOpenAICodexTickets(context.Background())
- require.Empty(t, upstream.requests)
-}
diff --git a/backend/internal/service/openai_codex_ticket_scheduler_test.go b/backend/internal/service/openai_codex_ticket_scheduler_test.go
index 19a27800c8de..aa69de765481 100644
--- a/backend/internal/service/openai_codex_ticket_scheduler_test.go
+++ b/backend/internal/service/openai_codex_ticket_scheduler_test.go
@@ -29,17 +29,7 @@ func schedulerTicketAccount(id int64, groupID int64, withTicket bool) *Account {
},
}
if withTicket {
- account.Extra = map[string]any{
- openAICodexTicketExtraKey("gpt-6-astra"): &openAICodexTicket{
- AccountID: id,
- Model: "gpt-6-astra",
- Identity: CodexTicketAccountIdentity(account),
- State: fakeCodexTicketState(292),
- Length: 292,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- },
- }
+ setTicketTestProjection(account, "gpt-6-astra", time.Now().Add(time.Hour))
}
return account
}
@@ -88,7 +78,7 @@ func TestCodexTicketCandidateGateDefersCredentialChecks(t *testing.T) {
require.False(t, svc.isOpenAIAccountCandidateRuntimeBlockedContext(ctx, &projectedWithTicket, "gpt-6-astra"))
require.True(t, svc.isOpenAIAccountRequestRuntimeBlockedContext(ctx, &projectedWithTicket, "gpt-6-astra"))
- // Missing, expired, wrong-principal and wrong-length tickets remain blocked
+ // Missing, expired and wrong-principal grants remain blocked
// once the authoritative account is available.
ticketless := *full
ticketless.Extra = nil
@@ -102,10 +92,8 @@ func TestCodexTicketCandidateGateDefersCredentialChecks(t *testing.T) {
}
require.True(t, svc.isOpenAIAccountRequestRuntimeBlockedContext(ctx, &changedPrincipal, "gpt-6-astra"))
- expiredTicket := *full.Extra[openAICodexTicketExtraKey("gpt-6-astra")].(*openAICodexTicket)
- expiredTicket.ExpiresAt = time.Now().Add(-time.Minute)
expired := *full
- expired.Extra = map[string]any{openAICodexTicketExtraKey("gpt-6-astra"): &expiredTicket}
+ setTicketTestProjection(&expired, "gpt-6-astra", time.Now().Add(-time.Minute))
require.True(t, svc.isOpenAIAccountRequestRuntimeBlockedContext(ctx, &expired, "gpt-6-astra"))
// Models outside the configured ticket scope keep their existing behavior.
@@ -139,6 +127,7 @@ func TestSelectAccountWithSchedulerDefersTicketGateToAuthoritativeAccount(t *tes
if tc.advanced {
svc.rateLimitService = newOpenAIAdvancedSchedulerRateLimitService("true")
}
+ svc.pluginManager = ticketTestManager(t, svc.cfg.Gateway.OpenAICodexTicket, nil)
selection, decision, err := svc.SelectAccountWithScheduler(
ctx, &groupID, "", "", "gpt-6-astra", nil, OpenAIUpstreamTransportAny, false,
@@ -149,7 +138,7 @@ func TestSelectAccountWithSchedulerDefersTicketGateToAuthoritativeAccount(t *tes
require.NotNil(t, selection.Account)
require.Equal(t, full.ID, selection.Account.ID)
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
- require.NotNil(t, selection.Account.Extra[openAICodexTicketExtraKey("gpt-6-astra")])
+ require.NotNil(t, selection.Account.Extra[PluginAccountProjectionKey])
})
}
}
@@ -170,6 +159,7 @@ func TestSelectAccountWithSchedulerKeepsFailClosedForTicketlessAccounts(t *testi
schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache},
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
}
+ svc.pluginManager = ticketTestManager(t, svc.cfg.Gateway.OpenAICodexTicket, nil)
selection, _, err := svc.SelectAccountWithScheduler(
ctx, &groupID, "", "", "gpt-6-astra", nil, OpenAIUpstreamTransportAny, false,
diff --git a/backend/internal/service/openai_codex_ticket_test.go b/backend/internal/service/openai_codex_ticket_test.go
index 7a2b5150f737..01e6984d05d8 100644
--- a/backend/internal/service/openai_codex_ticket_test.go
+++ b/backend/internal/service/openai_codex_ticket_test.go
@@ -2,454 +2,153 @@ package service
import (
"context"
- "io"
+ "encoding/json"
+ "errors"
"net/http"
"strings"
- "sync"
- "sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ hcplugin "github.com/hashicorp/go-plugin"
"github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/protobuf/types/known/wrapperspb"
)
func fakeCodexTicketState(n int) string {
- if n < len(openAICodexTicketStatePrefix) {
+ if n < 6 {
return strings.Repeat("A", n)
}
- return openAICodexTicketStatePrefix + strings.Repeat("B", n-len(openAICodexTicketStatePrefix))
+ return "gAAAAA" + strings.Repeat("B", n-6)
}
-
func ticketTestAccount(id int64) *Account {
- return &Account{
- ID: id,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"},
- }
-}
-
-func ticketTestService(t *testing.T, cfg config.OpenAICodexTicketConfig, upstream HTTPUpstream) *OpenAIGatewayService {
- t.Helper()
- return &OpenAIGatewayService{
- cfg: &config.Config{
- Gateway: config.GatewayConfig{OpenAICodexTicket: cfg},
- },
- httpUpstream: upstream,
- }
-}
-
-func TestApplyOpenAICodexTicket_ReplacesHeader(t *testing.T) {
- state := fakeCodexTicketState(292)
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- FailClosed: true,
- }, nil)
- account := ticketTestAccount(41)
- svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: state,
- Length: 292,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- })
-
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(312))
- err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)
- require.NoError(t, err)
- require.Equal(t, state, h.Get(openAICodexTurnStateHeader))
- require.Equal(t, 292, len(h.Get(openAICodexTurnStateHeader)))
+ return &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"}}
}
-func TestApplyOpenAICodexTicket_DoesNotReuseOtherModelOrAccount(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- FailClosed: true,
- HarvestProxyURL: "socks5h://harvest",
- }, &httpUpstreamRecorder{err: io.EOF})
- a := ticketTestAccount(41)
- b := ticketTestAccount(42)
- astra := fakeCodexTicketState(292)
- svc.storeOpenAICodexTicket(context.Background(), a, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: astra,
- Length: 292,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- })
-
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, "keep-ungated")
- err := svc.applyOpenAICodexTicket(context.Background(), a, "gpt-5.5", h)
- require.NoError(t, err)
- require.Equal(t, "keep-ungated", h.Get(openAICodexTurnStateHeader))
- require.False(t, svc.openAICodexTicketBlocksAccount(a, "gpt-5.5"))
- require.True(t, svc.openAICodexTicketBlocksAccount(b, "gpt-6-astra"))
- require.False(t, svc.openAICodexTicketBlocksAccount(a, "gpt-6-astra"))
-
- h = http.Header{}
- err = svc.applyOpenAICodexTicket(context.Background(), b, "gpt-6-astra", h)
- require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable)
- require.Empty(t, h.Get(openAICodexTurnStateHeader))
+// Host tests stub only the process boundary. Ticket validation, acquisition and
+// renewal are tested against the independent module in plugins/codex-runtime.
+type ticketExtensionTestConn struct {
+ grpc.ClientConnInterface
+ invoke func(extensionv1.Invocation) (extensionv1.Result, error)
}
-func TestLookupOpenAICodexTicket_PrefersNewerExtra(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, TargetLength: 292, TTLSeconds: 3600}, nil)
- account := ticketTestAccount(41)
- oldState := fakeCodexTicketState(292)
- newState := openAICodexTicketStatePrefix + strings.Repeat("C", 286)
- svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: oldState,
- Length: 292,
- CapturedAt: time.Now().Add(-30 * time.Minute),
- ExpiresAt: time.Now().Add(-time.Minute),
- })
- account.Extra = map[string]any{openAICodexTicketExtraKey("gpt-6-astra"): &openAICodexTicket{
- Model: "gpt-6-astra",
- State: newState,
- Length: 292,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- },
+func (c *ticketExtensionTestConn) Invoke(_ context.Context, _ string, args, reply any, _ ...grpc.CallOption) error {
+ var request extensionv1.Invocation
+ if err := json.Unmarshal(args.(*wrapperspb.BytesValue).Value, &request); err != nil {
+ return err
}
- got := svc.lookupOpenAICodexTicket(account, "gpt-6-astra")
- require.NotNil(t, got)
- require.Equal(t, newState, got.State)
- require.True(t, got.valid(time.Now(), 292))
-}
-
-func TestApplyOpenAICodexTicket_ExpiredNotInjected(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- FailClosed: true,
- HarvestProxyURL: "socks5h://harvest",
- }, &httpUpstreamRecorder{err: io.EOF})
- account := ticketTestAccount(41)
- svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: fakeCodexTicketState(292),
- Length: 292,
- CapturedAt: time.Now().Add(-2 * time.Hour),
- ExpiresAt: time.Now().Add(-time.Minute),
- })
- h := http.Header{}
- err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)
- require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable)
- require.Empty(t, h.Get(openAICodexTurnStateHeader))
-}
-
-func TestApplyOpenAICodexTicket_WrongLengthNotInjected(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- FailClosed: true,
- }, nil)
- account := ticketTestAccount(41)
- svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: fakeCodexTicketState(312),
- Length: 312,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- })
- h := http.Header{}
- err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)
- require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable)
- require.Empty(t, h.Get(openAICodexTurnStateHeader))
-}
-
-func TestApplyOpenAICodexTicket_FailOpenSkipsInject(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- FailClosed: false,
- }, nil)
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, "client-state")
- err := svc.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", h)
- require.NoError(t, err)
- require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader))
- require.False(t, svc.openAICodexTicketBlocksAccount(ticketTestAccount(41), "gpt-6-astra"))
-}
-
-func TestApplyOpenAICodexTicket_DisabledNoop(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: false, FailClosed: true}, nil)
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, "client-state")
- err := svc.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", h)
- require.NoError(t, err)
- require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader))
-}
-
-func TestHarvestOpenAICodexTicket_StopsAt292AndUsesHarvestProxy(t *testing.T) {
- state312 := fakeCodexTicketState(312)
- state292 := fakeCodexTicketState(292)
- header312 := http.Header{}
- header312.Set(openAICodexTurnStateHeader, state312)
- header292 := http.Header{}
- header292.Set(openAICodexTurnStateHeader, state292)
- upstream := &httpUpstreamRecorder{
- responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: header312,
- Body: io.NopCloser(strings.NewReader("data: {}\n\n")),
- },
- {
- StatusCode: http.StatusOK,
- Header: header292,
- Body: io.NopCloser(strings.NewReader("data: {}\n\n")),
- },
- },
+ result, err := c.invoke(request)
+ if err != nil {
+ return err
}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- HarvestProxyURL: "socks5h://user:pass@harvest.example:31",
- HarvestAttemptTimeoutSeconds: 5,
- FailClosed: true,
- }, upstream)
- account := ticketTestAccount(41)
-
- svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra")
- require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra"))
- svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra")
- ticket := svc.lookupOpenAICodexTicket(account, "gpt-6-astra")
- require.NotNil(t, ticket)
- require.Equal(t, state292, ticket.State)
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, "stale")
- require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h))
- require.Equal(t, state292, h.Get(openAICodexTurnStateHeader))
- require.Equal(t, "socks5h://user:pass@harvest.example:31", upstream.lastProxyURL)
- require.Len(t, upstream.requests, 2)
- require.Empty(t, upstream.requests[0].Header.Get(openAICodexTurnStateHeader))
- require.Equal(t, openAICodexAstraMinVersion, upstream.requests[0].Header.Get("version"))
- require.Equal(t, HTTPUpstreamProfileOpenAIHarvest, HTTPUpstreamProfileFromContext(upstream.requests[0].Context()))
- require.True(t, upstream.requests[0].Close)
-}
-
-func TestHarvestOpenAICodexTicket_HTTP503DoesNotAbortHunt(t *testing.T) {
- state292 := fakeCodexTicketState(292)
- header503 := http.Header{}
- header292 := http.Header{}
- header292.Set(openAICodexTurnStateHeader, state292)
- responses := make([]*http.Response, 0, 3)
- responses = append(responses, &http.Response{
- StatusCode: http.StatusServiceUnavailable,
- Header: header503,
- Body: io.NopCloser(strings.NewReader(`{"error":"overloaded"}`)),
- })
- responses = append(responses, &http.Response{
- StatusCode: http.StatusOK,
- Header: header292,
- Body: io.NopCloser(strings.NewReader("data: {}\n\n")),
- })
- upstream := &httpUpstreamRecorder{responses: responses}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- HarvestProxyURL: "socks5h://harvest.example:31",
- HarvestAttemptTimeoutSeconds: 5,
- FailClosed: true,
- }, upstream)
- account := ticketTestAccount(41)
- svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra")
- require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra"))
- svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra")
- ticket := svc.lookupOpenAICodexTicket(account, "gpt-6-astra")
- require.NotNil(t, ticket)
- require.Equal(t, state292, ticket.State)
- require.Len(t, upstream.requests, 2)
+ reply.(*wrapperspb.BytesValue).Value, err = json.Marshal(result)
+ return err
}
-
-func TestLookupOpenAICodexTicket_HydratesFromExtra(t *testing.T) {
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, TargetLength: 292, TTLSeconds: 3600}, nil)
- state := fakeCodexTicketState(292)
- account := ticketTestAccount(9)
- account.Extra = map[string]any{
- openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{
- "state": state,
- "length": 292,
- "model": "gpt-6-astra",
- "captured_at": time.Now().Add(-time.Minute),
- "expires_at": time.Now().Add(time.Hour),
- },
+func ticketTestManager(t *testing.T, cfg config.OpenAICodexTicketConfig, invoke func(extensionv1.Invocation) (extensionv1.Result, error)) *PluginManager {
+ t.Helper()
+ if len(cfg.Models) == 0 {
+ cfg.Models = []string{"gpt-6-astra", "gpt-5.6-sol"}
}
- got := svc.lookupOpenAICodexTicket(account, "gpt-6-astra")
- require.NotNil(t, got)
- require.Equal(t, state, got.State)
- require.True(t, got.valid(time.Now(), 292))
-}
-
-func TestOpenAICodexTicketStatuses_ReportsRemainingTTL(t *testing.T) {
- account := ticketTestAccount(41)
- account.Extra = map[string]any{
- openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{
- "state": fakeCodexTicketState(292),
- "length": 292,
- "model": "gpt-6-astra",
- "captured_at": time.Now().Add(-10 * time.Minute),
- "expires_at": time.Now().Add(50 * time.Minute),
- },
+ installation := &PluginInstallation{ID: 1, PluginKey: codexRuntimePluginKey}
+ for _, capability := range []string{extensionv1.CapabilityScheduling, extensionv1.CapabilityRequest, extensionv1.CapabilityAdmin} {
+ for _, kind := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
+ installation.Bindings = append(installation.Bindings, PluginBinding{Capability: capability, Platform: PlatformOpenAI, AccountType: kind, Enabled: true})
+ }
}
- now := time.Now()
- got := OpenAICodexTicketStatuses(account, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, now)
- require.Len(t, got, 2)
- require.Equal(t, "gpt-6-astra", got[0].Model)
- require.True(t, got[0].Ready)
- require.Greater(t, got[0].RemainingSeconds, int64(40*60))
- require.LessOrEqual(t, got[0].RemainingSeconds, int64(50*60))
- require.Equal(t, "gpt-5.6-sol", got[1].Model)
- require.False(t, got[1].Ready)
-}
-
-func TestExtractOpenAICodexTicketModel(t *testing.T) {
- require.Equal(t, "gpt-6-astra", extractOpenAICodexTicketModel([]byte(`{"model":"gpt-6-astra"}`)))
- require.Empty(t, extractOpenAICodexTicketModel([]byte(`{}`)))
-}
-
-// These stubs exercise the real continuous refresh path with both default models
-// completing together. Run under -race to catch writes to the shared account maps.
-type codexTicketRefreshRepo struct {
- AccountRepository
- accounts []Account
- mu sync.Mutex
- updates map[string]any
-}
-
-func (r *codexTicketRefreshRepo) ListByPlatform(context.Context, string) ([]Account, error) {
- return r.accounts, nil
-}
-func (r *codexTicketRefreshRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.updates == nil {
- r.updates = make(map[string]any)
+ if invoke == nil {
+ invoke = func(extensionv1.Invocation) (extensionv1.Result, error) {
+ return extensionv1.Result{Code: "ticket_missing"}, nil
+ }
}
- for k, v := range updates {
- r.updates[k] = v
+ runtime := &pluginRuntime{client: &hcplugin.Client{}, extension: extensionv1.NewClient(&ticketExtensionTestConn{invoke: invoke})}
+ raw, _ := json.Marshal(map[string]any{"enabled": cfg.Enabled, "fail_closed": cfg.FailClosed, "models": cfg.Models, "proxy_url": cfg.HarvestProxyURL})
+ snapshot := json.RawMessage(raw)
+ runtime.configSnapshot.Store(&snapshot)
+ rules := []extensionv1.SchedulingRule{}
+ if cfg.Enabled && cfg.FailClosed {
+ rules = append(rules, extensionv1.SchedulingRule{Models: cfg.Models, Default: "deny", Reason: "ticket_missing", ExcludeShadows: true})
}
- return nil
+ runtime.scheduling.Store(&rules)
+ manager := &PluginManager{}
+ manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{1: installation}, runtimes: map[int64]*pluginRuntime{1: runtime}})
+ return manager
}
-
-type codexTicketConcurrentUpstream struct {
- HTTPUpstream
- started atomic.Int64
- ready chan struct{}
+func ticketTestService(t *testing.T, cfg config.OpenAICodexTicketConfig, upstream HTTPUpstream) *OpenAIGatewayService {
+ return &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{OpenAICodexTicket: cfg}}, httpUpstream: upstream, pluginManager: ticketTestManager(t, cfg, nil)}
}
-
-func (u *codexTicketConcurrentUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- if u.started.Add(1) == 2 {
- close(u.ready)
- }
- select {
- case <-u.ready:
- case <-req.Context().Done():
- return nil, req.Context().Err()
- }
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292))
- return &http.Response{StatusCode: 200, Header: h, Body: io.NopCloser(strings.NewReader("data: {}\n\n"))}, nil
+func setTicketTestProjection(account *Account, model string, expiry time.Time) {
+ projection := PluginAccountProjection{Identity: CodexTicketAccountIdentity(account), Scheduling: map[string]extensionv1.SchedulingConstraint{model: {Model: model, Effect: "allow", Until: &expiry, Reason: "ticket_ready"}}}
+ raw, _ := json.Marshal(projection)
+ var value map[string]any
+ _ = json.Unmarshal(raw, &value)
+ account.Extra = map[string]any{PluginAccountProjectionKey: map[string]any{codexRuntimePluginKey: value}}
}
-func TestRefreshOpenAICodexTickets_ConcurrentModelsPreserveAccountSnapshot(t *testing.T) {
+func TestCodexTicketFacadeUsesPluginWithoutLegacyFallback(t *testing.T) {
+ cfg := config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}
account := ticketTestAccount(41)
- account.Status = StatusActive
- account.Extra = map[string]any{"existing": true}
- repo := newCodexTicketMemoryStore(account)
- expiry := time.Now().Add(30 * time.Second)
- due := expiry.Add(-time.Minute)
- for _, model := range []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel} {
- repo.states[model] = &CodexTicketLifecycle{AccountID: account.ID, Model: model, Identity: CodexTicketAccountIdentity(account), Phase: "ready", ExpiresAt: &expiry, NextAt: &due}
- }
- upstream := &codexTicketConcurrentUpstream{ready: make(chan struct{})}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "socks5h://proxy.example.com:1080"}, upstream)
- svc.accountRepo = repo
- svc.refreshOpenAICodexTickets(context.Background())
- require.Equal(t, int64(2), upstream.started.Load())
- require.Equal(t, map[string]any{"existing": true}, account.Extra)
- require.Len(t, repo.updates, 2)
- for _, model := range []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel} {
- ticket := svc.lookupOpenAICodexTicket(account, model)
- require.NotNil(t, ticket)
- require.True(t, ticket.valid(time.Now(), 292))
+ account.Extra = map[string]any{openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{"state": fakeCodexTicketState(292), "expires_at": time.Now().Add(time.Hour)}}
+ var observed extensionv1.SchedulingRequest
+ manager := ticketTestManager(t, cfg, func(in extensionv1.Invocation) (extensionv1.Result, error) {
+ require.Equal(t, extensionv1.CapabilityRequest, in.Capability)
+ require.Equal(t, "inject", in.Operation)
+ require.NoError(t, json.Unmarshal(in.Payload, &observed))
+ raw, _ := json.Marshal(map[string]any{"headers": map[string]string{openAICodexTurnStateHeader: "plugin-owned-ticket"}})
+ return extensionv1.Result{Payload: raw}, nil
+ })
+ service := &OpenAIGatewayService{pluginManager: manager}
+ headers := http.Header{http.CanonicalHeaderKey(openAICodexTurnStateHeader): []string{"caller-ticket"}}
+ require.NoError(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", headers))
+ require.Equal(t, "plugin-owned-ticket", headers.Get(openAICodexTurnStateHeader))
+ require.Equal(t, account.ID, observed.Account.ID)
+ require.Equal(t, CodexTicketAccountIdentity(account), observed.Account.Identity)
+ require.Equal(t, "gpt-6-astra", observed.Model)
+ registry := manager.extensions.Load()
+ registry.runtimes = map[int64]*pluginRuntime{}
+ manager.extensions.Store(registry)
+ require.ErrorIs(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", http.Header{}), ErrOpenAICodexTicketUnavailable)
+ require.True(t, service.openAICodexTicketBlocksAccount(account, "gpt-6-astra"), "legacy Extra must not bypass an unavailable plugin")
+ for i := range registry.installations[1].Bindings {
+ registry.installations[1].Bindings[i].Enabled = false
}
- // Valid tickets do not produce another probe on the next cycle.
- svc.refreshOpenAICodexTickets(context.Background())
- require.Equal(t, int64(2), upstream.started.Load())
+ manager.extensions.Store(registry)
+ headers = http.Header{}
+ require.NoError(t, service.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", headers))
+ require.Empty(t, headers)
+ require.False(t, service.openAICodexTicketBlocksAccount(account, "gpt-6-astra"))
+}
+func TestCodexTicketPluginRPCFailureDoesNotFallBack(t *testing.T) {
+ manager := ticketTestManager(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, func(extensionv1.Invocation) (extensionv1.Result, error) {
+ return extensionv1.Result{}, errors.New("offline fixture")
+ })
+ service := &OpenAIGatewayService{pluginManager: manager}
+ require.ErrorIs(t, service.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", http.Header{}), ErrOpenAICodexTicketUnavailable)
}
-func TestOpenAICodexTicketStatuses_RespectRuntimeConfiguration(t *testing.T) {
+func TestOpenAICodexTicketStatusesUsesSanitizedProjection(t *testing.T) {
account := ticketTestAccount(41)
- require.Empty(t, OpenAICodexTicketStatuses(account, config.OpenAICodexTicketConfig{}, time.Now()))
- cfg := config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"custom-model"}}
- status := OpenAICodexTicketStatuses(account, cfg, time.Now())
- require.Len(t, status, 1)
- require.Equal(t, "custom-model", status[0].Model)
- require.False(t, status[0].Blocked)
- cfg.FailClosed = true
- require.True(t, OpenAICodexTicketStatuses(account, cfg, time.Now())[0].Blocked)
-}
-func TestProbeOpenAICodexTicket_RejectsInvalidState(t *testing.T) {
- for _, state := range []string{fakeCodexTicketState(312), strings.Repeat("X", 292), ""} {
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, state)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{{StatusCode: 200, Header: h, Body: io.NopCloser(strings.NewReader(""))}}}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream)
- account := ticketTestAccount(41)
- svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra")
- require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra"))
- }
+ now := time.Now()
+ setTicketTestProjection(account, "gpt-6-astra", now.Add(time.Hour))
+ cfg := config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra", "gpt-5.6-sol"}}
+ statuses := OpenAICodexTicketStatuses(account, cfg, now)
+ require.Len(t, statuses, 2)
+ require.True(t, statuses[0].Ready)
+ require.EqualValues(t, 3600, statuses[0].RemainingSeconds)
+ require.True(t, statuses[1].Blocked)
+ account.Credentials["chatgpt_account_id"] = "changed"
+ require.True(t, OpenAICodexTicketStatuses(account, cfg, now)[0].Blocked)
+ cfg.Enabled = false
+ require.Empty(t, OpenAICodexTicketStatuses(account, cfg, now))
}
-func TestOpenAICodexTicket_RequiresActualLengthAndExpiry(t *testing.T) {
- ticket := &openAICodexTicket{State: fakeCodexTicketState(312), Length: 292, ExpiresAt: time.Now().Add(time.Hour)}
- require.False(t, ticket.valid(time.Now(), 292))
- ticket.State = fakeCodexTicketState(292)
- ticket.ExpiresAt = time.Time{}
- require.False(t, ticket.valid(time.Now(), 292))
+func TestExtractOpenAICodexTicketModel(t *testing.T) {
+ require.Equal(t, "gpt-6-astra", extractOpenAICodexTicketModel([]byte(`{"model":" gpt-6-astra "}`)))
}
-
-// /responses/compact 的出站模型被 Forward 改写为 gateway.openai_compact_model
-// (默认非空),门票门控必须按该出站模型判定。否则对门控模型发 compact 请求时,
-// 所有无票账号都会被 fail_closed 误判为不可调度,而这些请求实际不需要票。
func TestOpenAICodexTicketGate_CompactRequestUsesForwardOutboundModel(t *testing.T) {
- svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
- OpenAICompactModel: "gpt-5.5",
- OpenAICodexTicket: config.OpenAICodexTicketConfig{
- Enabled: true,
- TargetLength: 292,
- TTLSeconds: 3600,
- FailClosed: true,
- Models: []string{"gpt-6-astra"},
- },
- }}}
- account := ticketTestAccount(41) // 无票
-
- // 出站模型预测必须与 Forward 的解析链一致。
+ svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, Models: []string{"gpt-6-astra"}}, nil)
+ svc.cfg.Gateway.OpenAICompactModel = "gpt-5.5"
+ account := ticketTestAccount(41)
require.Equal(t, "gpt-6-astra", svc.openAICodexTicketOutboundModel(account, "gpt-6-astra", false))
require.Equal(t, "gpt-5.5", svc.openAICodexTicketOutboundModel(account, "gpt-6-astra", true))
-
- // 普通请求:出站仍是门控模型且无票 → fail_closed 必须拦号。
require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-6-astra", false))
-
- // compact 请求:出站已被改写成非门控的 gpt-5.5 → 不得拦号。
require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-6-astra", true))
-
- // 回归锚点:按客户端原始模型判定(旧实现的口径)在 compact 下必然误拦。
- require.True(t, svc.openAICodexTicketBlocksAccount(account, canonicalOpenAIAccountSchedulingModel(account, "gpt-6-astra")))
}
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index f6e410824c90..ac39c2b96616 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -24,7 +24,6 @@ import (
"github.com/cespare/xxhash/v2"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
- "golang.org/x/sync/singleflight"
)
const (
@@ -128,14 +127,16 @@ var codexCLIOnlyDebugHeaderWhitelist = []string{
// OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
type OpenAICodexUsageSnapshot struct {
- PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
- PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
- PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
- SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
- SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
- SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
- PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
- UpdatedAt string `json:"updated_at,omitempty"`
+ PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
+ PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
+ PrimaryResetAt *time.Time `json:"primary_reset_at,omitempty"`
+ PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
+ SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
+ SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
+ SecondaryResetAt *time.Time `json:"secondary_reset_at,omitempty"`
+ SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
+ PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
}
// NormalizedCodexLimits contains normalized 5h/7d rate limit data
@@ -530,14 +531,6 @@ type OpenAIGatewayService struct {
// 剥离跨账号回带(openai_codex_turn_state.go)。
openaiCodexTurnStateOrigins sync.Map
openaiCodexTurnStateWrites atomic.Uint64
- // openaiCodexTickets: accountID\x00model → *openAICodexTicket,292 长度门票。
- openaiCodexTickets sync.Map
- openaiCodexTicketFlight singleflight.Group
- openaiCodexTicketLifecycleMu sync.Mutex
- openaiCodexTicketCancel context.CancelFunc
- openaiCodexTicketDone chan struct{}
- openaiCodexTicketStopped bool
- openaiCodexTicketSlots chan struct{}
}
// SetBusinessSystemPromptService attaches the global business prompt policy to
@@ -649,7 +642,6 @@ func NewOpenAIGatewayService(
openAITokenProvider.SetAccountRuntimeBlocker(svc)
}
svc.logOpenAIWSModeBootstrap()
- svc.StartOpenAICodexTicketHarvester()
return svc
}
diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go
index f25421c05c39..8d7634a10bd9 100644
--- a/backend/internal/service/openai_gateway_usage.go
+++ b/backend/internal/service/openai_gateway_usage.go
@@ -1245,7 +1245,17 @@ func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow
if snapshot.PrimaryOverSecondaryPercent != nil {
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
}
- updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339)
+ updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339Nano)
+ for _, window := range snapshot.Windows(baseTime) {
+ prefix := "codex_" + window.ID + "_"
+ updates[prefix+"observed_at"] = baseTime.Format(time.RFC3339Nano)
+ if window.ResetsAt != nil {
+ updates[prefix+"reset_at"] = window.ResetsAt.UTC().Format(time.RFC3339Nano)
+ } else {
+ // A missing countdown must not be rebound to a newer sampling time.
+ updates[prefix+"reset_after_seconds"] = nil
+ }
+ }
// 归一化到 5h/7d 规范字段
if normalized := snapshot.Normalize(); normalized != nil {
diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go
index ea672b340a93..167bc89355d7 100644
--- a/backend/internal/service/openai_quota_auto_reset.go
+++ b/backend/internal/service/openai_quota_auto_reset.go
@@ -557,47 +557,7 @@ func buildOpenAIAutoResetUsageUpdates(usage *OpenAIQuotaUsage, now time.Time) ma
if usage == nil || usage.RateLimit == nil {
return nil
}
- rateLimit := usage.RateLimit
- snapshot := &OpenAICodexUsageSnapshot{UpdatedAt: now.UTC().Format(time.RFC3339)}
- applyWindow := func(window *OpenAIRateLimitWindow, primary bool) {
- if window == nil {
- return
- }
- used := window.UsedPercent
- resetAfter := int(window.ResetAfterSeconds)
- windowMinutes := int(window.LimitWindowSeconds / 60)
- if primary {
- snapshot.PrimaryUsedPercent = &used
- snapshot.PrimaryResetAfterSeconds = &resetAfter
- snapshot.PrimaryWindowMinutes = &windowMinutes
- } else {
- snapshot.SecondaryUsedPercent = &used
- snapshot.SecondaryResetAfterSeconds = &resetAfter
- snapshot.SecondaryWindowMinutes = &windowMinutes
- }
- }
- applyWindow(rateLimit.PrimaryWindow, true)
- applyWindow(rateLimit.SecondaryWindow, false)
- updates := buildCodexUsageExtraUpdates(snapshot, now)
- // /wham supplies absolute reset timestamps. Preserve them rather than
- // recreating the period identity from a rounded countdown plus network delay.
- for _, entry := range []struct {
- name string
- window *OpenAIRateLimitWindow
- }{{"primary", rateLimit.PrimaryWindow}, {"secondary", rateLimit.SecondaryWindow}} {
- if entry.window == nil || entry.window.ResetAt <= 0 {
- continue
- }
- reset := time.Unix(entry.window.ResetAt, 0).UTC().Format(time.RFC3339)
- updates["codex_"+entry.name+"_reset_at"] = reset
- switch entry.window.LimitWindowSeconds {
- case 18000:
- updates["codex_5h_reset_at"] = reset
- case 604800:
- updates["codex_7d_reset_at"] = reset
- }
- }
- return updates
+ return buildCodexRateLimitExtraUpdates(usage.RateLimit, now)
}
func (s *OpenAIQuotaAutoResetService) persistFreshUsage(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage, now time.Time) error {
diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go
index 552b16228818..49cca19cee33 100644
--- a/backend/internal/service/openai_quota_service.go
+++ b/backend/internal/service/openai_quota_service.go
@@ -121,8 +121,7 @@ type OpenAIQuotaService struct {
privacyClientFactory PrivacyClientFactory
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
- concurrency *ConcurrencyService
- usagePool *UsageRecordWorkerPool
+ activity *QuotaActivityService
}
// NewOpenAIQuotaService constructs a quota service. token provider is required —
@@ -608,73 +607,12 @@ func buildCodexSparkWindowExtraUpdates(usage *OpenAIQuotaUsage, now time.Time) m
if usage == nil {
return nil
}
- var spark *OpenAIRateLimit
- for i := range usage.AdditionalRateLimits {
- a := usage.AdditionalRateLimits[i]
- if a.MeteredFeature == "codex_bengalfox" {
- spark = a.RateLimit
- break
+ for _, limit := range usage.AdditionalRateLimits {
+ if limit.MeteredFeature == "codex_bengalfox" {
+ return buildCodexRateLimitExtraUpdates(limit.RateLimit, now)
}
}
- if spark == nil {
- return nil
- }
-
- // Reuse OpenAICodexUsageSnapshot / Normalize to map primary/secondary windows
- // to canonical 5h/7d buckets (same logic as probeOpenAICodexSnapshot).
- snap := &OpenAICodexUsageSnapshot{}
- if w := spark.PrimaryWindow; w != nil {
- p := w.UsedPercent
- snap.PrimaryUsedPercent = &p
- ra := int(w.ResetAfterSeconds)
- snap.PrimaryResetAfterSeconds = &ra
- wm := int(w.LimitWindowSeconds / 60)
- snap.PrimaryWindowMinutes = &wm
- }
- if w := spark.SecondaryWindow; w != nil {
- p := w.UsedPercent
- snap.SecondaryUsedPercent = &p
- ra := int(w.ResetAfterSeconds)
- snap.SecondaryResetAfterSeconds = &ra
- wm := int(w.LimitWindowSeconds / 60)
- snap.SecondaryWindowMinutes = &wm
- }
-
- normalized := snap.Normalize()
- if normalized == nil {
- return nil
- }
-
- updates := make(map[string]any)
- if normalized.Used5hPercent != nil {
- updates["codex_5h_used_percent"] = *normalized.Used5hPercent
- }
- if normalized.Reset5hSeconds != nil {
- updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds
- }
- if normalized.Window5hMinutes != nil {
- updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes
- }
- if normalized.Used7dPercent != nil {
- updates["codex_7d_used_percent"] = *normalized.Used7dPercent
- }
- if normalized.Reset7dSeconds != nil {
- updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds
- }
- if normalized.Window7dMinutes != nil {
- updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
- }
- if r := codexResetAtRFC3339(now, normalized.Reset5hSeconds); r != nil {
- updates["codex_5h_reset_at"] = *r
- }
- if r := codexResetAtRFC3339(now, normalized.Reset7dSeconds); r != nil {
- updates["codex_7d_reset_at"] = *r
- }
- if len(updates) == 0 {
- return nil
- }
- updates["codex_usage_updated_at"] = now.Format(time.RFC3339)
- return updates
+ return nil
}
// mapUpstreamStatus collapses upstream HTTP statuses into a stable set we
diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go
index ba8478e20d01..546181c2d327 100644
--- a/backend/internal/service/ops_service.go
+++ b/backend/internal/service/ops_service.go
@@ -43,9 +43,10 @@ type OpsRuntimeSettingsRefreshHealth struct {
// OpsService provides ingestion and query APIs for the Ops monitoring module.
type OpsService struct {
- opsRepo OpsRepository
- settingRepo SettingRepository
- cfg *config.Config
+ quotaActivity *QuotaActivityService
+ opsRepo OpsRepository
+ settingRepo SettingRepository
+ cfg *config.Config
accountRepo AccountRepository
userRepo UserRepository
diff --git a/backend/internal/service/plugin_bundle.go b/backend/internal/service/plugin_bundle.go
new file mode 100644
index 000000000000..405320539a5a
--- /dev/null
+++ b/backend/internal/service/plugin_bundle.go
@@ -0,0 +1,174 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+type PluginBundle struct {
+ SchemaVersion int `json:"schema_version"`
+ HostVersion string `json:"host_version"`
+ PublisherKeyID string `json:"publisher_key_id"`
+ PublisherPublicKey string `json:"publisher_public_key"`
+ Plugins []BundledPlugin `json:"plugins"`
+}
+
+type BundledPlugin struct {
+ DefaultEnabled bool `json:"default_enabled,omitempty"`
+ ID string `json:"id"`
+ Version string `json:"version"`
+ File string `json:"file"`
+ SHA256 string `json:"sha256"`
+ Migration string `json:"migration"`
+}
+
+type PluginBundleSeed struct {
+ Enabled bool
+ Config json.RawMessage
+}
+
+type PluginBundleRepository interface {
+ BundleApplied(context.Context, string, string) (bool, error)
+ PrepareBundledPlugin(context.Context, *PluginInstallation, string, string, bool, string) (*PluginInstallation, error)
+ CompleteBundledPlugin(context.Context, int64, string) error
+ LegacyBundleSeed(context.Context, string, string, PluginBundleSeed) (PluginBundleSeed, error)
+ ImportLegacyPluginState(context.Context, *PluginInstallation, string, json.RawMessage) error
+}
+
+// Bundles are read from the immutable application image. They never cause a
+// remote download, and a completed bundle is not reapplied on process restart.
+func (m *PluginManager) bootstrapBundle(ctx context.Context) error {
+ path := m.bundlePath
+ if path == "" {
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ path = filepath.Join(filepath.Dir(executable), "bundled-plugins", "lock.json")
+ }
+ info, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) && m.bundlePath == "" {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() || info.Size() > 1<<20 {
+ return errors.New("invalid plugin bundle manifest")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var bundle PluginBundle
+ if json.Unmarshal(raw, &bundle) != nil || bundle.SchemaVersion != 1 || len(bundle.Plugins) == 0 || bundle.PublisherKeyID == "" {
+ return errors.New("invalid plugin bundle")
+ }
+ public, err := base64.StdEncoding.DecodeString(bundle.PublisherPublicKey)
+ if err != nil || len(public) != 32 {
+ return errors.New("invalid bundled publisher")
+ }
+ if normalizeSemver(bundle.HostVersion) != normalizeSemver(m.hostInfo.Version) {
+ return errors.New("plugin bundle does not match host version")
+ }
+ store, ok := m.repo.(PluginBundleRepository)
+ if !ok {
+ return errors.New("plugin bundle repository unavailable")
+ }
+ if m.installer.bundledPublishers == nil {
+ m.installer.bundledPublishers = make(map[string]string)
+ }
+ m.installer.bundledPublishers[bundle.PublisherKeyID] = bundle.PublisherPublicKey
+ digest := sha256.Sum256(raw)
+ bundleID := hex.EncodeToString(digest[:])
+ seen := make(map[string]bool)
+ for _, entry := range bundle.Plugins {
+ if seen[entry.ID] || !strings.HasPrefix(entry.ID, "codexrip.") || !safePluginRelativePath(entry.File) || filepath.Base(entry.File) != entry.File {
+ return errors.New("invalid first-party plugin bundle entry")
+ }
+ seen[entry.ID] = true
+ applied, err := store.BundleApplied(ctx, entry.ID, bundleID)
+ if err != nil {
+ return err
+ }
+ if applied {
+ continue
+ }
+ packagePath := filepath.Join(filepath.Dir(path), entry.File)
+ info, err := os.Lstat(packagePath)
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return errors.New("bundled plugin must be a regular file")
+ }
+ content, err := os.ReadFile(packagePath)
+ if err != nil {
+ return err
+ }
+ sum := sha256.Sum256(content)
+ if hex.EncodeToString(sum[:]) != entry.SHA256 {
+ return errors.New("bundled plugin digest mismatch")
+ }
+ file, err := os.Open(packagePath)
+ if err != nil {
+ return err
+ }
+ installation, installErr := m.installer.Install(ctx, file, nil)
+ _ = file.Close()
+ if installErr != nil {
+ return installErr
+ }
+ if installation.PluginKey != entry.ID || installation.Version != entry.Version {
+ return errors.New("bundled plugin identity mismatch")
+ }
+ seed := PluginBundleSeed{Enabled: entry.DefaultEnabled, Config: json.RawMessage(`{}`)}
+ if entry.Migration == "codex-tickets-v1" {
+ config := m.cfg.Gateway.OpenAICodexTicket
+ models := config.Models
+ if len(models) == 0 {
+ models = []string{"gpt-6-astra", "gpt-5.6-sol"}
+ }
+ seed.Enabled = true
+ seed.Config, _ = json.Marshal(map[string]any{"enabled": config.Enabled, "fail_closed": config.FailClosed, "proxy_url": config.HarvestProxyURL, "models": models})
+ }
+ seed, err = store.LegacyBundleSeed(ctx, entry.ID, entry.Migration, seed)
+ if err != nil {
+ return fmt.Errorf("read plugin migration seed: %w", err)
+ }
+ cipher, err := m.encryptor.Encrypt(string(seed.Config))
+ if err != nil {
+ return errors.New("could not encrypt migrated plugin configuration")
+ }
+ uncommitted := installation
+ installation, err = store.PrepareBundledPlugin(ctx, installation, bundleID, entry.Migration, seed.Enabled, cipher)
+ if err != nil {
+ return err
+ }
+ if installation == nil {
+ if err := m.cleanupInstallationFiles(uncommitted); err != nil {
+ return err
+ }
+ continue
+ }
+ migrationConfig, err := m.decryptConfig(installation)
+ if err != nil {
+ return err
+ }
+ if err := store.ImportLegacyPluginState(ctx, installation, entry.Migration, migrationConfig); err != nil {
+ return err
+ }
+ if err := store.CompleteBundledPlugin(ctx, installation.ID, bundleID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/backend/internal/service/plugin_catalog_test.go b/backend/internal/service/plugin_catalog_test.go
new file mode 100644
index 000000000000..e54b32496cd5
--- /dev/null
+++ b/backend/internal/service/plugin_catalog_test.go
@@ -0,0 +1,47 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPluginCatalogCacheHonorsActivationHealthAndConfiguration(t *testing.T) {
+ calls := 0
+ capacity := int64(1050000)
+ manager := ticketTestManager(t, config.OpenAICodexTicketConfig{}, func(in extensionv1.Invocation) (extensionv1.Result, error) {
+ calls++
+ require.Equal(t, extensionv1.CapabilityCatalog, in.Capability)
+ raw, _ := json.Marshal(extensionv1.CatalogMatch{Matched: true, Entry: &OfficialModelContextCapacity{ModelContextCapacity: ModelContextCapacity{ContextWindow: capacity}, ModelID: "test-model"}})
+ return extensionv1.Result{Payload: raw}, nil
+ })
+ registry := manager.extensions.Load()
+ registry.installations[1].Bindings = []PluginBinding{{Capability: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*", Enabled: true}}
+ query := extensionv1.CatalogQuery{Candidates: []string{"test-model"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey}
+ first, err := manager.ResolveCatalog(context.Background(), query)
+ require.NoError(t, err)
+ first.Entry.ContextWindow = 1
+ second, err := manager.ResolveCatalog(context.Background(), query)
+ require.NoError(t, err)
+ require.EqualValues(t, 1050000, second.Entry.ContextWindow)
+ require.Equal(t, 1, calls)
+ registry.runtimes[1].draining.Store(true)
+ _, err = manager.ResolveCatalog(context.Background(), query)
+ require.Error(t, err, "a cached reference cannot hide a failed plugin")
+ registry.runtimes[1].draining.Store(false)
+ registry.installations[1].Bindings[0].Enabled = false
+ disabled, err := manager.ResolveCatalog(context.Background(), query)
+ require.NoError(t, err)
+ require.Nil(t, disabled.Entry)
+ registry.installations[1].Bindings[0].Enabled = true
+ registry.runtimes[1].configRevision.Add(1)
+ capacity = 128000
+ changed, err := manager.ResolveCatalog(context.Background(), query)
+ require.NoError(t, err)
+ require.EqualValues(t, 128000, changed.Entry.ContextWindow)
+ require.Equal(t, 2, calls)
+}
diff --git a/backend/internal/service/plugin_extension_package_test.go b/backend/internal/service/plugin_extension_package_test.go
new file mode 100644
index 000000000000..8ee29a82692d
--- /dev/null
+++ b/backend/internal/service/plugin_extension_package_test.go
@@ -0,0 +1,80 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFirstPartyExtensionSignedPackageContainsIndependentRuntimeAndUI(t *testing.T) {
+ path := os.Getenv("SUB2API_EXTENSION_TEST_PACKAGE")
+ public := os.Getenv("SUB2API_EXTENSION_TEST_PUBLIC_KEY")
+ keyID := os.Getenv("SUB2API_EXTENSION_TEST_KEY_ID")
+ if path == "" || public == "" {
+ t.Skip("provide the locally signed first-party extension package and public test key")
+ }
+ if keyID == "" {
+ keyID = "codexrip-plugins-test"
+ }
+ file, err := os.Open(path)
+ require.NoError(t, err)
+ defer file.Close()
+ cfg := testPluginConfig(t.TempDir(), false)
+ cfg.Plugins.TrustedPublishers = map[string]string{keyID: public}
+ installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.7", BuildType: "release"})
+ installed, err := installer.Install(context.Background(), file, nil)
+ require.NoError(t, err)
+ require.Equal(t, "codexrip.codex-runtime", installed.PluginKey)
+ require.Equal(t, PluginSignatureTrusted, installed.SignatureStatus)
+ require.Equal(t, 1, installed.Manifest.Requires.ExtensionAPI)
+ require.FileExists(t, installed.BinaryPath)
+ for _, name := range []string{"index.html", "assets/app.js", "assets/styles.css", "assets/bridge.js"} {
+ require.FileExists(t, filepath.Join(installed.InstallPath, "ui", name))
+ }
+ for name := range installed.Manifest.Files {
+ require.NotContains(t, name, "publisher.key")
+ require.NotContains(t, name, "internal/service")
+ }
+}
+
+func TestFirstPartyBundleContainsMatchingSignedDomainPackages(t *testing.T) {
+ path := os.Getenv("SUB2API_EXTENSION_TEST_BUNDLE")
+ if path == "" {
+ t.Skip("provide the freshly built first-party bundle lock")
+ }
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err)
+ var bundle PluginBundle
+ require.NoError(t, json.Unmarshal(raw, &bundle))
+ require.Len(t, bundle.Plugins, 2)
+ cfg := testPluginConfig(t.TempDir(), false)
+ cfg.Plugins.TrustedPublishers = map[string]string{bundle.PublisherKeyID: bundle.PublisherPublicKey}
+ installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: bundle.HostVersion, BuildType: "release"})
+ for _, entry := range bundle.Plugins {
+ packagePath := filepath.Join(filepath.Dir(path), entry.File)
+ content, err := os.ReadFile(packagePath)
+ require.NoError(t, err)
+ sum := sha256.Sum256(content)
+ require.Equal(t, entry.SHA256, hex.EncodeToString(sum[:]))
+ file, err := os.Open(packagePath)
+ require.NoError(t, err)
+ installed, err := installer.Install(context.Background(), file, nil)
+ _ = file.Close()
+ require.NoError(t, err)
+ require.Equal(t, entry.ID, installed.PluginKey)
+ require.Equal(t, PluginSignatureTrusted, installed.SignatureStatus)
+ for _, asset := range []string{"index.html", "assets/app.js", "assets/bridge.js", "assets/styles.css"} {
+ require.FileExists(t, filepath.Join(installed.InstallPath, "ui", asset))
+ }
+ for name := range installed.Manifest.Files {
+ require.NotContains(t, name, "publisher.key")
+ require.NotContains(t, name, "internal/service")
+ }
+ }
+}
diff --git a/backend/internal/service/plugin_extension_runtime_test.go b/backend/internal/service/plugin_extension_runtime_test.go
new file mode 100644
index 000000000000..fbcbbdc99e2b
--- /dev/null
+++ b/backend/internal/service/plugin_extension_runtime_test.go
@@ -0,0 +1,86 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+ "github.com/stretchr/testify/require"
+)
+
+type extensionReadOnlyFixture struct{ PluginExtensionStateStore }
+
+func (*extensionReadOnlyFixture) ReadExtensionState(context.Context, string, extensionv1.StateRequest) (extensionv1.StateResult, error) {
+ return extensionv1.StateResult{}, nil
+}
+func (*extensionReadOnlyFixture) DueExtensionStates(context.Context, string, extensionv1.DueStateRequest) ([]extensionv1.DueState, error) {
+ return nil, nil
+}
+
+func TestExtensionRuntimeUsesOfficialProcessAndHostBroker(t *testing.T) {
+ binary := os.Getenv("SUB2API_EXTENSION_TEST_BINARY")
+ if binary == "" {
+ t.Skip("set SUB2API_EXTENSION_TEST_BINARY to the locally built codex runtime")
+ }
+ data, err := os.ReadFile(binary)
+ require.NoError(t, err)
+ digest := sha256.Sum256(data)
+ installation := &PluginInstallation{ID: 7, PluginKey: "codexrip.codex-runtime", Version: "0.2.7", BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}}}
+ installation.Manifest.Capabilities = []PluginCapability{{ID: extensionv1.CapabilityScheduling, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}
+ host := newPluginHostServiceServer(installation.PluginKey, nil, nil)
+ host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}}
+ socketDir := filepath.Join(t.TempDir(), "runtime")
+ require.NoError(t, os.MkdirAll(socketDir, 0700))
+ runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host)
+ require.NoError(t, err)
+ t.Cleanup(runtime.kill)
+ require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{"enabled":true,"fail_closed":true}`)))
+ require.NotNil(t, runtime.scheduling.Load())
+ require.Len(t, *runtime.scheduling.Load(), 1)
+ health, err := runtime.status(context.Background())
+ require.NoError(t, err)
+ require.True(t, health.Healthy)
+ raw, err := json.Marshal(extensionv1.SchedulingRequest{Account: extensionv1.Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Identity: "test-principal"}, Model: "gpt-6-astra", Now: time.Now().UTC()})
+ require.NoError(t, err)
+ result, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityScheduling, Operation: "admit", Payload: raw})
+ require.NoError(t, err)
+ var decision extensionv1.SchedulingDecision
+ require.NoError(t, json.Unmarshal(result.Payload, &decision))
+ require.False(t, decision.Allowed)
+ require.Equal(t, "ticket_missing", decision.Reason)
+ // Only a read-only missing-state fixture is exposed. No token resolver or
+ // HTTP upstream exists in this test, so it cannot issue a real model request.
+}
+
+func TestCatalogExtensionRuntimeUsesIndependentProcess(t *testing.T) {
+ binary := os.Getenv("SUB2API_MODEL_POLICY_TEST_BINARY")
+ if binary == "" {
+ t.Skip("set SUB2API_MODEL_POLICY_TEST_BINARY to the independent model-policy program")
+ }
+ data, err := os.ReadFile(binary)
+ require.NoError(t, err)
+ digest := sha256.Sum256(data)
+ installation := &PluginInstallation{ID: 8, PluginKey: "codexrip.model-policy", Version: "0.2.7", BinaryPath: binary, BinarySHA256: hex.EncodeToString(digest[:]), Manifest: PluginManifest{Requires: PluginRequirements{ExtensionAPI: 1}, Capabilities: []PluginCapability{{ID: extensionv1.CapabilityCatalog, Platform: "*", AccountType: "*"}}}}
+ host := newPluginHostServiceServer(installation.PluginKey, nil, nil)
+ host.extension = &pluginExtensionHost{key: installation.PluginKey, state: &extensionReadOnlyFixture{}}
+ socketDir := filepath.Join(t.TempDir(), "runtime")
+ require.NoError(t, os.MkdirAll(socketDir, 0700))
+ runtime, err := startPluginRuntime(context.Background(), installation, 15*time.Second, socketDir, host)
+ require.NoError(t, err)
+ t.Cleanup(runtime.kill)
+ require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{}`)))
+ raw, _ := json.Marshal(extensionv1.CatalogQuery{Candidates: []string{"gpt-6-astra"}, Platform: PlatformOpenAI, AccountType: AccountTypeAPIKey, Scheme: "https", Host: "api.openai.com"})
+ result, err := runtime.extension.Invoke(context.Background(), extensionv1.Invocation{Capability: extensionv1.CapabilityCatalog, Operation: "resolve", Payload: raw})
+ require.NoError(t, err)
+ var match extensionv1.CatalogMatch
+ require.NoError(t, json.Unmarshal(result.Payload, &match))
+ require.NotNil(t, match.Entry)
+ require.EqualValues(t, 1050000, match.Entry.ContextWindow)
+ require.Equal(t, "gpt-6-astra", match.Entry.ModelID)
+}
diff --git a/backend/internal/service/plugin_extension_state.go b/backend/internal/service/plugin_extension_state.go
index 61dfb2ff380e..ffe3b8c17762 100644
--- a/backend/internal/service/plugin_extension_state.go
+++ b/backend/internal/service/plugin_extension_state.go
@@ -26,6 +26,8 @@ type PluginExtensionAccountDirectory interface {
// The owning plugin comes from the authenticated broker connection. No payload
// field can select another plugin namespace or a database table/SQL statement.
type pluginExtensionHost struct {
+ active func() bool
+ activity *QuotaActivityService
key string
state PluginExtensionStateStore
directory PluginExtensionAccountDirectory
@@ -33,6 +35,9 @@ type pluginExtensionHost struct {
}
func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvocation) (extensionv1.Result, error) {
+ if (in.Operation == extensionv1.HostResolveIdentity || in.Operation == extensionv1.HostLeaseAcquire || in.Operation == extensionv1.HostStateDue || in.Operation == extensionv1.HostJobSubmit) && h.active != nil && !h.active() {
+ return extensionv1.Result{}, status.Error(codes.Unavailable, "plugin capability is no longer active")
+ }
if h.state == nil {
return extensionv1.Result{}, status.Error(codes.Unavailable, "extension state unavailable")
}
@@ -45,7 +50,7 @@ func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvoc
return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid due-state query")
}
value, err = h.state.DueExtensionStates(ctx, h.key, req)
- case extensionv1.HostAccountRead, extensionv1.HostAccountList, extensionv1.HostResolveIdentity:
+ case extensionv1.HostAccountRead, extensionv1.HostAccountList, extensionv1.HostResolveIdentity, extensionv1.HostFinishObservation:
if h.directory == nil {
return extensionv1.Result{}, status.Error(codes.Unavailable, "account directory unavailable")
}
@@ -78,8 +83,16 @@ func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvoc
}
if in.Operation == extensionv1.HostAccountRead {
value = account
+ } else if in.Operation == extensionv1.HostFinishObservation {
+ err = h.activity.FinishUnbilled(ctx, req.AccountID, h.key, req.ObservationID)
+ value = map[string]bool{"finished": err == nil}
} else {
- value, err = h.directory.ResolveExtensionIdentity(ctx, req)
+ identity, resolveErr := h.directory.ResolveExtensionIdentity(ctx, req)
+ err = resolveErr
+ if err == nil && identity != nil && req.PrepareCredentials {
+ identity.ObservationID, err = h.activity.BeginUnbilled(ctx, req.AccountID, h.key)
+ }
+ value = identity
}
}
case extensionv1.HostStateRead, extensionv1.HostStateCompareSwap:
@@ -93,6 +106,28 @@ func (h *pluginExtensionHost) Call(ctx context.Context, in extensionv1.HostInvoc
if !json.Valid(req.Value) || len(req.Value) > pluginKVMaxValueBytes {
return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid state value")
}
+ if projection := req.Projection; projection != nil {
+ if h.directory == nil || projection.AccountID <= 0 || projection.Identity == "" || len(projection.Scheduling) > 64 {
+ return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid account projection")
+ }
+ account, lookupErr := h.directory.ReadExtensionAccount(ctx, projection.AccountID)
+ if lookupErr != nil || account == nil || account.Identity != projection.Identity || !h.permitsAccount(account.Platform, account.Type, false) {
+ return extensionv1.Result{}, status.Error(codes.PermissionDenied, "account projection outside credential scope")
+ }
+ for _, constraint := range projection.Scheduling {
+ if constraint.Model == "" || len(constraint.Model) > 256 || (constraint.Effect != "allow" && constraint.Effect != "deny") || len(constraint.Reason) > 80 {
+ return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid scheduling projection")
+ }
+ }
+ if len(projection.Observations) > 64 {
+ return extensionv1.Result{}, status.Error(codes.InvalidArgument, "too many observations")
+ }
+ for _, observation := range projection.Observations {
+ if observation.Key == "" || len(observation.Key) > 256 || len(observation.Kind) > 64 || len(observation.State) > 64 || len(observation.Code) > 80 {
+ return extensionv1.Result{}, status.Error(codes.InvalidArgument, "invalid account observation")
+ }
+ }
+ }
value, err = h.state.CompareSwapExtensionState(ctx, h.key, req)
}
case extensionv1.HostLeaseAcquire, extensionv1.HostLeaseRelease:
diff --git a/backend/internal/service/plugin_extensions.go b/backend/internal/service/plugin_extensions.go
index fe0f4665518a..e45c5364d09c 100644
--- a/backend/internal/service/plugin_extensions.go
+++ b/backend/internal/service/plugin_extensions.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"sort"
+ "strings"
extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
)
@@ -46,9 +47,14 @@ func (m *PluginManager) validateAdminExtension(ctx context.Context, id, accountI
return "", "", errors.New("plugin disabled")
}
declared := false
+ var accountFilter *extensionv1.AccountFilter
for _, contribution := range installation.Manifest.Contributions {
if contribution.Action == operation && contribution.Permission == "admin" {
+ if strings.HasPrefix(contribution.Slot, "account.") && accountID <= 0 {
+ return "", "", errors.New("account context required")
+ }
declared = true
+ accountFilter = contribution.AccountFilter
break
}
}
@@ -56,6 +62,14 @@ func (m *PluginManager) validateAdminExtension(ctx context.Context, id, accountI
return "", "", errors.New("undeclared plugin admin operation")
}
platform, accountType := "*", "*"
+ if accountID == 0 {
+ for _, binding := range installation.Bindings {
+ if binding.Enabled && binding.Capability == extensionv1.CapabilityAdmin {
+ platform, accountType = binding.Platform, binding.AccountType
+ break
+ }
+ }
+ }
if accountID > 0 {
directory, ok := m.accountDirectory.(PluginExtensionAccountDirectory)
if !ok {
@@ -66,6 +80,9 @@ func (m *PluginManager) validateAdminExtension(ctx context.Context, id, accountI
return "", "", errors.New("account unavailable")
}
platform, accountType = account.Platform, account.Type
+ if !extensionv1.AccountMatchesFilter(*account, accountFilter) {
+ return "", "", errors.New("plugin action is not available for this account")
+ }
}
if !pluginHasCapability(installation, extensionv1.CapabilityAdmin, platform, accountType) {
return "", "", errors.New("plugin admin capability outside scope")
@@ -83,6 +100,93 @@ type pluginExtensionRegistry struct {
unavailable string
}
+func validatePluginRegistry(installations []*PluginInstallation) error {
+ exclusive := make(map[string]int64)
+ byID := make(map[int64]*PluginInstallation)
+ for _, installation := range installations {
+ if !hasEnabledPluginBinding(installation.Bindings) {
+ continue
+ }
+ byID[installation.ID] = installation
+ for _, binding := range installation.Bindings {
+ if !binding.Enabled || (binding.Capability != PluginCapabilityOpenAIOAuthOutbound && binding.Capability != extensionv1.CapabilityProvider) {
+ continue
+ }
+ key := binding.Capability + "/" + binding.Platform + "/" + binding.AccountType
+ if owner, ok := exclusive[key]; ok && owner != installation.ID {
+ return fmt.Errorf("exclusive plugin capability already enabled: %s", key)
+ }
+ exclusive[key] = installation.ID
+ }
+ if err := pluginDependenciesReady(installation, installations); err != nil {
+ return err
+ }
+ }
+ visiting, done := make(map[int64]bool), make(map[int64]bool)
+ var visit func(int64) error
+ visit = func(id int64) error {
+ if visiting[id] {
+ return errors.New("plugin dependency cycle")
+ }
+ if done[id] {
+ return nil
+ }
+ visiting[id] = true
+ for _, dependency := range byID[id].Manifest.Dependencies {
+ for target, candidate := range byID {
+ if target == id {
+ continue
+ }
+ for _, binding := range candidate.Bindings {
+ if binding.Enabled && binding.Capability == dependency.Capability && (dependency.Platform == "" || pluginScopeMatches(binding.Platform, dependency.Platform)) && (dependency.AccountType == "" || pluginScopeMatches(binding.AccountType, dependency.AccountType)) {
+ if err := visit(target); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ visiting[id] = false
+ done[id] = true
+ return nil
+ }
+ for id := range byID {
+ if err := visit(id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func pluginDependenciesHealthy(installation *PluginInstallation, registry *pluginExtensionRegistry, seen map[int64]bool) bool {
+ if installation == nil || seen[installation.ID] {
+ return false
+ }
+ seen[installation.ID] = true
+ defer delete(seen, installation.ID)
+ for _, dependency := range installation.Manifest.Dependencies {
+ ready := false
+ for id, candidate := range registry.installations {
+ if id == installation.ID {
+ continue
+ }
+ runtime := registry.runtimes[id]
+ if runtime == nil || runtime.draining.Load() || runtime.client.Exited() {
+ continue
+ }
+ for _, binding := range candidate.Bindings {
+ if binding.Enabled && binding.Capability == dependency.Capability && (dependency.Platform == "" || pluginScopeMatches(binding.Platform, dependency.Platform)) && (dependency.AccountType == "" || pluginScopeMatches(binding.AccountType, dependency.AccountType)) && pluginDependenciesHealthy(candidate, registry, seen) {
+ ready = true
+ }
+ }
+ }
+ if !ready {
+ return false
+ }
+ }
+ return true
+}
+
type PluginContribution struct {
extensionv1.Contribution
PluginID int64 `json:"plugin_id"`
@@ -147,7 +251,7 @@ func (m *PluginManager) Contributions() []PluginContribution {
continue
}
runtime := registry.runtimes[id]
- available := registry.unavailable == "" && runtime != nil && !runtime.draining.Load() && !runtime.client.Exited()
+ available := registry.unavailable == "" && runtime != nil && !runtime.draining.Load() && !runtime.client.Exited() && pluginDependenciesHealthy(installation, registry, map[int64]bool{})
for _, contribution := range installation.Manifest.Contributions {
item := PluginContribution{Contribution: contribution, PluginID: id, Available: available}
if !available {
@@ -177,6 +281,9 @@ func (m *PluginManager) InvokeExtension(ctx context.Context, id int64, platform,
if !pluginHasCapability(installation, in.Capability, platform, accountType) {
return extensionv1.Result{}, errors.New("plugin capability is disabled or outside its scope")
}
+ if !pluginDependenciesHealthy(installation, registry, map[int64]bool{}) {
+ return extensionv1.Result{}, errors.New("plugin dependency unavailable")
+ }
runtime := registry.runtimes[id]
if runtime == nil || runtime.extension == nil || runtime.client.Exited() || !runtime.beginRequest() {
return extensionv1.Result{}, errors.New("enabled plugin is unavailable")
diff --git a/backend/internal/service/plugin_jobs.go b/backend/internal/service/plugin_jobs.go
index 258dc1fed80e..d31a37accc76 100644
--- a/backend/internal/service/plugin_jobs.go
+++ b/backend/internal/service/plugin_jobs.go
@@ -8,9 +8,9 @@ import (
)
type PluginJobPayload struct {
- PluginID int64 `json:"plugin_id"`
- Operation string `json:"operation"`
- Items map[int64]json.RawMessage `json:"items"`
+ PluginID int64 `json:"plugin_id"`
+ Operation string `json:"operation"`
+ Items map[string]json.RawMessage `json:"items"`
}
// PluginJobExecutor preserves the host's durable job, cancellation, retry and
@@ -48,28 +48,35 @@ func (e *PluginJobExecutor) ExecuteAccountJob(ctx context.Context, job *AccountJ
}
id := *item.TargetAccountID
var operation map[string]json.RawMessage
- if json.Unmarshal(request.Items[id], &operation) != nil || operation == nil {
+ if json.Unmarshal(request.Items[item.Action], &operation) != nil || operation == nil {
return []AccountJobExecutionResult{result}, nil
}
- operation["operation_id"], _ = json.Marshal(fmt.Sprintf("job-%d-item-%d", job.ID, item.ID))
+ operation["operation_id"], _ = json.Marshal(fmt.Sprintf("job-%d", job.ID))
raw, err := json.Marshal(operation)
if err != nil {
return nil, err
}
out, err := e.manager.InvokeAdminExtension(ctx, request.PluginID, id, request.Operation, raw)
- if ctx.Err() != nil {
- result.Status = AccountJobItemStatusCanceled
- result.ErrorCode = ""
- result.ErrorMessage = ""
- return []AccountJobExecutionResult{result}, nil
- }
if err != nil {
+ if ctx.Err() != nil {
+ result.Status = AccountJobItemStatusCanceled
+ result.ErrorCode = ""
+ result.ErrorMessage = ""
+ }
return []AccountJobExecutionResult{result}, nil
}
if ValidateAccountJobMetadata(out.Payload) != nil {
return []AccountJobExecutionResult{result}, nil
}
- result.Metadata = out.Payload
+ metadata := map[string]any{}
+ _ = json.Unmarshal(item.Metadata, &metadata)
+ var details map[string]any
+ if json.Unmarshal(out.Payload, &details) == nil {
+ for key, value := range details {
+ metadata[key] = value
+ }
+ }
+ result.Metadata, _ = json.Marshal(metadata)
var outcome struct {
Success bool `json:"success"`
}
@@ -77,6 +84,10 @@ func (e *PluginJobExecutor) ExecuteAccountJob(ctx context.Context, job *AccountJ
result.Status = AccountJobItemStatusSucceeded
result.ErrorCode = ""
result.ErrorMessage = ""
+ } else if ctx.Err() != nil {
+ result.Status = AccountJobItemStatusCanceled
+ result.ErrorCode = ""
+ result.ErrorMessage = ""
}
return []AccountJobExecutionResult{result}, nil
}
diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go
index 3a6e7dc684da..57a32b5d2371 100644
--- a/backend/internal/service/plugin_manager.go
+++ b/backend/internal/service/plugin_manager.go
@@ -41,11 +41,13 @@ type pluginRoute struct {
// PluginManager 管理插件安装、配置、进程生命周期和 OpenAI OAuth 能力绑定。
type PluginManager struct {
- repo PluginRepository
- encryptor SecretEncryptor
- cfg *config.Config
- hostInfo PluginHostInfo
- installer *PluginPackageInstaller
+ bundlePath string
+ quotaActivity *QuotaActivityService
+ repo PluginRepository
+ encryptor SecretEncryptor
+ cfg *config.Config
+ hostInfo PluginHostInfo
+ installer *PluginPackageInstaller
// kvStore 为运行中的插件提供通用宿主键值存储;为 nil 时不向插件暴露宿主服务。
kvStore PluginKVStore
// accountDirectory 为声明了对应能力的插件提供账号目录与出站身份解析(敏感能力);
@@ -64,7 +66,7 @@ type PluginManager struct {
}
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore) *PluginManager {
- return &PluginManager{
+ manager := &PluginManager{
repo: repo,
encryptor: encryptor,
cfg: cfg,
@@ -74,6 +76,8 @@ func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *con
runtimes: make(map[int64]*pluginRuntime),
localInstallations: make(map[int64]*PluginInstallation),
}
+ manager.extensions.Store(&pluginExtensionRegistry{installations: map[int64]*PluginInstallation{}, runtimes: map[int64]*pluginRuntime{}, unavailable: "plugin registry not initialized"})
+ return manager
}
func (m *PluginManager) MaxUploadBytes() int64 {
@@ -91,6 +95,11 @@ func (m *PluginManager) Start(ctx context.Context) error {
m.operationMu.Unlock()
return nil
}
+ if err := m.bootstrapBundle(ctx); err != nil {
+ m.mu.Unlock()
+ m.operationMu.Unlock()
+ return err
+ }
if err := os.MkdirAll(filepath.Join(m.installer.RootDir(), "runtime"), 0o700); err != nil {
m.mu.Unlock()
m.operationMu.Unlock()
@@ -112,6 +121,9 @@ func (m *PluginManager) Start(ctx context.Context) error {
}
func (m *PluginManager) Stop() {
+ if provider := processExtensionCatalog.Load(); provider != nil && provider.resolver == m {
+ processExtensionCatalog.CompareAndSwap(provider, nil)
+ }
m.mu.Lock()
cancel := m.reconcileCancel
done := m.reconcileDone
@@ -284,6 +296,13 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error {
return fmt.Errorf("读取插件启用状态: %w", err)
}
m.cleanupStaleLocalInstallations(installations)
+ if err := validatePluginRegistry(installations); err != nil {
+ m.publishUnavailableRoute(0, 100, err.Error())
+ m.mu.Lock()
+ m.publishExtensionRegistryLocked(installations, "plugin bindings invalid")
+ m.mu.Unlock()
+ return err
+ }
desired := make(map[int64]bool)
var failures []error
var transport *PluginInstallation
@@ -585,10 +604,14 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo
if err != nil {
return nil, err
}
+ if installation.Manifest.Requires.ExtensionAPI > 0 && rolloutPercent != 100 {
+ return nil, errors.New("功能插件整体启用,不支持按流量比例启用")
+ }
if active := m.route.Load(); active != nil && active.pluginID != id && pluginDeclaresOpenAIOAuthCapability(installation.Manifest) {
return nil, errors.New("OpenAI OAuth 出站能力已有启用插件,请先停用当前插件")
}
if installation.State == PluginStateEnabled && hasEnabledPluginBinding(installation.Bindings) {
+ installation.DesiredEnabled = true
installation.Compatibility = EvaluatePluginCompatibility(installation.Manifest, m.hostInfo)
m.mu.Lock()
runtime := m.runtimes[id]
@@ -619,7 +642,12 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo
if err != nil {
return nil, err
}
- if err := pluginDependenciesReady(installation, all); err != nil {
+ for i, candidate := range all {
+ if candidate.ID == installation.ID {
+ all[i] = installation
+ }
+ }
+ if err := validatePluginRegistry(all); err != nil {
return nil, err
}
if err := m.repo.UpdateBindingsAndState(ctx, id, installation.Bindings, PluginStateStarting, "", installation.EnabledAt, installation.State, installation.BinarySHA256); err != nil {
@@ -654,6 +682,7 @@ func (m *PluginManager) Enable(ctx context.Context, id int64, acceptUntested boo
return nil, err
}
result.Compatibility = compatibility
+ result.DesiredEnabled = hasEnabledPluginBinding(result.Bindings)
result.RuntimeHealthy = true
result.RuntimeMessage = "插件进程运行中"
return result, nil
@@ -1117,7 +1146,14 @@ func (m *PluginManager) buildHostServices(installation *PluginInstallation) plug
if installation.Manifest.Requires.ExtensionAPI > 0 {
state, _ := m.repo.(PluginExtensionStateStore)
directory, _ := m.accountDirectory.(PluginExtensionAccountDirectory)
- host.extension = &pluginExtensionHost{key: installation.PluginKey, state: state, directory: directory, installation: installation}
+ host.extension = &pluginExtensionHost{key: installation.PluginKey, state: state, directory: directory, installation: installation, activity: m.quotaActivity, active: func() bool {
+ registry := m.extensions.Load()
+ if registry == nil || registry.unavailable != "" {
+ return false
+ }
+ current := registry.installations[installation.ID]
+ return current != nil && hasEnabledPluginBinding(current.Bindings) && pluginDependenciesHealthy(current, registry, map[int64]bool{})
+ }}
}
return host
}
diff --git a/backend/internal/service/plugin_manifest.go b/backend/internal/service/plugin_manifest.go
index 926f2c856cc6..203f057ca079 100644
--- a/backend/internal/service/plugin_manifest.go
+++ b/backend/internal/service/plugin_manifest.go
@@ -150,6 +150,10 @@ func (m PluginManifest) RuntimeKey() string {
}
func (m PluginManifest) Validate() error {
+ return m.ValidateForRuntime(m.RuntimeKey())
+}
+
+func (m PluginManifest) ValidateForRuntime(runtimeKey string) error {
if m.SchemaVersion != 1 {
return fmt.Errorf("不支持的插件清单版本: %d", m.SchemaVersion)
}
@@ -178,6 +182,9 @@ func (m PluginManifest) Validate() error {
if m.Requires.ExtensionAPI != extensionv1.Version || capability.Platform == "" || capability.AccountType == "" {
return errors.New("扩展能力必须声明兼容版本及明确作用范围")
}
+ if capability.ID == extensionv1.CapabilityProvider && (capability.Platform == "*" || capability.AccountType == "*") {
+ return errors.New("Provider能力必须声明具体平台与账号类型")
+ }
} else if capability.ID != PluginCapabilityOpenAIOAuthOutbound || capability.Platform != PlatformOpenAI || capability.AccountType != AccountTypeOAuth {
return fmt.Errorf("不支持插件能力 %s", capability.ID)
}
@@ -201,9 +208,9 @@ func (m PluginManifest) Validate() error {
return errors.New("插件依赖能力未知")
}
}
- runtimeEntry, ok := m.Runtimes[m.RuntimeKey()]
+ runtimeEntry, ok := m.Runtimes[runtimeKey]
if !ok || !safePluginRelativePath(runtimeEntry.Path) {
- return fmt.Errorf("插件不支持当前运行平台 %s", m.RuntimeKey())
+ return fmt.Errorf("插件不支持当前运行平台 %s", runtimeKey)
}
if !safePluginRelativePath(m.UI.Entrypoint) || !strings.HasPrefix(m.UI.Entrypoint, "ui/") {
return errors.New("插件 UI 入口必须位于 ui/ 目录")
diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go
index a6aceaf95702..c80590dff26f 100644
--- a/backend/internal/service/plugin_package.go
+++ b/backend/internal/service/plugin_package.go
@@ -29,9 +29,10 @@ const (
)
type PluginPackageInstaller struct {
- cfg *config.Config
- hostInfo PluginHostInfo
- rootDir string
+ bundledPublishers map[string]string
+ cfg *config.Config
+ hostInfo PluginHostInfo
+ rootDir string
}
func NewPluginPackageInstaller(cfg *config.Config, hostInfo PluginHostInfo) *PluginPackageInstaller {
@@ -271,6 +272,9 @@ func (i *PluginPackageInstaller) verifySignature(file *zip.File, manifestRaw []b
return "", errors.New("插件签名算法或密钥 ID 无效")
}
encodedKey := trustedPluginPublisherKey(i.cfg, signature.KeyID, pluginID)
+ if encodedKey == "" && strings.HasPrefix(pluginID, "codexrip.") {
+ encodedKey = i.bundledPublishers[signature.KeyID]
+ }
if encodedKey == "" {
return "", fmt.Errorf("插件发布者密钥不受信任: %s", signature.KeyID)
}
diff --git a/backend/internal/service/plugin_provider.go b/backend/internal/service/plugin_provider.go
new file mode 100644
index 000000000000..47ca90674212
--- /dev/null
+++ b/backend/internal/service/plugin_provider.go
@@ -0,0 +1,11 @@
+package service
+
+import "github.com/Wei-Shaw/sub2api/internal/config"
+
+func ProvidePluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kv PluginKVStore, activity *QuotaActivityService, gateway *OpenAIGatewayService) *PluginManager {
+ manager := NewPluginManager(repo, encryptor, cfg, hostInfo, kv)
+ manager.quotaActivity = activity
+ manager.SetAccountDirectory(gateway)
+ processExtensionCatalog.Store(&extensionCatalogProvider{resolver: manager})
+ return manager
+}
diff --git a/backend/internal/service/plugin_request.go b/backend/internal/service/plugin_request.go
new file mode 100644
index 000000000000..75657fcad940
--- /dev/null
+++ b/backend/internal/service/plugin_request.go
@@ -0,0 +1,80 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "sort"
+ "time"
+
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+)
+
+func (m *PluginManager) ApplyRequestHeaders(ctx context.Context, account *Account, model string, headers http.Header) error {
+ if m == nil || account == nil || headers == nil {
+ return nil
+ }
+ registry := m.extensions.Load()
+ if registry == nil || registry.unavailable != "" {
+ return errors.New("plugin registry unavailable")
+ }
+ var ids []int64
+ for id, installation := range registry.installations {
+ if pluginHasCapability(installation, extensionv1.CapabilityRequest, account.Platform, account.Type) {
+ ids = append(ids, id)
+ }
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ raw, err := json.Marshal(extensionv1.SchedulingRequest{Account: *extensionAccount(account), Model: model, Now: time.Now().UTC()})
+ if err != nil {
+ return err
+ }
+ for _, id := range ids {
+ result, err := m.InvokeExtension(ctx, id, account.Platform, account.Type, extensionv1.Invocation{Capability: extensionv1.CapabilityRequest, Operation: "inject", Payload: raw})
+ if err != nil {
+ return err
+ }
+ if result.Code != "" {
+ return errors.New("plugin request prerequisite unavailable")
+ }
+ var changes struct {
+ Headers map[string]string `json:"headers"`
+ }
+ if json.Unmarshal(result.Payload, &changes) != nil {
+ return errors.New("invalid plugin header result")
+ }
+ for name, value := range changes.Headers {
+ headers.Set(name, value)
+ }
+ }
+ return nil
+}
+
+func (m *PluginManager) installedByKey(key string) (*PluginInstallation, *pluginRuntime) {
+ if m == nil {
+ return nil, nil
+ }
+ registry := m.extensions.Load()
+ if registry == nil {
+ return nil, nil
+ }
+ for id, installation := range registry.installations {
+ if installation.PluginKey == key {
+ return installation, registry.runtimes[id]
+ }
+ }
+ return nil, nil
+}
+
+func (m *PluginManager) activeConfig(key string) (json.RawMessage, bool) {
+ installation, runtime := m.installedByKey(key)
+ if installation == nil || !hasEnabledPluginBinding(installation.Bindings) || runtime == nil {
+ return nil, false
+ }
+ config := runtime.configSnapshot.Load()
+ if config == nil {
+ return nil, false
+ }
+ return append(json.RawMessage(nil), (*config)...), true
+}
diff --git a/backend/internal/service/plugin_runtime.go b/backend/internal/service/plugin_runtime.go
index 39bcb91f98ce..6cb60553b7a2 100644
--- a/backend/internal/service/plugin_runtime.go
+++ b/backend/internal/service/plugin_runtime.go
@@ -27,14 +27,19 @@ import (
)
type pluginRuntime struct {
- installation *PluginInstallation
- client *hcplugin.Client
- api pluginv1.TransportPluginClient
- extension *extensionv1.Client
- inFlight atomic.Int64
- draining atomic.Bool
- done chan struct{}
- doneOnce sync.Once
+ installation *PluginInstallation
+ client *hcplugin.Client
+ api pluginv1.TransportPluginClient
+ extension *extensionv1.Client
+ scheduling atomic.Pointer[[]extensionv1.SchedulingRule]
+ configSnapshot atomic.Pointer[json.RawMessage]
+ configRevision atomic.Uint64
+ catalogCache sync.Map
+ catalogCacheSize atomic.Int64
+ inFlight atomic.Int64
+ draining atomic.Bool
+ done chan struct{}
+ doneOnce sync.Once
}
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string, hostServices pluginv1.HostServiceServer) (*pluginRuntime, error) {
@@ -202,6 +207,35 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co
if !applied.Applied {
return nil, fmt.Errorf("插件拒绝应用配置: %s", applied.Message)
}
+ var capabilities []PluginCapability
+ if r.installation != nil {
+ capabilities = r.installation.Manifest.Capabilities
+ }
+ for _, capability := range capabilities {
+ if capability.ID != extensionv1.CapabilityScheduling {
+ continue
+ }
+ result, err := r.extension.Invoke(ctx, extensionv1.Invocation{Capability: extensionv1.CapabilityScheduling, Operation: "describe", Payload: json.RawMessage(`{}`)})
+ if err != nil {
+ return nil, fmt.Errorf("read plugin scheduling contract: %w", err)
+ }
+ var rules []extensionv1.SchedulingRule
+ if json.Unmarshal(result.Payload, &rules) != nil {
+ return nil, errors.New("invalid plugin scheduling contract")
+ }
+ for _, rule := range rules {
+ if (rule.Default != "allow" && rule.Default != "deny") || len(rule.Models) == 0 {
+ return nil, errors.New("invalid plugin scheduling rule")
+ }
+ }
+ r.scheduling.Store(&rules)
+ break
+ }
+ snapshot := json.RawMessage(append([]byte(nil), configJSON...))
+ r.configSnapshot.Store(&snapshot)
+ r.configRevision.Add(1)
+ r.catalogCache.Clear()
+ r.catalogCacheSize.Store(0)
return configJSON, nil
}
diff --git a/backend/internal/service/plugin_scheduling.go b/backend/internal/service/plugin_scheduling.go
new file mode 100644
index 000000000000..2adbccb8f72d
--- /dev/null
+++ b/backend/internal/service/plugin_scheduling.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "encoding/json"
+ "path"
+ "time"
+
+ extensionv1 "github.com/Wei-Shaw/sub2api/pkg/extensionapi/v1"
+)
+
+const PluginAccountProjectionKey = "plugin_account_projections"
+
+type PluginAccountProjection struct {
+ Identity string `json:"identity"`
+ Scheduling map[string]extensionv1.SchedulingConstraint `json:"scheduling"`
+ Observations map[string]extensionv1.AccountObservation `json:"observations,omitempty"`
+}
+
+// Scheduling uses a sanitized account projection and immutable plugin policy,
+// so candidate filtering never launches a process RPC or reads ticket material.
+func (m *PluginManager) SchedulingDecision(account *Account, model string, now time.Time) extensionv1.SchedulingDecision {
+ allow := extensionv1.SchedulingDecision{Allowed: true}
+ if m == nil || account == nil {
+ return allow
+ }
+ registry := m.extensions.Load()
+ if registry == nil || registry.unavailable != "" {
+ return extensionv1.SchedulingDecision{Reason: "plugin_registry_unavailable", Scope: model}
+ }
+ for id, installation := range registry.installations {
+ if !pluginHasCapability(installation, extensionv1.CapabilityScheduling, account.Platform, account.Type) {
+ continue
+ }
+ runtime := registry.runtimes[id]
+ if registry.unavailable != "" || runtime == nil || runtime.draining.Load() || runtime.client.Exited() || !pluginDependenciesHealthy(installation, registry, map[int64]bool{}) {
+ return extensionv1.SchedulingDecision{Reason: "plugin_unavailable", Scope: model}
+ }
+ rules := runtime.scheduling.Load()
+ if rules == nil {
+ return extensionv1.SchedulingDecision{Reason: "plugin_policy_unavailable", Scope: model}
+ }
+ projection := accountPluginProjection(account, installation.PluginKey)
+ for _, rule := range *rules {
+ if rule.ExcludeShadows && account.IsShadow() {
+ continue
+ }
+ matched := false
+ for _, pattern := range rule.Models {
+ if pattern == model {
+ matched = true
+ break
+ }
+ if ok, _ := path.Match(pattern, model); ok {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ continue
+ }
+ constraint, observed := projection.Scheduling[model]
+ if !observed {
+ constraint, observed = projection.Scheduling["*"]
+ }
+ if projection.Identity != CodexTicketAccountIdentity(account) {
+ observed = false
+ }
+ if observed && constraint.Until != nil && !now.Before(*constraint.Until) {
+ observed = false
+ }
+ if observed {
+ if constraint.Effect == "deny" {
+ return extensionv1.SchedulingDecision{Reason: constraint.Reason, Until: constraint.Until, Scope: model}
+ }
+ continue
+ }
+ if rule.Default == "deny" {
+ return extensionv1.SchedulingDecision{Reason: rule.Reason, Scope: model}
+ }
+ }
+ }
+ return allow
+}
+
+func accountPluginProjection(account *Account, key string) PluginAccountProjection {
+ var projection PluginAccountProjection
+ if account == nil {
+ return projection
+ }
+ if values, ok := account.Extra[PluginAccountProjectionKey].(map[string]any); ok {
+ if raw, err := json.Marshal(values[key]); err == nil {
+ _ = json.Unmarshal(raw, &projection)
+ }
+ }
+ return projection
+}
diff --git a/backend/internal/service/quota_activity.go b/backend/internal/service/quota_activity.go
new file mode 100644
index 000000000000..a16b7f1b0f3c
--- /dev/null
+++ b/backend/internal/service/quota_activity.go
@@ -0,0 +1,304 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+)
+
+func (s *QuotaActivityService) BeginUnbilled(ctx context.Context, accountID int64, owner string) (string, error) {
+ if s == nil || s.store == nil {
+ return "", errors.New("quota observation unavailable")
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return "", err
+ }
+ id := owner + "." + hex.EncodeToString(nonce[:])
+ if err := s.store.Begin(ctx, accountID, id); err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+func (s *QuotaActivityService) FinishUnbilled(ctx context.Context, accountID int64, owner, id string) error {
+ if s == nil || s.store == nil || !strings.HasPrefix(id, owner+".") || len(id) != len(owner)+33 {
+ return errors.New("invalid observation ownership")
+ }
+ return s.store.Finish(ctx, accountID, id, false)
+}
+
+type QuotaActivityStamp struct {
+ Epoch string
+ Active int64
+ Revision int64
+ Gaps int64
+}
+
+type QuotaActivityStore interface {
+ Begin(context.Context, int64, string) error
+ Refresh(context.Context, int64, string) error
+ Finish(context.Context, int64, string, bool) error
+ Read(context.Context, int64) (QuotaActivityStamp, error)
+}
+
+type QuotaActivityService struct {
+ store QuotaActivityStore
+ uncertain atomic.Bool
+ mu sync.Mutex
+ local map[int64]int
+ dirty map[int64]bool
+}
+
+func NewQuotaActivityService(store QuotaActivityStore) *QuotaActivityService {
+ return &QuotaActivityService{store: store, local: make(map[int64]int), dirty: make(map[int64]bool)}
+}
+func (s *QuotaActivityService) localActivity(id int64, delta int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.local[id] += delta
+ if s.local[id] == 0 {
+ delete(s.local, id)
+ }
+}
+func (s *QuotaActivityService) markUncertain(id int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.dirty[id] = true
+}
+
+type quotaActivityContextKey struct{}
+type quotaActivityEntry struct {
+ pending int
+ logged bool
+ gap bool
+ closed bool
+}
+type quotaUsageTaskContextKey struct{}
+type quotaUsageTaskReceipt struct {
+ accountID int64
+ logged atomic.Bool
+}
+type quotaActivityTrace struct {
+ service *QuotaActivityService
+ id string
+ mu sync.Mutex
+ accounts map[int64]*quotaActivityEntry
+ ended bool
+ done chan struct{}
+ stop sync.Once
+}
+
+func (s *QuotaActivityService) Attach(ctx context.Context) (context.Context, func()) {
+ if quotaActivity(ctx) != nil {
+ return ctx, func() {}
+ }
+ if s == nil || s.store == nil {
+ return ctx, func() {}
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ s.uncertain.Store(true)
+ return ctx, func() {}
+ }
+ trace := "aActivityTrace{service: s, id: hex.EncodeToString(nonce[:]), accounts: make(map[int64]*quotaActivityEntry), done: make(chan struct{})}
+ return context.WithValue(ctx, quotaActivityContextKey{}, trace), trace.finishRequest
+}
+
+func quotaActivity(ctx context.Context) *quotaActivityTrace {
+ if ctx == nil {
+ return nil
+ }
+ trace, _ := ctx.Value(quotaActivityContextKey{}).(*quotaActivityTrace)
+ return trace
+}
+
+func CopyQuotaActivityContext(parent, base context.Context) context.Context {
+ if trace := quotaActivity(parent); trace != nil {
+ base = context.WithValue(base, quotaActivityContextKey{}, trace)
+ }
+ return base
+}
+
+func ObserveQuotaAccount(ctx context.Context, accountID int64) {
+ trace := quotaActivity(ctx)
+ if trace == nil || accountID <= 0 {
+ return
+ }
+ trace.mu.Lock()
+ defer trace.mu.Unlock()
+ if trace.accounts[accountID] != nil {
+ return
+ }
+ trace.accounts[accountID] = "aActivityEntry{}
+ trace.service.localActivity(accountID, 1)
+ if err := trace.service.store.Begin(ctx, accountID, trace.id); err != nil {
+ trace.service.markUncertain(accountID)
+ }
+ if len(trace.accounts) == 1 {
+ go trace.heartbeat()
+ }
+}
+
+func MarkQuotaLogPersisted(ctx context.Context, accountID int64) {
+ if ctx != nil {
+ if receipt, ok := ctx.Value(quotaUsageTaskContextKey{}).(*quotaUsageTaskReceipt); ok && receipt.accountID == accountID {
+ receipt.logged.Store(true)
+ }
+ }
+ trace := quotaActivity(ctx)
+ if trace == nil {
+ return
+ }
+ trace.mu.Lock()
+ defer trace.mu.Unlock()
+ if entry := trace.accounts[accountID]; entry != nil {
+ entry.logged = true
+ }
+}
+
+func MarkQuotaLogFailed(ctx context.Context, accountID int64) {
+ trace := quotaActivity(ctx)
+ if trace == nil {
+ return
+ }
+ trace.mu.Lock()
+ defer trace.mu.Unlock()
+ if entry := trace.accounts[accountID]; entry != nil {
+ entry.gap = true
+ }
+}
+
+// Pending is registered before queue submission, closing the gap between
+// releasing a network slot and a worker eventually committing the usage log.
+func TrackQuotaUsageTask(parent context.Context, task UsageRecordTask) (UsageRecordTask, func()) {
+ if parent == nil {
+ return task, func() {}
+ }
+ trace := quotaActivity(parent)
+ accountID, _ := parent.Value(ctxkey.AccountID).(int64)
+ if trace == nil || accountID <= 0 {
+ return task, func() {}
+ }
+ trace.mu.Lock()
+ entry := trace.accounts[accountID]
+ if entry == nil {
+ trace.mu.Unlock()
+ return task, func() {}
+ }
+ entry.pending++
+ trace.mu.Unlock()
+ var once sync.Once
+ receipt := "aUsageTaskReceipt{accountID: accountID}
+ finish := func() {
+ once.Do(func() {
+ trace.mu.Lock()
+ defer trace.mu.Unlock()
+ entry.pending--
+ if !receipt.logged.Load() {
+ entry.gap = true
+ }
+ trace.closeSettledLocked()
+ })
+ }
+ return func(ctx context.Context) {
+ defer finish()
+ task(context.WithValue(CopyQuotaActivityContext(parent, ctx), quotaUsageTaskContextKey{}, receipt))
+ }, finish
+}
+
+func (t *quotaActivityTrace) finishRequest() {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.ended = true
+ t.closeSettledLocked()
+}
+
+func (t *quotaActivityTrace) closeSettledLocked() {
+ if !t.ended {
+ return
+ }
+ allClosed := true
+ for id, entry := range t.accounts {
+ if entry.closed {
+ continue
+ }
+ if entry.pending > 0 {
+ allClosed = false
+ continue
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ if err := t.service.store.Finish(ctx, id, t.id, entry.logged && !entry.gap); err != nil {
+ t.service.markUncertain(id)
+ }
+ t.service.localActivity(id, -1)
+ cancel()
+ entry.closed = true
+ }
+ if allClosed {
+ t.stop.Do(func() { close(t.done) })
+ }
+}
+
+func (t *quotaActivityTrace) heartbeat() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-t.done:
+ return
+ case <-ticker.C:
+ }
+ t.mu.Lock()
+ for id, entry := range t.accounts {
+ if entry.closed {
+ continue
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ if err := t.service.store.Refresh(ctx, id, t.id); err != nil {
+ t.service.markUncertain(id)
+ }
+ cancel()
+ }
+ t.mu.Unlock()
+ }
+}
+
+func (s *QuotaActivityService) Read(ctx context.Context, id int64) (QuotaActivityStamp, bool) {
+ if s == nil || s.store == nil || s.uncertain.Load() {
+ return QuotaActivityStamp{}, false
+ }
+ s.mu.Lock()
+ if s.dirty[id] {
+ if s.local[id] != 0 {
+ s.mu.Unlock()
+ return QuotaActivityStamp{}, false
+ }
+ if err := s.store.Finish(ctx, id, "uncertain-observation", false); err != nil {
+ s.mu.Unlock()
+ return QuotaActivityStamp{}, false
+ }
+ delete(s.dirty, id)
+ }
+ local := s.local[id]
+ s.mu.Unlock()
+ stamp, err := s.store.Read(ctx, id)
+ if int64(local) > stamp.Active {
+ stamp.Active = int64(local)
+ }
+ return stamp, err == nil
+}
+
+func (s *OpsService) AttachQuotaActivity(ctx context.Context) (context.Context, func()) {
+ if s == nil {
+ return ctx, func() {}
+ }
+ return s.quotaActivity.Attach(ctx)
+}
diff --git a/backend/internal/service/quota_activity_test.go b/backend/internal/service/quota_activity_test.go
new file mode 100644
index 000000000000..aa1a2e72ad29
--- /dev/null
+++ b/backend/internal/service/quota_activity_test.go
@@ -0,0 +1,90 @@
+package service
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/stretchr/testify/require"
+)
+
+type quotaActivityMemoryStore struct {
+ mu sync.Mutex
+ stamp QuotaActivityStamp
+}
+
+func (s *quotaActivityMemoryStore) Begin(context.Context, int64, string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.stamp.Active++
+ s.stamp.Revision++
+ return nil
+}
+func (s *quotaActivityMemoryStore) Refresh(context.Context, int64, string) error { return nil }
+func (s *quotaActivityMemoryStore) Finish(_ context.Context, _ int64, _ string, logged bool) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.stamp.Active > 0 {
+ s.stamp.Active--
+ }
+ s.stamp.Revision++
+ if !logged {
+ s.stamp.Gaps++
+ }
+ return nil
+}
+func (s *quotaActivityMemoryStore) Read(context.Context, int64) (QuotaActivityStamp, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.stamp, nil
+}
+
+func TestQuotaActivityWaitsForQueuedUsageAfterRequestEnds(t *testing.T) {
+ store := "aActivityMemoryStore{}
+ activity := NewQuotaActivityService(store)
+ ctx, finishRequest := activity.Attach(context.Background())
+ ctx = context.WithValue(ctx, ctxkey.AccountID, int64(7))
+ ObserveQuotaAccount(ctx, 7)
+ task, discard := TrackQuotaUsageTask(ctx, func(ctx context.Context) { MarkQuotaLogPersisted(ctx, 7) })
+ finishRequest()
+ stamp, known := activity.Read(context.Background(), 7)
+ require.True(t, known)
+ require.EqualValues(t, 1, stamp.Active, "HTTP completion must not hide an uncommitted queued bill")
+ task(context.Background())
+ discard() // completion is once-only, even on cancellation races.
+ stamp, known = activity.Read(context.Background(), 7)
+ require.True(t, known)
+ require.Zero(t, stamp.Active)
+ require.Zero(t, stamp.Gaps)
+}
+
+func TestQuotaActivityDroppedUsageInvalidatesCalibration(t *testing.T) {
+ activity := NewQuotaActivityService("aActivityMemoryStore{})
+ ctx, finishRequest := activity.Attach(context.Background())
+ ctx = context.WithValue(ctx, ctxkey.AccountID, int64(7))
+ ObserveQuotaAccount(ctx, 7)
+ _, discard := TrackQuotaUsageTask(ctx, func(context.Context) { t.Fatal("dropped job must not execute") })
+ finishRequest()
+ discard()
+ stamp, known := activity.Read(context.Background(), 7)
+ require.True(t, known)
+ require.Zero(t, stamp.Active)
+ require.EqualValues(t, 1, stamp.Gaps)
+}
+
+func TestQuotaActivityOneSuccessfulTurnDoesNotHideLaterDroppedBill(t *testing.T) {
+ activity := NewQuotaActivityService("aActivityMemoryStore{})
+ ctx, finishRequest := activity.Attach(context.Background())
+ ctx = context.WithValue(ctx, ctxkey.AccountID, int64(7))
+ ObserveQuotaAccount(ctx, 7)
+ first, _ := TrackQuotaUsageTask(ctx, func(ctx context.Context) { MarkQuotaLogPersisted(ctx, 7) })
+ first(context.Background())
+ _, discard := TrackQuotaUsageTask(ctx, func(context.Context) { t.Fatal("dropped turn must not execute") })
+ discard()
+ finishRequest()
+ stamp, known := activity.Read(context.Background(), 7)
+ require.True(t, known)
+ require.Zero(t, stamp.Active)
+ require.EqualValues(t, 1, stamp.Gaps)
+}
diff --git a/backend/internal/service/quota_estimate.go b/backend/internal/service/quota_estimate.go
index ea6833c0d11f..82b56d996ce1 100644
--- a/backend/internal/service/quota_estimate.go
+++ b/backend/internal/service/quota_estimate.go
@@ -63,8 +63,9 @@ func EstimateQuotaValue(base, latest *QuotaEstimateObservation, current AccountQ
type quotaMeasurementFence struct {
point QuotaCostPoint
- pool UsageRecordWorkerPoolStats
+ activity QuotaActivityStamp
identity string
+ shadow bool
}
// Only existing /wham/usage queries can create calibration observations. A
@@ -72,43 +73,55 @@ type quotaMeasurementFence struct {
// settled local bill. No extra upstream query is made for estimates.
func (s *OpenAIQuotaService) beginQuotaMeasurement(ctx context.Context, id int64) *quotaMeasurementFence {
store, ok := s.accountRepo.(QuotaEstimateRepository)
- if !ok || s.concurrency == nil || s.usagePool == nil {
+ if !ok || s.activity == nil {
return nil
}
- pool := s.usagePool.Stats()
- if pool.WaitingTasks != 0 || pool.SubmittedTasks != pool.CompletedTasks+pool.DroppedTasks {
- return nil
- }
- counts, err := s.concurrency.GetAccountConcurrencyBatch(ctx, []int64{id})
- if err != nil || counts[id] != 0 {
+ activity, known := s.activity.Read(ctx, id)
+ if !known || activity.Active != 0 {
return nil
}
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil || account == nil {
return nil
}
+ account, err = s.quotaEstimateAccount(ctx, account)
+ if err != nil {
+ return nil
+ }
point, err := store.QuotaCostPoint(ctx, id)
if err != nil {
return nil
}
- return "aMeasurementFence{point: point, pool: pool, identity: quotaEstimateIdentity(account)}
+ return "aMeasurementFence{point: point, activity: activity, identity: quotaEstimateBasis(account, activity), shadow: account.IsShadow()}
}
func (s *OpenAIQuotaService) finishQuotaMeasurement(ctx context.Context, id int64, usage *OpenAIQuotaUsage, before *quotaMeasurementFence) {
- if before == nil || usage == nil || usage.RateLimit == nil {
+ if before == nil || usage == nil {
return
}
after := s.beginQuotaMeasurement(ctx, id)
- if after == nil || before.identity != after.identity || before.point != after.point ||
- before.pool.SubmittedTasks != after.pool.SubmittedTasks || before.pool.FailedTasks != after.pool.FailedTasks || before.pool.DroppedTasks != after.pool.DroppedTasks {
+ if after == nil || before.identity != after.identity || before.point != after.point || before.activity != after.activity {
return
}
store := s.accountRepo.(QuotaEstimateRepository)
+ limits := usage.RateLimit
+ if after.shadow {
+ limits = nil
+ for _, additional := range usage.AdditionalRateLimits {
+ if additional.MeteredFeature == "codex_bengalfox" {
+ limits = additional.RateLimit
+ break
+ }
+ }
+ }
+ if limits == nil {
+ return
+ }
observed := time.Unix(usage.FetchedAt, 0).UTC()
for _, entry := range []struct {
id string
window *OpenAIRateLimitWindow
- }{{"primary", usage.RateLimit.PrimaryWindow}, {"secondary", usage.RateLimit.SecondaryWindow}} {
+ }{{"primary", limits.PrimaryWindow}, {"secondary", limits.SecondaryWindow}} {
w := entry.window
if w == nil || w.LimitWindowSeconds <= 0 || w.ResetAt <= 0 {
continue
@@ -121,3 +134,39 @@ func (s *OpenAIQuotaService) finishQuotaMeasurement(ctx context.Context, id int6
_ = store.ObserveQuotaEstimate(ctx, QuotaEstimateObservation{AccountID: id, Identity: after.identity, Period: window.PeriodKey(), ObservedAt: observed, ResetsAt: reset, Utilization: w.UsedPercent, Cost: after.point.Cost})
}
}
+
+func quotaEstimateBasis(account *Account, stamp QuotaActivityStamp) string {
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%s/epoch=%s/gaps=%d", quotaEstimateIdentity(account), stamp.Epoch, stamp.Gaps)))
+ return hex.EncodeToString(sum[:])
+}
+
+func (s *OpenAIQuotaService) estimateBasis(ctx context.Context, account *Account) (string, bool) {
+ if s == nil || account == nil {
+ return "", false
+ }
+ stamp, ok := s.activity.Read(ctx, account.ID)
+ if !ok {
+ return "", false
+ }
+ account, err := s.quotaEstimateAccount(ctx, account)
+ if err != nil {
+ return "", false
+ }
+ return quotaEstimateBasis(account, stamp), true
+}
+
+// Spark consumes a separate upstream window but bills the parent credential's
+// current account rate. Keep the child usage-log identity while binding its
+// calibration to the same principal and rate used at billing time.
+func (s *OpenAIQuotaService) quotaEstimateAccount(ctx context.Context, account *Account) (*Account, error) {
+ if !account.IsShadow() {
+ return account, nil
+ }
+ parent, err := resolveCredentialAccount(ctx, s.accountRepo, account)
+ if err != nil {
+ return nil, err
+ }
+ copy := *account
+ copy.Credentials, copy.RateMultiplier = parent.Credentials, parent.RateMultiplier
+ return ©, nil
+}
diff --git a/backend/internal/service/quota_measurement_test.go b/backend/internal/service/quota_measurement_test.go
index 24a467796017..b2ff8cff4577 100644
--- a/backend/internal/service/quota_measurement_test.go
+++ b/backend/internal/service/quota_measurement_test.go
@@ -12,11 +12,42 @@ type quotaMeasurementRepo struct {
AccountRepository
point QuotaCostPoint
observations []QuotaEstimateObservation
+ accounts map[int64]*Account
}
-func (r *quotaMeasurementRepo) GetByID(context.Context, int64) (*Account, error) {
+func (r *quotaMeasurementRepo) GetByID(_ context.Context, id int64) (*Account, error) {
+ if r.accounts != nil {
+ return r.accounts[id], nil
+ }
return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "owner"}}, nil
}
+
+func TestQuotaMeasurementUsesShadowWindowAndParentBillingIdentity(t *testing.T) {
+ ctx := context.Background()
+ parentID := int64(1)
+ rate := 2.0
+ parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "parent-owner"}, RateMultiplier: &rate}
+ shadow := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}
+ repo := "aMeasurementRepo{accounts: map[int64]*Account{1: parent, 2: shadow}, point: QuotaCostPoint{Cost: 10}}
+ svc := &OpenAIQuotaService{accountRepo: repo, activity: NewQuotaActivityService("aActivityMemoryStore{})}
+ now := time.Now().UTC()
+ window := func(used float64) *OpenAIRateLimit {
+ return &OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: used, LimitWindowSeconds: 18000, ResetAt: now.Add(time.Hour).Unix()}}
+ }
+ usage := &OpenAIQuotaUsage{FetchedAt: now.Unix(), RateLimit: window(99), AdditionalRateLimits: []OpenAIAdditionalRateLimit{{MeteredFeature: "codex_bengalfox", RateLimit: window(25)}}}
+ before := svc.beginQuotaMeasurement(ctx, shadow.ID)
+ require.NotNil(t, before)
+ svc.finishQuotaMeasurement(ctx, shadow.ID, usage, before)
+ require.Len(t, repo.observations, 1)
+ require.Equal(t, 25.0, repo.observations[0].Utilization)
+ rate = 3
+ svc.finishQuotaMeasurement(ctx, shadow.ID, usage, before)
+ require.Len(t, repo.observations, 1, "a parent rate change invalidates the pairing")
+ before = svc.beginQuotaMeasurement(ctx, shadow.ID)
+ parent.Credentials["chatgpt_account_id"] = "new-owner"
+ svc.finishQuotaMeasurement(ctx, shadow.ID, usage, before)
+ require.Len(t, repo.observations, 1, "a parent identity change invalidates the pairing")
+}
func (r *quotaMeasurementRepo) QuotaCostPoint(context.Context, int64) (QuotaCostPoint, error) {
return r.point, nil
}
@@ -28,22 +59,11 @@ func (r *quotaMeasurementRepo) ReadQuotaEstimate(context.Context, int64, string,
return nil, nil, nil
}
-type quotaMeasurementConcurrency struct {
- ConcurrencyCache
- active int
-}
-
-func (c *quotaMeasurementConcurrency) GetAccountConcurrencyBatch(context.Context, []int64) (map[int64]int, error) {
- return map[int64]int{1: c.active}, nil
-}
-
func TestQuotaMeasurementRejectsInFlightAndLateBills(t *testing.T) {
ctx := context.Background()
repo := "aMeasurementRepo{point: QuotaCostPoint{Cursor: 4, Requests: 4, Cost: 10}}
- cache := "aMeasurementConcurrency{}
- pool := NewUsageRecordWorkerPoolWithOptions(UsageRecordWorkerPoolOptions{WorkerCount: 1, QueueSize: 4})
- t.Cleanup(pool.Stop)
- svc := &OpenAIQuotaService{accountRepo: repo, concurrency: NewConcurrencyService(cache), usagePool: pool}
+ cache := "aActivityMemoryStore{}
+ svc := &OpenAIQuotaService{accountRepo: repo, activity: NewQuotaActivityService(cache)}
now := time.Now().UTC()
usage := &OpenAIQuotaUsage{FetchedAt: now.Unix(), RateLimit: &OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{UsedPercent: 70, LimitWindowSeconds: 2592000, ResetAt: now.Add(time.Hour).Unix()}}}
before := svc.beginQuotaMeasurement(ctx, 1)
@@ -54,10 +74,15 @@ func TestQuotaMeasurementRejectsInFlightAndLateBills(t *testing.T) {
svc.finishQuotaMeasurement(ctx, 1, usage, before)
require.Empty(t, repo.observations, "a bill committed during the upstream read cannot be paired with that read")
before = svc.beginQuotaMeasurement(ctx, 1)
- cache.active = 1
+ require.NoError(t, cache.Begin(ctx, 1, "request"))
svc.finishQuotaMeasurement(ctx, 1, usage, before)
require.Empty(t, repo.observations)
- cache.active = 0
+ require.NoError(t, cache.Finish(ctx, 1, "request", true))
+ before = svc.beginQuotaMeasurement(ctx, 1)
+ require.NoError(t, cache.Begin(ctx, 1, "short-request"))
+ require.NoError(t, cache.Finish(ctx, 1, "short-request", true))
+ svc.finishQuotaMeasurement(ctx, 1, usage, before)
+ require.Empty(t, repo.observations, "a request entirely inside the observation interval must also invalidate the pair")
before = svc.beginQuotaMeasurement(ctx, 1)
svc.finishQuotaMeasurement(ctx, 1, usage, before)
require.Len(t, repo.observations, 1)
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index f8f49e78d9c1..b49f6b55e7d3 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -1253,16 +1253,9 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
if err := persistOpenAIQuotaClassification(ctx, s.accountRepo, account, classification, time.Now()); err != nil {
slog.Warn("quota_state_write_failed", "account_id", account.ID)
}
- if classification.ResetAt == nil {
- return
- }
- resetAt := classification.ResetAt
- s.notifyAccountSchedulingBlocked(account, *resetAt, "429")
- if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil {
- slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
- return
- }
- slog.Info("openai_account_rate_limited", "account_id", account.ID, "reset_at", *resetAt)
+ // Quota observations own this restriction. Writing the same deadline
+ // into the ordinary 429 field would survive a verified quota reset and
+ // could only be cleared by also clearing unrelated transient limits.
return
}
s.apply429FallbackRateLimit(ctx, account, "openai_transient_429")
diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go
index e2e861a943c6..3ecc083fc51d 100644
--- a/backend/internal/service/ratelimit_service_openai_test.go
+++ b/backend/internal/service/ratelimit_service_openai_test.go
@@ -255,7 +255,12 @@ func TestHandle429_OpenAITransientIncidentHeadersFallbackDisabledSkipsPenalty(t
}
func (r *openAI429SnapshotRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
- r.updatedExtra = updates
+ if r.updatedExtra == nil {
+ r.updatedExtra = make(map[string]any)
+ }
+ for key, value := range updates {
+ r.updatedExtra[key] = value
+ }
return nil
}
@@ -280,9 +285,9 @@ func TestHandle429_OpenAIPersistsCodexSnapshotImmediately(t *testing.T) {
svc.handle429(context.Background(), account, headers, nil)
- if repo.rateLimitedID != account.ID {
- t.Fatalf("rateLimitedID = %d, want %d", repo.rateLimitedID, account.ID)
- }
+ require.Zero(t, repo.rateLimitedID, "quota restrictions must not be duplicated as an unrelated 429 cooldown")
+ require.True(t, account.QuotaState(time.Now()).Blocked)
+ require.NotNil(t, account.QuotaState(time.Now()).Until)
if len(repo.updatedExtra) == 0 {
t.Fatal("expected codex snapshot to be persisted on 429")
}
@@ -603,17 +608,16 @@ func TestCalculateOpenAI429ResetTime_UserProvidedScenario(t *testing.T) {
t.Logf("User scenario: reset_at=%v, duration=%.2f days", resetAt, actualDays)
}
-func TestOpenAIOAuth429_HardQuotaWithoutResetUsesWindowFallback(t *testing.T) {
+func TestOpenAIOAuth429_HardQuotaWithoutResetKeepsDeadlineUnknown(t *testing.T) {
headers := http.Header{}
headers.Set("x-codex-primary-used-percent", "100")
// No reset_after_seconds!
before := time.Now()
- resetAt := classifyOpenAIOAuth429At(headers, nil, before).ResetAt
- require.NotNil(t, resetAt)
- require.GreaterOrEqual(t, resetAt.Sub(before), 5*time.Hour-time.Second)
- require.LessOrEqual(t, resetAt.Sub(before), 7*24*time.Hour+time.Second)
+ classification := classifyOpenAIOAuth429At(headers, nil, before)
+ require.NotEqual(t, openAIOAuth429Transient, classification.Disposition)
+ require.Nil(t, classification.ResetAt, "missing upstream evidence is not a five-hour or seven-day deadline")
require.Nil(t, calculateOpenAI429ResetTime(headers), "ordinary accounts do not invent OAuth quota windows")
}
diff --git a/backend/internal/service/setting_codex_ticket_test.go b/backend/internal/service/setting_codex_ticket_test.go
index c13ae6eccff4..5ea10c377d9d 100644
--- a/backend/internal/service/setting_codex_ticket_test.go
+++ b/backend/internal/service/setting_codex_ticket_test.go
@@ -1,98 +1,11 @@
package service
import (
- "context"
- "errors"
- "net/http"
"testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
-type codexTicketSettingRepo struct {
- *codexPolicyMigrationRepoStub
- err error
-}
-
-func (r *codexTicketSettingRepo) GetValue(ctx context.Context, key string) (string, error) {
- if r.err != nil {
- return "", r.err
- }
- return r.codexPolicyMigrationRepoStub.GetValue(ctx, key)
-}
-
-func TestCodexTicketEnabledRuntimeSettingOverridesYaml(t *testing.T) {
- repo := &codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{}}}
- settings := NewSettingService(repo, &config.Config{})
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: false, FailClosed: true}, nil)
- svc.settingService = settings
- account := ticketTestAccount(41)
- svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{
- AccountID: 41,
- Model: "gpt-6-astra",
- State: fakeCodexTicketState(292),
- Length: 292,
- CapturedAt: time.Now(),
- ExpiresAt: time.Now().Add(time.Hour),
- })
-
- h := http.Header{}
- h.Set(openAICodexTurnStateHeader, "client-state")
- require.False(t, svc.openAICodexTicketEnabled())
- require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h))
- require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader))
-
- repo.values[SettingKeyOpenAICodexTicketEnabled] = "true"
- settings.InvalidateOpenAICodexTicketEnabledCache()
- require.True(t, svc.openAICodexTicketEnabled())
- h = http.Header{}
- h.Set(openAICodexTurnStateHeader, "client-state")
- require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h))
- require.Equal(t, fakeCodexTicketState(292), h.Get(openAICodexTurnStateHeader))
-
- repo.values[SettingKeyOpenAICodexTicketEnabled] = "false"
- settings.InvalidateOpenAICodexTicketEnabledCache()
- require.False(t, svc.openAICodexTicketEnabled())
- h = http.Header{}
- h.Set(openAICodexTurnStateHeader, "client-state")
- require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h))
- require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader))
-}
-
-func TestRefreshOpenAICodexTickets_DisabledSkipsHarvest(t *testing.T) {
- upstream := &httpUpstreamRecorder{}
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{
- Enabled: false,
- HarvestProxyURL: "socks5h://proxy.example.com:1080",
- }, upstream)
- svc.accountRepo = &codexTicketRefreshRepo{accounts: []Account{*ticketTestAccount(41)}}
- svc.refreshOpenAICodexTickets(context.Background())
- require.Empty(t, upstream.requests)
-}
-
-func TestCodexTicketProxyRuntimeSettingAndFallback(t *testing.T) {
- repo := &codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{}}}
- settings := NewSettingService(repo, &config.Config{})
- svc := ticketTestService(t, config.OpenAICodexTicketConfig{HarvestProxyURL: "http://fallback.example.com:8080"}, nil)
- svc.settingService = settings
- require.Equal(t, "http://fallback.example.com:8080", svc.openAICodexTicketHarvestProxyURL())
- repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "socks5h://user:secret@first.example.com:1080"
- settings.InvalidateOpenAICodexTicketHarvestProxyCache()
- require.Equal(t, repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL], svc.openAICodexTicketHarvestProxyURL())
- repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "http://second.example.com:8080"
- settings.InvalidateOpenAICodexTicketHarvestProxyCache()
- require.Equal(t, "http://second.example.com:8080", svc.openAICodexTicketHarvestProxyURL())
- // Simulate another instance's settings write after the local cache expires.
- repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "https://third.example.com:443"
- settings.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{value: "http://second.example.com:8080", expiresAt: time.Now().Add(-time.Second).UnixNano()})
- require.Equal(t, "https://third.example.com:443", svc.openAICodexTicketHarvestProxyURL())
- repo.err = errors.New("database unavailable")
- settings.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{value: "https://third.example.com:443", expiresAt: 0})
- require.Equal(t, "https://third.example.com:443", svc.openAICodexTicketHarvestProxyURL())
-}
-
func TestCodexTicketProxyMaskAndValidation(t *testing.T) {
for _, raw := range []string{"http://user:secret@proxy.example.com:8080", "socks5h://user:secret@proxy.example.com:1080", "https://user:secret@[::1]:443"} {
require.NoError(t, ValidateOpenAICodexTicketHarvestProxyURL(raw))
@@ -109,11 +22,3 @@ func TestCodexTicketProxyMaskAndValidation(t *testing.T) {
require.Empty(t, MaskProxyURL(raw))
}
}
-
-func TestCodexTicketSettingsRefreshDoesNotMutateSharedConfig(t *testing.T) {
- cfg := &config.Config{}
- svc := NewSettingService(&codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{SettingKeyOpenAICodexTicketEnabled: "true"}}}, cfg)
- svc.refreshCachedSettings(&SystemSettings{OpenAICodexTicketEnabled: true})
- require.False(t, cfg.Gateway.OpenAICodexTicket.Enabled, "runtime settings must not write the shared immutable startup configuration")
- require.True(t, svc.GetOpenAICodexTicketEnabled(context.Background(), false))
-}
diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go
index 4bb6931ea482..9f6616e56710 100644
--- a/backend/internal/service/setting_gateway_runtime.go
+++ b/backend/internal/service/setting_gateway_runtime.go
@@ -387,159 +387,6 @@ func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) str
return fallback
}
-type cachedOpenAICodexTicketEnabled struct {
- value bool
- expiresAt int64
-}
-
-const openAICodexTicketEnabledCacheTTL = 5 * time.Second
-
-// GetOpenAICodexTicketEnabled 返回后台 292 打票总开关。
-// 设置键存在时以后台为准;缺失则回退 yaml/env。
-func (s *SettingService) GetOpenAICodexTicketEnabled(ctx context.Context, fallback bool) bool {
- if ctx == nil {
- ctx = context.Background()
- }
- if ctx.Err() != nil {
- return fallback
- }
- if s == nil || s.settingRepo == nil {
- return fallback
- }
- if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil {
- if time.Now().UnixNano() < cached.expiresAt {
- return cached.value
- }
- }
- resultCh := s.openAICodexTicketEnabledSF.DoChan(SettingKeyOpenAICodexTicketEnabled, func() (any, error) {
- if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil {
- if time.Now().UnixNano() < cached.expiresAt {
- return cached.value, nil
- }
- }
- dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexTicketEnabled)
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- if err != nil && !errors.Is(err, ErrSettingNotFound) {
- if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil {
- return cached.value, nil
- }
- return fallback, nil
- }
- enabled := fallback
- if err == nil && strings.TrimSpace(value) != "" {
- enabled = value == "true"
- }
- s.openAICodexTicketEnabledCache.Store(&cachedOpenAICodexTicketEnabled{
- value: enabled,
- expiresAt: time.Now().Add(openAICodexTicketEnabledCacheTTL).UnixNano(),
- })
- return enabled, nil
- })
- select {
- case <-ctx.Done():
- return fallback
- case result := <-resultCh:
- if v, ok := result.Val.(bool); ok && result.Err == nil {
- return v
- }
- return fallback
- }
-}
-
-func (s *SettingService) InvalidateOpenAICodexTicketEnabledCache() {
- if s == nil {
- return
- }
- s.openAICodexTicketEnabledSF.Forget(SettingKeyOpenAICodexTicketEnabled)
- s.openAICodexTicketEnabledCache.Store(&cachedOpenAICodexTicketEnabled{expiresAt: 0})
-}
-
-type cachedOpenAICodexTicketHarvestProxy struct {
- value string
- expiresAt int64
- found bool
-}
-
-func (s *SettingService) codexTicketProxySettingExists() bool {
- value, _ := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy)
- return value != nil && value.found
-}
-
-const openAICodexTicketHarvestProxyCacheTTL = 5 * time.Second
-
-// GetOpenAICodexTicketHarvestProxyURL 返回后台配置的 292 打票代理。空则调用方回退 yaml/env。
-func (s *SettingService) GetOpenAICodexTicketHarvestProxyURL(ctx context.Context) string {
- if ctx == nil {
- ctx = context.Background()
- }
- if ctx.Err() != nil {
- return ""
- }
- if s == nil || s.settingRepo == nil {
- return ""
- }
- if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil {
- if time.Now().UnixNano() < cached.expiresAt {
- return cached.value
- }
- }
- resultCh := s.openAICodexTicketHarvestProxySF.DoChan(SettingKeyOpenAICodexTicketHarvestProxyURL, func() (any, error) {
- if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil {
- if time.Now().UnixNano() < cached.expiresAt {
- return cached.value, nil
- }
- }
- dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexTicketHarvestProxyURL)
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- if err != nil && !errors.Is(err, ErrSettingNotFound) {
- // Keep the last known proxy during transient storage failures.
- found := false
- if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil {
- value = cached.value
- found = cached.found
- }
- s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{
- value: value,
- found: found,
- expiresAt: time.Now().Add(time.Second).UnixNano(),
- })
- return value, nil
- }
- value = strings.TrimSpace(value)
- s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{
- value: value,
- found: err == nil,
- expiresAt: time.Now().Add(openAICodexTicketHarvestProxyCacheTTL).UnixNano(),
- })
- return value, nil
- })
- select {
- case <-ctx.Done():
- return ""
- case result := <-resultCh:
- if v, ok := result.Val.(string); ok && result.Err == nil {
- return v
- }
- return ""
- }
-}
-
-func (s *SettingService) InvalidateOpenAICodexTicketHarvestProxyCache() {
- if s == nil {
- return
- }
- s.openAICodexTicketHarvestProxySF.Forget(SettingKeyOpenAICodexTicketHarvestProxyURL)
- s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{expiresAt: 0})
-}
-
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
// 后台设置优先;为空时回退到内置默认值。
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go
index ef71b157a809..f7a422864f6d 100644
--- a/backend/internal/service/setting_parse.go
+++ b/backend/internal/service/setting_parse.go
@@ -253,7 +253,6 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOpenAICodexClientVersion: "",
SettingKeyOpenAICodexClientVersionSynced: "",
SettingKeyOpenAICodexVersionAutoSyncEnabled: "true",
- SettingKeyOpenAICodexTicketHarvestProxyURL: "",
SettingPaymentVisibleMethodAlipaySource: "",
SettingPaymentVisibleMethodWxpaySource: "",
SettingPaymentVisibleMethodAlipayEnabled: "false",
@@ -906,12 +905,6 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else {
result.OpenAICodexVersionAutoSyncEnabled = true
}
- if v, ok := settings[SettingKeyOpenAICodexTicketEnabled]; ok && v != "" {
- result.OpenAICodexTicketEnabled = v == "true"
- } else if s != nil && s.cfg != nil {
- result.OpenAICodexTicketEnabled = s.cfg.Gateway.OpenAICodexTicket.Enabled
- }
- result.OpenAICodexTicketHarvestProxyURL = strings.TrimSpace(settings[SettingKeyOpenAICodexTicketHarvestProxyURL])
// codex_cli_only 加固
result.MinCodexVersion = settings[SettingKeyMinCodexVersion]
result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion]
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 692ad0e574db..1ee9b407e0ba 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -117,25 +117,21 @@ type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[i
// SettingService 系统设置服务
type SettingService struct {
- settingRepo SettingRepository
- defaultSubGroupReader DefaultSubscriptionGroupReader
- proxyRepo ProxyRepository // for resolving websearch provider proxy URLs
- cfg *config.Config
- onUpdate func() // Callback when settings are updated (for cache invalidation)
- version string // Application version
- webSearchManagerBuilder WebSearchManagerBuilder
- antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion
- antigravityUAVersionSF singleflight.Group
- openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
- openAICodexUASF singleflight.Group
- openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
- openAICodexVersionSF singleflight.Group
- openAICodexTicketEnabledCache atomic.Value // *cachedOpenAICodexTicketEnabled
- openAICodexTicketEnabledSF singleflight.Group
- openAICodexTicketHarvestProxyCache atomic.Value // *cachedOpenAICodexTicketHarvestProxy
- openAICodexTicketHarvestProxySF singleflight.Group
- codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
- codexRestrictionPolicySF singleflight.Group
+ settingRepo SettingRepository
+ defaultSubGroupReader DefaultSubscriptionGroupReader
+ proxyRepo ProxyRepository // for resolving websearch provider proxy URLs
+ cfg *config.Config
+ onUpdate func() // Callback when settings are updated (for cache invalidation)
+ version string // Application version
+ webSearchManagerBuilder WebSearchManagerBuilder
+ antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion
+ antigravityUAVersionSF singleflight.Group
+ openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
+ openAICodexUASF singleflight.Group
+ openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
+ openAICodexVersionSF singleflight.Group
+ codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
+ codexRestrictionPolicySF singleflight.Group
cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime
cyberSessionBlockRuntimeSF singleflight.Group
diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go
index eeb6d4d4fd2a..1180d3f5c212 100644
--- a/backend/internal/service/setting_update.go
+++ b/backend/internal/service/setting_update.go
@@ -504,11 +504,6 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion)
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
- updates[SettingKeyOpenAICodexTicketEnabled] = strconv.FormatBool(settings.OpenAICodexTicketEnabled)
- if err := ValidateOpenAICodexTicketHarvestProxyURL(settings.OpenAICodexTicketHarvestProxyURL); err != nil {
- return nil, infraerrors.BadRequest("INVALID_CODEX_HARVEST_PROXY", err.Error())
- }
- updates[SettingKeyOpenAICodexTicketHarvestProxyURL] = strings.TrimSpace(settings.OpenAICodexTicketHarvestProxyURL)
// SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
// 否则面板保存会把同步结果清空。
// codex_cli_only 加固
@@ -782,8 +777,6 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
// 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键,
// 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。
s.InvalidateOpenAICodexClientVersionCache()
- s.InvalidateOpenAICodexTicketEnabledCache()
- s.InvalidateOpenAICodexTicketHarvestProxyCache()
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 15f881488be1..ae1b4711068c 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -257,8 +257,6 @@ type SystemSettings struct {
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
- OpenAICodexTicketEnabled bool // Codex 292 打票总开关;关闭则不打票不注入
- OpenAICodexTicketHarvestProxyURL string // Codex 292 打票代理 URL;空则回退 yaml/env
MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查
MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查
CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny)
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 7fbb81bfb818..cad7e9bd496c 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -305,13 +305,11 @@ func ProvideOpenAIQuotaService(
tokenProvider *OpenAITokenProvider,
privacyClientFactory PrivacyClientFactory,
openAIGatewayService *OpenAIGatewayService,
- concurrencyService *ConcurrencyService,
- usagePool *UsageRecordWorkerPool,
+ activity *QuotaActivityService,
) *OpenAIQuotaService {
service := NewOpenAIQuotaService(accountRepo, proxyRepo, tokenProvider, privacyClientFactory)
service.agentIdentityWS = openAIGatewayService
- service.concurrency = concurrencyService
- service.usagePool = usagePool
+ service.activity = activity
return service
}
@@ -387,6 +385,7 @@ func ProvideAccountTestService(
openAIGatewayService *OpenAIGatewayService,
settingService *SettingService,
pluginManager *PluginManager,
+ activity *QuotaActivityService,
) *AccountTestService {
service := NewAccountTestService(
accountRepo,
@@ -402,6 +401,7 @@ func ProvideAccountTestService(
service.SetOpenAIGatewayService(openAIGatewayService)
service.SetSettingService(settingService)
service.SetPluginManager(pluginManager)
+ service.quotaActivity = activity
return service
}
@@ -863,6 +863,7 @@ func ProvideOpsService(
settingService *SettingService,
authCacheInvalidationWorker *AuthCacheInvalidationWorker,
apiKeyService *APIKeyService,
+ activity *QuotaActivityService,
) *OpsService {
svc := NewOpsService(
opsRepo,
@@ -885,6 +886,7 @@ func ProvideOpsService(
}
svc.authCacheInvalidationWorker = authCacheInvalidationWorker
svc.apiKeyService = apiKeyService
+ svc.quotaActivity = activity
svc.StartRuntimeSettingsRefresh(context.Background())
return svc
}
@@ -1018,6 +1020,7 @@ var ProviderSet = wire.NewSet(
ProvideGrokTokenProvider,
ProvideOpenAITokenProvider,
ProvideOpenAIQuotaService,
+ NewQuotaActivityService,
ProvideOpenAIQuotaAutoResetService,
ProvideGrokQuotaService,
ProvideCNProviderQuotaService,
@@ -1076,7 +1079,7 @@ var ProviderSet = wire.NewSet(
NewTotpService,
NewErrorPassthroughService,
NewTLSFingerprintProfileService,
- NewPluginManager,
+ ProvidePluginManager,
NewDigestSessionStore,
ProvideIdempotencyCoordinator,
ProvideSystemOperationLockService,
diff --git a/backend/migrations/246_plugin_extension_state.sql b/backend/migrations/246_plugin_extension_state.sql
index d855d8dafd86..ba320b75154b 100644
--- a/backend/migrations/246_plugin_extension_state.sql
+++ b/backend/migrations/246_plugin_extension_state.sql
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS sub2api_plugin_state (
state_key VARCHAR(256) NOT NULL,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
value JSONB NOT NULL,
- next_at TIMESTAMPTZ,
+ next_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (plugin_key, namespace, state_key)
);
diff --git a/backend/migrations/248_plugin_bundle_bootstrap.sql b/backend/migrations/248_plugin_bundle_bootstrap.sql
new file mode 100644
index 000000000000..08fd4d182754
--- /dev/null
+++ b/backend/migrations/248_plugin_bundle_bootstrap.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS sub2api_plugin_bootstrap (
+ plugin_key VARCHAR(160) PRIMARY KEY,
+ bundle_sha256 VARCHAR(64) NOT NULL,
+ migration_profile VARCHAR(160) NOT NULL,
+ desired_enabled BOOLEAN NOT NULL,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ state_imported BOOLEAN NOT NULL DEFAULT FALSE,
+ user_removed BOOLEAN NOT NULL DEFAULT FALSE,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/backend/pkg/extensionapi/proxy/address.go b/backend/pkg/extensionapi/proxy/address.go
new file mode 100644
index 000000000000..a78c46ced3e2
--- /dev/null
+++ b/backend/pkg/extensionapi/proxy/address.go
@@ -0,0 +1,105 @@
+package proxytransport
+
+import (
+ "errors"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// Normalize accepts the existing URL, host:port:user:password and labelled
+// multiline forms. It performs no DNS/network IO and never echoes credentials.
+func Normalize(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", nil
+ }
+ if len(raw) > 16384 {
+ return "", errors.New("proxy input is too long")
+ }
+ if strings.ContainsAny(raw, "\r\n") {
+ fields := map[string]string{}
+ for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || line == "(" || line == ")" {
+ continue
+ }
+ i := strings.IndexAny(line, "::=")
+ if i < 0 {
+ return "", errors.New("each proxy field needs a label")
+ }
+ key := strings.ToLower(strings.TrimSpace(line[:i]))
+ value := strings.TrimSpace(line[i+len(string([]rune(line[i:])[0])):])
+ switch key {
+ case "proxy server", "proxy_server", "server", "host", "hostname", "主机", "服务器", "节点", "代理服务器":
+ key = "host"
+ case "port", "端口":
+ key = "port"
+ case "user", "username", "用户名", "账号", "帐号":
+ key = "username"
+ case "pass", "password", "密码":
+ key = "password"
+ case "scheme", "protocol", "协议":
+ key = "scheme"
+ default:
+ return "", errors.New("unrecognized proxy field label")
+ }
+ if _, exists := fields[key]; exists {
+ return "", errors.New("duplicate proxy field")
+ }
+ fields[key] = value
+ }
+ if fields["host"] == "" || fields["port"] == "" {
+ return "", errors.New("proxy host and port are required")
+ }
+ scheme := fields["scheme"]
+ if scheme == "" {
+ scheme = "http"
+ }
+ address := url.URL{Scheme: strings.ToLower(scheme), Host: net.JoinHostPort(strings.Trim(fields["host"], "[]"), fields["port"])}
+ _, user := fields["username"]
+ _, password := fields["password"]
+ if user != password {
+ return "", errors.New("both username and password are required")
+ }
+ if user {
+ address.User = url.UserPassword(fields["username"], fields["password"])
+ }
+ raw = address.String()
+ } else if !strings.Contains(raw, "://") {
+ if strings.Contains(raw, "@") {
+ raw = "http://" + raw
+ } else {
+ parts := strings.SplitN(raw, ":", 4)
+ if len(parts) == 4 && !strings.HasPrefix(raw, "[") {
+ if _, err := strconv.Atoi(parts[1]); err != nil {
+ return "", errors.New("ambiguous proxy fields; use labelled fields")
+ }
+ address := url.URL{Scheme: "http", Host: net.JoinHostPort(parts[0], parts[1]), User: url.UserPassword(parts[2], parts[3])}
+ raw = address.String()
+ } else {
+ raw = "http://" + raw
+ }
+ }
+ }
+ address, err := url.Parse(raw)
+ if err != nil || address == nil {
+ return "", errors.New("invalid proxy URL")
+ }
+ address.Scheme = strings.ToLower(address.Scheme)
+ if address.Hostname() == "" || strings.ContainsAny(address.Hostname(), " \t\r\n") || address.Opaque != "" || address.RawQuery != "" || address.ForceQuery || address.Fragment != "" || (address.Path != "" && address.Path != "/") {
+ return "", errors.New("proxy URL requires a host and no path, query or fragment")
+ }
+ switch address.Scheme {
+ case "http", "https", "socks5", "socks5h":
+ default:
+ return "", errors.New("unsupported proxy protocol")
+ }
+ port, err := strconv.Atoi(address.Port())
+ if err != nil || port < 1 || port > 65535 {
+ return "", errors.New("proxy port must be between 1 and 65535")
+ }
+ address.Path = ""
+ return address.String(), nil
+}
diff --git a/backend/pkg/extensionapi/proxy/transport.go b/backend/pkg/extensionapi/proxy/transport.go
new file mode 100644
index 000000000000..fe08cbac4887
--- /dev/null
+++ b/backend/pkg/extensionapi/proxy/transport.go
@@ -0,0 +1,48 @@
+// Package proxytransport shares bounded, cancellable proxy transport primitives
+// between the host and independent plugins, without provider-specific policy.
+package proxytransport
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "golang.org/x/net/proxy"
+)
+
+const SOCKSDialTimeout = 10 * time.Second
+const SOCKSKeepAlive = 30 * time.Second
+
+func Configure(transport *http.Transport, address *url.URL, forward proxy.Dialer) error {
+ if transport == nil {
+ return fmt.Errorf("proxy transport is nil")
+ }
+ if address == nil {
+ return nil
+ }
+ switch strings.ToLower(address.Scheme) {
+ case "http", "https":
+ transport.Proxy = http.ProxyURL(address)
+ return nil
+ case "socks5", "socks5h":
+ if forward == nil {
+ forward = &net.Dialer{Timeout: SOCKSDialTimeout, KeepAlive: SOCKSKeepAlive}
+ }
+ dialer, err := proxy.FromURL(address, forward)
+ if err != nil {
+ return fmt.Errorf("create SOCKS dialer: %w", err)
+ }
+ contextDialer, ok := dialer.(proxy.ContextDialer)
+ if !ok {
+ return fmt.Errorf("SOCKS dialer does not support cancellation")
+ }
+ transport.Proxy = nil
+ transport.DialContext = contextDialer.DialContext
+ return nil
+ default:
+ return fmt.Errorf("unsupported proxy scheme: %s", address.Scheme)
+ }
+}
diff --git a/backend/pkg/extensionapi/ui/bridge.js b/backend/pkg/extensionapi/ui/bridge.js
new file mode 100644
index 000000000000..aee98d84ac43
--- /dev/null
+++ b/backend/pkg/extensionapi/ui/bridge.js
@@ -0,0 +1,50 @@
+// Public UI SDK. The sandbox receives a short-lived bridge token, never the
+// administrator's session credentials. Network actions are performed by the host.
+class PluginBridge {
+ constructor() {
+ this.token = new URLSearchParams(location.hash.slice(1)).get('bridge_token') || ''
+ this.sequence = 0
+ this.pending = new Map()
+ this.listener = event => {
+ const message = event.data
+ if (event.source !== parent || !message || message.source !== 'sub2api-plugin-host' || message.bridge_token !== this.token) return
+ const request = this.pending.get(message.request_id)
+ if (!request) return
+ this.pending.delete(message.request_id)
+ clearTimeout(request.timer)
+ if (message.ok) request.resolve(message)
+ else request.reject(new Error(message.error || 'Operation failed'))
+ }
+ window.addEventListener('message', this.listener)
+ this.notify('sub2api.plugin.ready')
+ }
+
+ notify(type, fields = {}) {
+ parent.postMessage({ source: 'sub2api-plugin-ui', bridge_token: this.token, type, ...fields }, '*')
+ }
+
+ request(type, fields = {}) {
+ const request_id = `extension-${++this.sequence}`
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { this.pending.delete(request_id); reject(new Error('Operation timed out')) }, 30000)
+ this.pending.set(request_id, { resolve, reject, timer })
+ this.notify(type, { ...fields, request_id })
+ })
+ }
+
+ async context() { return (await this.request('extension.context')).context }
+ async config() { return (await this.request('config.load')).config }
+ async status() { return (await this.request('plugin.status')).result }
+ async save(config) { return (await this.request('config.save', { config })).config }
+ async invoke(operation, payload = {}) { return (await this.request('extension.invoke', { operation, payload })).result }
+ async submit(operation, items, operation_key) { return (await this.request('extension.job.submit', { operation, items, operation_key })).job }
+ async job(job_id) { return (await this.request('extension.job.get', { job_id })).job }
+ resize(height) { this.notify('ui.resize', { height }) }
+ dispose() {
+ window.removeEventListener('message', this.listener)
+ for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new Error('Plugin view closed')) }
+ this.pending.clear()
+ }
+}
+
+globalThis.Sub2APIPluginBridge = PluginBridge
diff --git a/backend/pkg/extensionapi/v1/api.go b/backend/pkg/extensionapi/v1/api.go
index 4145ad1d417a..494c68d89403 100644
--- a/backend/pkg/extensionapi/v1/api.go
+++ b/backend/pkg/extensionapi/v1/api.go
@@ -34,13 +34,39 @@ type Dependency struct {
}
type Contribution struct {
- ID string `json:"id"`
- Slot string `json:"slot"`
- Label map[string]string `json:"label"`
- Action string `json:"action,omitempty"`
- Entrypoint string `json:"entrypoint,omitempty"`
- Permission string `json:"permission"`
- Order int `json:"order,omitempty"`
+ AccountFilter *AccountFilter `json:"account_filter,omitempty"`
+ ID string `json:"id"`
+ Slot string `json:"slot"`
+ Label map[string]string `json:"label"`
+ Action string `json:"action,omitempty"`
+ Entrypoint string `json:"entrypoint,omitempty"`
+ Permission string `json:"permission"`
+ Order int `json:"order,omitempty"`
+}
+
+type AccountFilter struct {
+ Platforms []string `json:"platforms,omitempty"`
+ Types []string `json:"types,omitempty"`
+ Statuses []string `json:"statuses,omitempty"`
+ ExcludeShadows bool `json:"exclude_shadows,omitempty"`
+}
+
+func AccountMatchesFilter(account Account, filter *AccountFilter) bool {
+ if filter == nil {
+ return true
+ }
+ contains := func(choices []string, value string) bool {
+ if len(choices) == 0 {
+ return true
+ }
+ for _, choice := range choices {
+ if choice == value {
+ return true
+ }
+ }
+ return false
+ }
+ return contains(filter.Platforms, account.Platform) && contains(filter.Types, account.Type) && contains(filter.Statuses, account.Status) && (!filter.ExcludeShadows || !account.Shadow)
}
var slots = map[string]bool{
@@ -64,6 +90,7 @@ type Account struct {
}
type AccountQuery struct {
+ ObservationID string `json:"observation_id,omitempty"`
AccountID int64 `json:"account_id,omitempty"`
Platform string `json:"platform,omitempty"`
AccountType string `json:"account_type,omitempty"`
@@ -72,11 +99,12 @@ type AccountQuery struct {
}
type OutboundIdentity struct {
- AccountID int64 `json:"account_id"`
- Identity string `json:"identity"`
- Token string `json:"token"`
- ProxyURL string `json:"proxy_url,omitempty"`
- Headers map[string][]string `json:"headers"`
+ ObservationID string `json:"observation_id,omitempty"`
+ AccountID int64 `json:"account_id"`
+ Identity string `json:"identity"`
+ Token string `json:"token"`
+ ProxyURL string `json:"proxy_url,omitempty"`
+ Headers map[string][]string `json:"headers"`
}
type SchedulingRequest struct {
@@ -93,6 +121,38 @@ type SchedulingDecision struct {
Until *time.Time `json:"until,omitempty"`
}
+type SchedulingRule struct {
+ ExcludeShadows bool `json:"exclude_shadows,omitempty"`
+ Models []string `json:"models"`
+ Default string `json:"default"`
+ Reason string `json:"reason"`
+}
+
+type SchedulingConstraint struct {
+ Model string `json:"model"`
+ Effect string `json:"effect"`
+ Until *time.Time `json:"until,omitempty"`
+ Reason string `json:"reason"`
+}
+
+type AccountProjection struct {
+ AccountID int64 `json:"account_id"`
+ Identity string `json:"identity"`
+ Scheduling []SchedulingConstraint `json:"scheduling"`
+ Observations []AccountObservation `json:"observations,omitempty"`
+}
+
+type AccountObservation struct {
+ Key string `json:"key"`
+ Kind string `json:"kind"`
+ State string `json:"state"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ NextAt *time.Time `json:"next_at,omitempty"`
+ CheckedAt *time.Time `json:"checked_at,omitempty"`
+ Code string `json:"code,omitempty"`
+ Count int `json:"count,omitempty"`
+}
+
// Invocation carries one declared capability and a domain operation. The host
// validates the capability binding before crossing the process boundary.
type Invocation struct {
@@ -107,21 +167,34 @@ type Result struct {
Message string `json:"message,omitempty"`
}
+type JobTarget struct {
+ AccountID int64 `json:"account_id"`
+ Payload json.RawMessage `json:"payload"`
+ Label string `json:"label,omitempty"`
+}
+
+type DisplayFact struct {
+ Label map[string]string `json:"label"`
+ Value string `json:"value"`
+ Timestamp bool `json:"timestamp,omitempty"`
+}
+
type HostOperation string
const (
- HostAccountRead HostOperation = "account.read"
- HostAccountList HostOperation = "account.list"
- HostResolveIdentity HostOperation = "account.resolve_identity"
- HostUsageQuery HostOperation = "usage.query"
- HostStateRead HostOperation = "state.read"
- HostStateDue HostOperation = "state.due"
- HostStateCompareSwap HostOperation = "state.compare_swap"
- HostLeaseAcquire HostOperation = "lease.acquire"
- HostLeaseRelease HostOperation = "lease.release"
- HostJobRead HostOperation = "job.read"
- HostJobSubmit HostOperation = "job.submit"
- HostJobComplete HostOperation = "job.complete"
+ HostAccountRead HostOperation = "account.read"
+ HostAccountList HostOperation = "account.list"
+ HostResolveIdentity HostOperation = "account.resolve_identity"
+ HostUsageQuery HostOperation = "usage.query"
+ HostFinishObservation HostOperation = "usage.observation_finish"
+ HostStateRead HostOperation = "state.read"
+ HostStateDue HostOperation = "state.due"
+ HostStateCompareSwap HostOperation = "state.compare_swap"
+ HostLeaseAcquire HostOperation = "lease.acquire"
+ HostLeaseRelease HostOperation = "lease.release"
+ HostJobRead HostOperation = "job.read"
+ HostJobSubmit HostOperation = "job.submit"
+ HostJobComplete HostOperation = "job.complete"
)
type HostInvocation struct {
@@ -130,11 +203,12 @@ type HostInvocation struct {
}
type StateRequest struct {
- Namespace string `json:"namespace"`
- Key string `json:"key"`
- ExpectedRevision int64 `json:"expected_revision"`
- Value json.RawMessage `json:"value,omitempty"`
- NextAt *time.Time `json:"next_at,omitempty"`
+ Namespace string `json:"namespace"`
+ Key string `json:"key"`
+ ExpectedRevision int64 `json:"expected_revision"`
+ Value json.RawMessage `json:"value,omitempty"`
+ NextAt *time.Time `json:"next_at,omitempty"`
+ Projection *AccountProjection `json:"account_projection,omitempty"`
}
type DueStateRequest struct {
diff --git a/backend/pkg/extensionapi/v1/catalog.go b/backend/pkg/extensionapi/v1/catalog.go
new file mode 100644
index 000000000000..49162e3f5a18
--- /dev/null
+++ b/backend/pkg/extensionapi/v1/catalog.go
@@ -0,0 +1,65 @@
+// Versioned model-reference contracts shared by the host and domain plugins.
+package extensionv1
+
+import "context"
+
+const (
+ ModelContextCapacityBasisTotal = "total_context"
+ ModelContextCapacityBasisInput = "input_limit"
+ ModelContextCapacityBasisMaximum = "max_context_window"
+ MaxModelContextTokens int64 = 9007199254740991
+)
+
+type ModelContextCapacity struct {
+ ContextWindow int64 `json:"context_window,omitempty"`
+ MaxContextWindow int64 `json:"max_context_window,omitempty"`
+ MaxInputTokens int64 `json:"max_input_tokens,omitempty"`
+ MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
+ CapacityBasis string `json:"capacity_basis,omitempty"`
+ ObservedAt string `json:"observed_at,omitempty"`
+}
+
+type ModelContextCapacityReference struct {
+ Product string `json:"product"`
+ SourceURL string `json:"source_url"`
+ Release string `json:"release"`
+ VerifiedAt string `json:"verified_at"`
+ ContextWindow int64 `json:"context_window"`
+ MaxContextWindow int64 `json:"max_context_window"`
+}
+
+type OfficialModelContextCapacity struct {
+ ModelContextCapacity
+ ModelID string `json:"model_id"`
+ Aliases []string `json:"aliases,omitempty"`
+ Provider string `json:"provider"`
+ Product string `json:"product"`
+ SourceURL string `json:"source_url"`
+ SourceURLs []string `json:"source_urls,omitempty"`
+ VerifiedAt string `json:"verified_at"`
+ OriginalText string `json:"original_text"`
+ NormalizationBasis string `json:"normalization_basis,omitempty"`
+ Conditions string `json:"conditions,omitempty"`
+ Reference *ModelContextCapacityReference `json:"reference,omitempty"`
+ MatchHosts []string `json:"-"`
+ MatchAccountModes []string `json:"-"`
+}
+
+type CatalogQuery struct {
+ Candidates []string `json:"candidates"`
+ Platform string `json:"platform"`
+ AccountType string `json:"account_type"`
+ AccountMode string `json:"account_mode,omitempty"`
+ Scheme string `json:"scheme,omitempty"`
+ Host string `json:"host,omitempty"`
+ Port string `json:"port,omitempty"`
+ Path string `json:"path,omitempty"`
+ HasURLCredentials bool `json:"has_url_credentials,omitempty"`
+}
+type CatalogMatch struct {
+ Matched bool `json:"matched"`
+ Entry *OfficialModelContextCapacity `json:"entry,omitempty"`
+}
+type CatalogResolver interface {
+ ResolveCatalog(context.Context, CatalogQuery) (CatalogMatch, error)
+}
diff --git a/backend/pkg/pluginapi/v1/manifest.schema.json b/backend/pkg/pluginapi/v1/manifest.schema.json
index 99720bc6dc11..3b97b97fa04f 100644
--- a/backend/pkg/pluginapi/v1/manifest.schema.json
+++ b/backend/pkg/pluginapi/v1/manifest.schema.json
@@ -58,7 +58,16 @@
"label": { "type": "object", "minProperties": 1, "additionalProperties": { "type": "string" } },
"permission": { "type": "string", "minLength": 1 },
"action": { "type": "string" }, "entrypoint": { "type": "string", "pattern": "^ui/.+" },
- "order": { "type": "integer" }
+ "order": { "type": "integer" },
+ "account_filter": {
+ "type": "object", "additionalProperties": false,
+ "properties": {
+ "platforms": { "type": "array", "items": { "type": "string" } },
+ "types": { "type": "array", "items": { "type": "string" } },
+ "statuses": { "type": "array", "items": { "type": "string" } },
+ "exclude_shadows": { "type": "boolean" }
+ }
+ }
}
}
},
diff --git a/frontend/src/api/admin/plugins.ts b/frontend/src/api/admin/plugins.ts
index 016e6bdef1c4..b83d19657be8 100644
--- a/frontend/src/api/admin/plugins.ts
+++ b/frontend/src/api/admin/plugins.ts
@@ -1,4 +1,5 @@
import { apiClient } from '../client'
+import { accountJobIdempotencyHeaders, type AccountJob } from './accountJobs'
export interface PluginCapability {
id: string
@@ -76,6 +77,7 @@ export interface PluginInstallation {
}
export interface PluginContribution {
+ account_filter?: { platforms?: string[]; types?: string[]; statuses?: string[]; exclude_shadows?: boolean }
id: string
slot: string
label: Record
@@ -94,7 +96,13 @@ export async function contributions(): Promise {
}
export async function invokeAdmin(id: number, operation: string, accountID: number | undefined, payload: Record): Promise<{ payload?: unknown; code?: string; message?: string }> {
- const { data } = await apiClient.post(`/admin/plugins/${id}/actions`, { operation, account_id: accountID, payload })
+ const { data } = await apiClient.post(`/admin/plugins/${id}/actions`, { operation, account_id: accountID, payload }, { timeout: 30000 })
+ return data
+}
+
+export async function submitJob(id: number, operation: string, items: Array<{ account_id: number; payload: Record; label?: string }>, key?: string): Promise {
+ const config = key ? { headers: { 'Idempotency-Key': key } } : accountJobIdempotencyHeaders('plugin_operation')
+ const { data } = await apiClient.post(`/admin/plugins/${id}/jobs`, { operation, items }, config)
return data
}
@@ -142,16 +150,19 @@ export async function enable(
rollout_percent: rolloutPercent,
accept_untested: acceptUntested
})
+ changed()
return data
}
export async function disable(id: number): Promise {
const { data } = await apiClient.post(`/admin/plugins/${id}/disable`)
+ changed()
return data
}
export async function remove(id: number): Promise {
await apiClient.delete(`/admin/plugins/${id}`)
+ changed()
}
export async function getConfig(id: number): Promise> {
@@ -164,6 +175,7 @@ export async function saveConfig(
config: Record
): Promise> {
const { data } = await apiClient.put>(`/admin/plugins/${id}/config`, config)
+ changed()
return data
}
@@ -183,6 +195,9 @@ export async function createUISession(id: number): Promise {
}
export default {
+ contributions,
+ invokeAdmin,
+ submitJob,
list,
upload,
enable,
@@ -194,3 +209,5 @@ export default {
status,
createUISession
}
+
+function changed() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('sub2api:plugins-changed')) }
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 98911c3bd904..659a652b9c17 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -688,9 +688,6 @@ export interface SystemSettings {
openai_codex_client_version: string;
openai_codex_client_version_synced: string;
openai_codex_version_auto_sync_enabled: boolean;
- openai_codex_ticket_enabled: boolean;
- openai_codex_ticket_harvest_proxy_url: string;
- openai_codex_ticket_harvest_proxy_configured: boolean;
// codex_cli_only 加固
min_codex_version: string;
max_codex_version: string;
@@ -1016,9 +1013,6 @@ export interface UpdateSettingsRequest {
openai_codex_user_agent?: string;
openai_codex_client_version?: string;
openai_codex_version_auto_sync_enabled?: boolean;
- openai_codex_ticket_enabled?: boolean;
- openai_codex_ticket_clear_proxy?: boolean;
- openai_codex_ticket_harvest_proxy_url?: string;
// codex_cli_only 加固
min_codex_version?: string;
max_codex_version?: string;
diff --git a/frontend/src/components/admin/CodexTicketProxyEditor.vue b/frontend/src/components/admin/CodexTicketProxyEditor.vue
deleted file mode 100644
index 85cf4483d3ed..000000000000
--- a/frontend/src/components/admin/CodexTicketProxyEditor.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
{{ t('admin.accounts.ticketProxy.hint') }}
-
{{ error }}
-
-
{{ result.message }}
-
{{ result.failure_detail }}
-
{{ t('admin.accounts.ticketProxy.stages.' + stage.name) }} · {{ stage.success ? '✓' : '✗' }} {{ stage.message }} {{ stage.duration_ms }} ms
-
{{ t('admin.accounts.ticketProxy.trust') }}: {{ result.certificate_trust }} {{ result.certificate_fingerprint }}
-
{{ result.protocol_suggestion }}
-
-
-
-
diff --git a/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts b/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts
deleted file mode 100644
index b8a5123e0a49..000000000000
--- a/frontend/src/components/admin/__tests__/CodexTicketProxyEditor.spec.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { mount, flushPromises } from '@vue/test-utils'
-import { describe, it, expect, vi } from 'vitest'
-import CodexTicketProxyEditor from '../CodexTicketProxyEditor.vue'
-const mocks = vi.hoisted(() => ({ testProxy: vi.fn() }))
-vi.mock('@/api/admin/codexTickets', () => ({ codexTicketsAPI: mocks }))
-vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
-describe('draft ticket proxy editor', () => {
- it('only parses on blur and invalidates old connection evidence on edit', async () => {
- const wrapper = mount(CodexTicketProxyEditor, { props: { modelValue: 'Password: fixture\nPort: 2721\nHost: proxy.example.com\nUsername: demo' } })
- await wrapper.get('textarea').trigger('blur')
- expect(mocks.testProxy).not.toHaveBeenCalled()
- const normalized = wrapper.emitted('update:modelValue')![0][0] as string
- expect(normalized).toBe('http://demo:fixture@proxy.example.com:2721')
- await wrapper.setProps({ modelValue: normalized })
- mocks.testProxy.mockResolvedValue({ success: false, network_reachable: true, code: 'target_http_status', message: 'HTTP 403; not verified', stages: [{ name: 'http', success: false, duration_ms: 12 }] })
- await wrapper.findAll('button').find(b => b.text().endsWith('.test'))!.trigger('click'); await flushPromises()
- expect(mocks.testProxy).toHaveBeenCalledWith(normalized)
- expect(wrapper.get('[role="status"]').text()).toContain('HTTP 403')
- await wrapper.setProps({ modelValue: 'http://other.example.com:8080' })
- expect(wrapper.find('[role="status"]').exists()).toBe(false)
- wrapper.unmount()
- })
- it('keeps ambiguous source input and reports a parsing error', async () => {
- const wrapper = mount(CodexTicketProxyEditor, { props: { modelValue: 'Host: one\nHost: two\nPort: 8080' } })
- await wrapper.get('textarea').trigger('blur')
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- expect(wrapper.get('[role="alert"]').text()).toContain('duplicate_field')
- wrapper.unmount()
- })
- it('shows the safe transport cause supplied by the server', async () => {
- const wrapper = mount(CodexTicketProxyEditor, { props: { modelValue: 'http://proxy.example.com:8080' } })
- mocks.testProxy.mockResolvedValue({ success: false, network_reachable: false, code: 'ticket_proxy_eof', message: 'Connection closed', failure_detail: 'Head [redacted]: EOF', stages: [] })
- await wrapper.findAll('button').find(b => b.text().endsWith('.test'))!.trigger('click'); await flushPromises()
- expect(wrapper.get('[role="status"]').text()).toContain('Head [redacted]: EOF')
- wrapper.unmount()
- })
-})
diff --git a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue
index e485eb310bba..7e84185e48c7 100644
--- a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue
+++ b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue
@@ -1,7 +1,7 @@
-
+
{{ t('admin.accountTasks.configuration') }}
@@ -17,7 +17,7 @@ import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { useAccountJobsStore } from '@/stores/accountJobs'
import type { AccountJob } from '@/api/admin/accountJobs'
-const props = withDefaults(defineProps<{ show: boolean; title: string; job?: AccountJob | null; width?: 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full' }>(), { job: null, width: 'wide' })
+const props = withDefaults(defineProps<{ show: boolean; title: string; job?: AccountJob | null; showConfiguration?: boolean; width?: 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full' }>(), { job: null, width: 'wide', showConfiguration: true })
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const AccountOperationProgress = defineAsyncComponent(() => import('./AccountOperationProgress.vue'))
diff --git a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue
index fa9f821a253f..995222813d19 100644
--- a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue
+++ b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue
@@ -46,12 +46,19 @@
{{ itemLabel(item) }}
{{ item.metadata.model_id }}
+
{{ item.metadata.label }}
- {{ ticketResult(item)?.code === 'ticket_skipped' ? t('admin.accountTasks.skipped') : statusLabel(item.status) }}
+ {{ statusLabel(item.status) }}
- {{ item.error_message }}
- {{ ticketResultLabel(item) }}
- {{ item.metadata.latency_ms }} ms
+ {{ item.metadata.message }}
+ {{ item.error_message }}
+
+
+
- {{ fact.label[locale || 'zh'] || fact.label.zh || fact.label.en }}
+ - {{ fact.timestamp ? formatDateTime(fact.value) : fact.value }}
+
+
+ {{ item.metadata.latency_ms }} ms
{{ t(store.loadingCurrent ? 'common.loading' : store.itemFilter ? 'admin.accountTasks.noFailures' : 'admin.accountTasks.awaitingResults') }}
@@ -78,15 +85,19 @@ import Icon from '@/components/icons/Icon.vue'
import { isTerminalAccountJob, useAccountJobsStore } from '@/stores/accountJobs'
import { list as listAccounts } from '@/api/admin/accounts'
import type { AccountJobItem, DuplicateReviewMetadata } from '@/api/admin/accountJobs'
-import type { TicketResult } from '@/api/admin/codexTickets'
+import { formatDateTime } from '@/utils/format'
const emit = defineEmits<{ close: [] }>()
-const { t } = useI18n()
+const { t, locale } = useI18n()
const store = useAccountJobsStore()
const job = computed(() => store.currentJob)
const terminal = computed(() => !!job.value && isTerminalAccountJob(job.value))
const progress = computed(() => job.value?.target_count ? Math.min(100, Math.round(job.value.processed_count / job.value.target_count * 100)) : 0)
const busy = ref(false), error = ref(''), retryExpired = ref(false), survivorID = ref(null), confirmMerge = ref(false)
const names = ref>({})
+type ResultFact = { label: Record; value: string; timestamp?: boolean }
+function resultFacts(item: AccountJobItem): ResultFact[] {
+ return Array.isArray(item.metadata.facts) ? item.metadata.facts.filter((fact): fact is ResultFact => !!fact && typeof fact === 'object' && typeof fact.value === 'string' && !!fact.label && typeof fact.label === 'object') : []
+}
let nameVersion = 0
watch(() => job.value?.id, () => { error.value = ''; retryExpired.value = false; survivorID.value = null; confirmMerge.value = false })
watch(survivorID, () => { confirmMerge.value = false })
@@ -114,12 +125,6 @@ function statusClass(status: string) {
return status === 'failed' ? 'text-red-600 dark:text-red-400' : ['partially_succeeded', 'canceled'].includes(status) ? 'text-amber-600 dark:text-amber-400' : 'text-primary-600 dark:text-primary-400'
}
function itemLabel(item: AccountJobItem) { return (typeof item.metadata.name === 'string' && item.metadata.name) || (item.target_account_id && names.value[item.target_account_id]) || (item.target_account_id ? t('admin.accountTasks.account', { id: item.target_account_id }) : t('admin.accountTasks.item', { ordinal: item.ordinal })) }
-function ticketResult(item: AccountJobItem): TicketResult | null { return item.metadata.ticket_result && typeof item.metadata.ticket_result === 'object' ? item.metadata.ticket_result as TicketResult : null }
-function ticketResultLabel(item: AccountJobItem) {
- const r = ticketResult(item)
- if (!r) return ''
- return [r.message, r.http_status ? `HTTP ${r.http_status}` : '', r.observed_length ? `${t('admin.accounts.tickets.length')}: ${r.observed_length}` : '', r.duration_ms ? `${r.duration_ms} ms` : ''].filter(Boolean).join(' · ')
-}
async function action(callback: () => Promise) {
if (busy.value) return
busy.value = true; error.value = ''
diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue
index c359725e65cf..4bdf3be22bf8 100644
--- a/frontend/src/components/admin/account/AccountActionMenu.vue
+++ b/frontend/src/components/admin/account/AccountActionMenu.vue
@@ -12,7 +12,7 @@
{{ t('common.processing') }}
-
+
-
-
-
- {{ t("admin.settings.gatewayForwarding.codexTicketEnabled") }}
-
-
- {{ t("admin.settings.gatewayForwarding.codexTicketEnabledDesc") }}
-
-
-
-
-
-
- {{ t("admin.settings.gatewayForwarding.codexTicketHarvestProxy") }}
-
-
- {{ t("admin.settings.gatewayForwarding.codexTicketHarvestProxyDesc") }}
-
-
-
- {{ t("admin.settings.gatewayForwarding.codexTicketHarvestProxyConfigured") }}
-
-
+
{{ t("admin.settings.gatewayForwarding.codexClientRestrictionTitle") }}
@@ -9056,7 +9028,7 @@