Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,49 @@ other key or a non-boolean value (including `null`), returns HTTP 400 and stores
nothing. While no Codex runtime row is stored,
`gateway.openai_codex_request_zstd` decides the compression switch.

## Codex fingerprint simulation

Extensions owns `/admin/codex-fingerprint`. General settings and account
create/edit/bulk-edit forms no longer submit fingerprint configuration.
`GET/PUT /admin/settings/codex-fingerprint` uses `enabled`, `user_agent`,
`client_version` and `version_auto_sync_enabled`; all four fields are required
on PUT. GET also returns the synced and effective simulation versions and the
default UA. Existing UA/version/sync setting keys remain authoritative; only
`codex_fingerprint_enabled` is new. Without that key the startup identity flag
provides the default. Writes use existing admin authentication and audit, with
no additional step-up. Legacy settings APIs use the same keys.

`GET/PUT /admin/accounts/:id/codex-fingerprint` applies only to OpenAI OAuth and
setup-token accounts. GET computes effective identity through the forwarding
selector, including credential shadows. PUT accepts only `mode`; bulk changes
use ordinary account jobs with explicit frozen IDs and only that extra key.
General account edits preserve the latest stored mode when it is omitted.
Existing seeds and persisted TUI identities are retained, and new accounts still
default to `device`.

Enabled simulation applies a fixed account TUI identity to HTTP, passthrough,
WS, account tests and credential refresh. Disabled simulation preserves caller
identity and identifiers, filling only missing protocol identity headers.
Mode `off` preserves device/session identifiers while simulation stays enabled;
`device` changes only device identifiers; `session` keeps separate real threads
and maps their parent references; `full` combines threads and removes self-parent
references. Real turn IDs, parent/root turn IDs, window indices and timestamps
are retained. Sandbox `none`, `external`, permission modes and unknown tags are
preserved; known platform backends are aligned without inventing a sandbox for
an absent tag. Explicit custom cache keys are not rewritten.

The built-in protocol/version baseline is official Codex `rust-v0.161.0`.
The existing six-hour stable-version sync and manual-version precedence remain.
Successful saves publish an immutable policy; failed saves retain the old one.
Each attempt shares it across headers/body. WS reuse includes identity and
policy revision. Setting changes let the current turn finish, then request a
retryable reconnect before another turn is sent through an old handshake.
Account mode changes invalidate only that account's next handshake.

Account connection tests exclude and reject `ultra` across every capability
source before dispatch; stale UI selections reset to default. Codex client
orchestration catalogs retain their existing capabilities.

## Codex gateway borrowing

The Extensions sidebar has separate Codex Borrowing and Pelican Tests entries.
Expand Down
95 changes: 95 additions & 0 deletions backend/internal/handler/admin/codex_fingerprint_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package admin

import (
"strconv"

infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)

func (h *SettingHandler) GetCodexFingerprintSettings(c *gin.Context) {
view, err := h.settingService.GetCodexFingerprintSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
c.Header("Cache-Control", "no-store")
response.Success(c, view)
}

func (h *SettingHandler) UpdateCodexFingerprintSettings(c *gin.Context) {
raw, err := c.GetRawData()
var config service.CodexFingerprintSettings
if err == nil {
config, err = service.DecodeCodexFingerprintSettings(raw)
}
if err != nil {
response.BadRequest(c, "Invalid Codex fingerprint settings: "+err.Error())
return
}
view, err := h.settingService.UpdateCodexFingerprintSettings(c.Request.Context(), config)
if err != nil {
response.ErrorFrom(c, err)
return
}
middleware.SetAuditExtra(c, map[string]any{"enabled": config.Enabled, "client_version": config.ClientVersion, "version_auto_sync_enabled": config.VersionAutoSyncEnabled})
response.Success(c, view)
}

func (h *AccountHandler) codexFingerprintAccount(c *gin.Context) (*service.Account, *service.Account, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return nil, nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "invalid account ID")
}
account, err := h.adminService.GetAccount(c.Request.Context(), id)
if err != nil {
return nil, nil, err
}
if !account.IsOpenAIOAuthLike() {
return nil, nil, infraerrors.BadRequest("CODEX_FINGERPRINT_UNSUPPORTED_ACCOUNT", "Codex fingerprint settings require an OpenAI OAuth or setup-token account")
}
source := account
if account.IsShadow() {
source, err = h.adminService.GetAccount(c.Request.Context(), *account.ParentAccountID)
if err != nil {
return nil, nil, err
}
}
return account, source, nil
}

func (h *AccountHandler) GetCodexFingerprint(c *gin.Context) {
account, source, err := h.codexFingerprintAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
c.Header("Cache-Control", "no-store")
response.Success(c, service.DescribeCodexFingerprintAccount(account, source))
}

func (h *AccountHandler) UpdateCodexFingerprint(c *gin.Context) {
raw, err := c.GetRawData()
var mode string
if err == nil {
mode, err = service.DecodeCodexFingerprintMode(raw)
}
if err != nil {
response.BadRequest(c, "Invalid Codex fingerprint mode: "+err.Error())
return
}
account, _, err := h.codexFingerprintAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, map[string]any{"codex_fingerprint_mode": mode}); err != nil {
response.ErrorFrom(c, err)
return
}
middleware.SetAuditExtra(c, map[string]any{"account_id": account.ID, "mode": mode})
h.GetCodexFingerprint(c)
}
64 changes: 64 additions & 0 deletions backend/internal/handler/admin/codex_fingerprint_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package admin

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

type fingerprintAccountAdmin struct {
service.AdminService
accounts map[int64]*service.Account
updates map[string]any
}

func (s *fingerprintAccountAdmin) GetAccount(_ context.Context, id int64) (*service.Account, error) {
return s.accounts[id], nil
}
func (s *fingerprintAccountAdmin) UpdateAccountExtra(_ context.Context, id int64, updates map[string]any) error {
s.updates = updates
for key, value := range updates {
s.accounts[id].Extra[key] = value
}
return nil
}

func TestCodexFingerprintAccountHandlerOnlyChangesModeAndUsesParentIdentity(t *testing.T) {
parentID := int64(1)
parent := &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: map[string]any{"codex_fingerprint_seed": "ac6a7159-d158-41e8-bd52-3e66fd5c6515", "codex_fingerprint_mode": "device"}}
shadow := &service.Account{ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, ParentAccountID: &parentID, Extra: map[string]any{"codex_fingerprint_mode": "full", "preserved": "value"}}
admin := &fingerprintAccountAdmin{accounts: map[int64]*service.Account{1: parent, 2: shadow}}
h := &AccountHandler{adminService: admin}
call := func(method, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(method, "/api/v1/admin/accounts/2/codex-fingerprint", strings.NewReader(body))
c.Params = gin.Params{{Key: "id", Value: "2"}}
if method == http.MethodGet {
h.GetCodexFingerprint(c)
} else {
h.UpdateCodexFingerprint(c)
}
return rec
}
read := call(http.MethodGet, "")
require.Equal(t, http.StatusOK, read.Code)
require.Equal(t, int64(1), gjson.Get(read.Body.String(), "data.identity.identity_account_id").Int())
saved := call(http.MethodPut, `{"mode":"off"}`)
require.Equal(t, http.StatusOK, saved.Code)
require.Equal(t, map[string]any{"codex_fingerprint_mode": "off"}, admin.updates)
require.Equal(t, "value", shadow.Extra["preserved"])
require.Equal(t, "device", parent.Extra["codex_fingerprint_mode"])
for _, body := range []string{`{"mode":"invalid"}`, `{"mode":"full","codex_fingerprint_seed":"injected"}`, `{"mode":null}`} {
rejected := call(http.MethodPut, body)
require.Equal(t, http.StatusBadRequest, rejected.Code)
require.Equal(t, "off", shadow.Extra["codex_fingerprint_mode"])
}
}
2 changes: 2 additions & 0 deletions backend/internal/server/middleware/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ var auditActionOverrides = map[string]string{
"PUT /api/v1/admin/system-prompts": "admin.system_prompts.update",
"PUT /api/v1/admin/system-prompts/bindings": "admin.system_prompts.bindings.update",
"PUT /api/v1/admin/reasoning-recovery": "admin.reasoning_recovery.update",
"PUT /api/v1/admin/settings/codex-fingerprint": "admin.codex_fingerprint.update",
"PUT /api/v1/admin/accounts/:id/codex-fingerprint": "admin.account.codex_fingerprint.update",
}

// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/server/routes/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.GET("/:id/codex-fingerprint", h.Admin.Account.GetCodexFingerprint)
accounts.PUT("/:id/codex-fingerprint", h.Admin.Account.UpdateCodexFingerprint)
accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility)
accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility)
accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled)
Expand Down Expand Up @@ -645,6 +647,8 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpA
adminSettings.GET("/observability", h.Admin.Setting.GetObservabilitySettings)
adminSettings.PUT("/observability", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateObservabilitySettings)
adminSettings.GET("/codex-runtime", h.Admin.Setting.GetCodexRuntimeSettings)
adminSettings.GET("/codex-fingerprint", h.Admin.Setting.GetCodexFingerprintSettings)
adminSettings.PUT("/codex-fingerprint", h.Admin.Setting.UpdateCodexFingerprintSettings)
adminSettings.PUT("/codex-runtime", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.UpdateCodexRuntimeSettings)
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
Expand Down
19 changes: 14 additions & 5 deletions backend/internal/service/account_test_reasoning.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,17 @@ const accountTestReasoningContextKey = "account_test_reasoning_effort"

// AccountTestReasoningOptions uses the same account mapping and capability
// sources as the model catalog. Unknown model capabilities stay unknown.
func AccountTestReasoningOptions(account *Account, model string) ([]string, string) {
func AccountTestReasoningOptions(account *Account, model string) (efforts []string, defaultEffort string) {
// Ultra coordinates client-side work; it is never a single inference effort.
// Filter every capability source, including aliases and upstream metadata.
defer func() {
efforts = slices.DeleteFunc(slices.Clone(efforts), func(effort string) bool {
return normalizeReasoningLevel(effort) == "ultra"
})
if normalizeReasoningLevel(defaultEffort) == "ultra" {
defaultEffort = ""
}
}()
if account == nil || strings.TrimSpace(model) == "" {
return nil, ""
}
Expand Down Expand Up @@ -56,10 +66,6 @@ func AccountTestReasoningOptions(account *Account, model string) ([]string, stri
}
out := make([]string, 0, len(levels))
for _, level := range levels {
// Ultra is client orchestration, never a native inference effort.
if level.Effort == "ultra" && isOpenAIGPT6SolOrLunaModel(model) {
continue
}
out = append(out, level.Effort)
}
return out, ""
Expand Down Expand Up @@ -109,6 +115,9 @@ func ValidateAccountTestReasoning(account *Account, model, mode, effort string)
if effort == "" {
return nil
}
if normalizeReasoningLevel(effort) == "ultra" {
return errors.New("reasoning effort is not supported by the selected account model")
}
if effort != strings.TrimSpace(effort) || len(effort) > 32 || (mode != "" && mode != AccountTestModeDefault && mode != AccountTestModeGrokText) {
return errors.New("reasoning effort is unsupported for this test mode")
}
Expand Down
13 changes: 8 additions & 5 deletions backend/internal/service/account_test_reasoning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import (
func TestAccountTestReasoningValidatedAgainstMappedModel(t *testing.T) {
a := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"friendly": "gpt-6-astra"}}}
levels, _ := AccountTestReasoningOptions(a, "friendly")
require.Contains(t, levels, "ultra")
require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "ultra"))
require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "text", "ultra"))
require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "compact", "ultra"), "reasoning effort is unsupported for this test mode")
require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", " ultra "), "reasoning effort is unsupported for this test mode")
require.NotContains(t, levels, "ultra")
require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "default", "max"))
require.NoError(t, ValidateAccountTestReasoning(a, "friendly", "text", "max"))
require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "compact", "max"), "reasoning effort is unsupported for this test mode")
for _, mode := range []string{"default", "text", "compact"} {
require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", mode, "ultra"), "reasoning effort is not supported by the selected account model")
}
require.Error(t, ValidateAccountTestReasoning(a, "friendly", "default", " ultra "))
require.EqualError(t, ValidateAccountTestReasoning(a, "friendly", "default", "invented"), "reasoning effort is not supported by the selected account model")
require.Error(t, ValidateAccountTestReasoning(a, "gpt-image-2", "default", "high"))
require.NoError(t, ValidateAccountTestReasoning(a, "unknown-model", "default", ""))
Expand Down
12 changes: 8 additions & 4 deletions backend/internal/service/account_test_reasoning_wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,26 @@ func TestAccountTestReasoningSurvivesMappingAndWireCompression(t *testing.T) {
svc := &AccountTestService{accountRepo: &reasoningTestRepo{account: a}, httpUpstream: upstream}
c, rec := newTestContext()
enableCodexRequestZstd(t)
require.NoError(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "ultra"}))
require.NoError(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "max"}))
require.Len(t, upstream.requests, 1)
raw, err := io.ReadAll(upstream.requests[0].Body)
require.NoError(t, err)
body := zstdDecodeForTest(t, raw)
require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String())
require.Equal(t, "ultra", gjson.GetBytes(body, "reasoning.effort").String())
require.Contains(t, rec.Body.String(), `"effective_reasoning_effort":"ultra"`)
require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String())
require.Contains(t, rec.Body.String(), `"effective_reasoning_effort":"max"`)
c, rec = newTestContext()
require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeCompact, AccountTestOptions{ReasoningEffort: "ultra"}))
require.Contains(t, rec.Body.String(), `"error":"reasoning effort is unsupported for this test mode"`)
require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`)
require.Len(t, upstream.requests, 1, "unsupported mode must fail before any upstream request")
c, rec = newTestContext()
require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "invented"}))
require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`)
require.Len(t, upstream.requests, 1, "unsupported effort must fail before any upstream request")
c, rec = newTestContext()
require.Error(t, svc.TestAccountConnection(c, a.ID, "friendly", "test", AccountTestModeDefault, AccountTestOptions{ReasoningEffort: "ultra"}))
require.Contains(t, rec.Body.String(), `"error":"reasoning effort is not supported by the selected account model"`)
require.Len(t, upstream.requests, 1, "ultra must fail before any upstream request")
}

func TestAccountTestReasoningOpenCodeGoSerializesNativeProtocol(t *testing.T) {
Expand Down
Loading
Loading