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
40 changes: 29 additions & 11 deletions .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,19 @@ source, proxy and profile. The Codex-only WebSocket adapter retains upstream
coder/websocket v1.8.14 and its license, adding actual bounded deflate-window support;
other services use the original dependency.

Version discovery still runs every six hours. Automatic effective identity advances
only within the shipped verified profile set; newly discovered unsupported releases
are visible as pending validation. Saving an unsupported manual version returns an
error. Settings reads add verified/pending/strict-profile details without changing
the four-field write payload. Existing device seeds and persisted hardware identities
are retained; their supported platform/architecture classes match the verified set.
Version discovery runs every six hours and directly advances the effective client
identity. Precedence is a normalized manual version, the discovered stable version,
then the built-in default (subject to the existing minimum version). Client releases
are not gated on transport sampling. The transport retains its recorded reference
version and source; `Binding.client_version` records the actual requested identity
and participates in pool keys. `Binding.version` and `validated` describe the sampled
transport reference, not validation of a later client release. Settings GET adds
`version_source` (`manual`, `synced`, `builtin`); `verified_version` retains the
reference version and the compatibility `version_pending_validation` is false.
The four-field settings write payload is unchanged. Existing device seeds and
persisted hardware identities are retained. The identity page shows the effective
and discovered versions with their source, with full UA/reference details available
in disclosures. No new sampling or per-client-release publishing pipeline is required.

One immutable policy/identity selection is shared by each attempt's headers, body
and transport. New requests freeze the compression choice too. WS pool compatibility
Expand All @@ -139,11 +146,22 @@ handshake. Account mode edits invalidate only that account's subsequent connecti

## Independent Pelican tests

`/admin/pelican-tests` opens a paginated gallery of all observations from the last
24 hours, with task/account/model/effort/scene/execution/verdict filters. Wide
screens show four cards, medium screens two, and phones one. The new-task composer loads
accounts in pages of 50, retains selection across pages, and offers explicit
current-page/all-matching scopes. Models use locally saved mappings and catalogs;
`/admin/pelican-tests` opens a paginated gallery of observations from the last
24 hours with Works and Tasks views. Account name/ID search, model and verdict are
primary filters; effort, execution and legacy scene remain in More filters. Active
filters are removable. URL query keys retain the view (`gallery`, `tasks`, `new`),
selected `task`, `page`, `task_page` and existing gallery filters (`account_id`,
`model`, `effort`, `scene`, `verdict`, `status`). Four-to-three previews use one
column below 768px, two from 768px, three from 1280px and four from 1920px.
The single-page new-task composer shows account selection and model configuration
side by side on wide screens, with a sticky launch summary. It loads accounts in
pages of 50, retains cross-page selections and explicit current-page/all-matching
scopes. Selected account groups are paginated in sets of 20 without changing the
submitted selection. Per-user session drafts retain configuration on navigation or
rejected submissions and are cleared only on accepted creation or explicit clear.
Task cards expose progress and open the task gallery; the comparison tray remains
at the bottom. Work details group the preview, summary, results and complete raw
evidence; comparisons collapse detailed evidence by default. Models use locally saved mappings and catalogs;
searchable dropdowns accept catalog choices only. Backend defaults prefer
`gpt-6.1-sol` for OpenAI and `claude-opus-5-5` for Anthropic, falling back to the
first text-capable local choice. Effort defaults to the model default.
Expand Down
25 changes: 25 additions & 0 deletions backend/internal/pkg/codextransport/client_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package codextransport

import "testing"

func TestCodexTransportClientVersionIndependentOfReference(t *testing.T) {
var previous Binding
for _, version := range []string{"0.162.1", "0.163.0", "0.164.0"} {
binding, err := Select("codex-tui/"+version+" (Windows 10.0.26100; x86_64) WindowsTerminal", "same-account", "http")
if err != nil {
t.Fatal(err)
}
if binding.ClientVersion != version || binding.Version != LatestVersion() || !binding.Validated {
t.Fatalf("client and reference versions were conflated: %+v", binding)
}
transport, err := New(binding, Options{})
if err != nil {
t.Fatal(err)
}
transport.CloseIdleConnections()
if previous.ClientVersion != "" && previous.Key() == binding.Key() {
t.Fatal("client versions shared a pool")
}
previous = binding
}
}
10 changes: 5 additions & 5 deletions backend/internal/pkg/codextransport/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type Binding struct {
RequestZstd bool `json:"-"`
ProfileID string `json:"profile_id"`
Version string `json:"version"`
ClientVersion string `json:"client_version"`
SourceCommit string `json:"source_commit"`
System string `json:"system"`
Arch string `json:"arch"`
Expand All @@ -96,9 +97,8 @@ func Select(ua, scope, plane string) (Binding, error) {
if len(m) == 0 {
return Binding{}, &Error{"User-Agent has no supported fixed device profile"}
}
if !SupportsVersion(m[1]) {
return Binding{}, &Error{fmt.Sprintf("version %s has no verified profile", m[1])}
}
// The archive version describes the sampled transport, not an admission
// list for client releases. Identity follows the selected client version.
system := map[string]string{"Mac OS": "Darwin", "Windows": "Windows", "Ubuntu": "Linux"}[m[2]]
arch := m[3]
matched := false
Expand All @@ -121,12 +121,12 @@ func Select(ua, scope, plane string) (Binding, error) {
if plane != "ws" {
plane = "http"
}
return Binding{ProfileID: "codex-rust-" + verified.Version + "-r1", Version: verified.Version, SourceCommit: verified.SourceCommit,
return Binding{ProfileID: "codex-rust-" + verified.Version + "-r1", Version: verified.Version, ClientVersion: m[1], SourceCommit: verified.SourceCommit,
System: system, Arch: arch, Strict: true, Validated: true, Scope: scope, Plane: plane, ReferenceConfiguration: "official binary with isolated custom CA"}, nil
}

func (b Binding) Key() string {
sum := sha256.Sum256([]byte(b.ProfileID + "\x00" + b.System + "\x00" + b.Arch + "\x00" + b.Scope + "\x00" + b.Plane))
sum := sha256.Sum256([]byte(b.ProfileID + "\x00" + b.ClientVersion + "\x00" + b.System + "\x00" + b.Arch + "\x00" + b.Scope + "\x00" + b.Plane))
return fmt.Sprintf("%x", sum[:16])
}

Expand Down
4 changes: 2 additions & 2 deletions backend/internal/pkg/codextransport/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ func TestStrictCodexTLSValidationAndHTTP2Streaming(t *testing.T) {
}

func TestStrictCodexUnsupportedAndCanceledRequests(t *testing.T) {
if _, err := Select("codex-tui/999.0.0 (Windows 10.0.26100; x86_64) WindowsTerminal", "test", "http"); err == nil {
t.Fatal("accepted unverified version")
if _, err := Select("codex-tui/0.163.0 (Windows 10.0.26100; arm64) WindowsTerminal", "test", "http"); err == nil {
t.Fatal("accepted unsupported device architecture")
}
if _, err := New(Binding{Version: LatestVersion()}, Options{}); err == nil {
t.Fatal("accepted unverified binding")
Expand Down
61 changes: 61 additions & 0 deletions backend/internal/service/codex_version_follow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package service

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

"github.com/Wei-Shaw/sub2api/internal/pkg/codextransport"
"github.com/stretchr/testify/require"
)

func TestCodexVersionFollowsStableWithoutProfileGate(t *testing.T) {
fingerprintPolicyTestState(t)
for _, test := range []struct{ name, manual, synced, syncEnabled, version, source string }{
{"synced", "", "0.163.0", "true", "0.163.0", "synced"},
{"future release", "", "0.164.0", "true", "0.164.0", "synced"},
{"manual", "0.165.0", "0.163.0", "true", "0.165.0", "manual"},
{"sync off retains discovery", "", "0.163.0", "false", "0.163.0", "synced"},
{"default", "", "", "true", codexCLIVersion, "builtin"},
{"minimum", "0.100.0", "", "false", codexCLIVersion, "builtin"},
} {
t.Run(test.name, func(t *testing.T) {
svc := NewSettingService(&codexFingerprintSettingsRepo{values: map[string]string{
SettingKeyOpenAICodexClientVersion: test.manual, SettingKeyOpenAICodexClientVersionSynced: test.synced,
SettingKeyOpenAICodexVersionAutoSyncEnabled: test.syncEnabled,
}}, nil)
view, err := svc.GetCodexFingerprintSettings(context.Background())
require.NoError(t, err)
require.Equal(t, test.version, view.EffectiveVersion)
require.Equal(t, test.source, view.VersionSource)
require.False(t, view.VersionPendingValidation)
require.Equal(t, codextransport.LatestVersion(), view.VerifiedVersion)
publishCodexFingerprintSettings(view)
identity := resolveCodexOutboundIdentity("")
require.Equal(t, test.version, identity.version)
require.Contains(t, identity.userAgent, "/"+test.version+" ")
for _, plane := range []string{"http", "ws"} {
binding, err := codextransport.Select(identity.userAgent, "test", plane)
require.NoError(t, err)
require.Equal(t, test.version, binding.ClientVersion)
}
})
}
}

func TestCodexVersionSavePublishesNewIdentityWithoutChangingActiveAttempt(t *testing.T) {
fingerprintPolicyTestState(t)
repo := &codexFingerprintSettingsRepo{values: map[string]string{SettingKeyOpenAICodexClientVersionSynced: "0.163.0"}}
svc := NewSettingService(repo, nil)
require.NoError(t, svc.LoadCodexFingerprintSettings(context.Background()))
frozen := currentCodexFingerprintPolicy()
view, err := svc.UpdateCodexFingerprintSettings(context.Background(), CodexFingerprintSettings{Enabled: true, ClientVersion: "0.164.0", VersionAutoSyncEnabled: true})
require.NoError(t, err)
require.Equal(t, "0.164.0", view.EffectiveVersion)
require.Equal(t, "0.163.0", resolveCodexOutboundIdentity("", frozen).version)
require.Equal(t, "0.164.0", resolveCodexOutboundIdentity("").version)
require.NotEqual(t, frozen.revision, currentCodexFingerprintPolicy().revision)
headers := http.Header{}
ApplyCodexCanonicalAuthIdentity(headers)
require.Contains(t, headers.Get("User-Agent"), "/0.164.0 ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,9 @@ func TestGetOpenAICodexClientVersionPriority(t *testing.T) {
want string
}{
{name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"},
{name: "未验证同步值保持待支持", synced: "0.146.0", want: codexCLIVersion},
{name: "同步稳定版直接生效", synced: "0.146.0", want: "0.146.0"},
{name: "两者皆空时用内置常量", want: codexCLIVersion},
{name: "非法覆写且同步未验证时用已验证版本", override: "latest", synced: "0.146.0", want: codexCLIVersion},
{name: "非法覆写使用同步版本", override: "latest", synced: "0.146.0", want: "0.146.0"},
{name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion},
}

Expand Down Expand Up @@ -334,7 +334,7 @@ func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) {
}}, nil)

require.Equal(t,
"codex-tui/"+codexCLIVersion+codexCLIUserAgentSuffix,
"codex-tui/0.200.1"+codexCLIUserAgentSuffix,
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
}
Expand All @@ -351,7 +351,7 @@ func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
}}, nil)

require.Equal(t,
"codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color",
"codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
})
Expand All @@ -363,7 +363,7 @@ func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
}}, nil)

require.Equal(t,
"codex_cli_rs/"+codexCLIVersion+" (Mac OS X 15.1.0; arm64) iTerm.app",
"codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app",
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
})
Expand All @@ -375,7 +375,7 @@ func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
}}, nil)

require.Equal(t,
"codex-tui/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; "+codexCLIVersion+")",
"codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)",
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
})
Expand Down Expand Up @@ -489,8 +489,8 @@ func TestGetOpenAICodexCanonicalUserAgentOutboundIdentity(t *testing.T) {
name, manual, synced, want string
}{
{"manual", "0.154.0", "0.155.1", "0.154.0"},
{"synced", "", "0.155.1", codexCLIVersion},
{"invalid_manual", "latest", "0.155.1", codexCLIVersion},
{"synced", "", "0.155.1", "0.155.1"},
{"invalid_manual", "latest", "0.155.1", "0.155.1"},
{"default", "", "", codexCLIVersion},
{"invalid_versions", "latest", "invalid", codexCLIVersion},
} {
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_gateway_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const (
// 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出
// 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本,
// 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。
codexCLIVersion = "0.162.1"
codexCLIVersion = "0.163.0"
// Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
openAICodexSnapshotPersistMinInterval = 30 * time.Second
// 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。
Expand Down
31 changes: 18 additions & 13 deletions backend/internal/service/setting_codex_fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type CodexFingerprintSettingsView struct {
VersionPendingValidation bool `json:"version_pending_validation"`
TransportPolicy string `json:"transport_policy"`
EffectiveVersion string `json:"effective_version"`
VersionSource string `json:"version_source"`
EffectiveUserAgent string `json:"effective_user_agent"`
}

Expand Down Expand Up @@ -180,23 +181,27 @@ func (s *SettingService) codexFingerprintSettingsFromValues(values map[string]st
config.VersionAutoSyncEnabled = raw == "true"
}
synced := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced])
version := NormalizeCodexClientVersion(config.ClientVersion)
if version == "" {
version = synced
}
if config.ClientVersion == "" && !codextransport.SupportsVersion(version) {
version = codextransport.LatestVersion()
}
if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
version = codextransport.LatestVersion()
}
version, source := resolveCodexClientVersion(config.ClientVersion, synced)
ua := buildCodexCLIUserAgent(version)
if _, paired, ok := openai.PairCodexClientIdentity(config.UserAgent); ok {
if rebuilt := openai.SetCodexUserAgentVersion(paired, version); rebuilt != "" {
ua = rebuilt
}
}
return CodexFingerprintSettingsView{CodexFingerprintSettings: config, SyncedVersion: synced, EffectiveVersion: version, EffectiveUserAgent: ua, VerifiedVersion: codextransport.LatestVersion(), VersionPendingValidation: synced != "" && CompareVersions(synced, version) > 0 && !codextransport.SupportsVersion(synced), TransportPolicy: "strict"}, nil
return CodexFingerprintSettingsView{CodexFingerprintSettings: config, SyncedVersion: synced, EffectiveVersion: version, VersionSource: source, EffectiveUserAgent: ua, VerifiedVersion: codextransport.LatestVersion(), VersionPendingValidation: false, TransportPolicy: "strict"}, nil
}

// resolveCodexClientVersion is shared by the settings view and outbound resolver.
// Transport archive metadata never determines the selected client release.
func resolveCodexClientVersion(manual, synced string) (string, string) {
version, source := NormalizeCodexClientVersion(manual), "manual"
if version == "" {
version, source = NormalizeCodexClientVersion(synced), "synced"
}
if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
return codexCLIVersion, "builtin"
}
return version, source
}

func publishCodexFingerprintSettings(view CodexFingerprintSettingsView) {
Expand Down Expand Up @@ -227,8 +232,8 @@ func (s *SettingService) LoadCodexFingerprintSettings(ctx context.Context) error
}

func (s *SettingService) UpdateCodexFingerprintSettings(ctx context.Context, config CodexFingerprintSettings) (CodexFingerprintSettingsView, error) {
if config.ClientVersion != "" && !codextransport.SupportsVersion(config.ClientVersion) {
return CodexFingerprintSettingsView{}, fmt.Errorf("codex version %s has no verified transport profile", config.ClientVersion)
if config.ClientVersion != "" && NormalizeCodexClientVersion(config.ClientVersion) == "" {
return CodexFingerprintSettingsView{}, errors.New("client_version must be empty or a valid Codex version")
}
s.codexFingerprintMu.Lock()
defer s.codexFingerprintMu.Unlock()
Expand Down
12 changes: 1 addition & 11 deletions backend/internal/service/setting_gateway_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/codextransport"
"log/slog"
"strconv"
"strings"
Expand Down Expand Up @@ -496,16 +495,7 @@ func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string
})
return fallback, nil
}
version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion])
if version == "" {
version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced])
if !codextransport.SupportsVersion(version) {
version = codextransport.LatestVersion()
}
}
if version == "" {
version = fallback
}
version, _ := resolveCodexClientVersion(values[SettingKeyOpenAICodexClientVersion], values[SettingKeyOpenAICodexClientVersionSynced])
s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
version: version,
expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(),
Expand Down
5 changes: 2 additions & 3 deletions backend/internal/service/setting_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/codextransport"
"log/slog"
"math"
"strconv"
Expand Down Expand Up @@ -515,8 +514,8 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
if settings.OpenAICodexClientVersion != "" && !codextransport.SupportsVersion(NormalizeCodexClientVersion(settings.OpenAICodexClientVersion)) {
return nil, fmt.Errorf("codex version has no verified transport profile")
if settings.OpenAICodexClientVersion != "" && NormalizeCodexClientVersion(settings.OpenAICodexClientVersion) == "" {
return nil, fmt.Errorf("client_version must be empty or a valid Codex version")
}
updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion)
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/admin/codexFingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CodexFingerprintSettingsView extends CodexFingerprintSettings {
transport_policy?: 'strict'
synced_version: string
effective_version: string
version_source?: 'manual' | 'synced' | 'builtin'
effective_user_agent: string
}

Expand All @@ -31,7 +32,7 @@ export interface CodexFingerprintAccountView {
fingerprint_mode_configured: string
fingerprint_mode_effective: CodexFingerprintMode
fingerprint_reason: string
transport_profile?: { profile_id: string; version: string; source_commit: string; system: string; arch: string; strict: boolean; validated: boolean; plane: string }
transport_profile?: { profile_id: string; version: string; client_version?: string; source_commit: string; system: string; arch: string; strict: boolean; validated: boolean; plane: string }
transport_profile_error?: string
}
device_identity: {
Expand Down
Loading
Loading