From a3ebcd016b2d4e8bd5a7ba2506605afdbd5c9cdc Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:06:01 +0800 Subject: [PATCH 01/37] refactor(service): fold nativeapi and codexruntime into the service package Both packages had one user, the service package, and existed because their contents once crossed a plugin boundary. Nothing an admin, a user or an API client can observe changes. - The model capacity structs, the two switch setting structs and the Codex client identity are defined next to the code that uses them. JSON tags and stored formats are unchanged. The handler builds the setting structs from the service package. - The Codex client identity is one struct with its own methods; the conversions between three copies of the same type are gone. Building a User-Agent still checks the identity and the version and still returns the same errors. - Reasoning recovery reads the upstream payload and the request body directly. The four carrier structs between collecting facts and deciding are gone. The order of the checks and the reason strings are the same. - validOfficialCatalogCapacity repeated validModelContextTokens and is removed. - The account traffic telemetry types (the decision table, the observation policy and the counter snapshot) move unchanged. - CI: the selector script that ran ./internal/codexruntime is removed together with its workflow step and its assertions in the contract test. The tests it selected run in the required test job. The contract test also loses the assertions that only named files deleted earlier. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_native_domains.sh | 15 -- .../test_upstream_automation_contract.py | 32 +-- .github/workflows/downstream-verify.yml | 2 - .../internal/codexruntime/profile/identity.go | 205 ------------------ .../codexruntime/profile/identity_test.go | 35 --- .../internal/codexruntime/recovery/policy.go | 59 ----- .../codexruntime/recovery/policy_test.go | 45 ---- .../account_handler_traffic_telemetry_test.go | 3 +- .../internal/handler/admin/setting_handler.go | 5 +- backend/internal/nativeapi/catalog.go | 45 ---- backend/internal/nativeapi/codex_identity.go | 13 -- backend/internal/nativeapi/codex_recovery.go | 73 ------- backend/internal/nativeapi/codex_runtime.go | 5 - backend/internal/nativeapi/doc.go | 3 - backend/internal/nativeapi/observability.go | 21 -- .../account_traffic_decision.go} | 2 +- .../service/account_traffic_observe.go | 17 +- .../internal/service/admin_observability.go | 38 ++-- .../service/admin_observability_test.go | 15 +- .../service/model_context_capacity.go | 36 ++- .../service/model_context_official_catalog.go | 8 +- .../service/native_setting_load_test.go | 15 +- .../service/openai_codex_client_identity.go | 184 ++++++++++++++-- .../openai_codex_client_identity_test.go | 32 ++- .../internal/service/openai_codex_identity.go | 4 +- .../service/openai_reasoning_recovery.go | 31 +-- .../openai_reasoning_recovery_policy.go | 154 ++++++++----- .../openai_reasoning_recovery_policy_test.go | 38 ++++ .../service/setting_admin_observability.go | 16 +- .../internal/service/setting_codex_runtime.go | 16 +- 30 files changed, 451 insertions(+), 716 deletions(-) delete mode 100644 .github/scripts/test_native_domains.sh delete mode 100644 backend/internal/codexruntime/profile/identity.go delete mode 100644 backend/internal/codexruntime/profile/identity_test.go delete mode 100644 backend/internal/codexruntime/recovery/policy.go delete mode 100644 backend/internal/codexruntime/recovery/policy_test.go delete mode 100644 backend/internal/nativeapi/catalog.go delete mode 100644 backend/internal/nativeapi/codex_identity.go delete mode 100644 backend/internal/nativeapi/codex_recovery.go delete mode 100644 backend/internal/nativeapi/codex_runtime.go delete mode 100644 backend/internal/nativeapi/doc.go delete mode 100644 backend/internal/nativeapi/observability.go rename backend/internal/{nativeapi/decision.go => service/account_traffic_decision.go} (99%) diff --git a/.github/scripts/test_native_domains.sh b/.github/scripts/test_native_domains.sh deleted file mode 100644 index 992c421d5cbb..000000000000 --- a/.github/scripts/test_native_domains.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) -cd "$repo_root" - -# The Codex runtime domain packages. -go -C backend test -p=1 -tags unit \ - ./internal/codexruntime/... \ - -count=1 - -# Preserve the native configuration contracts without starting real IO. -go -C backend test -p=1 -tags unit ./internal/service \ - -run '^(TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault|TestPrepareOpenAICodexWireRequest.*|TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt|TestNativeSettingLoadFailureNeverReenablesSavedSwitches|TestTrafficObservation.*|TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals|TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues)$' \ - -count=1 diff --git a/.github/scripts/test_upstream_automation_contract.py b/.github/scripts/test_upstream_automation_contract.py index 578aaef422ed..03e70555a316 100644 --- a/.github/scripts/test_upstream_automation_contract.py +++ b/.github/scripts/test_upstream_automation_contract.py @@ -2,7 +2,6 @@ import json import re -import shlex import unittest from pathlib import Path from types import SimpleNamespace @@ -83,51 +82,22 @@ def test_image_build_skips_validation_without_changing_daily_build(self): vite = (ROOT / "frontend/vite.config.ts").read_text(encoding="utf-8") package = json.loads((ROOT / "frontend/package.json").read_text(encoding="utf-8")) self.assertIn("RUN SUB2API_ARTIFACT_BUILD=1 pnpm exec vite build", dockerfile) - self.assertNotIn("backend/pkg/extensionapi/ui/build.mjs", dockerfile) self.assertNotIn("RUN pnpm run build", dockerfile) self.assertNotIn("ENV SUB2API_ARTIFACT_BUILD", dockerfile) self.assertIn("process.env.SUB2API_ARTIFACT_BUILD === '1'", vite) self.assertIn("enableBuild: !artifactBuild", vite) self.assertEqual(package["scripts"]["build"], "pnpm run check:i18n && vue-tsc -b && vite build") - def test_native_ci_preserves_required_checks_and_selects_existing_contracts(self): + def test_verify_workflow_keeps_required_checks_and_selects_existing_specs(self): verify = (ROOT / ".github/workflows/downstream-verify.yml").read_text(encoding="utf-8") for name in ("Downstream backend", "Downstream frontend", "Candidate OCI image"): self.assertRegex(verify, rf"(?m)^ name: {re.escape(name)}$") - self.assertIn("run: bash .github/scripts/test_native_domains.sh", verify) - for retired in ("test_first_party_plugins.sh", "initialize_plugin_publisher", - "backend/pkg/extensionapi/ui/tsconfig.json", "../plugins/"): - self.assertNotIn(retired, verify) specs = re.findall(r"(?m)^\s+(src/\S+\.spec\.ts)(?:\s+\\)?$", verify) self.assertTrue(specs, "the frontend gate must select real spec files") self.assertEqual(len(specs), len(set(specs)), "do not repeat identical frontend specs") for spec in specs: self.assertTrue((ROOT / "frontend" / spec).is_file(), spec) - script = (ROOT / ".github/scripts/test_native_domains.sh").read_text(encoding="utf-8") - self.assertNotRegex(script, r"package-plugin|generate-key|publisher\.key|plugins/\*") - commands = [shlex.split(line) for line in script.replace("\\\n", " ").splitlines() - if line.startswith("go -C backend test ")] - self.assertEqual(len(commands), 2) - for command in commands: - selector = command[command.index("-run") + 1] if "-run" in command else r"^Test" - packages = [arg for arg in command if arg.startswith("./")] - self.assertTrue(packages) - for package in packages: - path = ROOT / "backend" / package.removeprefix("./").removesuffix("/...") - self.assertTrue(path.is_dir(), package) - files = path.rglob("*_test.go") if package.endswith("/...") else path.glob("*_test.go") - names = [] - for file in files: - source = file.read_text(encoding="utf-8") - # These commands select unit fixtures on the Linux PR runner. - build = re.search(r"(?m)^//go:build (.+)$", source) - if build and re.search(r"\b(integration|windows)\b", build.group(1)): - continue - names.extend(re.findall(r"(?m)^func (Test\w+)\(", source)) - selected = [name for name in names if re.search(selector, name)] - self.assertTrue(selected, f"{package}: {selector} selected no unit tests") - def test_bot_chain_is_explicit_and_does_not_overwrite_manual_resolutions(self): self.assertIn("upstream_tag:", self.sync) self.assertIn("preserve manual resolutions", self.sync) diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 3687cdfce1d3..a42cafe2cd9e 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -34,8 +34,6 @@ jobs: python3 .github/scripts/test_release_preflight.py - name: Agent protocol contracts run: bash .github/scripts/test_agent_protocol.sh - - name: Native domain contracts - run: bash .github/scripts/test_native_domains.sh - name: Reasoning recovery contracts run: bash .github/scripts/test_reasoning_recovery.sh - name: Model context capacity contracts diff --git a/backend/internal/codexruntime/profile/identity.go b/backend/internal/codexruntime/profile/identity.go deleted file mode 100644 index 9bc0de31e16f..000000000000 --- a/backend/internal/codexruntime/profile/identity.go +++ /dev/null @@ -1,205 +0,0 @@ -package profile - -import ( - "crypto/sha256" - "errors" - "fmt" - "regexp" - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -// ErrInvalidVersion reports a Codex client version that cannot be written into -// a User-Agent. -var ErrInvalidVersion = errors.New("invalid identity version") - -// codexClientVersionPattern allows the two official forms, 0.146.0 and -// 0.147.0-alpha.4. -var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) - -// Derive returns the client identity of a fingerprint seed. -func Derive(seed string) extensionv1.CodexClientProfile { - return extensionv1.CodexClientProfile(deriveCodexClientIdentity(seed)) -} - -// Valid reports whether an identity passes the schema check. -func Valid(profile extensionv1.CodexClientProfile) bool { - return codexClientIdentity(profile).valid() -} - -// UserAgent returns the Codex TUI User-Agent of an identity at a client -// version. An identity that fails the schema check, or a malformed version, is -// an error: neither may be written into an outbound header. -func UserAgent(profile extensionv1.CodexClientProfile, version string) (string, error) { - identity := codexClientIdentity(profile) - if !identity.valid() { - return "", errors.New("invalid identity profile") - } - if len(version) > 64 || !codexClientVersionPattern.MatchString(version) { - return "", ErrInvalidVersion - } - return identity.UserAgent(version), nil -} - -// SandboxForUserAgent returns the sandbox tag that belongs to the operating -// system a User-Agent declares, or "" when the User-Agent declares none. -func SandboxForUserAgent(userAgent string) string { - return codexSandboxForUserAgent(userAgent) -} - -const codexClientIdentitySchemaVersion = 1 - -// codexClientIdentity 描述一台"运行 Codex TUI 的机器":操作系统、系统版本、架构、 -// 终端以及与该系统配套的 sandbox 标签。字符串形态严格对齐 codex-rs 的 -// os_info / terminal-detection / sandbox_tags 输出,保证 UA 与 turn metadata 自洽。 -type codexClientIdentity extensionv1.CodexClientProfile - -const ( - codexClientOSMac = "Mac OS" - codexClientOSWindows = "Windows" - codexClientOSUbuntu = "Ubuntu" - - codexClientSandboxMac = "seatbelt" - codexClientSandboxWindows = "windows_sandbox" - codexClientSandboxLinux = "seccomp" -) - -var ( - codexClientMacVersions = []string{"15.5.0", "15.6.1", "15.7.0", "26.0.1", "26.1.0"} - codexClientWindowsVersions = []string{"10.0.26100", "10.0.22631", "10.0.19045"} - codexClientUbuntuVersions = []string{"22.4.0", "24.4.0"} - codexClientAppleTerminals = []string{"453", "455", "456"} -) - -var ( - codexClientOSVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) - codexClientTerminalPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]{1,64}$`) -) - -// valid 做 schema 校验而不只是非空:系统/沙箱必须配对(seatbelt=Mac OS、 -// windows_sandbox=Windows、seccomp=Ubuntu),版本/架构/终端形态受限且不含空白或 -// CR/LF。持久化值或外部写入的坏值一律视为缺失并重新按种子派生,避免脏值进入 UA。 -func (id codexClientIdentity) valid() bool { - if id.Version != codexClientIdentitySchemaVersion { - return false - } - switch id.OSType { - case codexClientOSMac: - if id.Sandbox != codexClientSandboxMac || (id.Arch != "arm64" && id.Arch != "x86_64") { - return false - } - case codexClientOSWindows: - if id.Sandbox != codexClientSandboxWindows || id.Arch != "x86_64" { - return false - } - case codexClientOSUbuntu: - if id.Sandbox != codexClientSandboxLinux || id.Arch != "x86_64" { - return false - } - default: - return false - } - return codexClientOSVersionPattern.MatchString(id.OSVersion) && - codexClientTerminalPattern.MatchString(id.Terminal) -} - -// codexSandboxForUserAgent 从有效出站 UA 的 `( ; )` 段推出与之配套的 -// sandbox 标签;无法解析时返回空串(调用方不改写 sandbox)。turn metadata 声明的沙箱 -// 必须跟随最终发出的 UA,而不是账号身份本身:管理员显式 UA 覆盖时二者可能不同。 -var codexUserAgentPlatformPattern = regexp.MustCompile(`\(([^();]+?) [^ ();]+; [^();]+\)`) - -func codexSandboxForUserAgent(userAgent string) string { - m := codexUserAgentPlatformPattern.FindStringSubmatch(userAgent) - if m == nil { - return "" - } - osType := strings.ToLower(strings.TrimSpace(m[1])) - switch { - case osType == "": - return "" - case strings.HasPrefix(osType, "mac") || strings.HasPrefix(osType, "darwin"): - return codexClientSandboxMac - case strings.HasPrefix(osType, "windows"): - return codexClientSandboxWindows - default: - return codexClientSandboxLinux - } -} - -// UserAgent 拼出真实 Codex TUI 的 User-Agent: -// codex-tui/ ( ; ) (codex-tui; )。 -// 首段版本、尾部括号组版本与 version 头必须同源(codex-rs 三处都取同一个 -// CARGO_PKG_VERSION)。 -func (id codexClientIdentity) UserAgent(version string) string { - version = strings.TrimSpace(version) - return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", - codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version) -} - -// codexTUIOriginator 是交互式 Codex TUI 的 originator(app-server initialize 的 -// clientInfo.name,codex-rs tui/src/lib.rs)。 -const codexTUIOriginator = "codex-tui" - -// deriveCodexClientIdentity 从种子确定性派生一份自洽身份。分布按真实 TUI 用户 -// 的大致构成加权:macOS 60%(arm64 为主),Windows 25%,Ubuntu 15%;终端与 -// 系统匹配,sandbox 标签与系统匹配。 -func deriveCodexClientIdentity(seed string) codexClientIdentity { - h := sha256.Sum256([]byte("sub2api:codex-client-identity:v1:" + seed)) - id := codexClientIdentity{Version: codexClientIdentitySchemaVersion} - switch osPick := h[0]; { - case osPick < 154: // 60% - id.OSType = codexClientOSMac - id.Sandbox = codexClientSandboxMac - if h[1] < 230 { // 90% - id.Arch = "arm64" - } else { - id.Arch = "x86_64" - } - id.OSVersion = codexClientMacVersions[int(h[2])%len(codexClientMacVersions)] - switch term := h[3]; { - case term < 102: // 40% - id.Terminal = fmt.Sprintf("iTerm.app/3.5.%d", 10+int(h[4])%6) - case term < 153: // 20% - id.Terminal = "Apple_Terminal/" + codexClientAppleTerminals[int(h[4])%len(codexClientAppleTerminals)] - case term < 191: // 15% - id.Terminal = fmt.Sprintf("ghostty/1.3.%d", int(h[4])%3) - case term < 230: // 15% - id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) - default: // 10% - id.Terminal = fmt.Sprintf("WarpTerminal/v0.2026.%02d.%02d.08.12.stable_%02d", - 1+int(h[4])%9, 1+int(h[5])%28, 1+int(h[6])%3) - } - case osPick < 218: // 25% - id.OSType = codexClientOSWindows - id.Sandbox = codexClientSandboxWindows - id.Arch = "x86_64" - id.OSVersion = codexClientWindowsVersions[int(h[2])%len(codexClientWindowsVersions)] - switch term := h[3]; { - case term < 153: // 60% - id.Terminal = "WindowsTerminal" - case term < 217: // 25% - id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) - default: // 15% - id.Terminal = "unknown" - } - default: // 15% - id.OSType = codexClientOSUbuntu - id.Sandbox = codexClientSandboxLinux - id.Arch = "x86_64" - id.OSVersion = codexClientUbuntuVersions[int(h[2])%len(codexClientUbuntuVersions)] - switch int(h[3]) % 3 { - case 0: - id.Terminal = "gnome-terminal" - case 1: - id.Terminal = "xterm-256color" - default: - id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) - } - } - return id -} - -func codexClientVSCodeTerminal(major, minor byte) string { - return fmt.Sprintf("vscode/1.%d.%d", 103+int(major)%3, int(minor)%3) -} diff --git a/backend/internal/codexruntime/profile/identity_test.go b/backend/internal/codexruntime/profile/identity_test.go deleted file mode 100644 index 26a845fd943b..000000000000 --- a/backend/internal/codexruntime/profile/identity_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package profile - -import ( - "errors" - "strings" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestIdentityProfilePreservesDeterminismAndProtocolPairing(t *testing.T) { - first := deriveCodexClientIdentity("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55") - if first != deriveCodexClientIdentity("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55") || !first.valid() { - t.Fatal("stable profile changed") - } - ua := first.UserAgent("0.150.0") - if !strings.HasPrefix(ua, "codex-tui/0.150.0 ") || !strings.HasSuffix(ua, "(codex-tui; 0.150.0)") || codexSandboxForUserAgent(ua) != first.Sandbox { - t.Fatal("inconsistent profile and agent") - } - bad := first - bad.Terminal = "terminal\r\nx-header: value" - if bad.valid() { - t.Fatal("header injection accepted") - } - if _, err := UserAgent(extensionv1.CodexClientProfile(first), "0.150.0\r\nx: value"); !errors.Is(err, ErrInvalidVersion) { - t.Fatal("invalid version accepted") - } - if _, err := UserAgent(extensionv1.CodexClientProfile(bad), "0.150.0"); err == nil { - t.Fatal("invalid profile accepted") - } - typed := Derive("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55") - if got, err := UserAgent(typed, "0.150.0"); err != nil || got != ua || !Valid(typed) || SandboxForUserAgent(ua) != first.Sandbox { - t.Fatal("typed calls disagree with the profile") - } -} diff --git a/backend/internal/codexruntime/recovery/policy.go b/backend/internal/codexruntime/recovery/policy.go deleted file mode 100644 index 79cbe89d62de..000000000000 --- a/backend/internal/codexruntime/recovery/policy.go +++ /dev/null @@ -1,59 +0,0 @@ -package recovery - -import ( - "regexp" - "strconv" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -var inputParam = regexp.MustCompile(`^input(?:\[(\d+)\]|\.(\d+))(?:\.encrypted_content)?$`) - -func Rejection(in extensionv1.RecoveryEnvelope) extensionv1.RecoveryRejection { - if !extensionv1.ValidReasoningRejectionEnvelope(in) { - return extensionv1.RecoveryRejection{} - } - if *in.Code != "thinking_signature_invalid" && *in.Code != "invalid_encrypted_content" { - return extensionv1.RecoveryRejection{} - } - return extensionv1.RecoveryRejection{Recognized: true, Code: *in.Code, Param: in.Param} -} - -func Select(in extensionv1.RecoverySelectionQuery) extensionv1.RecoverySelection { - if !in.BodyValid { - return extensionv1.RecoverySelection{Reason: "invalid_request_snapshot"} - } - if !in.ToolHistoryValid { - return extensionv1.RecoverySelection{Reason: "invalid_tool_history"} - } - if len(in.CipherIndices) == 0 { - return extensionv1.RecoverySelection{Reason: "no_reasoning_ciphertext"} - } - if match := inputParam.FindStringSubmatch(in.Param); match != nil { - text := match[1] - if text == "" { - text = match[2] - } - index, err := strconv.Atoi(text) - if err == nil { - for _, candidate := range in.CipherIndices { - if candidate == index { - return extensionv1.RecoverySelection{Indices: []int{index}, Reason: "recovery_not_dispatched"} - } - } - } - return extensionv1.RecoverySelection{Reason: "target_not_reasoning_ciphertext"} - } - if in.Param != "" && in.Param != "input" && in.Param != "reasoning.encrypted_content" { - return extensionv1.RecoverySelection{Reason: "unsupported_error_param"} - } - if in.ServerContext { - return extensionv1.RecoverySelection{Reason: "server_held_context"} - } - // Another ciphertext carrier (compaction, an inter-agent message) may be the - // rejected one, unless the upstream names a reasoning item itself. - if in.EncryptedFields != len(in.CipherIndices) && !in.NamedCipher { - return extensionv1.RecoverySelection{Reason: "ambiguous_encrypted_carriers"} - } - return extensionv1.RecoverySelection{Indices: append([]int(nil), in.CipherIndices...), Reason: "recovery_not_dispatched"} -} diff --git a/backend/internal/codexruntime/recovery/policy_test.go b/backend/internal/codexruntime/recovery/policy_test.go deleted file mode 100644 index 032bf23ca507..000000000000 --- a/backend/internal/codexruntime/recovery/policy_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package recovery - -import ( - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -func TestRecoveryPolicyRequiresExactErrorAndUnambiguousLocalCipherScope(t *testing.T) { - code := "invalid_encrypted_content" - envelope := extensionv1.RecoveryEnvelope{ValidJSON: true, ErrorLocation: "error", Code: &code, ParamValid: true} - if !Rejection(envelope).Recognized { - t.Fatal("exact rejection not recognized") - } - envelope.Statuses = []int{429} - if Rejection(envelope).Recognized { - t.Fatal("protected quota error accepted") - } - query := extensionv1.RecoverySelectionQuery{BodyValid: true, ToolHistoryValid: true, CipherIndices: []int{1, 3}, EncryptedFields: 2} - if len(Select(query).Indices) != 2 { - t.Fatal("unambiguous local set was lost") - } - query.ServerContext = true - if Select(query).Reason != "server_held_context" { - t.Fatal("server-owned context incorrectly changed") - } - query.Param = "input[3].encrypted_content" - if result := Select(query); len(result.Indices) != 1 || result.Indices[0] != 3 { - t.Fatal("explicit target changed") - } - query.Param = "input[2].encrypted_content" - if Select(query).Reason != "target_not_reasoning_ciphertext" { - t.Fatal("unrelated item selected") - } - query.Param = "input" - query.ServerContext = false - query.EncryptedFields = 3 - if Select(query).Reason != "ambiguous_encrypted_carriers" { - t.Fatal("compaction ciphertext may be affected") - } - query.NamedCipher = true - if len(Select(query).Indices) != 2 { - t.Fatal("a rejection that names a reasoning item is not ambiguous") - } -} diff --git a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go index eebe90a656a4..17f63c0feb17 100644 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go @@ -8,7 +8,6 @@ import ( "strconv" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -47,7 +46,7 @@ func TestAccountHandlerTrafficTelemetryFollowsTelemetrySwitch(t *testing.T) { for _, enabled := range []bool{false, true} { t.Run(strconv.FormatBool(enabled), func(t *testing.T) { t.Cleanup(func() { service.ConfigureAdminObservability(nil) }) - service.ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) + service.ConfigureAdminObservability(&service.AdminObservabilityConfig{TelemetryEnabled: enabled}) stub := &scopedTrafficAdminService{stubAdminService: newStubAdminService()} stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, Credentials: map[string]any{"api_key": "never-return-secret"}} cache := &stubAccountTrafficObserveCache{snapshot: map[service.AccountTrafficProtocol]service.AccountTrafficObserveState{service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2}}} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 870e7d7ad1ae..6ae17eb5831e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -120,7 +119,7 @@ func (h *SettingHandler) GetObservabilitySettings(c *gin.Context) { // switches. Omitted switches stay on. // PUT /api/v1/admin/settings/observability func (h *SettingHandler) UpdateObservabilitySettings(c *gin.Context) { - req := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + req := service.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} raw, err := c.GetRawData() if err == nil { err = service.DecodeSwitchSettings(raw, &req, "telemetry_enabled", "theme_enabled") @@ -147,7 +146,7 @@ func (h *SettingHandler) GetCodexRuntimeSettings(c *gin.Context) { // omitted switch is on. // PUT /api/v1/admin/settings/codex-runtime func (h *SettingHandler) UpdateCodexRuntimeSettings(c *gin.Context) { - req := extensionv1.CodexRuntimeConfig{RequestZstd: true} + req := service.CodexRuntimeConfig{RequestZstd: true} raw, err := c.GetRawData() if err == nil { err = service.DecodeSwitchSettings(raw, &req, "request_zstd") diff --git a/backend/internal/nativeapi/catalog.go b/backend/internal/nativeapi/catalog.go deleted file mode 100644 index c3d69a1aec33..000000000000 --- a/backend/internal/nativeapi/catalog.go +++ /dev/null @@ -1,45 +0,0 @@ -// Model context capacity values shared by the capacity resolver and its -// reference catalog. -package nativeapi - -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:"match_hosts,omitempty"` - MatchAccountModes []string `json:"match_account_modes,omitempty"` -} diff --git a/backend/internal/nativeapi/codex_identity.go b/backend/internal/nativeapi/codex_identity.go deleted file mode 100644 index c3d0c2707937..000000000000 --- a/backend/internal/nativeapi/codex_identity.go +++ /dev/null @@ -1,13 +0,0 @@ -package nativeapi - -// Profile fields are persisted in accounts.extra. codexruntime/profile generates -// and interprets them; no credential is part of a profile. -type CodexClientProfile struct { - Version int `json:"v"` - OSType string `json:"os_type"` - OSVersion string `json:"os_version"` - Arch string `json:"arch"` - Terminal string `json:"terminal"` - Sandbox string `json:"sandbox"` - GeneratedAt string `json:"generated_at,omitempty"` -} diff --git a/backend/internal/nativeapi/codex_recovery.go b/backend/internal/nativeapi/codex_recovery.go deleted file mode 100644 index c8b0a2078470..000000000000 --- a/backend/internal/nativeapi/codex_recovery.go +++ /dev/null @@ -1,73 +0,0 @@ -package nativeapi - -// The recovery policy decides on protocol shape and item indices. Neither -// ciphertext, prompt history, credentials nor complete upstream errors are part -// of its inputs. -type RecoveryEnvelope struct { - ValidJSON bool `json:"valid_json"` - Statuses []int `json:"statuses"` - Event string `json:"event"` - Status string `json:"status"` - ResponseStatus string `json:"response_status"` - ErrorLocation string `json:"error_location"` - Code *string `json:"code,omitempty"` - Param string `json:"param"` - ParamValid bool `json:"param_valid"` -} - -type RecoveryRejection struct { - Recognized bool `json:"recognized"` - Code string `json:"code,omitempty"` - Param string `json:"param,omitempty"` -} - -type RecoverySelectionQuery struct { - BodyValid bool `json:"body_valid"` - ToolHistoryValid bool `json:"tool_history_valid"` - CipherIndices []int `json:"cipher_indices"` - EncryptedFields int `json:"encrypted_fields"` - ServerContext bool `json:"server_context"` - Param string `json:"param"` - // NamedCipher reports that the upstream error names, by item id, an item - // listed in CipherIndices. - NamedCipher bool `json:"named_cipher"` -} - -type RecoverySelection struct { - Indices []int `json:"indices,omitempty"` - Reason string `json:"reason"` -} - -// A structured request-state rejection must stay request-scoped even when -// recovery is switched off. This validates protocol framing only; the recovery -// policy separately decides recovery eligibility and rewrite selection. -func ValidReasoningRejectionEnvelope(in RecoveryEnvelope) bool { - if !in.ValidJSON || !in.ParamValid || in.Code == nil { - return false - } - for _, status := range in.Statuses { - switch status { - case 401, 402, 403, 407, 429: - return false - } - } - if in.Event != "" && in.Event != "error" && in.Event != "response.failed" && in.Event != "response.done" { - return false - } - if in.Event == "response.done" && in.ResponseStatus != "failed" { - return false - } - if in.Status != "" && in.Status != "failed" { - return false - } - switch in.ErrorLocation { - case "response": - return in.Event == "response.failed" || in.ResponseStatus == "failed" - case "error": - return true - case "root": - return in.Event == "error" - default: - return false - } -} diff --git a/backend/internal/nativeapi/codex_runtime.go b/backend/internal/nativeapi/codex_runtime.go deleted file mode 100644 index 8ec48e0cb396..000000000000 --- a/backend/internal/nativeapi/codex_runtime.go +++ /dev/null @@ -1,5 +0,0 @@ -package nativeapi - -type CodexRuntimeConfig struct { - RequestZstd bool `json:"request_zstd"` -} diff --git a/backend/internal/nativeapi/doc.go b/backend/internal/nativeapi/doc.go deleted file mode 100644 index f9223531d791..000000000000 --- a/backend/internal/nativeapi/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package nativeapi contains the data types shared by the in-process domains. -// Stored JSON field names remain compatible with existing account data. -package nativeapi diff --git a/backend/internal/nativeapi/observability.go b/backend/internal/nativeapi/observability.go deleted file mode 100644 index 1774fd3c1714..000000000000 --- a/backend/internal/nativeapi/observability.go +++ /dev/null @@ -1,21 +0,0 @@ -package nativeapi - -type AdminObservabilityConfig struct { - TelemetryEnabled bool `json:"telemetry_enabled"` - ThemeEnabled bool `json:"theme_enabled"` -} -type TrafficObservationPolicy struct { - Enabled bool `json:"enabled"` - Classification DecisionTable `json:"classification"` -} -type AccountTrafficCounters struct { - Started int64 `json:"started"` - Completed2xx int64 `json:"completed_2xx"` - Upstream429 int64 `json:"upstream_429"` - Upstream5xx int64 `json:"upstream_5xx"` - Cancelled int64 `json:"cancelled"` - FailedOther int64 `json:"failed_other"` - PeakInFlight int `json:"peak_in_flight"` - RequestsLast60s int `json:"requests_last_60s"` - ObservedSinceMs int64 `json:"observed_since_ms"` -} diff --git a/backend/internal/nativeapi/decision.go b/backend/internal/service/account_traffic_decision.go similarity index 99% rename from backend/internal/nativeapi/decision.go rename to backend/internal/service/account_traffic_decision.go index e14b2f9c2cb8..477afc1aa627 100644 --- a/backend/internal/nativeapi/decision.go +++ b/backend/internal/service/account_traffic_decision.go @@ -1,4 +1,4 @@ -package nativeapi +package service import ( "errors" diff --git a/backend/internal/service/account_traffic_observe.go b/backend/internal/service/account_traffic_observe.go index 87afa355aac8..a7c4afb9614f 100644 --- a/backend/internal/service/account_traffic_observe.go +++ b/backend/internal/service/account_traffic_observe.go @@ -7,7 +7,6 @@ import ( "sync" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "go.uber.org/zap" ) @@ -99,7 +98,17 @@ const AccountTrafficObserveTTLSeconds = 24 * 60 * 60 const accountTrafficObserveRedisTimeout = 2 * time.Second // AccountTrafficObserveState is the per-protocol counter snapshot of one account. -type AccountTrafficObserveState = extensionv1.AccountTrafficCounters +type AccountTrafficObserveState struct { + Started int64 `json:"started"` + Completed2xx int64 `json:"completed_2xx"` + Upstream429 int64 `json:"upstream_429"` + Upstream5xx int64 `json:"upstream_5xx"` + Cancelled int64 `json:"cancelled"` + FailedOther int64 `json:"failed_other"` + PeakInFlight int `json:"peak_in_flight"` + RequestsLast60s int `json:"requests_last_60s"` + ObservedSinceMs int64 `json:"observed_since_ms"` +} // AccountTrafficObserveCache is the Redis-backed counter store (repository). type AccountTrafficObserveCache interface { @@ -173,7 +182,7 @@ func (o *AccountTrafficObserver) Snapshot(ctx context.Context, account *Account) // AccountTrafficTurn is one started turn awaiting its single Finish. type AccountTrafficTurn struct { - classification extensionv1.DecisionTable + classification DecisionTable cache AccountTrafficObserveCache accountID int64 protocol AccountTrafficProtocol @@ -206,7 +215,7 @@ func (t *AccountTrafficTurn) Finish(result *OpenAIForwardResult, err error, clie }) } -func evaluateAccountTrafficOutcome(table extensionv1.DecisionTable, result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { +func evaluateAccountTrafficOutcome(table DecisionTable, result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { facts := map[string]string{"client_cancelled": strconv.FormatBool(clientCancelled || (result != nil && result.ClientDisconnect)), "has_result": strconv.FormatBool(result != nil), "has_error": strconv.FormatBool(err != nil), "ws": "false", "terminal": "", "terminal_status": "0", "error_status": "0"} if result != nil { facts["ws"] = strconv.FormatBool(result.OpenAIWSMode) diff --git a/backend/internal/service/admin_observability.go b/backend/internal/service/admin_observability.go index ebbabc08ede9..0c8c13b17a23 100644 --- a/backend/internal/service/admin_observability.go +++ b/backend/internal/service/admin_observability.go @@ -4,25 +4,24 @@ import ( "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) -var adminObservabilityConfigOverride atomic.Pointer[extensionv1.AdminObservabilityConfig] +var adminObservabilityConfigOverride atomic.Pointer[AdminObservabilityConfig] // LegacyAdminObservabilityConfig is the deploy-time default: account traffic // telemetry follows the legacy gateway flag and the flat theme is on. -func LegacyAdminObservabilityConfig(cfg *config.Config) extensionv1.AdminObservabilityConfig { - return extensionv1.AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} +func LegacyAdminObservabilityConfig(cfg *config.Config) AdminObservabilityConfig { + return AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} } // ConfigureAdminObservability installs the effective telemetry and theme // switches (startup load, admin update, tests). A nil value restores the // built-in default. -func ConfigureAdminObservability(config *extensionv1.AdminObservabilityConfig) { +func ConfigureAdminObservability(config *AdminObservabilityConfig) { adminObservabilityConfigOverride.Store(config) } -func currentAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { +func currentAdminObservabilityConfig() AdminObservabilityConfig { if config := adminObservabilityConfigOverride.Load(); config != nil { return *config } @@ -34,30 +33,35 @@ func FlatThemeEnabled() bool { return currentAdminObservabilityConfig().ThemeEnabled } +type TrafficObservationPolicy struct { + Enabled bool `json:"enabled"` + Classification DecisionTable `json:"classification"` +} + // currentAccountTrafficObservationPolicy returns the telemetry switch and the // outcome rules for one account. Accounts without a concrete identity are never // observed. -func currentAccountTrafficObservationPolicy(account *Account) (extensionv1.TrafficObservationPolicy, bool) { +func currentAccountTrafficObservationPolicy(account *Account) (TrafficObservationPolicy, bool) { if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { - return extensionv1.TrafficObservationPolicy{}, false + return TrafficObservationPolicy{}, false } - return extensionv1.TrafficObservationPolicy{Enabled: currentAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true + return TrafficObservationPolicy{Enabled: currentAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true } // accountTrafficOutcomeRules classifies a finished turn. Client cancellation // outranks errors, and a WebSocket turn counts as completed only with a // demonstrable terminal event. Every result is an AccountTrafficOutcome. -var accountTrafficOutcomeRules = func() extensionv1.DecisionTable { - eq := func(field, value string) extensionv1.DecisionCondition { - return extensionv1.DecisionCondition{Field: field, Operator: "eq", Value: value} +var accountTrafficOutcomeRules = func() DecisionTable { + eq := func(field, value string) DecisionCondition { + return DecisionCondition{Field: field, Operator: "eq", Value: value} } - num := func(field, op, value string) extensionv1.DecisionCondition { - return extensionv1.DecisionCondition{Field: field, Operator: op, Value: value} + num := func(field, op, value string) DecisionCondition { + return DecisionCondition{Field: field, Operator: op, Value: value} } - rule := func(result AccountTrafficOutcome, when ...extensionv1.DecisionCondition) extensionv1.DecisionRule { - return extensionv1.DecisionRule{When: when, Result: string(result)} + rule := func(result AccountTrafficOutcome, when ...DecisionCondition) DecisionRule { + return DecisionRule{When: when, Result: string(result)} } - return extensionv1.DecisionTable{Default: string(AccountTrafficOutcomeFailedOther), Rules: []extensionv1.DecisionRule{ + return DecisionTable{Default: string(AccountTrafficOutcomeFailedOther), Rules: []DecisionRule{ rule(AccountTrafficOutcomeCancelled, eq("client_cancelled", "true")), rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.cancelled")), rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.incomplete")), diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go index eca865d56eff..445bbe4c5a25 100644 --- a/backend/internal/service/admin_observability_test.go +++ b/backend/internal/service/admin_observability_test.go @@ -5,7 +5,6 @@ import ( "strconv" "testing" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -28,7 +27,7 @@ func (c *trafficScopeCache) Snapshot(_ context.Context, id int64) (map[AccountTr return map[AccountTrafficProtocol]AccountTrafficObserveState{AccountTrafficProtocolHTTP: {Started: 1}}, nil } -func useAdminObservability(t *testing.T, config extensionv1.AdminObservabilityConfig) { +func useAdminObservability(t *testing.T, config AdminObservabilityConfig) { t.Helper() previous := adminObservabilityConfigOverride.Load() t.Cleanup(func() { adminObservabilityConfigOverride.Store(previous) }) @@ -39,7 +38,7 @@ func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth} for _, enabled := range []bool{false, true} { t.Run(strconv.FormatBool(enabled), func(t *testing.T) { - useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: enabled}) + useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: enabled}) cache := &trafficScopeCache{} observer := NewAccountTrafficObserver(cache) turn := observer.Begin(context.Background(), account, AccountTrafficProtocolWS) @@ -61,7 +60,7 @@ func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { } func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { - useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) + useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: true}) cache := &trafficScopeCache{} observer := NewAccountTrafficObserver(cache) for _, account := range []*Account{ @@ -82,13 +81,13 @@ func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { } func TestTrafficObservationFinishesStartedTurnAfterTelemetryIsSwitchedOff(t *testing.T) { - useAdminObservability(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}) + useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: true}) cache := &trafficScopeCache{} observer := NewAccountTrafficObserver(cache) account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} turn := observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP) require.NotNil(t, turn) - ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + ConfigureAdminObservability(&AdminObservabilityConfig{}) turn.Finish(&OpenAIForwardResult{}, nil, false) turn.Finish(nil, nil, false) require.Equal(t, []AccountTrafficOutcome{AccountTrafficOutcomeCompleted2xx}, cache.outcomes) @@ -115,9 +114,9 @@ func TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTermin } func TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues(t *testing.T) { - config := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} require.NoError(t, DecodeSwitchSettings([]byte(`{"theme_enabled":false}`), &config, "telemetry_enabled", "theme_enabled")) - require.Equal(t, extensionv1.AdminObservabilityConfig{TelemetryEnabled: true}, config, "omitted switches keep their defaults") + require.Equal(t, AdminObservabilityConfig{TelemetryEnabled: true}, config, "omitted switches keep their defaults") for _, raw := range []string{`{"theme_enabled":null}`, `{"theme":true}`, `{"theme_enabled":"false"}`, `[]`, `null`} { require.Error(t, DecodeSwitchSettings([]byte(raw), &config, "telemetry_enabled", "theme_enabled"), raw) } diff --git a/backend/internal/service/model_context_capacity.go b/backend/internal/service/model_context_capacity.go index a07eb6e3cb59..36aa1a2f483a 100644 --- a/backend/internal/service/model_context_capacity.go +++ b/backend/internal/service/model_context_capacity.go @@ -6,7 +6,6 @@ import ( "sort" "strings" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -31,15 +30,44 @@ const ( // ModelContextCapacity keeps an upstream's distinct limits intact. A zero is // unknown, never a promise of an unlimited window. -type ModelContextCapacity = extensionv1.ModelContextCapacity +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"` +} // ModelContextCapacityReference preserves the raw product reference of a // catalog entry. -type ModelContextCapacityReference = extensionv1.ModelContextCapacityReference +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"` +} // OfficialModelContextCapacity is release-owned evidence. Match constraints and // reference values are not client-writeable. -type OfficialModelContextCapacity = extensionv1.OfficialModelContextCapacity +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:"match_hosts,omitempty"` + MatchAccountModes []string `json:"match_account_modes,omitempty"` +} // ResolvedModelContextCapacity is one answer for one model. An empty Source // means unknown; callers must not advertise it. diff --git a/backend/internal/service/model_context_official_catalog.go b/backend/internal/service/model_context_official_catalog.go index 052dff6bd195..647d5fdfb6b3 100644 --- a/backend/internal/service/model_context_official_catalog.go +++ b/backend/internal/service/model_context_official_catalog.go @@ -3,8 +3,6 @@ package service import ( "slices" "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // officialCatalogQuery carries the real upstream model spellings and the @@ -39,7 +37,7 @@ func lookupOfficialModelCatalog(query officialCatalogQuery) *OfficialModelContex if !officialCatalogEntryMatches(entry, key) || !officialCatalogApplies(query, entry) { continue } - if !validOfficialCatalogCapacity(entry.ContextWindow) && !validOfficialCatalogCapacity(entry.MaxInputTokens) && !validOfficialCatalogCapacity(entry.MaxContextWindow) { + if !validModelContextTokens(entry.ContextWindow) && !validModelContextTokens(entry.MaxInputTokens) && !validModelContextTokens(entry.MaxContextWindow) { continue } if found != nil && found.ModelContextCapacity != entry.ModelContextCapacity { @@ -106,10 +104,6 @@ func OfficialModelCatalogSnapshot() []OfficialModelContextCapacity { return result } -func validOfficialCatalogCapacity(value int64) bool { - return value > 0 && value <= extensionv1.MaxModelContextTokens -} - func officialCatalogContainsFold(values []string, target string) bool { for _, value := range values { if strings.EqualFold(value, target) { diff --git a/backend/internal/service/native_setting_load_test.go b/backend/internal/service/native_setting_load_test.go index c7fda2ce243f..f639b93ee747 100644 --- a/backend/internal/service/native_setting_load_test.go +++ b/backend/internal/service/native_setting_load_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/stretchr/testify/require" ) @@ -28,7 +27,7 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { ConfigureAdminObservability(&oldObservability) SetCodexRequestZstdEnabled(oldCodexRuntime.RequestZstd) }) - ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + ConfigureAdminObservability(&AdminObservabilityConfig{}) SetCodexRequestZstdEnabled(false) for _, test := range []struct { name string @@ -45,8 +44,8 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { require.Error(t, load(context.Background())) } - require.Equal(t, extensionv1.AdminObservabilityConfig{}, EffectiveAdminObservabilityConfig()) - require.Equal(t, extensionv1.CodexRuntimeConfig{}, EffectiveCodexRuntimeConfig()) + require.Equal(t, AdminObservabilityConfig{}, EffectiveAdminObservabilityConfig()) + require.Equal(t, CodexRuntimeConfig{}, EffectiveCodexRuntimeConfig()) }) } svc := NewSettingService(&nativeSwitchReadRepository{err: ErrSettingNotFound}, nil) @@ -81,7 +80,7 @@ func TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault(t *testing.T) { old := EffectiveCodexRuntimeConfig() t.Cleanup(func() { SetCodexRequestZstdEnabled(old.RequestZstd) }) ctx := context.Background() - load := func(deploy bool, stored ...string) (extensionv1.CodexRuntimeConfig, error) { + load := func(deploy bool, stored ...string) (CodexRuntimeConfig, error) { repo := &codexRuntimeSettingRepository{values: map[string]string{}} if len(stored) > 0 { repo.values["codex_runtime_config"] = stored[0] @@ -107,7 +106,7 @@ func TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault(t *testing.T) { SetCodexRequestZstdEnabled(!test.expected) got, err := load(test.deploy, test.stored...) require.NoError(t, err) - require.Equal(t, extensionv1.CodexRuntimeConfig{RequestZstd: test.expected}, got) + require.Equal(t, CodexRuntimeConfig{RequestZstd: test.expected}, got) }) } for _, stored := range []string{`{"request_zstd":"true"}`, `{"request_zstd":null}`, `{"request_zstd":true,"other":true}`, `{"other":true}`, `null`, `[]`, `true`, ``} { @@ -119,10 +118,10 @@ func TestCodexRuntimeSettingIsOnePlainRowOverTheDeployDefault(t *testing.T) { repo := &codexRuntimeSettingRepository{values: map[string]string{}} svc := NewSettingService(repo, &config.Config{}) - require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, extensionv1.CodexRuntimeConfig{RequestZstd: true})) + require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, CodexRuntimeConfig{RequestZstd: true})) require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":true}`}, repo.values) require.True(t, EffectiveCodexRuntimeConfig().RequestZstd) - require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, extensionv1.CodexRuntimeConfig{})) + require.NoError(t, svc.UpdateCodexRuntimeConfig(ctx, CodexRuntimeConfig{})) require.Equal(t, map[string]string{"codex_runtime_config": `{"request_zstd":false}`}, repo.values) require.False(t, EffectiveCodexRuntimeConfig().RequestZstd) } diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go index d391269dbe8b..763c2aebb90b 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -2,14 +2,15 @@ package service import ( "context" + "crypto/sha256" "encoding/json" + "errors" "fmt" "log/slog" + "regexp" + "strings" "sync" "time" - - codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // CodexClientIdentityExtraKey 持久化在 accounts.extra 中的每账号 Codex 客户端身份。 @@ -18,35 +19,188 @@ import ( // 是同一台设备上运行的同一个真实 Codex TUI,而不是多个用户各自的客户端。 const CodexClientIdentityExtraKey = "codex_client_identity" -type codexClientIdentity extensionv1.CodexClientProfile +const codexClientIdentitySchemaVersion = 1 + +// codexClientIdentity 描述一台"运行 Codex TUI 的机器":操作系统、系统版本、架构、 +// 终端以及与该系统配套的 sandbox 标签。字符串形态严格对齐 codex-rs 的 +// os_info / terminal-detection / sandbox_tags 输出,保证 UA 与 turn metadata 自洽。 +type codexClientIdentity struct { + Version int `json:"v"` + OSType string `json:"os_type"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + Terminal string `json:"terminal"` + Sandbox string `json:"sandbox"` + GeneratedAt string `json:"generated_at,omitempty"` +} -// Stable values used by the persisted protocol representation and diagnostics. const ( - codexClientOSMac = "Mac OS" - codexClientOSWindows = "Windows" - codexClientOSUbuntu = "Ubuntu" + codexClientOSMac = "Mac OS" + codexClientOSWindows = "Windows" + codexClientOSUbuntu = "Ubuntu" + codexClientSandboxMac = "seatbelt" codexClientSandboxWindows = "windows_sandbox" codexClientSandboxLinux = "seccomp" - codexTUIOriginator = "codex-tui" ) +var ( + codexClientMacVersions = []string{"15.5.0", "15.6.1", "15.7.0", "26.0.1", "26.1.0"} + codexClientWindowsVersions = []string{"10.0.26100", "10.0.22631", "10.0.19045"} + codexClientUbuntuVersions = []string{"22.4.0", "24.4.0"} + codexClientAppleTerminals = []string{"453", "455", "456"} +) + +var ( + codexClientOSVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + codexClientTerminalPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]{1,64}$`) +) + +// valid 做 schema 校验而不只是非空:系统/沙箱必须配对(seatbelt=Mac OS、 +// windows_sandbox=Windows、seccomp=Ubuntu),版本/架构/终端形态受限且不含空白或 +// CR/LF。持久化值或外部写入的坏值一律视为缺失并重新按种子派生,避免脏值进入 UA。 func (id codexClientIdentity) valid() bool { - return codexprofile.Valid(extensionv1.CodexClientProfile(id)) + if id.Version != codexClientIdentitySchemaVersion { + return false + } + switch id.OSType { + case codexClientOSMac: + if id.Sandbox != codexClientSandboxMac || (id.Arch != "arm64" && id.Arch != "x86_64") { + return false + } + case codexClientOSWindows: + if id.Sandbox != codexClientSandboxWindows || id.Arch != "x86_64" { + return false + } + case codexClientOSUbuntu: + if id.Sandbox != codexClientSandboxLinux || id.Arch != "x86_64" { + return false + } + default: + return false + } + return codexClientOSVersionPattern.MatchString(id.OSVersion) && + codexClientTerminalPattern.MatchString(id.Terminal) +} + +// codexSandboxForUserAgent 从有效出站 UA 的 `( ; )` 段推出与之配套的 +// sandbox 标签;无法解析时返回空串(调用方不改写 sandbox)。turn metadata 声明的沙箱 +// 必须跟随最终发出的 UA,而不是账号身份本身:管理员显式 UA 覆盖时二者可能不同。 +var codexUserAgentPlatformPattern = regexp.MustCompile(`\(([^();]+?) [^ ();]+; [^();]+\)`) + +func codexSandboxForUserAgent(userAgent string) string { + m := codexUserAgentPlatformPattern.FindStringSubmatch(userAgent) + if m == nil { + return "" + } + osType := strings.ToLower(strings.TrimSpace(m[1])) + switch { + case osType == "": + return "" + case strings.HasPrefix(osType, "mac") || strings.HasPrefix(osType, "darwin"): + return codexClientSandboxMac + case strings.HasPrefix(osType, "windows"): + return codexClientSandboxWindows + default: + return codexClientSandboxLinux + } +} + +// errCodexClientVersionInvalid reports a Codex client version that cannot be +// written into a User-Agent. +var errCodexClientVersionInvalid = errors.New("invalid identity version") + +// codexClientIdentityVersionPattern allows the two official forms, 0.146.0 and +// 0.147.0-alpha.4. +var codexClientIdentityVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) + +// buildUserAgent 拼出真实 Codex TUI 的 User-Agent: +// codex-tui/ ( ; ) (codex-tui; )。 +// 首段版本、尾部括号组版本与 version 头必须同源(codex-rs 三处都取同一个 +// CARGO_PKG_VERSION)。身份未通过 schema 校验或版本号形态不合法时返回错误: +// 二者都不得写进出站请求头。 +func (id codexClientIdentity) buildUserAgent(version string) (string, error) { + if !id.valid() { + return "", errors.New("invalid identity profile") + } + if len(version) > codexClientVersionMaxLen || !codexClientIdentityVersionPattern.MatchString(version) { + return "", errCodexClientVersionInvalid + } + return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", + codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version), nil } // UserAgent 返回该身份在给定版本号下的 Codex TUI User-Agent;身份或版本号不合法时返回空串。 func (id codexClientIdentity) UserAgent(version string) string { - userAgent, _ := codexprofile.UserAgent(extensionv1.CodexClientProfile(id), version) + userAgent, _ := id.buildUserAgent(version) return userAgent } -func codexSandboxForUserAgent(userAgent string) string { - return codexprofile.SandboxForUserAgent(userAgent) -} +// codexTUIOriginator 是交互式 Codex TUI 的 originator(app-server initialize 的 +// clientInfo.name,codex-rs tui/src/lib.rs)。 +const codexTUIOriginator = "codex-tui" +// deriveCodexClientIdentity 从种子确定性派生一份自洽身份。分布按真实 TUI 用户 +// 的大致构成加权:macOS 60%(arm64 为主),Windows 25%,Ubuntu 15%;终端与 +// 系统匹配,sandbox 标签与系统匹配。 func deriveCodexClientIdentity(seed string) codexClientIdentity { - return codexClientIdentity(codexprofile.Derive(seed)) + h := sha256.Sum256([]byte("sub2api:codex-client-identity:v1:" + seed)) + id := codexClientIdentity{Version: codexClientIdentitySchemaVersion} + switch osPick := h[0]; { + case osPick < 154: // 60% + id.OSType = codexClientOSMac + id.Sandbox = codexClientSandboxMac + if h[1] < 230 { // 90% + id.Arch = "arm64" + } else { + id.Arch = "x86_64" + } + id.OSVersion = codexClientMacVersions[int(h[2])%len(codexClientMacVersions)] + switch term := h[3]; { + case term < 102: // 40% + id.Terminal = fmt.Sprintf("iTerm.app/3.5.%d", 10+int(h[4])%6) + case term < 153: // 20% + id.Terminal = "Apple_Terminal/" + codexClientAppleTerminals[int(h[4])%len(codexClientAppleTerminals)] + case term < 191: // 15% + id.Terminal = fmt.Sprintf("ghostty/1.3.%d", int(h[4])%3) + case term < 230: // 15% + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + default: // 10% + id.Terminal = fmt.Sprintf("WarpTerminal/v0.2026.%02d.%02d.08.12.stable_%02d", + 1+int(h[4])%9, 1+int(h[5])%28, 1+int(h[6])%3) + } + case osPick < 218: // 25% + id.OSType = codexClientOSWindows + id.Sandbox = codexClientSandboxWindows + id.Arch = "x86_64" + id.OSVersion = codexClientWindowsVersions[int(h[2])%len(codexClientWindowsVersions)] + switch term := h[3]; { + case term < 153: // 60% + id.Terminal = "WindowsTerminal" + case term < 217: // 25% + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + default: // 15% + id.Terminal = "unknown" + } + default: // 15% + id.OSType = codexClientOSUbuntu + id.Sandbox = codexClientSandboxLinux + id.Arch = "x86_64" + id.OSVersion = codexClientUbuntuVersions[int(h[2])%len(codexClientUbuntuVersions)] + switch int(h[3]) % 3 { + case 0: + id.Terminal = "gnome-terminal" + case 1: + id.Terminal = "xterm-256color" + default: + id.Terminal = codexClientVSCodeTerminal(h[4], h[5]) + } + } + return id +} + +func codexClientVSCodeTerminal(major, minor byte) string { + return fmt.Sprintf("vscode/1.%d.%d", 103+int(major)%3, int(minor)%3) } // codexClientIdentityFromExtra 读取持久化身份;缺失、非对象或字段不全时返回 false。 diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index 592b0932e125..4e73f5390bcf 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -7,10 +7,10 @@ import ( "net/http" "net/http/httptest" "regexp" + "strings" "testing" "time" - codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" @@ -121,15 +121,15 @@ func TestCodexIdentityInvalidVersionCannotSendCanonicalFallback(t *testing.T) { require.Equal(t, "9.9.9_bad", resolveCodexOutboundIdentity("").version) _, err := resolveCodexOutboundIdentityForAccount(account, "") - require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + require.ErrorIs(t, err, errCodexClientVersionInvalid) headers := http.Header{"Originator": []string{"fixture-original"}} err = enforceCodexIdentityHeadersForAccount(headers, account, "") - require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + require.ErrorIs(t, err, errCodexClientVersionInvalid) require.Equal(t, "fixture-original", headers.Get("Originator")) require.Empty(t, headers.Get("User-Agent")) client := &identityRefreshingOAuthClientStub{} _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) + require.ErrorIs(t, err, errCodexClientVersionInvalid) require.Empty(t, client.userAgent) } @@ -228,6 +228,30 @@ func (id codexClientIdentity) withoutGeneratedAt() codexClientIdentity { return id } +// 同一种子派生同一身份;UA 的两处版本与 sandbox 都和身份自洽;带 CR/LF 的终端或版本号 +// 不得写进 User-Agent。 +func TestCodexClientIdentityPreservesDeterminismAndProtocolPairing(t *testing.T) { + const seed = "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55" + first := deriveCodexClientIdentity(seed) + require.Equal(t, first, deriveCodexClientIdentity(seed)) + require.True(t, first.valid()) + + ua, err := first.buildUserAgent("0.150.0") + require.NoError(t, err) + require.True(t, strings.HasPrefix(ua, "codex-tui/0.150.0 "), ua) + require.True(t, strings.HasSuffix(ua, "(codex-tui; 0.150.0)"), ua) + require.Equal(t, first.Sandbox, codexSandboxForUserAgent(ua)) + require.Equal(t, ua, first.UserAgent("0.150.0")) + + bad := first + bad.Terminal = "terminal\r\nx-header: value" + require.False(t, bad.valid(), "header injection accepted") + _, err = first.buildUserAgent("0.150.0\r\nx: value") + require.ErrorIs(t, err, errCodexClientVersionInvalid) + _, err = bad.buildUserAgent("0.150.0") + require.Error(t, err, "invalid profile accepted") +} + // 凭据面与推理面共用显式 UA 策略:ForceCodexCLI 开启时忽略账号自定义 UA,刷新 token 也用派生身份。 func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) { t.Cleanup(func() { SetCodexForceCLIEnabled(false) }) diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index bd0e9d425ce2..d18405d37db0 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -7,8 +7,6 @@ import ( "sync" "sync/atomic" - codexprofile "github.com/Wei-Shaw/sub2api/internal/codexruntime/profile" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/google/uuid" ) @@ -286,7 +284,7 @@ func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) if !ok { return canonical, nil } - userAgent, err := codexprofile.UserAgent(extensionv1.CodexClientProfile(identity), canonical.version) + userAgent, err := identity.buildUserAgent(canonical.version) if err != nil { return codexOutboundIdentity{}, err } diff --git a/backend/internal/service/openai_reasoning_recovery.go b/backend/internal/service/openai_reasoning_recovery.go index acb4b67d5fc7..68a8c66d8ce8 100644 --- a/backend/internal/service/openai_reasoning_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery.go @@ -16,7 +16,6 @@ import ( "strings" "time" - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -405,18 +404,6 @@ func openAIReasoningCipherItems(body []byte) []openAIReasoningCipherItem { return out } -// message is the upstream error text; selection reads it only for the id of -// the rejected item. -type openAIReasoningRejection struct{ code, param, message string } - -func parseOpenAIReasoningRejection(payload []byte) (openAIReasoningRejection, bool) { - result := codexrecovery.Rejection(openAIRecoveryEnvelope(payload)) - if !result.Recognized { - return openAIReasoningRejection{}, false - } - return openAIReasoningRejection{code: result.Code, param: result.Param, message: openAIRecoveryErrorMessage(payload)}, true -} - func openAIReasoningToolHistoryAllowsRecovery(body []byte) bool { var input []any if err := decodeOpenAIJSONUseNumber([]byte(gjson.GetBytes(body, "input").Raw), &input); err != nil { @@ -428,24 +415,24 @@ func openAIReasoningToolHistoryAllowsRecovery(body []byte) bool { } func openAIReasoningRejectedIndices(body []byte, rejection openAIReasoningRejection) ([]int, []string) { - selection := selectOpenAIRecoveryIndices(body, rejection) - if len(selection.Indices) == 0 { + indices, _ := selectOpenAIRecoveryIndices(body, rejection) + if len(indices) == 0 { return nil, nil } byIndex := map[int]string{} for _, item := range openAIReasoningCipherItems(body) { byIndex[item.index] = item.hash } - hashes := make([]string, 0, len(selection.Indices)) + hashes := make([]string, 0, len(indices)) seen := map[string]bool{} - for _, index := range selection.Indices { + for _, index := range indices { hash := byIndex[index] if !seen[hash] { hashes = append(hashes, hash) seen[hash] = true } } - return selection.Indices, hashes + return indices, hashes } // Evidence preserves numeric field presence, not default-zero struct members. @@ -847,13 +834,13 @@ func (r *openAIReasoningRecoveryState) recoveryNotAttemptedReason(payload []byte if !ok { return "not_signature_rejection" } - selection := selectOpenAIRecoveryIndices(r.wire, rejection) - if len(selection.Indices) > 0 { - if _, err := stripOpenAIReasoningCipherIndices(r.wire, selection.Indices); err != nil { + indices, reason := selectOpenAIRecoveryIndices(r.wire, rejection) + if len(indices) > 0 { + if _, err := stripOpenAIReasoningCipherIndices(r.wire, indices); err != nil { return "rewrite_failed" } } - return selection.Reason + return reason } func (r *openAIReasoningRecoveryState) diagnosticClassification(payload []byte) string { diff --git a/backend/internal/service/openai_reasoning_recovery_policy.go b/backend/internal/service/openai_reasoning_recovery_policy.go index 818a1a3cf1d1..0f98980b3006 100644 --- a/backend/internal/service/openai_reasoning_recovery_policy.go +++ b/backend/internal/service/openai_reasoning_recovery_policy.go @@ -2,60 +2,70 @@ package service import ( "encoding/json" + "regexp" + "strconv" "strings" - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" ) -// openAIRecoveryErrorSource locates the error object of an upstream payload. -func openAIRecoveryErrorSource(root gjson.Result) (string, gjson.Result) { - switch { - case root.Get("response.error").IsObject(): - return "response", root.Get("response.error") - case root.Get("error").IsObject(): - return "error", root.Get("error") - default: - return "root", root - } -} +// openAIReasoningRejection is an upstream error that rejects reasoning +// ciphertext of the request. message is the upstream error text; selection +// reads it only for the id of the rejected item. +type openAIReasoningRejection struct{ code, param, message string } -// openAIRecoveryEnvelope reduces an upstream error payload to the protocol -// shape the recovery policy decides on. -func openAIRecoveryEnvelope(payload []byte) extensionv1.RecoveryEnvelope { - var envelope extensionv1.RecoveryEnvelope +// parseOpenAIReasoningRejection recognizes an upstream error, response.failed or +// failed response.done payload whose error code is thinking_signature_invalid +// or invalid_encrypted_content. A payload that carries a protected status is +// never one. This reads protocol framing only; whether the request can be +// repaired is decided by selectOpenAIRecoveryIndices. +func parseOpenAIReasoningRejection(payload []byte) (openAIReasoningRejection, bool) { if _, err := canonicalReasoningCacheJSON(payload); err != nil { - return envelope + return openAIReasoningRejection{}, false } - envelope.ValidJSON = true root := gjson.ParseBytes(payload) for _, path := range []string{"status_code", "error.status_code", "error.status", "response.error.status_code", "response.error.status"} { - if value := root.Get(path); value.Type == gjson.Number || value.Type == gjson.String { - envelope.Statuses = append(envelope.Statuses, int(value.Int())) + if value := root.Get(path); (value.Type == gjson.Number || value.Type == gjson.String) && openAIReasoningRecoveryProtectedStatus(int(value.Int())) { + return openAIReasoningRejection{}, false } } - envelope.Event, envelope.Status, envelope.ResponseStatus = root.Get("type").String(), root.Get("status").String(), root.Get("response.status").String() + event, responseStatus := root.Get("type").String(), root.Get("response.status").String() + if event != "" && event != "error" && event != "response.failed" && event != "response.done" { + return openAIReasoningRejection{}, false + } + if event == "response.done" && responseStatus != "failed" { + return openAIReasoningRejection{}, false + } + if status := root.Get("status").String(); status != "" && status != "failed" { + return openAIReasoningRejection{}, false + } var source gjson.Result - envelope.ErrorLocation, source = openAIRecoveryErrorSource(root) - if code := source.Get("code"); code.Type == gjson.String { - value := code.String() - envelope.Code = &value + switch { + case root.Get("response.error").IsObject(): + if event != "response.failed" && responseStatus != "failed" { + return openAIReasoningRejection{}, false + } + source = root.Get("response.error") + case root.Get("error").IsObject(): + source = root.Get("error") + case event == "error": + source = root + default: + return openAIReasoningRejection{}, false + } + code := source.Get("code") + if code.Type != gjson.String || (code.String() != "thinking_signature_invalid" && code.String() != "invalid_encrypted_content") { + return openAIReasoningRejection{}, false } param := source.Get("param") - envelope.ParamValid = !param.Exists() || param.Type == gjson.String || param.Type == gjson.Null - envelope.Param = param.String() - return envelope -} - -// openAIRecoveryErrorMessage returns the message of the error object the -// envelope takes its code and param from. -func openAIRecoveryErrorMessage(payload []byte) string { - _, source := openAIRecoveryErrorSource(gjson.ParseBytes(payload)) + if param.Exists() && param.Type != gjson.String && param.Type != gjson.Null { + return openAIReasoningRejection{}, false + } + rejection := openAIReasoningRejection{code: code.String(), param: param.String()} if message := source.Get("message"); message.Type == gjson.String { - return message.String() + rejection.message = message.String() } - return "" + return rejection, true } // openAIRecoveryMessageNamesItem reports whether the upstream message contains @@ -83,25 +93,59 @@ func openAIRecoveryMessageNamesItem(message, id string) bool { } } -// selectOpenAIRecoveryIndices asks the recovery policy which reasoning items of -// the sent body may lose their ciphertext for the rejection. -func selectOpenAIRecoveryIndices(body []byte, rejection openAIReasoningRejection) extensionv1.RecoverySelection { - query := extensionv1.RecoverySelectionQuery{Param: rejection.param} - if _, err := canonicalReasoningCacheJSON(body); err == nil { - query.BodyValid = true - query.ToolHistoryValid = openAIReasoningToolHistoryAllowsRecovery(body) - for _, item := range openAIReasoningCipherItems(body) { - query.CipherIndices = append(query.CipherIndices, item.index) - if !query.NamedCipher && rejection.message != "" && openAIRecoveryMessageNamesItem(rejection.message, item.id) { - query.NamedCipher = true - } +var openAIReasoningErrorInputParam = regexp.MustCompile(`^input(?:\[(\d+)\]|\.(\d+))(?:\.encrypted_content)?$`) + +// selectOpenAIRecoveryIndices returns the reasoning items of the sent body that +// may lose their ciphertext for the rejection, and the reason the continuation +// diagnostic reports while no stripped retry has been sent: why nothing is +// selected, or recovery_not_dispatched when something is. +func selectOpenAIRecoveryIndices(body []byte, rejection openAIReasoningRejection) ([]int, string) { + if _, err := canonicalReasoningCacheJSON(body); err != nil { + return nil, "invalid_request_snapshot" + } + // Removing ciphertext cannot repair a locally provable orphan tool result. + if !openAIReasoningToolHistoryAllowsRecovery(body) { + return nil, "invalid_tool_history" + } + items := openAIReasoningCipherItems(body) + if len(items) == 0 { + return nil, "no_reasoning_ciphertext" + } + if match := openAIReasoningErrorInputParam.FindStringSubmatch(rejection.param); match != nil { + text := match[1] + if text == "" { + text = match[2] } - conversation := gjson.GetBytes(body, "conversation") - query.ServerContext = gjson.GetBytes(body, "previous_response_id").String() != "" || (conversation.Exists() && conversation.Type != gjson.Null) - var parsed any - if json.Unmarshal(body, &parsed) == nil { - query.EncryptedFields = countOpenAIEncryptedFields(parsed) + if index, err := strconv.Atoi(text); err == nil { + for _, item := range items { + if item.index == index { + return []int{index}, "recovery_not_dispatched" + } + } } + return nil, "target_not_reasoning_ciphertext" + } + if rejection.param != "" && rejection.param != "input" && rejection.param != "reasoning.encrypted_content" { + return nil, "unsupported_error_param" + } + if openAIRequestHoldsServerContext(body) { + return nil, "server_held_context" + } + indices := make([]int, 0, len(items)) + named := false + for _, item := range items { + indices = append(indices, item.index) + named = named || (rejection.message != "" && openAIRecoveryMessageNamesItem(rejection.message, item.id)) + } + encryptedFields := 0 + var parsed any + if json.Unmarshal(body, &parsed) == nil { + encryptedFields = countOpenAIEncryptedFields(parsed) + } + // Another ciphertext carrier (compaction, an inter-agent message) may be the + // rejected one, unless the upstream names a reasoning item itself. + if encryptedFields != len(items) && !named { + return nil, "ambiguous_encrypted_carriers" } - return codexrecovery.Select(query) + return indices, "recovery_not_dispatched" } diff --git a/backend/internal/service/openai_reasoning_recovery_policy_test.go b/backend/internal/service/openai_reasoning_recovery_policy_test.go index 7e878b0a388a..44e6891b959a 100644 --- a/backend/internal/service/openai_reasoning_recovery_policy_test.go +++ b/backend/internal/service/openai_reasoning_recovery_policy_test.go @@ -14,3 +14,41 @@ func TestOpenAIReasoningRecoveryRejectionExcludesProtectedStatuses(t *testing.T) _, ok = parseOpenAIReasoningRejection([]byte(`{"error":{"status":429,"code":"thinking_signature_invalid"}}`)) require.False(t, ok, "quota/protected failures cannot become recovery markers") } + +func TestOpenAIReasoningRecoverySelectionRequiresUnambiguousLocalCipherScope(t *testing.T) { + const ( + history = `"input":[{"role":"user","content":"first"},{"type":"reasoning","id":"rs_one","encrypted_content":"a"},{"role":"user","content":"second"},{"type":"reasoning","id":"rs_three","encrypted_content":"b"}]` + local = `{` + history + `}` + held = `{"previous_response_id":"resp_old",` + history + `}` + carriers = `{"input":[{"role":"user","content":"first"},{"type":"reasoning","id":"rs_one","encrypted_content":"a"},{"type":"compaction","encrypted_content":"c"},{"type":"reasoning","id":"rs_three","encrypted_content":"b"}]}` + ) + rejection := func(payload string) openAIReasoningRejection { + t.Helper() + parsed, ok := parseOpenAIReasoningRejection([]byte(payload)) + require.True(t, ok, payload) + return parsed + } + unqualified := rejection(`{"error":{"code":"invalid_encrypted_content"}}`) + + indices, reason := selectOpenAIRecoveryIndices([]byte(local), unqualified) + require.Equal(t, []int{1, 3}, indices, "the unambiguous local set is selected whole") + require.Equal(t, "recovery_not_dispatched", reason) + + indices, reason = selectOpenAIRecoveryIndices([]byte(held), unqualified) + require.Empty(t, indices, "server-held context is left alone") + require.Equal(t, "server_held_context", reason) + + indices, _ = selectOpenAIRecoveryIndices([]byte(held), rejection(`{"error":{"code":"invalid_encrypted_content","param":"input[3].encrypted_content"}}`)) + require.Equal(t, []int{3}, indices, "an explicit target is selected alone") + + indices, reason = selectOpenAIRecoveryIndices([]byte(held), rejection(`{"error":{"code":"invalid_encrypted_content","param":"input[2].encrypted_content"}}`)) + require.Empty(t, indices, "an item without reasoning ciphertext is never selected") + require.Equal(t, "target_not_reasoning_ciphertext", reason) + + indices, reason = selectOpenAIRecoveryIndices([]byte(carriers), rejection(`{"error":{"code":"invalid_encrypted_content","param":"input"}}`)) + require.Empty(t, indices, "the compaction ciphertext may be the rejected one") + require.Equal(t, "ambiguous_encrypted_carriers", reason) + + indices, _ = selectOpenAIRecoveryIndices([]byte(carriers), rejection(`{"error":{"code":"invalid_encrypted_content","param":"input","message":"The encrypted content for item rs_three could not be verified."}}`)) + require.Equal(t, []int{1, 3}, indices, "a rejection that names a reasoning item is not ambiguous") +} diff --git a/backend/internal/service/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go index 78af5ac845dc..4575bb418365 100644 --- a/backend/internal/service/setting_admin_observability.go +++ b/backend/internal/service/setting_admin_observability.go @@ -2,23 +2,29 @@ package service import ( "context" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // SettingKeyAdminObservabilityConfig stores the account traffic telemetry and // flat theme switches. const SettingKeyAdminObservabilityConfig = "admin_observability_config" +// AdminObservabilityConfig is the stored value of +// SettingKeyAdminObservabilityConfig and the body of the observability settings +// endpoints. +type AdminObservabilityConfig struct { + TelemetryEnabled bool `json:"telemetry_enabled"` + ThemeEnabled bool `json:"theme_enabled"` +} + // EffectiveAdminObservabilityConfig returns the switches this process applies. -func EffectiveAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { +func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { return currentAdminObservabilityConfig() } // LoadAdminObservabilityConfig installs the effective switches for this process // at startup: the stored value, or the deploy-time default when none is stored. func (s *SettingService) LoadAdminObservabilityConfig(ctx context.Context) error { - config := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} found, err := s.readNativeSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "telemetry_enabled", "theme_enabled") if err != nil { return err @@ -36,7 +42,7 @@ func (s *SettingService) LoadAdminObservabilityConfig(ctx context.Context) error // UpdateAdminObservabilityConfig persists the switches and applies them to this // process. -func (s *SettingService) UpdateAdminObservabilityConfig(ctx context.Context, config extensionv1.AdminObservabilityConfig) error { +func (s *SettingService) UpdateAdminObservabilityConfig(ctx context.Context, config AdminObservabilityConfig) error { if err := s.writeJSONSetting(ctx, SettingKeyAdminObservabilityConfig, config); err != nil { return err } diff --git a/backend/internal/service/setting_codex_runtime.go b/backend/internal/service/setting_codex_runtime.go index e24b416f8121..6d245561cfd3 100644 --- a/backend/internal/service/setting_codex_runtime.go +++ b/backend/internal/service/setting_codex_runtime.go @@ -2,24 +2,28 @@ package service import ( "context" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" ) // SettingKeyCodexRuntimeConfig stores the Codex request compression switch. // Without a stored value gateway.openai_codex_request_zstd applies. const SettingKeyCodexRuntimeConfig = "codex_runtime_config" +// CodexRuntimeConfig is the stored value of SettingKeyCodexRuntimeConfig and +// the body of the Codex runtime settings endpoints. +type CodexRuntimeConfig struct { + RequestZstd bool `json:"request_zstd"` +} + // EffectiveCodexRuntimeConfig returns the switch this process applies. -func EffectiveCodexRuntimeConfig() extensionv1.CodexRuntimeConfig { - return extensionv1.CodexRuntimeConfig{RequestZstd: codexRequestZstd.Load()} +func EffectiveCodexRuntimeConfig() CodexRuntimeConfig { + return CodexRuntimeConfig{RequestZstd: codexRequestZstd.Load()} } // LoadCodexRuntimeConfig installs the effective switch for this process at // startup: the stored value, or gateway.openai_codex_request_zstd when none is // stored. func (s *SettingService) LoadCodexRuntimeConfig(ctx context.Context) error { - config := extensionv1.CodexRuntimeConfig{RequestZstd: true} + config := CodexRuntimeConfig{RequestZstd: true} found, err := s.readNativeSwitchSetting(ctx, SettingKeyCodexRuntimeConfig, &config, "request_zstd") if err != nil { return err @@ -32,7 +36,7 @@ func (s *SettingService) LoadCodexRuntimeConfig(ctx context.Context) error { } // UpdateCodexRuntimeConfig persists the switch and applies it to this process. -func (s *SettingService) UpdateCodexRuntimeConfig(ctx context.Context, config extensionv1.CodexRuntimeConfig) error { +func (s *SettingService) UpdateCodexRuntimeConfig(ctx context.Context, config CodexRuntimeConfig) error { if err := s.writeJSONSetting(ctx, SettingKeyCodexRuntimeConfig, config); err != nil { return err } From dc14a8539c3ddbdf875eeab70837ee176dabe207 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:09:17 +0800 Subject: [PATCH 02/37] refactor(settings): name the switch settings and their tests for what they are "native" and "legacy" in these names described the move out of plugins, not the settings themselves. - readNativeSwitchSetting is readSwitchSetting. Its three errors no longer say "native": "settings repository is unavailable", "cannot read setting " and "invalid setting ". They appear only in the process output when the server fails to start. - LegacyAdminObservabilityConfig is defaultAdminObservabilityConfig: it is the deploy-time default read from gateway.account_traffic_telemetry_disabled. - EffectiveAdminObservabilityConfig reads the switches itself instead of forwarding to a second function, and the holder is no longer called an override. - LoadAdminObservabilityConfig loses a branch for a nil service that the read above it already returns from. - Tests: native_setting_load_test.go is setting_switch_load_test.go, and four test names lose the word. Co-Authored-By: Claude Opus 5.5 --- .../handler/admin/account_job_submit_test.go | 2 +- .../admin/account_taxonomy_handler_test.go | 2 +- .../service/account_traffic_observe.go | 2 +- .../internal/service/admin_observability.go | 22 ++++++++++--------- .../service/admin_observability_test.go | 4 ++-- .../service/codex_error_diagnostics_test.go | 2 +- .../service/setting_admin_observability.go | 13 ++--------- .../internal/service/setting_codex_runtime.go | 2 +- backend/internal/service/setting_json.go | 8 +++---- ...ad_test.go => setting_switch_load_test.go} | 12 +++++----- 10 files changed, 31 insertions(+), 38 deletions(-) rename backend/internal/service/{native_setting_load_test.go => setting_switch_load_test.go} (89%) diff --git a/backend/internal/handler/admin/account_job_submit_test.go b/backend/internal/handler/admin/account_job_submit_test.go index ac54a9b98abe..db5a026a16df 100644 --- a/backend/internal/handler/admin/account_job_submit_test.go +++ b/backend/internal/handler/admin/account_job_submit_test.go @@ -15,7 +15,7 @@ import ( const accountJobTestActorID int64 = 77 -func TestAccountJobSubmissionRetainsActorAndNativeMetadata(t *testing.T) { +func TestAccountJobSubmissionRetainsActorAndMetadata(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() handler := &AccountHandler{} diff --git a/backend/internal/handler/admin/account_taxonomy_handler_test.go b/backend/internal/handler/admin/account_taxonomy_handler_test.go index 285475afe70d..f0090f9115be 100644 --- a/backend/internal/handler/admin/account_taxonomy_handler_test.go +++ b/backend/internal/handler/admin/account_taxonomy_handler_test.go @@ -69,7 +69,7 @@ func (s *accountTaxonomyHandlerStub) SetAccountTaxonomy(context.Context, int64, return &service.Account{}, nil } -func TestTaxonomyNativeResponsePreservesRedactedAccount(t *testing.T) { +func TestTaxonomyResponsePreservesRedactedAccount(t *testing.T) { stub := newAccountTaxonomyHandlerStub() stub.assignedAccount = &service.Account{ID: 7, Credentials: map[string]any{"api_key": "synthetic-secret"}} h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) diff --git a/backend/internal/service/account_traffic_observe.go b/backend/internal/service/account_traffic_observe.go index a7c4afb9614f..50c80896207d 100644 --- a/backend/internal/service/account_traffic_observe.go +++ b/backend/internal/service/account_traffic_observe.go @@ -128,7 +128,7 @@ type AccountTrafficObserver struct { } // Whether turns are recorded follows the admin_observability_config setting, -// whose default is the legacy gateway flag. +// whose default follows gateway.account_traffic_telemetry_disabled. func NewAccountTrafficObserver(cache AccountTrafficObserveCache) *AccountTrafficObserver { return &AccountTrafficObserver{ cache: cache, diff --git a/backend/internal/service/admin_observability.go b/backend/internal/service/admin_observability.go index 0c8c13b17a23..58ce84f9d8ca 100644 --- a/backend/internal/service/admin_observability.go +++ b/backend/internal/service/admin_observability.go @@ -6,11 +6,12 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" ) -var adminObservabilityConfigOverride atomic.Pointer[AdminObservabilityConfig] +var adminObservabilityConfig atomic.Pointer[AdminObservabilityConfig] -// LegacyAdminObservabilityConfig is the deploy-time default: account traffic -// telemetry follows the legacy gateway flag and the flat theme is on. -func LegacyAdminObservabilityConfig(cfg *config.Config) AdminObservabilityConfig { +// defaultAdminObservabilityConfig is the deploy-time default: account traffic +// telemetry follows gateway.account_traffic_telemetry_disabled and the flat +// theme is on. +func defaultAdminObservabilityConfig(cfg *config.Config) AdminObservabilityConfig { return AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} } @@ -18,19 +19,20 @@ func LegacyAdminObservabilityConfig(cfg *config.Config) AdminObservabilityConfig // switches (startup load, admin update, tests). A nil value restores the // built-in default. func ConfigureAdminObservability(config *AdminObservabilityConfig) { - adminObservabilityConfigOverride.Store(config) + adminObservabilityConfig.Store(config) } -func currentAdminObservabilityConfig() AdminObservabilityConfig { - if config := adminObservabilityConfigOverride.Load(); config != nil { +// EffectiveAdminObservabilityConfig returns the switches this process applies. +func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { + if config := adminObservabilityConfig.Load(); config != nil { return *config } - return LegacyAdminObservabilityConfig(nil) + return defaultAdminObservabilityConfig(nil) } // FlatThemeEnabled reports whether the flat site theme is switched on. func FlatThemeEnabled() bool { - return currentAdminObservabilityConfig().ThemeEnabled + return EffectiveAdminObservabilityConfig().ThemeEnabled } type TrafficObservationPolicy struct { @@ -45,7 +47,7 @@ func currentAccountTrafficObservationPolicy(account *Account) (TrafficObservatio if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { return TrafficObservationPolicy{}, false } - return TrafficObservationPolicy{Enabled: currentAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true + return TrafficObservationPolicy{Enabled: EffectiveAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true } // accountTrafficOutcomeRules classifies a finished turn. Client cancellation diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go index 445bbe4c5a25..6a1e8c54e29a 100644 --- a/backend/internal/service/admin_observability_test.go +++ b/backend/internal/service/admin_observability_test.go @@ -29,8 +29,8 @@ func (c *trafficScopeCache) Snapshot(_ context.Context, id int64) (map[AccountTr func useAdminObservability(t *testing.T, config AdminObservabilityConfig) { t.Helper() - previous := adminObservabilityConfigOverride.Load() - t.Cleanup(func() { adminObservabilityConfigOverride.Store(previous) }) + previous := adminObservabilityConfig.Load() + t.Cleanup(func() { adminObservabilityConfig.Store(previous) }) ConfigureAdminObservability(&config) } diff --git a/backend/internal/service/codex_error_diagnostics_test.go b/backend/internal/service/codex_error_diagnostics_test.go index 9f24b55c3da2..fe4a09747077 100644 --- a/backend/internal/service/codex_error_diagnostics_test.go +++ b/backend/internal/service/codex_error_diagnostics_test.go @@ -8,7 +8,7 @@ import ( // Every stored attempt diagnostic is projected with its own attempt context, // including the bounded original values; account state no longer filters it. -func TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt(t *testing.T) { +func TestCodexErrorDiagnosticsProjectEveryStoredAttempt(t *testing.T) { detail := &OpsErrorLogDetail{UpstreamErrors: `[ {"account_id":1,"account_name":"primary","platform":"openai","kind":"request_rejected","upstream_status_code":400,"upstream_request_id":"req_1","message":"Missing required parameter","continuation_diagnostic":{"classification":"thinking_signature_invalid","upstream_error":{"error_code":{"kind":"string","value":"thinking_signature_invalid"},"message":{"kind":"string","value":"signature invalid for input[3]"},"hints":["unlisted-hint","call_id"]},"incoming":{"instructions":{"kind":"string","value":"You are Codex"}}}}, {"account_id":2,"platform":"anthropic","continuation_diagnostic":{"classification":"invalid_encrypted_content"}}, diff --git a/backend/internal/service/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go index 4575bb418365..2c61b2b284c5 100644 --- a/backend/internal/service/setting_admin_observability.go +++ b/backend/internal/service/setting_admin_observability.go @@ -16,25 +16,16 @@ type AdminObservabilityConfig struct { ThemeEnabled bool `json:"theme_enabled"` } -// EffectiveAdminObservabilityConfig returns the switches this process applies. -func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { - return currentAdminObservabilityConfig() -} - // LoadAdminObservabilityConfig installs the effective switches for this process // at startup: the stored value, or the deploy-time default when none is stored. func (s *SettingService) LoadAdminObservabilityConfig(ctx context.Context) error { config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} - found, err := s.readNativeSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "telemetry_enabled", "theme_enabled") + found, err := s.readSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "telemetry_enabled", "theme_enabled") if err != nil { return err } if !found { - if s == nil { - config = LegacyAdminObservabilityConfig(nil) - } else { - config = LegacyAdminObservabilityConfig(s.cfg) - } + config = defaultAdminObservabilityConfig(s.cfg) } ConfigureAdminObservability(&config) return nil diff --git a/backend/internal/service/setting_codex_runtime.go b/backend/internal/service/setting_codex_runtime.go index 6d245561cfd3..cdf5aaa13ba8 100644 --- a/backend/internal/service/setting_codex_runtime.go +++ b/backend/internal/service/setting_codex_runtime.go @@ -24,7 +24,7 @@ func EffectiveCodexRuntimeConfig() CodexRuntimeConfig { // stored. func (s *SettingService) LoadCodexRuntimeConfig(ctx context.Context) error { config := CodexRuntimeConfig{RequestZstd: true} - found, err := s.readNativeSwitchSetting(ctx, SettingKeyCodexRuntimeConfig, &config, "request_zstd") + found, err := s.readSwitchSetting(ctx, SettingKeyCodexRuntimeConfig, &config, "request_zstd") if err != nil { return err } diff --git a/backend/internal/service/setting_json.go b/backend/internal/service/setting_json.go index 911381f004d7..d9fbd069ccdf 100644 --- a/backend/internal/service/setting_json.go +++ b/backend/internal/service/setting_json.go @@ -9,9 +9,9 @@ import ( // Startup must distinguish a missing key from a failed read, so a stored false // switch cannot silently become an enabled deployment default. -func (s *SettingService) readNativeSwitchSetting(ctx context.Context, key string, target any, allowed ...string) (bool, error) { +func (s *SettingService) readSwitchSetting(ctx context.Context, key string, target any, allowed ...string) (bool, error) { if s == nil || s.settingRepo == nil { - return false, errors.New("native settings repository is unavailable") + return false, errors.New("settings repository is unavailable") } dbCtx, cancel := context.WithTimeout(ctx, gatewayForwardingDBTimeout) defer cancel() @@ -20,10 +20,10 @@ func (s *SettingService) readNativeSwitchSetting(ctx context.Context, key string return false, nil } if err != nil { - return false, fmt.Errorf("cannot read native setting %s", key) + return false, fmt.Errorf("cannot read setting %s", key) } if err := DecodeSwitchSettings([]byte(raw), target, allowed...); err != nil { - return false, fmt.Errorf("invalid native setting %s", key) + return false, fmt.Errorf("invalid setting %s", key) } return true, nil } diff --git a/backend/internal/service/native_setting_load_test.go b/backend/internal/service/setting_switch_load_test.go similarity index 89% rename from backend/internal/service/native_setting_load_test.go rename to backend/internal/service/setting_switch_load_test.go index f639b93ee747..a1a07de8403a 100644 --- a/backend/internal/service/native_setting_load_test.go +++ b/backend/internal/service/setting_switch_load_test.go @@ -10,17 +10,17 @@ import ( "github.com/stretchr/testify/require" ) -type nativeSwitchReadRepository struct { +type switchReadRepository struct { SettingRepository value string err error } -func (r *nativeSwitchReadRepository) GetValue(context.Context, string) (string, error) { +func (r *switchReadRepository) GetValue(context.Context, string) (string, error) { return r.value, r.err } -func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { +func TestSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { oldObservability := EffectiveAdminObservabilityConfig() oldCodexRuntime := EffectiveCodexRuntimeConfig() t.Cleanup(func() { @@ -40,7 +40,7 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { {name: "empty existing key", value: ""}, } { t.Run(test.name, func(t *testing.T) { - svc := NewSettingService(&nativeSwitchReadRepository{value: test.value, err: test.err}, nil) + svc := NewSettingService(&switchReadRepository{value: test.value, err: test.err}, nil) for _, load := range []func(context.Context) error{svc.LoadAdminObservabilityConfig, svc.LoadCodexRuntimeConfig} { require.Error(t, load(context.Background())) } @@ -48,9 +48,9 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { require.Equal(t, CodexRuntimeConfig{}, EffectiveCodexRuntimeConfig()) }) } - svc := NewSettingService(&nativeSwitchReadRepository{err: ErrSettingNotFound}, nil) + svc := NewSettingService(&switchReadRepository{err: ErrSettingNotFound}, nil) var value map[string]json.RawMessage - found, err := svc.readNativeSwitchSetting(context.Background(), SettingKeyAdminObservabilityConfig, &value, "telemetry_enabled", "theme_enabled") + found, err := svc.readSwitchSetting(context.Background(), SettingKeyAdminObservabilityConfig, &value, "telemetry_enabled", "theme_enabled") require.NoError(t, err) require.False(t, found) } From af8f3663f02a89d1109bf18dab9b0275fa99c404 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:09:59 +0800 Subject: [PATCH 03/37] refactor(admin): serve error diagnostics from OpsHandler; drop the catalog lookup's input guard Two shapes left from calls that used to cross a plugin boundary. - GET /admin/ops/errors/:id/diagnostics was a method of AccountHandler that took the OpsHandler as an argument and used nothing of its own receiver. It is OpsHandler.GetCodexErrorDiagnostics, registered like the routes around it. Path, headers, status codes and bodies are the same. - lookupOfficialModelCatalog validated its candidates as if they came over an RPC: at most 16, at most 512 bytes each, none blank. Its one caller, LookupOfficialModelContextCapacity, passes at most five candidates that each passed validModelContextID, so none of these checks could fail. Co-Authored-By: Claude Opus 5.5 --- .../handler/admin/codex_error_diagnostics.go | 39 +++++++++---------- backend/internal/server/routes/admin.go | 2 +- .../service/model_context_official_catalog.go | 6 --- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/backend/internal/handler/admin/codex_error_diagnostics.go b/backend/internal/handler/admin/codex_error_diagnostics.go index c4d4e1920887..c4bdece8bc31 100644 --- a/backend/internal/handler/admin/codex_error_diagnostics.go +++ b/backend/internal/handler/admin/codex_error_diagnostics.go @@ -9,25 +9,24 @@ import ( "github.com/gin-gonic/gin" ) -// CodexErrorDiagnostics is installed behind the existing administrator -// middleware and returns every continuation diagnostic stored on the error. -func (h *AccountHandler) CodexErrorDiagnostics(ops *OpsHandler) gin.HandlerFunc { - return func(c *gin.Context) { - c.Header("Cache-Control", "private, no-store") - if h == nil || ops == nil || ops.opsService == nil { - response.Error(c, http.StatusServiceUnavailable, "Diagnostics unavailable") - return - } - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 { - response.BadRequest(c, "Invalid error id") - return - } - detail, err := ops.opsService.GetErrorLogByID(c.Request.Context(), id) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, service.ProjectCodexErrorDiagnostics(detail)) +// GetCodexErrorDiagnostics returns every continuation diagnostic stored on the +// error. +// GET /api/v1/admin/ops/errors/:id/diagnostics +func (h *OpsHandler) GetCodexErrorDiagnostics(c *gin.Context) { + c.Header("Cache-Control", "private, no-store") + if h == nil || h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Diagnostics unavailable") + return } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid error id") + return + } + detail, err := h.opsService.GetErrorLogByID(c.Request.Context(), id) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, service.ProjectCodexErrorDiagnostics(detail)) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index c59bc6f66024..3c5319b20786 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -277,7 +277,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Error logs (legacy) ops.GET("/errors", h.Admin.Ops.GetErrorLogs) ops.GET("/errors/:id", h.Admin.Ops.GetErrorLogByID) - ops.GET("/errors/:id/diagnostics", h.Admin.Account.CodexErrorDiagnostics(h.Admin.Ops)) + ops.GET("/errors/:id/diagnostics", h.Admin.Ops.GetCodexErrorDiagnostics) ops.PUT("/errors/:id/resolve", h.Admin.Ops.UpdateErrorResolution) // Request errors (client-visible failures) diff --git a/backend/internal/service/model_context_official_catalog.go b/backend/internal/service/model_context_official_catalog.go index 647d5fdfb6b3..2c2fbfc2dea1 100644 --- a/backend/internal/service/model_context_official_catalog.go +++ b/backend/internal/service/model_context_official_catalog.go @@ -24,13 +24,7 @@ type officialCatalogQuery struct { // look-alike ID from inheriting a vendor product's capacity; two applicable // entries with different limits are ambiguous and match nothing. func lookupOfficialModelCatalog(query officialCatalogQuery) *OfficialModelContextCapacity { - if len(query.Candidates) == 0 || len(query.Candidates) > 16 { - return nil - } for _, candidate := range query.Candidates { - if len(candidate) > 512 || strings.TrimSpace(candidate) == "" { - return nil - } key := modelReferenceKey(candidate) var found *OfficialModelContextCapacity for _, entry := range officialModelContextCapacityCatalog { From 066a90952a064cb61d1338807bf4613b2fb896dd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:46:53 +0800 Subject: [PATCH 04/37] refactor(accounts): drop the unread account_view_facts object from account responses Every admin account response carried an account_view_facts object (version, status, plan, privacy_mode) that was built for the plugin account views. Nothing reads it: the frontend only declared its type. - service.AccountViewFactsV1 and AccountViewFactsFromAccount are removed with the test that covered only them. accountConsoleStatus and accountConsolePlan stay; the account filters and facets use them. - dto.Account starts with ID again, as upstream does, and dto.AccountListItem loses the same field. - The frontend Account type loses the declaration. Observable change: the admin account JSON (full and list form) no longer has the account_view_facts key. Co-Authored-By: Claude Opus 5.5 --- .../handler/dto/account_view_facts_test.go | 29 ------------------- backend/internal/handler/dto/mappers.go | 2 -- backend/internal/handler/dto/types.go | 12 ++++---- .../internal/service/account_view_facts.go | 19 ------------ frontend/src/types/index.ts | 7 ----- 5 files changed, 5 insertions(+), 64 deletions(-) delete mode 100644 backend/internal/handler/dto/account_view_facts_test.go delete mode 100644 backend/internal/service/account_view_facts.go diff --git a/backend/internal/handler/dto/account_view_facts_test.go b/backend/internal/handler/dto/account_view_facts_test.go deleted file mode 100644 index 76fae94caaa4..000000000000 --- a/backend/internal/handler/dto/account_view_facts_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package dto - -import ( - "encoding/json" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestAccountViewNativeFactsProjection(t *testing.T) { - now := time.Now().UTC() - account := &service.Account{ID: 17, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: false, - Credentials: map[string]any{"base_url": "https://relay.example.test", "api_key": "synthetic-private-key", "plan_type": "plus"}, Extra: map[string]any{"privacy_mode": "true", "private_secret": "synthetic-extra-secret"}} - full := AccountFromServiceShallow(account) - require.NotNil(t, full.AccountViewFacts) - require.Equal(t, service.AccountViewFactsFromAccount(account, now), full.AccountViewFacts) - require.Equal(t, "unschedulable", full.AccountViewFacts.Status) - require.Equal(t, "plus", full.AccountViewFacts.Plan) - compact := AccountListItemFromAccount(full) - require.Equal(t, full.AccountViewFacts, compact.AccountViewFacts) - raw, err := json.Marshal(compact.AccountViewFacts) - require.NoError(t, err) - require.NotContains(t, string(raw), "synthetic-private-key") - require.NotContains(t, string(raw), "synthetic-extra-secret") - require.NotContains(t, string(raw), "credentials") - require.NotContains(t, string(raw), "extra") -} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2647ce3374d1..22ae6b34304c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -250,7 +250,6 @@ func AccountFromServiceShallow(a *service.Account) *Account { openCodeGoUsage = state } out := &Account{ - AccountViewFacts: service.AccountViewFactsFromAccount(a, time.Now()), ID: a.ID, Name: a.Name, Notes: a.Notes, @@ -492,7 +491,6 @@ func AccountListItemFromAccount(a *Account) *AccountListItem { return nil } return &AccountListItem{ - AccountViewFacts: a.AccountViewFacts, ManagementFolder: a.ManagementFolder, Tags: a.Tags, ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index cd65c900410e..a623473b913a 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -210,12 +210,11 @@ type AdminGroup struct { } type Account struct { - AccountViewFacts *service.AccountViewFactsV1 `json:"account_view_facts,omitempty"` - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` // Credentials 经 RedactCredentials 处理后默认只含非敏感子键;敏感 token / api_key / 私钥 // 的存在性通过 CredentialsStatus(has_)暴露。账号详情在管理员开启 API Key // 可见性后可额外恢复 api_key 原文,其它敏感键仍不返回。 @@ -377,7 +376,6 @@ type AccountFacetOption struct { // repeated account_groups and groups object graphs. Fetch /admin/accounts/:id // for the complete Account DTO when editing or inspecting an account. type AccountListItem struct { - AccountViewFacts *service.AccountViewFactsV1 `json:"account_view_facts,omitempty"` QuotaState *service.UpstreamQuotaState `json:"quota_state,omitempty"` ManagementFolder *AccountManagementFolder `json:"management_folder,omitempty"` Tags []AccountManagementTag `json:"tags"` diff --git a/backend/internal/service/account_view_facts.go b/backend/internal/service/account_view_facts.go deleted file mode 100644 index 702648f4fa45..000000000000 --- a/backend/internal/service/account_view_facts.go +++ /dev/null @@ -1,19 +0,0 @@ -package service - -import "time" - -// Native, non-secret scalar facts shared by the full and compact account DTOs. -type AccountViewFactsV1 struct { - Version int `json:"version"` - Status string `json:"status"` - Plan string `json:"plan"` - PrivacyMode string `json:"privacy_mode"` -} - -func AccountViewFactsFromAccount(account *Account, now time.Time) *AccountViewFactsV1 { - if account == nil { - return nil - } - privacy, _ := account.Extra["privacy_mode"].(string) - return &AccountViewFactsV1{Version: 1, Status: accountConsoleStatus(account, now), Plan: accountConsolePlan(account), PrivacyMode: privacy} -} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bbb8ad1c4287..619e02ae85ad 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1312,13 +1312,6 @@ export interface Account { // Rate limit & scheduling fields schedulable: boolean - // Native host projection, not an SDK Account or a client authorization grant. - account_view_facts?: { - version: 1 - status: string - plan: string - privacy_mode: string - } rate_limited_at: string | null rate_limit_reset_at: string | null overload_until: string | null From 6ba569642ed578774dcb919ebe6fa4b108dff0f7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:47:05 +0800 Subject: [PATCH 05/37] refactor(account-jobs): remove the job item action field that nothing writes AccountJobItem.Action and AccountJobItemSeed.Action had one writer, the plugin operation job, which no longer exists. Both seed constructors leave the field empty, so every item row stores '' and the JSON key, being omitempty, was never sent. - The two Go fields are removed. - The repository no longer names the column: the item insert, the item column list and the retry seed query drop it. admin_account_job_items.action stays in the database with its NOT NULL DEFAULT '', so inserts keep working and an older image can still write to the table. - The frontend AccountJobItem type loses the declaration. - account_job_repo_test.go also loses a trailing blank line gofmt rejected. No migration. Nothing an admin or an API client can observe changes. Co-Authored-By: Claude Opus 5.5 --- .../admin/account_codex_import_job_test.go | 2 +- .../handler/admin/account_job_submit_test.go | 1 - backend/internal/repository/account_job_repo.go | 16 ++++++++-------- .../internal/repository/account_job_repo_test.go | 7 +++---- backend/internal/service/account_job.go | 2 -- backend/internal/service/account_job_test.go | 7 +++---- frontend/src/api/admin/accountJobs.ts | 1 - 7 files changed, 15 insertions(+), 21 deletions(-) diff --git a/backend/internal/handler/admin/account_codex_import_job_test.go b/backend/internal/handler/admin/account_codex_import_job_test.go index 7d8df8abbafe..d6fa0bfecb1f 100644 --- a/backend/internal/handler/admin/account_codex_import_job_test.go +++ b/backend/internal/handler/admin/account_codex_import_job_test.go @@ -362,7 +362,7 @@ func executeCodexImportJobTestItems(t *testing.T, handler *AccountHandler, param results := make([]service.AccountJobExecutionResult, 0, len(params.Items)) for index, seed := range params.Items { result, executeErr := handler.ExecuteAccountJob(ctx, job, raw, []service.AccountJobItem{{ - ID: int64(index + 1), Ordinal: seed.Ordinal, Action: seed.Action, + ID: int64(index + 1), Ordinal: seed.Ordinal, TargetAccountID: seed.TargetAccountID, Metadata: seed.Metadata, }}) require.NoError(t, executeErr) diff --git a/backend/internal/handler/admin/account_job_submit_test.go b/backend/internal/handler/admin/account_job_submit_test.go index db5a026a16df..cf7aa5238213 100644 --- a/backend/internal/handler/admin/account_job_submit_test.go +++ b/backend/internal/handler/admin/account_job_submit_test.go @@ -105,7 +105,6 @@ func executeSubmittedAccountJobItems(handler *AccountHandler, params service.Cre results = append(results, handler.executeAccountJobItem(context.Background(), params.Kind, json.RawMessage(params.PayloadCipher), service.AccountJobItem{ ID: int64(index + 1), Ordinal: seed.Ordinal, - Action: seed.Action, TargetAccountID: seed.TargetAccountID, Metadata: seed.Metadata, })) diff --git a/backend/internal/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go index 7672a0987ec0..1bfa8b51e419 100644 --- a/backend/internal/repository/account_job_repo.go +++ b/backend/internal/repository/account_job_repo.go @@ -47,7 +47,7 @@ const accountJobSelectColumns = `id, created_by, kind, idempotency_key, request_ cancel_requested_at, error_code, error_message, retry_of_job_id, attempt, started_at, finished_at, created_at, updated_at` -const accountJobItemSelectColumns = `id, job_id, ordinal, action, target_account_id, status, metadata, +const accountJobItemSelectColumns = `id, job_id, ordinal, target_account_id, status, metadata, error_code, error_message, started_at, finished_at, created_at, updated_at` // accountJobResultKeysField is the item metadata key that lists the keys a @@ -99,7 +99,7 @@ func scanAccountJobItem(row accountJobScanner) (*service.AccountJobItem, error) var errorCode, errorMessage sql.NullString var started, finished sql.NullTime if err := row.Scan( - &item.ID, &item.JobID, &item.Ordinal, &item.Action, &target, &item.Status, &metadata, + &item.ID, &item.JobID, &item.Ordinal, &target, &item.Status, &metadata, &errorCode, &errorMessage, &started, &finished, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, err @@ -162,7 +162,7 @@ func (r *accountJobRepository) Create(ctx context.Context, params service.Create for start := 0; start < len(params.Items); start += service.AccountJobBatchSize { end := min(start+service.AccountJobBatchSize, len(params.Items)) values := make([]string, 0, end-start) - args := make([]any, 0, (end-start)*5) + args := make([]any, 0, (end-start)*4) for index := start; index < end; index++ { seed := params.Items[index] ordinal := seed.Ordinal @@ -170,11 +170,11 @@ func (r *accountJobRepository) Create(ctx context.Context, params service.Create ordinal = index + 1 } n := len(args) - values = append(values, fmt.Sprintf("($%d,$%d,$%d,$%d,$%d::jsonb)", n+1, n+2, n+3, n+4, n+5)) - args = append(args, job.ID, ordinal, seed.Action, seed.TargetAccountID, string(normalizeRepositoryJobMetadata(seed.Metadata))) + values = append(values, fmt.Sprintf("($%d,$%d,$%d,$%d::jsonb)", n+1, n+2, n+3, n+4)) + args = append(args, job.ID, ordinal, seed.TargetAccountID, string(normalizeRepositoryJobMetadata(seed.Metadata))) } if _, err = tx.ExecContext(ctx, `INSERT INTO admin_account_job_items - (job_id, ordinal, action, target_account_id, metadata) VALUES `+strings.Join(values, ","), args...); err != nil { + (job_id, ordinal, target_account_id, metadata) VALUES `+strings.Join(values, ","), args...); err != nil { return nil, false, err } } @@ -610,7 +610,7 @@ func (r *accountJobRepository) FailedItemSeeds(ctx context.Context, jobID, creat // A retry is seeded with the original item keys; keys a previous result // added (warnings, matched ids, proxy errors, account id, action, ...) are // results of that attempt, not input of the next one. - rows, err := r.db.QueryContext(ctx, `SELECT ordinal, action, target_account_id, + rows, err := r.db.QueryContext(ctx, `SELECT ordinal, target_account_id, (metadata - '`+accountJobResultKeysField+`') - ARRAY(SELECT jsonb_array_elements_text( CASE WHEN jsonb_typeof(metadata->'`+accountJobResultKeysField+`')='array' THEN metadata->'`+accountJobResultKeysField+`' ELSE '[]'::jsonb END)) @@ -624,7 +624,7 @@ func (r *accountJobRepository) FailedItemSeeds(ctx context.Context, jobID, creat var seed service.AccountJobItemSeed var target sql.NullInt64 var metadata []byte - if err = rows.Scan(&seed.Ordinal, &seed.Action, &target, &metadata); err != nil { + if err = rows.Scan(&seed.Ordinal, &target, &metadata); err != nil { return nil, nil, "", time.Time{}, err } if target.Valid { diff --git a/backend/internal/repository/account_job_repo_test.go b/backend/internal/repository/account_job_repo_test.go index 465f947acfc3..4a6d4dff1aff 100644 --- a/backend/internal/repository/account_job_repo_test.go +++ b/backend/internal/repository/account_job_repo_test.go @@ -34,7 +34,7 @@ func accountJobTestColumns() []string { func accountJobItemTestColumns() []string { return []string{ - "id", "job_id", "ordinal", "action", "target_account_id", "status", "metadata", + "id", "job_id", "ordinal", "target_account_id", "status", "metadata", "error_code", "error_message", "started_at", "finished_at", "created_at", "updated_at", } } @@ -57,14 +57,14 @@ func TestAccountJobRepositoryCreatePersistsJobAndItemsAtomically(t *testing.T) { WithArgs(int64(7), service.AccountJobKindBatchDelete, "key", requestHash, "cipher", sqlmock.AnyArg(), "{}", 1, nil, 1). WillReturnRows(accountJobRows(now, 41, service.AccountJobStatusPending)) mock.ExpectExec("INSERT INTO admin_account_job_items"). - WithArgs(int64(41), 1, "delete", targetID, "{}"). + WithArgs(int64(41), 1, targetID, "{}"). WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() job, replayed, err := repo.Create(context.Background(), service.CreateAccountJobParams{ CreatedBy: 7, Kind: service.AccountJobKindBatchDelete, IdempotencyKey: "key", RequestHash: requestHash, PayloadCipher: "cipher", PayloadExpires: now.Add(time.Hour), Metadata: json.RawMessage(`{}`), - Items: []service.AccountJobItemSeed{{Ordinal: 1, Action: "delete", TargetAccountID: &targetID, Metadata: json.RawMessage(`{}`)}}, Attempt: 1, + Items: []service.AccountJobItemSeed{{Ordinal: 1, TargetAccountID: &targetID, Metadata: json.RawMessage(`{}`)}}, Attempt: 1, }) require.NoError(t, err) require.False(t, replayed) @@ -180,4 +180,3 @@ func TestAccountJobRepositoryPruneOnlyFinishedBeforeCutoff(t *testing.T) { require.NoError(t, err) require.Contains(t, string(migration), "job_id BIGINT NOT NULL REFERENCES admin_account_jobs(id) ON DELETE CASCADE") } - diff --git a/backend/internal/service/account_job.go b/backend/internal/service/account_job.go index 98526dfad8b6..d63621071b5e 100644 --- a/backend/internal/service/account_job.go +++ b/backend/internal/service/account_job.go @@ -86,7 +86,6 @@ type AccountJobItem struct { ID int64 `json:"id"` JobID int64 `json:"job_id"` Ordinal int `json:"ordinal"` - Action string `json:"action,omitempty"` TargetAccountID *int64 `json:"target_account_id,omitempty"` Status string `json:"status"` Metadata json.RawMessage `json:"metadata"` @@ -100,7 +99,6 @@ type AccountJobItem struct { type AccountJobItemSeed struct { Ordinal int - Action string TargetAccountID *int64 Metadata json.RawMessage } diff --git a/backend/internal/service/account_job_test.go b/backend/internal/service/account_job_test.go index ab83e46f623a..b36d9637a64e 100644 --- a/backend/internal/service/account_job_test.go +++ b/backend/internal/service/account_job_test.go @@ -83,7 +83,7 @@ func (r *accountJobTestRepo) Create(_ context.Context, params CreateAccountJobPa items := make([]AccountJobItem, len(params.Items)) for index, seed := range params.Items { items[index] = AccountJobItem{ID: int64(index + 1), JobID: id, Ordinal: seed.Ordinal, - Action: seed.Action, TargetAccountID: seed.TargetAccountID, Status: AccountJobItemStatusPending, + TargetAccountID: seed.TargetAccountID, Status: AccountJobItemStatusPending, Metadata: seed.Metadata} } r.items[id] = items @@ -201,8 +201,7 @@ func (r *accountJobTestRepo) FailedItemSeeds(_ context.Context, jobID, createdBy for _, item := range r.items[jobID] { if item.Status == AccountJobItemStatusFailed { seeds = append(seeds, AccountJobItemSeed{ - Ordinal: item.Ordinal, Action: item.Action, - TargetAccountID: item.TargetAccountID, Metadata: item.Metadata, + Ordinal: item.Ordinal, TargetAccountID: item.TargetAccountID, Metadata: item.Metadata, }) } } @@ -342,7 +341,7 @@ func TestAccountJobRetryRequiresKeyAndReplaysFailedItems(t *testing.T) { jobs := NewAccountJobService(repo, accountJobTestCipher{}) original, _, err := jobs.Submit(context.Background(), 9, AccountJobKindBatchDelete, "original", json.RawMessage(`{"account_ids":[7]}`), nil, - []AccountJobItemSeed{{Ordinal: 1, Action: "delete"}}) + []AccountJobItemSeed{{Ordinal: 1}}) require.NoError(t, err) repo.jobs[original.ID].Status = AccountJobStatusFailed repo.items[original.ID][0].Status = AccountJobItemStatusFailed diff --git a/frontend/src/api/admin/accountJobs.ts b/frontend/src/api/admin/accountJobs.ts index 894123a9341b..9330ea00bbdc 100644 --- a/frontend/src/api/admin/accountJobs.ts +++ b/frontend/src/api/admin/accountJobs.ts @@ -42,7 +42,6 @@ export interface AccountJobItem { id: number job_id: number ordinal: number - action?: string target_account_id?: number status: AccountJobItemStatus metadata: Record From e16d4af92429a2a430a6478579d082541d4dc8f7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 16:55:23 +0800 Subject: [PATCH 06/37] refactor(account-tests): remove wire_platform and schema_version from the test plan The account test plan was shaped to be shared with a provider plugin: it stated a wire platform and a schema version. The wire platform was always the account's own platform and the version was always 1. Backend - accountTestPlanView loses WirePlatform and SchemaVersion. - ordinaryAccountTestPlan is buildAccountTestPlan. basicAccountTestPlan, its one helper, is folded into it. The plans are byte for byte what they were in the four remaining fields; the server still orders the models and picks each mode's default. Frontend - isOpenAIAccount in both AccountTestModal components is upstream's props.account?.platform === 'openai' again, and so is the check in supportsOpenAIImageTest. isOpenAIOAuthAccount no longer tests the platform twice. - AccountTestPlanView and validateAccountTestPlan lose the two fields. - AccountAvailableModel loses nine fields the server never sends and nothing reads: live_upstream_id, base_context_window, codex_context_window, endpoints, source_revision, verified, alias_target, managed, public_model. Tests - The two cases written against a "cindy" account platform are removed; that platform does not exist. - AccountTestModal.policyPlan.spec.ts is AccountTestModal.testPlan.spec.ts and uses an OpenAI account and the "default" mode the server sends. The downstream-verify workflow lists the new name. - TestAccountTestPlanCoreSelectionSmallMatrix is TestAccountTestPlanModelOrderAndDefaults. Observable change: the response of GET /admin/accounts/:id/models ?view=account-test-plan-v1 no longer has wire_platform and schema_version. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/downstream-verify.yml | 2 +- .../handler/admin/account_test_plan.go | 41 +++++++------------ .../handler/admin/account_test_plan_test.go | 10 ++--- .../components/account/AccountTestModal.vue | 6 +-- .../__tests__/AccountTestModal.spec.ts | 40 +----------------- .../admin/account/AccountTestModal.vue | 10 ++--- .../AccountTestModal.displayContract.spec.ts | 2 +- .../AccountTestModal.qualityCheck.spec.ts | 2 +- .../__tests__/AccountTestModal.spec.ts | 34 ++------------- ...c.ts => AccountTestModal.testPlan.spec.ts} | 12 +++--- frontend/src/types/index.ts | 14 +------ frontend/src/utils/accountTestModels.ts | 3 +- 12 files changed, 39 insertions(+), 137 deletions(-) rename frontend/src/components/admin/account/__tests__/{AccountTestModal.policyPlan.spec.ts => AccountTestModal.testPlan.spec.ts} (89%) diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index a42cafe2cd9e..8eb284b1353c 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -108,7 +108,7 @@ jobs: pnpm exec vitest run \ src/components/admin/account/__tests__/AccountTestModal.spec.ts \ src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts \ - src/components/admin/account/__tests__/AccountTestModal.policyPlan.spec.ts \ + src/components/admin/account/__tests__/AccountTestModal.testPlan.spec.ts \ src/components/keys/__tests__/UseKeyModal.spec.ts \ src/views/admin/__tests__/SettingsView.spec.ts \ src/components/common/__tests__/VersionBadge.downstream.spec.ts \ diff --git a/backend/internal/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index e3828dcb5834..5643d9ea1211 100644 --- a/backend/internal/handler/admin/account_test_plan.go +++ b/backend/internal/handler/admin/account_test_plan.go @@ -18,12 +18,10 @@ type accountTestModeView struct { } type accountTestPlanView struct { - SchemaVersion int `json:"schema_version"` - AccountID int64 `json:"account_id"` - WirePlatform string `json:"wire_platform"` - DefaultMode string `json:"default_mode"` - Models []map[string]any `json:"models"` - ModeViews map[string]accountTestModeView `json:"mode_views"` + AccountID int64 `json:"account_id"` + DefaultMode string `json:"default_mode"` + Models []map[string]any `json:"models"` + ModeViews map[string]accountTestModeView `json:"mode_views"` } func accountTestPlanRequested(view string) (bool, error) { @@ -45,7 +43,7 @@ func (h *AccountHandler) accountTestPlan(ctx context.Context, account *service.A if err != nil { return nil, err } - return ordinaryAccountTestPlan(account, raw) + return buildAccountTestPlan(account, raw) } // accountTestModelRows decodes a typed model catalog into its JSON rows. @@ -110,19 +108,6 @@ func accountTestPlanModels(raw any) ([]map[string]any, error) { return models, nil } -func basicAccountTestPlan(account *service.Account, models []map[string]any, defaultID string) *accountTestPlanView { - ids := make([]string, 0, len(models)) - for _, model := range models { - id, _ := model["id"].(string) // Validated by accountTestPlanModels. - ids = append(ids, id) - } - view := accountTestModeView{ModelIDs: ids, DefaultModelID: defaultID} - return &accountTestPlanView{SchemaVersion: 1, AccountID: account.ID, WirePlatform: account.Platform, - DefaultMode: "default", Models: models, ModeViews: map[string]accountTestModeView{ - "default": view, "compact": view, "text": view, - }} -} - // connectionTestDefault applies the batch test's automatic model choice to a // displayed list, falling back to its first entry for media-only accounts. func connectionTestDefault(account *service.Account, ids []string) string { @@ -135,9 +120,10 @@ func connectionTestDefault(account *service.Account, ids []string) string { return "" } -// These choices originate in the official single-account test UI. Keep their -// current behavior in this one view producer. -func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPlanView, error) { +// buildAccountTestPlan arranges an account's test models for the single-account +// test dialog: Gemini and Antigravity list their preferred models first, Grok +// lists its models per test mode, and every mode names its default model. +func buildAccountTestPlan(account *service.Account, raw any) (*accountTestPlanView, error) { models, err := accountTestPlanModels(raw) if err != nil { return nil, err @@ -161,12 +147,13 @@ func ordinaryAccountTestPlan(account *service.Account, raw any) (*accountTestPla id, _ := model["id"].(string) // Validated above. ids = append(ids, id) } - plan := basicAccountTestPlan(account, models, connectionTestDefault(account, ids)) if account.Platform != service.PlatformGrok { - return plan, nil + view := accountTestModeView{ModelIDs: ids, DefaultModelID: connectionTestDefault(account, ids)} + return &accountTestPlanView{AccountID: account.ID, DefaultMode: "default", Models: models, + ModeViews: map[string]accountTestModeView{"default": view, "compact": view, "text": view}}, nil } - plan.DefaultMode = "text" - plan.ModeViews = map[string]accountTestModeView{} + plan := &accountTestPlanView{AccountID: account.ID, DefaultMode: "text", Models: models, + ModeViews: map[string]accountTestModeView{}} for _, mode := range []string{"text", "image", "video", "search", "tts", "stt", "realtime"} { view := accountTestModeView{ModelIDs: []string{}} for _, model := range models { diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index 5406b5297e1a..c6f6bebd3df6 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -54,15 +54,13 @@ func TestAccountTestPlanViewListsTheSameModelsAsThePlainList(t *testing.T) { var plan accountTestPlanView require.NoError(t, json.Unmarshal(read("?view=account-test-plan-v1"), &plan)) - require.Equal(t, 1, plan.SchemaVersion) require.Equal(t, int64(42), plan.AccountID) - require.Equal(t, "openai", plan.WirePlatform) require.Equal(t, plainModels, plan.Models) require.Equal(t, []string{"plain-id"}, plan.ModeViews["default"].ModelIDs) require.Equal(t, "plain-id", plan.ModeViews["default"].DefaultModelID) } -func TestAccountTestPlanCoreSelectionSmallMatrix(t *testing.T) { +func TestAccountTestPlanModelOrderAndDefaults(t *testing.T) { models := func(ids ...string) []map[string]any { rows := make([]map[string]any, 0, len(ids)) for _, id := range ids { @@ -81,12 +79,12 @@ func TestAccountTestPlanCoreSelectionSmallMatrix(t *testing.T) { {service.PlatformAntigravity, models("sonnet-custom", "gemini-3.1-flash-image"), "sonnet-custom", []string{"gemini-3.1-flash-image", "sonnet-custom"}}, {service.PlatformOpenAI, models("codex-auto-review", "first", "sonnet-custom"), "first", []string{"codex-auto-review", "first", "sonnet-custom"}}, } { - plan, err := ordinaryAccountTestPlan(&service.Account{ID: 42, Platform: tc.platform}, tc.ids) + plan, err := buildAccountTestPlan(&service.Account{ID: 42, Platform: tc.platform}, tc.ids) require.NoError(t, err) require.Equal(t, tc.order, plan.ModeViews["default"].ModelIDs) require.Equal(t, tc.want, plan.ModeViews["default"].DefaultModelID) } - plan, err := ordinaryAccountTestPlan(&service.Account{ID: 42, Platform: service.PlatformGrok}, models("grok-4.3", "grok", "grok-4.5-custom", "grok-imagine", "grok-imagine-video")) + plan, err := buildAccountTestPlan(&service.Account{ID: 42, Platform: service.PlatformGrok}, models("grok-4.3", "grok", "grok-4.5-custom", "grok-imagine", "grok-imagine-video")) require.NoError(t, err) require.Equal(t, "grok-4.3", plan.ModeViews["text"].DefaultModelID, "same automatic choice as batch tests: grok-4.5 when listed, else the first text model") require.Equal(t, []string{"grok-imagine"}, plan.ModeViews["image"].ModelIDs) @@ -97,7 +95,7 @@ func TestAccountTestPlanCoreSelectionSmallMatrix(t *testing.T) { } _, err = accountTestPlanRequested("unknown-view") require.Error(t, err) - _, err = ordinaryAccountTestPlan(&service.Account{ID: 42}, []map[string]any{{"id": "same"}, {"id": "same"}}) + _, err = buildAccountTestPlan(&service.Account{ID: 42}, []map[string]any{{"id": "same"}, {"id": "same"}}) require.Error(t, err) } diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index 7264f82bc34e..3d85b6882beb 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -292,9 +292,7 @@ const loadingModels = ref(false) let abortController: AbortController | null = null const generatedImages = ref([]) const testMode = ref<'default' | 'compact'>('default') -const isOpenAIAccount = computed(() => - (currentModelPlan.value?.wire_platform || props.account?.platform) === 'openai' -) +const isOpenAIAccount = computed(() => props.account?.platform === 'openai') const openAITestModeOptions = computed(() => [ { value: 'default', label: t('admin.accounts.openai.testModeDefault') }, { value: 'compact', label: t('admin.accounts.openai.testModeCompact') } @@ -310,7 +308,7 @@ const supportsGeminiImageTest = computed(() => { const supportsOpenAIImageTest = computed(() => { const modelID = selectedModelId.value.toLowerCase() if (!modelID.startsWith('gpt-image-')) return false - return isOpenAIAccount.value + return props.account?.platform === 'openai' }) const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value) diff --git a/frontend/src/components/account/__tests__/AccountTestModal.spec.ts b/frontend/src/components/account/__tests__/AccountTestModal.spec.ts index de48e908de68..b9b2b4e5266e 100644 --- a/frontend/src/components/account/__tests__/AccountTestModal.spec.ts +++ b/frontend/src/components/account/__tests__/AccountTestModal.spec.ts @@ -95,7 +95,7 @@ function buildAccount() { } function testPlan(models = [{ id: 'gpt-5.4', display_name: 'GPT-5.4' }], defaultID = models[0]?.id || '') { - return { schema_version: 1, account_id: 1, wire_platform: 'openai', default_mode: 'default', models, + return { account_id: 1, default_mode: 'default', models, mode_views: { default: { model_ids: models.map(model => model.id), default_model_id: defaultID } } } } @@ -192,42 +192,4 @@ describe('AccountTestModal', () => { expect(wrapper.text()).toContain('已通过 /v1/chat/completions 验证') }) - - it('uses the canonical Cindy provider plan without its own default policy', async () => { - getAccountTestPlanMock.mockResolvedValue(testPlan([ - { id: 'provider-choice-a', display_name: 'Choice A' }, - { id: 'provider-choice-b', display_name: 'Choice B' } - ], 'provider-choice-b')) - const cindy = { - ...buildAccount(), - platform: 'cindy', - type: 'apikey', - credentials: { base_url: 'https://api.laxarouter.ai' } - } - - const wrapper = mount(AccountTestModal, { - props: { - show: false, - account: cindy - }, - global: { - stubs: { - BaseDialog: BaseDialogStub, - Select: SelectStub, - TextArea: TextAreaStub, - Icon: true - } - } - }) - await wrapper.setProps({ show: true }) - await flushPromises() - - expect((wrapper.vm as any).availableModels.map((item: { id: string }) => item.id)).toEqual([ - 'provider-choice-a', - 'provider-choice-b' - ]) - expect((wrapper.vm as any).selectedModelId).toBe('provider-choice-b') - expect((wrapper.vm as any).isOpenAIAccount).toBe(true) - wrapper.unmount() - }) }) diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index c73355f5423e..171f01ac3d3e 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -513,14 +513,10 @@ const uploadAudioDataURL = ref('') const uploadAudioName = ref('') const imageFileInput = ref(null) const audioFileInput = ref(null) -const isOpenAIAccount = computed(() => - (currentModelPlan.value?.wire_platform || props.account?.platform) === 'openai' -) +const isOpenAIAccount = computed(() => props.account?.platform === 'openai') const isGrokAccount = computed(() => props.account?.platform === 'grok') // The quality check is offered to OpenAI OAuth accounts only. -const isOpenAIOAuthAccount = computed(() => - isOpenAIAccount.value && props.account?.platform === 'openai' && props.account?.type === 'oauth' -) +const isOpenAIOAuthAccount = computed(() => isOpenAIAccount.value && props.account?.type === 'oauth') const isQualityMode = computed(() => testMode.value === 'quality' && isOpenAIOAuthAccount.value) const openAITestModeOptions = computed(() => [ { value: 'default', label: t('admin.accounts.openai.testModeDefault') }, @@ -562,7 +558,7 @@ const supportsGeminiImageTest = computed(() => { const supportsOpenAIImageTest = computed(() => { const modelID = selectedModelId.value.toLowerCase() if (!modelID.startsWith('gpt-image-')) return false - return isOpenAIAccount.value + return props.account?.platform === 'openai' }) const supportsGrokImageTest = computed( diff --git a/frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts index c84011bbe89b..21d1a01a08fc 100644 --- a/frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts @@ -18,7 +18,7 @@ vi.mock('vue-i18n', async () => ({ let wrapper: VueWrapper | undefined function testPlan(models = structuredClone(contract.expected)) { - return { schema_version: 1, account_id: 501, wire_platform: 'openai', default_mode: 'default', models, + return { account_id: 501, default_mode: 'default', models, mode_views: { default: { model_ids: models.map(model => model.id), default_model_id: models[0]?.id || '' } } } } diff --git a/frontend/src/components/admin/account/__tests__/AccountTestModal.qualityCheck.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTestModal.qualityCheck.spec.ts index b450de9e0bae..7d63742ff82f 100644 --- a/frontend/src/components/admin/account/__tests__/AccountTestModal.qualityCheck.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountTestModal.qualityCheck.spec.ts @@ -20,7 +20,7 @@ function plan(accountID: number): AccountTestPlanView { { id: 'gpt-6-lite', type: 'model', created_at: '', display_name: 'GPT-6 Lite', reasoning_efforts: ['low', 'medium'] }, { id: 'gpt-image-2', type: 'model', created_at: '', display_name: 'GPT Image 2' } ] - return { schema_version: 1, account_id: accountID, wire_platform: 'openai', default_mode: 'default', models, + return { account_id: accountID, default_mode: 'default', models, mode_views: { default: { model_ids: models.map(model => model.id), default_model_id: 'gpt-6-astra' } } } } diff --git a/frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts index 628a4ab9dfaf..f6c3cb784629 100644 --- a/frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts @@ -64,9 +64,9 @@ function createStreamResponse(lines: string[]) { } as Response } -function testPlan(accountID: number, models: Array & Partial>, defaultID = models[0]?.id || '', wirePlatform = 'openai', defaultMode = 'default'): AccountTestPlanView { +function testPlan(accountID: number, models: Array & Partial>, defaultID = models[0]?.id || '', defaultMode = 'default'): AccountTestPlanView { const view = { model_ids: models.map(model => model.id), default_model_id: defaultID } - return { schema_version: 1, account_id: accountID, wire_platform: wirePlatform, default_mode: defaultMode, + return { account_id: accountID, default_mode: defaultMode, models: models.map(model => ({ type: 'model', created_at: '', ...model })), mode_views: { default: view, text: view, compact: view } } } @@ -105,7 +105,7 @@ describe('AccountTestModal', () => { { id: 'gemini-3.1-flash-image', display_name: 'Gemini 3.1 Flash Image' }, { id: 'gemini-2.5-flash-image', display_name: 'Gemini 2.5 Flash Image' }, { id: 'gemini-2.0-flash', display_name: 'Gemini 2.0 Flash' } - ], 'gemini-3.1-flash-image', 'gemini')) + ], 'gemini-3.1-flash-image')) copyToClipboard.mockReset() Object.defineProperty(globalThis, 'localStorage', { value: { @@ -162,7 +162,7 @@ describe('AccountTestModal', () => { getAccountTestPlan.mockResolvedValue(testPlan(13, [ { id: 'grok-4.3', display_name: 'Grok 4.3' }, { id: 'grok-build-0.1', display_name: 'Grok Build 0.1' } - ], 'grok-4.3', 'grok', 'text')) + ], 'grok-4.3', 'text')) global.fetch = vi.fn().mockResolvedValue( createStreamResponse([ 'data: {"type":"test_start","model":"grok-4.3"}\n', @@ -233,30 +233,4 @@ describe('AccountTestModal', () => { mode: 'compact' }) }) - - it('Cindy 账号测试消费 provider 返回的候选和更新后的默认', async () => { - getAccountTestPlan.mockResolvedValue(testPlan(88, [ - { id: 'provider-choice-a', display_name: 'Choice A' }, - { id: 'provider-choice-b', display_name: 'Choice B' } - ], 'provider-choice-b')) - const wrapper = mountModal({ - id: 88, - name: 'Cindy API Key', - platform: 'cindy', - type: 'apikey', - status: 'active', - credentials: { base_url: 'https://api.laxarouter.ai' }, - is_cindy: true - }) - - await wrapper.setProps({ show: true }) - await flushPromises() - - expect((wrapper.vm as any).availableModels.map((item: { id: string }) => item.id)).toEqual([ - 'provider-choice-a', - 'provider-choice-b' - ]) - expect((wrapper.vm as any).selectedModelId).toBe('provider-choice-b') - wrapper.unmount() - }) }) diff --git a/frontend/src/components/admin/account/__tests__/AccountTestModal.policyPlan.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTestModal.testPlan.spec.ts similarity index 89% rename from frontend/src/components/admin/account/__tests__/AccountTestModal.policyPlan.spec.ts rename to frontend/src/components/admin/account/__tests__/AccountTestModal.testPlan.spec.ts index 57c937534565..7eae7a1b3772 100644 --- a/frontend/src/components/admin/account/__tests__/AccountTestModal.policyPlan.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountTestModal.testPlan.spec.ts @@ -15,11 +15,11 @@ vi.mock('vue-i18n', async () => ({ })) function plan(accountID: number, ids = ['provider/raw-a', 'provider/raw-b'], defaultID = ids[ids.length - 1] || ''): AccountTestPlanView { - return { schema_version: 1, account_id: accountID, wire_platform: 'openai', default_mode: 'provider-view', + return { account_id: accountID, default_mode: 'default', models: ids.map(id => ({ id, type: 'model', created_at: '', display_name: `Label for ${id}` })), - mode_views: { 'provider-view': { model_ids: ids, default_model_id: defaultID } } } + mode_views: { default: { model_ids: ids, default_model_id: defaultID } } } } -function account(id: number, platform = 'cindy'): Account { +function account(id: number, platform = 'openai'): Account { return { id, name: `Account ${id}`, platform, type: 'apikey', status: 'active', credentials: {} } as Account } let wrapper: VueWrapper | undefined @@ -45,8 +45,8 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('AccountTestModal provider plan consumption', () => { - it('renders the provider default in the real Select and sends its original ID through the unchanged SSE endpoint', async () => { +describe('AccountTestModal test plan consumption', () => { + it('renders the plan default in the real Select and sends its original ID to the test endpoint', async () => { getAccountTestPlan.mockResolvedValue(plan(88)) const modal = open() await flushPromises() @@ -107,7 +107,7 @@ describe('AccountTestModal provider plan consumption', () => { await (modal.vm as any).startTest() expect(global.fetch).not.toHaveBeenCalled() const emptyView = { model_ids: [], default_model_id: '' } - getAccountTestPlan.mockResolvedValueOnce({ schema_version: 1, account_id: 14, wire_platform: 'grok', default_mode: 'text', models: [], mode_views: { text: emptyView, search: emptyView } }) + getAccountTestPlan.mockResolvedValueOnce({ account_id: 14, default_mode: 'text', models: [], mode_views: { text: emptyView, search: emptyView } }) await modal.setProps({ account: account(14, 'grok') }) await flushPromises() ;(modal.vm as any).grokTestMode = 'search' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 619e02ae85ad..c83e80db8658 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -933,8 +933,7 @@ export interface ClaudeModel { created_at: string } -// Admin account-model view, not an unadapted upstream or Claude-only catalog. -// The server supplies a nonempty display_name independently of the model id. +// Account model row (returned by the admin account models API); display_name is never empty. export interface AccountAvailableModel { id: string type: string @@ -943,21 +942,12 @@ export interface AccountAvailableModel { created?: number created_at?: string owned_by?: string - live_upstream_id?: string description?: string context_window?: number max_input_tokens?: number - base_context_window?: number - codex_context_window?: number max_output_tokens?: number reasoning_efforts?: string[] default_reasoning_effort?: string - endpoints?: string[] - source_revision?: string - verified?: boolean - alias_target?: string - managed?: boolean - public_model?: boolean } export interface AccountTestModeView { @@ -966,9 +956,7 @@ export interface AccountTestModeView { } export interface AccountTestPlanView { - schema_version: 1 account_id: number - wire_platform: string default_mode: string models: AccountAvailableModel[] mode_views: Record diff --git a/frontend/src/utils/accountTestModels.ts b/frontend/src/utils/accountTestModels.ts index f334f045f86c..610f75ca38a8 100644 --- a/frontend/src/utils/accountTestModels.ts +++ b/frontend/src/utils/accountTestModels.ts @@ -8,8 +8,7 @@ export function isAccountTestReasoningValid(model: AccountAvailableModel | undef export function validateAccountTestPlan(value: unknown, accountID: number): AccountTestPlanView { const plan = value as AccountTestPlanView | null - if (!plan || plan.schema_version !== 1 || plan.account_id !== accountID || !Number.isSafeInteger(accountID) || accountID <= 0 || - typeof plan.wire_platform !== 'string' || !plan.wire_platform || typeof plan.default_mode !== 'string' || + if (!plan || plan.account_id !== accountID || !Number.isSafeInteger(accountID) || accountID <= 0 || typeof plan.default_mode !== 'string' || !Array.isArray(plan.models) || !plan.mode_views || typeof plan.mode_views !== 'object' || Array.isArray(plan.mode_views) || !Object.prototype.hasOwnProperty.call(plan.mode_views, plan.default_mode)) throw new Error('Invalid account test plan') const ids = new Set() From 573be10820b393aceb843cfae3709ad3bc75fba1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 17:14:07 +0800 Subject: [PATCH 07/37] refactor(accounts): name the protocol-mode retention for what it does When an account is edited, the OpenAI protocol modes the admin did not touch keep their stored encoding. That behaviour stays. The code that implements it was the remainder of the plugin-era "account edit contract" and still carried its names and shapes. Backend - account_edit_codec.go is openai_apikey_protocol_modes.go. openAIAPIKeyOwnedExtraForUpdate and applyAccountEditOwned are openAIAPIKeyProtocolModesForUpdate and applyOpenAIAPIKeyProtocolModes. - accountEditRawValue, a present/value pair with JSON tags nothing serialised, is gone: the resolved modes are a plain map, and a key missing from it is a key to remove. The branch for a key that was "not owned" could not be taken and is removed. - The seven protected keys, openai_prompt_cache_key_mode among them, and the point in UpdateAccount where they are resolved and applied are unchanged. - New test TestUpdateAccountKeepsOmittedOpenAIAPIKeyProtocolModes pins the behaviour through UpdateAccount. It passed before this change and passes after it. Frontend - utils/accountEditCodec.ts is utils/openaiProtocolModeEdits.ts, and types/accountEdit.ts is folded into it: the value unions are derived from the table of modes instead of being written twice. - AccountEditChangesV1 is OpenAIProtocolModeEdits. An edit is the chosen value itself; the { op: 'set' | 'clear' } wrapper is gone because 'clear' was never produced and only threw. - applyCoreAccountModeChanges is applyOpenAIProtocolModeEdits; in EditAccountModal coreModeChanges and recordAccountMode are protocolModeEdits and recordProtocolModeEdit. - The spec follows the file name and keeps its assertions. Its one test of buildModelMappingObject moves to useModelWhitelist.spec.ts, where that function is tested. The downstream-verify workflow lists the new name. Nothing an admin or an API client can observe changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/downstream-verify.yml | 2 +- .../internal/service/account_edit_codec.go | 58 ----------------- backend/internal/service/admin_account.go | 8 +-- .../service/openai_apikey_protocol_modes.go | 53 +++++++++++++++ .../openai_apikey_protocol_modes_test.go | 65 +++++++++++++++++++ .../components/account/EditAccountModal.vue | 21 +++--- .../__tests__/useModelWhitelist.spec.ts | 12 ++++ frontend/src/types/accountEdit.ts | 12 ---- .../utils/__tests__/accountEditCodec.spec.ts | 48 -------------- .../__tests__/openaiProtocolModeEdits.spec.ts | 35 ++++++++++ frontend/src/utils/accountEditCodec.ts | 35 ---------- frontend/src/utils/openaiProtocolModeEdits.ts | 37 +++++++++++ 12 files changed, 217 insertions(+), 169 deletions(-) delete mode 100644 backend/internal/service/account_edit_codec.go create mode 100644 backend/internal/service/openai_apikey_protocol_modes.go create mode 100644 backend/internal/service/openai_apikey_protocol_modes_test.go delete mode 100644 frontend/src/types/accountEdit.ts delete mode 100644 frontend/src/utils/__tests__/accountEditCodec.spec.ts create mode 100644 frontend/src/utils/__tests__/openaiProtocolModeEdits.spec.ts delete mode 100644 frontend/src/utils/accountEditCodec.ts create mode 100644 frontend/src/utils/openaiProtocolModeEdits.ts diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index 8eb284b1353c..fbc41dc1e979 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -139,7 +139,7 @@ jobs: src/views/user/__tests__/StripePaymentView.spec.ts \ src/views/admin/__tests__/ReasoningRecoveryView.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ - src/utils/__tests__/accountEditCodec.spec.ts \ + src/utils/__tests__/openaiProtocolModeEdits.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ src/components/account/__tests__/ModelContextCapacityField.spec.ts \ src/utils/__tests__/modelContextCapacity.spec.ts \ diff --git a/backend/internal/service/account_edit_codec.go b/backend/internal/service/account_edit_codec.go deleted file mode 100644 index fa445a87f24d..000000000000 --- a/backend/internal/service/account_edit_codec.go +++ /dev/null @@ -1,58 +0,0 @@ -package service - -import "maps" - -// accountEditExtraKeys are the OpenAI API-key protocol-mode extras. A -// whole-object account edit replaces Extra, but an omitted key keeps its stored -// presence and value instead of silently falling back to the default mode. -var accountEditExtraKeys = []string{ - "openai_responses_mode", "openai_compact_mode", - "openai_apikey_responses_websockets_v2_mode", "openai_apikey_responses_websockets_v2_enabled", - "responses_websockets_v2_enabled", "openai_ws_enabled", - OpenAIPromptCacheKeyModeExtraKey, -} - -type accountEditRawValue struct { - Present bool `json:"present"` - Value any `json:"value"` -} - -// openAIAPIKeyOwnedExtraForUpdate resolves the protocol-mode keys an update -// writes: supplied values win, omitted keys retain their current raw state. -func openAIAPIKeyOwnedExtraForUpdate(account *Account, extra map[string]any) map[string]accountEditRawValue { - if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { - return nil - } - desired := make(map[string]accountEditRawValue, len(accountEditExtraKeys)) - for _, key := range accountEditExtraKeys { - if value, present := extra[key]; present { - desired[key] = accountEditRawValue{true, value} - } else if value, present := account.Extra[key]; present { - desired[key] = accountEditRawValue{true, value} - } else { - desired[key] = accountEditRawValue{} - } - } - return desired -} - -func applyAccountEditOwned(account *Account, values map[string]accountEditRawValue) { - if len(values) == 0 { - return - } - account.Extra = maps.Clone(account.Extra) - if account.Extra == nil { - account.Extra = map[string]any{} - } - for _, key := range accountEditExtraKeys { - value, owned := values[key] - if !owned { - continue - } - if value.Present { - account.Extra[key] = value.Value - } else { - delete(account.Extra, key) - } - } -} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index c15b19e7aac9..183ec71e52d6 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -617,9 +617,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if account.Platform == PlatformTypeSafe && input.Type != "" && input.Type != AccountTypeAPIKey { return nil, errors.New("typesafe accounts only support apikey credentials") } - var owned map[string]accountEditRawValue + var protocolModes map[string]any if input.Extra != nil { - owned = openAIAPIKeyOwnedExtraForUpdate(account, input.Extra) + protocolModes = openAIAPIKeyProtocolModesForUpdate(account, input.Extra) } NormalizeAccountCredentialBaseURLs(input.Credentials) if err := ValidateModelContextOverrides(input.ModelContextOverrides); err != nil { @@ -779,9 +779,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if input.Extra == nil { account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra) } - // Whole-object forms own the other Extra keys; omitted OpenAI API-key + // A whole-object edit replaces the other Extra keys; omitted OpenAI API-key // protocol modes retain their exact stored presence and value. - applyAccountEditOwned(account, owned) + applyOpenAIAPIKeyProtocolModes(account, protocolModes) if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate { if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate { return nil, infraerrors.BadRequest( diff --git a/backend/internal/service/openai_apikey_protocol_modes.go b/backend/internal/service/openai_apikey_protocol_modes.go new file mode 100644 index 000000000000..c730ea25797e --- /dev/null +++ b/backend/internal/service/openai_apikey_protocol_modes.go @@ -0,0 +1,53 @@ +package service + +import "maps" + +// openAIAPIKeyProtocolModeExtraKeys are the OpenAI API-key protocol-mode +// extras. A whole-object account edit replaces Extra, but an omitted key keeps +// its stored presence and value instead of silently falling back to the +// default mode. +var openAIAPIKeyProtocolModeExtraKeys = []string{ + "openai_responses_mode", "openai_compact_mode", + "openai_apikey_responses_websockets_v2_mode", "openai_apikey_responses_websockets_v2_enabled", + "responses_websockets_v2_enabled", "openai_ws_enabled", + OpenAIPromptCacheKeyModeExtraKey, +} + +// openAIAPIKeyProtocolModesForUpdate resolves the protocol-mode extras an +// update of an OpenAI API-key account writes: supplied values win, omitted +// keys retain their stored value, and a key in neither is left out. It +// returns nil for any other account. +func openAIAPIKeyProtocolModesForUpdate(account *Account, extra map[string]any) map[string]any { + if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return nil + } + modes := make(map[string]any, len(openAIAPIKeyProtocolModeExtraKeys)) + for _, key := range openAIAPIKeyProtocolModeExtraKeys { + if value, present := extra[key]; present { + modes[key] = value + } else if value, present := account.Extra[key]; present { + modes[key] = value + } + } + return modes +} + +// applyOpenAIAPIKeyProtocolModes gives the account's Extra exactly the resolved +// protocol-mode extras: each resolved key is written and every other +// protocol-mode key is removed. nil modes leave the account as it is. +func applyOpenAIAPIKeyProtocolModes(account *Account, modes map[string]any) { + if modes == nil { + return + } + account.Extra = maps.Clone(account.Extra) + if account.Extra == nil { + account.Extra = map[string]any{} + } + for _, key := range openAIAPIKeyProtocolModeExtraKeys { + if value, present := modes[key]; present { + account.Extra[key] = value + } else { + delete(account.Extra, key) + } + } +} diff --git a/backend/internal/service/openai_apikey_protocol_modes_test.go b/backend/internal/service/openai_apikey_protocol_modes_test.go new file mode 100644 index 000000000000..854d6b5a0082 --- /dev/null +++ b/backend/internal/service/openai_apikey_protocol_modes_test.go @@ -0,0 +1,65 @@ +package service + +import ( + "context" + "maps" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpdateAccountKeepsOmittedOpenAIAPIKeyProtocolModes(t *testing.T) { + stored := map[string]any{ + "openai_responses_mode": "force_chat_completions", + "openai_compact_mode": nil, + "openai_apikey_responses_websockets_v2_mode": "dedicated", + "openai_apikey_responses_websockets_v2_enabled": false, + "openai_ws_enabled": true, + OpenAIPromptCacheKeyModeExtraKey: OpenAIPromptCacheKeyModeSHA25664, + "unrelated_setting": "old", + } + update := func(accountType string, before, extra map[string]any) map[string]any { + t.Helper() + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{1: { + ID: 1, Name: "modes", Platform: PlatformOpenAI, Type: accountType, Status: StatusActive, + Credentials: map[string]any{"api_key": "test"}, Extra: maps.Clone(before), + }}} + _, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), 1, &UpdateAccountInput{Extra: extra}) + require.NoError(t, err) + return repo.accounts[1].Extra + } + // protocolModes keeps presence apart from a stored null. + protocolModes := func(extra map[string]any) map[string]any { + modes := map[string]any{} + for _, key := range []string{ + "openai_responses_mode", "openai_compact_mode", + "openai_apikey_responses_websockets_v2_mode", "openai_apikey_responses_websockets_v2_enabled", + "responses_websockets_v2_enabled", "openai_ws_enabled", + OpenAIPromptCacheKeyModeExtraKey, + } { + if value, present := extra[key]; present { + modes[key] = value + } + } + return modes + } + + kept := update(AccountTypeAPIKey, stored, map[string]any{"other_setting": "new"}) + require.Equal(t, protocolModes(stored), protocolModes(kept)) + require.NotContains(t, kept, "unrelated_setting") + require.Equal(t, "new", kept["other_setting"]) + + changed := update(AccountTypeAPIKey, stored, map[string]any{ + "openai_responses_mode": "auto", "openai_ws_enabled": nil, "responses_websockets_v2_enabled": true, + }) + want := protocolModes(stored) + want["openai_responses_mode"] = "auto" + want["openai_ws_enabled"] = nil + want["responses_websockets_v2_enabled"] = true + require.Equal(t, want, protocolModes(changed)) + + require.Empty(t, protocolModes(update(AccountTypeAPIKey, map[string]any{"unrelated_setting": "old"}, map[string]any{"other_setting": "new"}))) + + // Any other account replaces Extra as a whole. + require.Empty(t, protocolModes(update(AccountTypeOAuth, stored, map[string]any{"other_setting": "new"}))) +} diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 357730f0e32c..186a2ba15527 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1957,7 +1957,7 @@

-
@@ -1980,7 +1980,7 @@ :options="openAIResponsesModeOptions" :disabled="!openAITextGenerationCapabilityEnabled" data-testid="openai-responses-mode-select" - @update:model-value="recordAccountMode('responses_mode', $event)" + @update:model-value="recordProtocolModeEdit('responses_mode', $event)" /> @@ -2466,7 +2466,7 @@

-
@@ -3206,8 +3206,7 @@ import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' -import { accountEditModes, applyCoreAccountModeChanges } from '@/utils/accountEditCodec' -import type { AccountEditChangesV1, AccountEditModeTarget } from '@/types/accountEdit' +import { openAIProtocolModes, applyOpenAIProtocolModeEdits, type OpenAIProtocolMode, type OpenAIProtocolModeEdits } from '@/utils/openaiProtocolModeEdits' import { adminAPI } from '@/api/admin' import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState' @@ -3623,7 +3622,7 @@ const modelMappings = ref([]) const openAICompactModelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) -const coreModeChanges = ref({}) +const protocolModeEdits = ref({}) const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const MAX_POOL_MODE_RETRY_COUNT = 10 const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429] @@ -4655,9 +4654,9 @@ async function loadTLSProfiles() { } // Only protocol modes the admin explicitly changes are written back. -function recordAccountMode(target: AccountEditModeTarget, value: unknown) { - if (typeof value !== 'string' || !(accountEditModes[target].values as readonly string[]).includes(value)) return - coreModeChanges.value = { ...coreModeChanges.value, [target]: { op: 'set', value } } +function recordProtocolModeEdit(mode: OpenAIProtocolMode, value: unknown) { + if (typeof value !== 'string' || !(openAIProtocolModes[mode].values as readonly string[]).includes(value)) return + protocolModeEdits.value = { ...protocolModeEdits.value, [mode]: value } } watch( @@ -4668,7 +4667,7 @@ watch( } if (!wasShow || newAccount.id !== previousAccount?.id || newAccount.platform !== previousAccount?.platform || newAccount.type !== previousAccount?.type) { - coreModeChanges.value = {} + protocolModeEdits.value = {} syncFormFromAccount(newAccount) loadTLSProfiles() } @@ -5838,7 +5837,7 @@ const handleSubmit = async () => { const currentExtra = (props.account.extra as Record) || {} const newExtra: Record = { ...currentExtra } const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true - applyCoreAccountModeChanges(newExtra, currentExtra, props.account.type, coreModeChanges.value) + applyOpenAIProtocolModeEdits(newExtra, currentExtra, props.account.type, protocolModeEdits.value) const storedPassthrough = currentExtra.openai_passthrough === true || currentExtra.openai_oauth_passthrough === true if (openaiPassthroughEnabled.value !== storedPassthrough) { if (openaiPassthroughEnabled.value) newExtra.openai_passthrough = true diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index 14dfff43831f..c7278362f727 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -179,6 +179,18 @@ describe('useModelWhitelist', () => { }) }) + it('keeps plain-object, trimming, wildcard and last-entry rules with safe data keys', () => { + const value = buildModelMappingObject('combined', [' __proto__ ', ' exact ', 'bad*'], [ + { from: ' request* ', to: ' upstream ' }, { from: 'exact', to: 'first' }, { from: 'exact', to: 'last' }, { from: '__proto__', to: 'custom-target' } + ])! + expect(Object.getPrototypeOf(value)).toBe(Object.prototype) + expect(Object.keys(value)).toEqual(['__proto__', 'exact', 'request*']) + expect(value.__proto__).toBe('custom-target') + expect(value.exact).toBe('last') + expect(value['request*']).toBe('upstream') + expect(buildModelMappingObject('mapping', [], [])).toBeNull() + }) + it('split mapping keeps only identity entries in the whitelist after reopening', () => { expect(splitModelMappingObject({ 'gpt-latest': 'deepseek-chat', 'gpt-5.4': 'gpt-5.4' })).toEqual({ allowedModels: ['gpt-5.4'], diff --git a/frontend/src/types/accountEdit.ts b/frontend/src/types/accountEdit.ts deleted file mode 100644 index e0917d3fab1f..000000000000 --- a/frontend/src/types/accountEdit.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Explicit OpenAI protocol-mode edits; modes the admin did not touch keep their stored encoding. */ -export type AccountEditResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions' -export type AccountEditCompactMode = 'auto' | 'force_on' | 'force_off' -export type AccountEditWebSocketMode = 'off' | 'ctx_pool' | 'passthrough' | 'http_bridge' -export type AccountEditModeTarget = 'responses_mode' | 'compact_mode' | 'responses_websocket_mode' - -export type AccountEditModeChangeV1 = { op: 'set'; value: T } | { op: 'clear' } -export interface AccountEditChangesV1 { - responses_mode?: AccountEditModeChangeV1 - compact_mode?: AccountEditModeChangeV1 - responses_websocket_mode?: AccountEditModeChangeV1 -} diff --git a/frontend/src/utils/__tests__/accountEditCodec.spec.ts b/frontend/src/utils/__tests__/accountEditCodec.spec.ts deleted file mode 100644 index 78fabf461869..000000000000 --- a/frontend/src/utils/__tests__/accountEditCodec.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildModelMappingObject } from '@/composables/useModelWhitelist' -import { accountEditModes, applyCoreAccountModeChanges } from '@/utils/accountEditCodec' - -describe('account edit finite lossless codec', () => { - it.each([undefined, null, 'auto', 'force_responses', 'legacy-unknown'])('retains raw Responses state %s on unrelated edits', raw => { - const extra = raw === undefined ? {} : { openai_responses_mode: raw } - const next = { ...extra, openai_responses_mode: 'auto', openai_compact_mode: 'auto' } - applyCoreAccountModeChanges(next, extra, 'apikey', {}) - expect(next).toEqual(extra) - }) - - it.each(['shared', 'dedicated', null])('retains WS encoding %s and all legacy booleans without cleanup', raw => { - const extra = { openai_apikey_responses_websockets_v2_mode: raw, openai_apikey_responses_websockets_v2_enabled: false, responses_websockets_v2_enabled: true, openai_ws_enabled: false, openai_ws_force_http: true } - const next = { ...extra } - applyCoreAccountModeChanges(next, extra, 'apikey', {}) - expect(next).toEqual(extra) - applyCoreAccountModeChanges(next, extra, 'apikey', { responses_websocket_mode: { op: 'set', value: 'ctx_pool' } }) - expect(next).toEqual({ ...extra, openai_apikey_responses_websockets_v2_mode: 'ctx_pool' }) - }) - - it('stores explicit core auto literally and leaves OAuth WS fallback keys untouched', () => { - const extra = { openai_responses_mode: 'force_responses', openai_compact_mode: null, openai_oauth_responses_websockets_v2_mode: 'shared', openai_ws_enabled: true } - const next = { ...extra } - applyCoreAccountModeChanges(next, extra, 'apikey', { responses_mode: { op: 'set', value: 'auto' }, compact_mode: { op: 'set', value: 'auto' } }) - expect(next.openai_responses_mode).toBe('auto') - expect(next.openai_compact_mode).toBe('auto') - applyCoreAccountModeChanges(next, next, 'oauth', { responses_websocket_mode: { op: 'set', value: 'off' } }) - expect(next.openai_oauth_responses_websockets_v2_mode).toBe('off') - expect(next.openai_ws_enabled).toBe(true) - }) - - it('defines exactly four WS mode keys', () => { - expect(accountEditModes.responses_websocket_mode.keys).toEqual(['openai_apikey_responses_websockets_v2_mode', 'openai_apikey_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled']) - }) - - it('keeps shared builder plain-object, trimming, wildcard and last-entry rules with safe data keys', () => { - const value = buildModelMappingObject('combined', [' __proto__ ', ' exact ', 'bad*'], [ - { from: ' request* ', to: ' upstream ' }, { from: 'exact', to: 'first' }, { from: 'exact', to: 'last' }, { from: '__proto__', to: 'custom-target' } - ])! - expect(Object.getPrototypeOf(value)).toBe(Object.prototype) - expect(Object.keys(value)).toEqual(['__proto__', 'exact', 'request*']) - expect(value.__proto__).toBe('custom-target') - expect(value.exact).toBe('last') - expect(value['request*']).toBe('upstream') - expect(buildModelMappingObject('mapping', [], [])).toBeNull() - }) -}) diff --git a/frontend/src/utils/__tests__/openaiProtocolModeEdits.spec.ts b/frontend/src/utils/__tests__/openaiProtocolModeEdits.spec.ts new file mode 100644 index 000000000000..d0ad4bada5f6 --- /dev/null +++ b/frontend/src/utils/__tests__/openaiProtocolModeEdits.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { openAIProtocolModes, applyOpenAIProtocolModeEdits } from '@/utils/openaiProtocolModeEdits' + +describe('OpenAI protocol mode edits', () => { + it.each([undefined, null, 'auto', 'force_responses', 'legacy-unknown'])('retains raw Responses state %s on unrelated edits', raw => { + const extra = raw === undefined ? {} : { openai_responses_mode: raw } + const next = { ...extra, openai_responses_mode: 'auto', openai_compact_mode: 'auto' } + applyOpenAIProtocolModeEdits(next, extra, 'apikey', {}) + expect(next).toEqual(extra) + }) + + it.each(['shared', 'dedicated', null])('retains WS encoding %s and all legacy booleans without cleanup', raw => { + const extra = { openai_apikey_responses_websockets_v2_mode: raw, openai_apikey_responses_websockets_v2_enabled: false, responses_websockets_v2_enabled: true, openai_ws_enabled: false, openai_ws_force_http: true } + const next = { ...extra } + applyOpenAIProtocolModeEdits(next, extra, 'apikey', {}) + expect(next).toEqual(extra) + applyOpenAIProtocolModeEdits(next, extra, 'apikey', { responses_websocket_mode: 'ctx_pool' }) + expect(next).toEqual({ ...extra, openai_apikey_responses_websockets_v2_mode: 'ctx_pool' }) + }) + + it('stores an explicit auto literally and leaves OAuth WS fallback keys untouched', () => { + const extra = { openai_responses_mode: 'force_responses', openai_compact_mode: null, openai_oauth_responses_websockets_v2_mode: 'shared', openai_ws_enabled: true } + const next = { ...extra } + applyOpenAIProtocolModeEdits(next, extra, 'apikey', { responses_mode: 'auto', compact_mode: 'auto' }) + expect(next.openai_responses_mode).toBe('auto') + expect(next.openai_compact_mode).toBe('auto') + applyOpenAIProtocolModeEdits(next, next, 'oauth', { responses_websocket_mode: 'off' }) + expect(next.openai_oauth_responses_websockets_v2_mode).toBe('off') + expect(next.openai_ws_enabled).toBe(true) + }) + + it('defines exactly four WS mode keys', () => { + expect(openAIProtocolModes.responses_websocket_mode.keys).toEqual(['openai_apikey_responses_websockets_v2_mode', 'openai_apikey_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled']) + }) +}) diff --git a/frontend/src/utils/accountEditCodec.ts b/frontend/src/utils/accountEditCodec.ts deleted file mode 100644 index 6e4f1d416de6..000000000000 --- a/frontend/src/utils/accountEditCodec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AccountEditChangesV1, AccountEditModeTarget } from '@/types/accountEdit' - -export const accountEditModes = { - responses_mode: { key: 'openai_responses_mode', keys: ['openai_responses_mode'], values: ['auto', 'force_responses', 'force_chat_completions'] }, - compact_mode: { key: 'openai_compact_mode', keys: ['openai_compact_mode'], values: ['auto', 'force_on', 'force_off'] }, - responses_websocket_mode: { - key: 'openai_apikey_responses_websockets_v2_mode', - keys: ['openai_apikey_responses_websockets_v2_mode', 'openai_apikey_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled'], - values: ['off', 'ctx_pool', 'passthrough', 'http_bridge'] - } -} as const -export const accountEditExtraKeys = Object.values(accountEditModes).flatMap(mode => [...mode.keys]) -const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key) - -/** Preserve ordinary core encodings; an explicit mode set changes only its primary mode key. */ -export function applyCoreAccountModeChanges( - next: Record, current: Record | undefined, - type: string, changes: AccountEditChangesV1 -): void { - const raw = current || {} - const keys = [...accountEditExtraKeys, 'openai_oauth_responses_websockets_v2_mode', 'openai_oauth_responses_websockets_v2_enabled'] - for (const key of keys) { - if (own(raw, key)) next[key] = raw[key] - else delete next[key] - } - for (const target of Object.keys(accountEditModes) as AccountEditModeTarget[]) { - const change = changes[target] - if (!change) continue - if (change.op !== 'set' || !(accountEditModes[target].values as readonly string[]).includes(change.value)) throw new Error('Invalid core mode edit') - if (target === 'responses_mode' && type !== 'apikey') throw new Error('Responses mode is not applicable') - const key = target === 'responses_websocket_mode' && type !== 'apikey' - ? 'openai_oauth_responses_websockets_v2_mode' : accountEditModes[target].key - next[key] = change.value - } -} diff --git a/frontend/src/utils/openaiProtocolModeEdits.ts b/frontend/src/utils/openaiProtocolModeEdits.ts new file mode 100644 index 000000000000..a891f61b42a2 --- /dev/null +++ b/frontend/src/utils/openaiProtocolModeEdits.ts @@ -0,0 +1,37 @@ +export const openAIProtocolModes = { + responses_mode: { key: 'openai_responses_mode', keys: ['openai_responses_mode'], values: ['auto', 'force_responses', 'force_chat_completions'] }, + compact_mode: { key: 'openai_compact_mode', keys: ['openai_compact_mode'], values: ['auto', 'force_on', 'force_off'] }, + responses_websocket_mode: { + key: 'openai_apikey_responses_websockets_v2_mode', + keys: ['openai_apikey_responses_websockets_v2_mode', 'openai_apikey_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled'], + values: ['off', 'ctx_pool', 'passthrough', 'http_bridge'] + } +} as const +export type OpenAIProtocolMode = keyof typeof openAIProtocolModes +/** The OpenAI protocol modes the admin changed in the edit form; modes the admin did not touch keep their stored encoding. */ +export type OpenAIProtocolModeEdits = { [Mode in OpenAIProtocolMode]?: (typeof openAIProtocolModes)[Mode]['values'][number] } + +const protocolModeExtraKeys = Object.values(openAIProtocolModes).flatMap(mode => [...mode.keys]) +const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key) + +/** Preserve the stored mode encodings; an edited mode changes only its primary mode key. */ +export function applyOpenAIProtocolModeEdits( + next: Record, current: Record | undefined, + type: string, edits: OpenAIProtocolModeEdits +): void { + const raw = current || {} + const keys = [...protocolModeExtraKeys, 'openai_oauth_responses_websockets_v2_mode', 'openai_oauth_responses_websockets_v2_enabled'] + for (const key of keys) { + if (own(raw, key)) next[key] = raw[key] + else delete next[key] + } + for (const mode of Object.keys(openAIProtocolModes) as OpenAIProtocolMode[]) { + const value = edits[mode] + if (!value) continue + if (!(openAIProtocolModes[mode].values as readonly string[]).includes(value)) throw new Error('Invalid protocol mode edit') + if (mode === 'responses_mode' && type !== 'apikey') throw new Error('Responses mode is not applicable') + const key = mode === 'responses_websocket_mode' && type !== 'apikey' + ? 'openai_oauth_responses_websockets_v2_mode' : openAIProtocolModes[mode].key + next[key] = value + } +} From 82583b6025b7761955ac58bd3e0f75f0e65d084d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 18:20:57 +0800 Subject: [PATCH 08/37] refactor(backend): restore upstream comments, blank line and import order Four upstream files differed from v0.2.13 in places no fork feature needs: - middleware/group_model_allowlist.go: the GroupModelAllowlist comment described a rejection of reserved internal selectors that no longer exists. The code under it is upstream's fast path, and the comment is upstream's again. - handler/setting_handler.go: the NewSettingHandler comment is back. - service/settings_view.go: the blank line before the model fallback fields is back. - service/setting_service.go: "sync" is back at upstream's place in the import block. No code changes. What still differs from upstream in these files is live fork code: the flat theme flag, the refusal recovery settings and their cache, the tolerant JSON body read and the wildcard model route. Co-Authored-By: Claude Opus 5.5 --- backend/internal/handler/setting_handler.go | 1 + backend/internal/server/middleware/group_model_allowlist.go | 3 +-- backend/internal/service/setting_service.go | 2 +- backend/internal/service/settings_view.go | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index b63f80b77565..8e4c37bf63ad 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -20,6 +20,7 @@ type SettingHandler struct { version string } +// NewSettingHandler 创建公开设置处理器 func NewSettingHandler(settingService *service.SettingService, version string) *SettingHandler { return &SettingHandler{ settingService: settingService, diff --git a/backend/internal/server/middleware/group_model_allowlist.go b/backend/internal/server/middleware/group_model_allowlist.go index 8313bacad670..1b995e4a91a2 100644 --- a/backend/internal/server/middleware/group_model_allowlist.go +++ b/backend/internal/server/middleware/group_model_allowlist.go @@ -20,8 +20,7 @@ import ( // 保证校验发生在合成路由改写与调度之前,且只看客户端书写的公开模型名。 // // 行为: -// - 保留的内部 selector 在全部已鉴权网关请求中拒绝,与分组白名单开关无关; -// 没有已鉴权 API Key 的调用保持不读请求体。 +// - 快速路径:未绑定分组或白名单未开启时直接放行,不读请求体。 // - Responses WebSocket 入口跳过(首帧与后续 turn 由 ResponsesWebSocket 逐帧 // 校验);Grok Realtime 的升级请求模型固定在查询参数里,仍走中间件校验, // 其他路由伪造 Upgrade 头不得绕过校验。 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 1d8f5d4b5d13..3e0cc3e815db 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -6,13 +6,13 @@ import ( "errors" "fmt" "strings" - "sync" "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/sync/singleflight" + "sync" ) const ( diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8682fb891c81..8e27fc82399a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -184,6 +184,7 @@ type SystemSettings struct { AdminRechargeRebateEnabled bool DefaultUserRPMLimit int DefaultSubscriptions []DefaultSubscriptionSetting + // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` FallbackModelAnthropic string `json:"fallback_model_anthropic"` From 6b0785201c4dd6e0658d3f4b28aa42add172f7ed Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 18:21:04 +0800 Subject: [PATCH 09/37] refactor(codex): building an account's User-Agent cannot fail codexClientIdentity.buildUserAgent checked the identity and the version again and returned an error that every send path carried upwards. The check dates from the time the builder sat behind a plugin boundary. Inside the service package it cannot fail: - The version comes only from resolveCodexOutboundIdentity. It is either the built-in constant or a value NormalizeCodexClientVersion accepted, with the same expression and the same length limit the builder applied. - The identity comes only from Account.CodexClientIdentity. It is either a stored value that passed valid() when it was read, or a derived one, and the derivation can only produce identities that pass valid(). So the builder is UserAgent again, a plain formatter, and resolveCodexOutboundIdentityForAccount and enforceCodexIdentityHeadersForAccount return no error, as they did before the plugin era. Gone with it: errCodexClientVersionInvalid, the second copy of the version expression, the error branches on the send paths (account test, usage probe, alpha search, models manifest, token refresh, upstream models, the shared header finalizer), the two readers that discarded the error, and the account-test message "Codex client identity is invalid", which could never be shown. Tests: the case that relaxed the version expression to force the error is replaced by one that hands the resolver an invalid version and checks that the account still goes out as itself with the built-in version. The check that the builder refuses a bad profile becomes a check that a stored identity failing the schema check is not used, and that every branch of the derivation passes it. Call sites in tests are back to their one-line form. No request changes what it sends or when it fails. Co-Authored-By: Claude Opus 5.5 --- .../internal/service/account_test_service.go | 22 +---- ...ccount_test_service_openai_compact_test.go | 4 +- .../account_test_service_openai_image_test.go | 4 +- .../internal/service/account_usage_service.go | 4 +- .../service/codex_outbound_contract.go | 3 +- .../internal/service/openai_alpha_search.go | 8 +- .../openai_codex_account_identity_test.go | 4 +- .../service/openai_codex_client_identity.go | 30 +----- .../openai_codex_client_identity_test.go | 96 ++++++++++--------- .../internal/service/openai_codex_identity.go | 31 +++--- .../service/openai_codex_identity_snapshot.go | 4 +- .../service/openai_codex_models_service.go | 5 +- .../service/openai_compat_model_test.go | 20 ++-- .../service/openai_gateway_forward.go | 5 +- .../service/openai_oauth_passthrough_test.go | 20 +--- .../internal/service/openai_oauth_service.go | 5 +- .../service/openai_setup_token_compat_test.go | 6 +- backend/internal/service/upstream_models.go | 9 +- 18 files changed, 98 insertions(+), 182 deletions(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e812a3d1ea51..b9691c94a697 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -954,18 +954,13 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account req.Host = "chatgpt.com" req.Header.Set("accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - canonical, identityErr := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) - if identityErr != nil { - return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+identityErr.Error()) - } + canonical := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("Originator", canonical.originator) req.Header.Set("User-Agent", canonical.userAgent) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员 // 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。 - if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil { - return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error()) - } + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -2313,9 +2308,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account if isOAuth { // 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 经 ForceCodexCLI // 策略过滤后作为管理员显式配置传入(同普通 OAuth 连接测试)。 - if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil { - return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error()) - } + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) } probeSessionID := compactProbeSessionID(account.ID) req.Header.Set("Session_ID", probeSessionID) @@ -3005,18 +2998,13 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("OpenAI-Beta", "responses=experimental") - canonical, identityErr := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) - if identityErr != nil { - return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+identityErr.Error()) - } + canonical := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("originator", canonical.originator) req.Header.Set("User-Agent", canonical.userAgent) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) // 与真实转发一致(同普通 OAuth 连接测试):使用该账号的 Codex TUI 身份,账号级自定义 UA // 经 ForceCodexCLI 策略过滤后作为管理员显式配置传入。 - if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil { - return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error()) - } + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index b5477de7a8f8..a07967f923df 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -321,9 +321,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatc require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact", "探测标识不得是可被上游一眼识别的字面量") // 出站 UA = 该账号(种子派生)的 Codex TUI 身份,而不是全局规范身份。 - identity, err := resolveCodexOutboundIdentityForAccount(&account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(&account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) <-updateCalls } diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go index e181ae0fbe06..8ad5c20fb058 100644 --- a/backend/internal/service/account_test_service_openai_image_test.go +++ b/backend/internal/service/account_test_service_openai_image_test.go @@ -60,9 +60,7 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes require.NotNil(t, upstream.lastReq) require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) // Force 优先于自定义 UA;无种子账号使用规范身份。压缩后仍是 image_generation 工具调用。 - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator")) require.Equal(t, "zstd", upstream.lastReq.Header.Get("Content-Encoding")) require.Equal(t, "image_generation", gjson.GetBytes(zstdDecodeForTest(t, upstream.lastBody), "tools.0.type").String()) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 4c9d266accd4..764fa3a97692 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -917,9 +917,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。 // 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 - if err := enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account)); err != nil { - return nil, err - } + enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account)) setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" diff --git a/backend/internal/service/codex_outbound_contract.go b/backend/internal/service/codex_outbound_contract.go index 6c940060ea28..4cae91e4eb2c 100644 --- a/backend/internal/service/codex_outbound_contract.go +++ b/backend/internal/service/codex_outbound_contract.go @@ -19,5 +19,6 @@ func (s *OpenAIGatewayService) finalizeCodexOutboundHeaders(ctx context.Context, if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { return err } - return enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) + enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) + return nil } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index f187ce6a439a..81208b5a7b9e 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -324,9 +324,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c fillCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID)) } applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID) - if err := enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { - return nil, err - } + enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) account.ApplyHeaderOverrides(req.Header) return req, nil } @@ -465,9 +463,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context req.Header.Set("User-Agent", canonical.userAgent) } applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) - if err := enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil { - return nil, err - } + enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)) } account.ApplyHeaderOverrides(req.Header) diff --git a/backend/internal/service/openai_codex_account_identity_test.go b/backend/internal/service/openai_codex_account_identity_test.go index 49312a38acdf..2233790a7c64 100644 --- a/backend/internal/service/openai_codex_account_identity_test.go +++ b/backend/internal/service/openai_codex_account_identity_test.go @@ -114,9 +114,7 @@ func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t "token", true, "client-session", true, ) require.NoError(t, err) - identity, err := resolveCodexOutboundIdentityForAccount(parent, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, req.Header.Get("user-agent"), "影子账号使用凭据账号的身份") + require.Equal(t, resolveCodexOutboundIdentityForAccount(parent, "").userAgent, req.Header.Get("user-agent"), "影子账号使用凭据账号的身份") require.Empty(t, req.Header.Get("session_id")) require.Equal(t, "client-session", req.Header.Get("session-id"), "缺失连字符会话头时以最终 prompt_cache_key 补齐") diff --git a/backend/internal/service/openai_codex_client_identity.go b/backend/internal/service/openai_codex_client_identity.go index 763c2aebb90b..85540c8c7cde 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/json" - "errors" "fmt" "log/slog" "regexp" @@ -106,34 +105,13 @@ func codexSandboxForUserAgent(userAgent string) string { } } -// errCodexClientVersionInvalid reports a Codex client version that cannot be -// written into a User-Agent. -var errCodexClientVersionInvalid = errors.New("invalid identity version") - -// codexClientIdentityVersionPattern allows the two official forms, 0.146.0 and -// 0.147.0-alpha.4. -var codexClientIdentityVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`) - -// buildUserAgent 拼出真实 Codex TUI 的 User-Agent: +// UserAgent 拼出真实 Codex TUI 的 User-Agent: // codex-tui/ ( ; ) (codex-tui; )。 // 首段版本、尾部括号组版本与 version 头必须同源(codex-rs 三处都取同一个 -// CARGO_PKG_VERSION)。身份未通过 schema 校验或版本号形态不合法时返回错误: -// 二者都不得写进出站请求头。 -func (id codexClientIdentity) buildUserAgent(version string) (string, error) { - if !id.valid() { - return "", errors.New("invalid identity profile") - } - if len(version) > codexClientVersionMaxLen || !codexClientIdentityVersionPattern.MatchString(version) { - return "", errCodexClientVersionInvalid - } - return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", - codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version), nil -} - -// UserAgent 返回该身份在给定版本号下的 Codex TUI User-Agent;身份或版本号不合法时返回空串。 +// CARGO_PKG_VERSION)。 func (id codexClientIdentity) UserAgent(version string) string { - userAgent, _ := id.buildUserAgent(version) - return userAgent + return fmt.Sprintf("%s/%s (%s %s; %s) %s (%s; %s)", + codexTUIOriginator, version, id.OSType, id.OSVersion, id.Arch, id.Terminal, codexTUIOriginator, version) } // codexTUIOriginator 是交互式 Codex TUI 的 originator(app-server initialize 的 diff --git a/backend/internal/service/openai_codex_client_identity_test.go b/backend/internal/service/openai_codex_client_identity_test.go index 4e73f5390bcf..4b7f4dec773f 100644 --- a/backend/internal/service/openai_codex_client_identity_test.go +++ b/backend/internal/service/openai_codex_client_identity_test.go @@ -51,8 +51,7 @@ func TestRefreshAccountTokenUsesAccountCodexIdentity(t *testing.T) { info, err := svc.RefreshAccountToken(context.Background(), account) require.NoError(t, err) require.Equal(t, "with-identity", info.AccessToken) - expected, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) + expected := resolveCodexOutboundIdentityForAccount(account, "") require.Equal(t, expected.userAgent, stub.userAgent) require.Equal(t, "codex-tui", stub.originator) } @@ -65,9 +64,7 @@ func TestCodexIdentitySnapshotIsSecretFreeAndConsistent(t *testing.T) { snapshot := resolveCodexIdentitySnapshot(account, account, "") require.Equal(t, "account", snapshot.IdentitySource) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, snapshot.UserAgent) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, snapshot.UserAgent) raw, err := json.Marshal(snapshot) require.NoError(t, err) require.NotContains(t, string(raw), seed) @@ -99,38 +96,41 @@ func TestCodexIdentitySnapshotReportsTheSelectedFingerprintSource(t *testing.T) require.True(t, snapshot.UAOverridePresent) } -// 生效版本号写不进账号身份的 User-Agent 时发送必须失败:请求头保持原样、token 刷新不出站, -// 而不是退回全局规范身份。 +// 规范解析器给出的版本号不合法时,生效版本回退内置常量:账号仍以自己的身份出站, +// 请求头与 token 刷新带的都是这个合法版本,不合法的版本号进不了 User-Agent。 // -// 不得给本用例加 t.Parallel():它改写进程级解析器与版本号校验。 -func TestCodexIdentityInvalidVersionCannotSendCanonicalFallback(t *testing.T) { - // 生效版本号到达账号身份之前已由 NormalizeCodexClientVersion 校验;放宽这层校验, - // 让一个不合法的版本号走到身份层。 - previous := codexClientVersionPattern - codexClientVersionPattern = regexp.MustCompile(`^\S+$`) - SetCodexCanonicalUserAgentResolver(func() string { - return "codex-tui/9.9.9_bad (Ubuntu 22.4.0; x86_64) xterm-256color" - }) - t.Cleanup(func() { - codexClientVersionPattern = previous - SetCodexCanonicalUserAgentResolver(nil) - }) +// 不得给本用例加 t.Parallel():它改写进程级解析器。 +func TestCodexAccountIdentityUsesBuiltInVersionWhenCanonicalVersionIsInvalid(t *testing.T) { + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"refresh_token": "fixture-refresh"}, Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}} - require.Equal(t, "9.9.9_bad", resolveCodexOutboundIdentity("").version) - - _, err := resolveCodexOutboundIdentityForAccount(account, "") - require.ErrorIs(t, err, errCodexClientVersionInvalid) - headers := http.Header{"Originator": []string{"fixture-original"}} - err = enforceCodexIdentityHeadersForAccount(headers, account, "") - require.ErrorIs(t, err, errCodexClientVersionInvalid) - require.Equal(t, "fixture-original", headers.Get("Originator")) - require.Empty(t, headers.Get("User-Agent")) - client := &identityRefreshingOAuthClientStub{} - _, err = NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) - require.ErrorIs(t, err, errCodexClientVersionInvalid) - require.Empty(t, client.userAgent) + profile, ok := account.CodexClientIdentity() + require.True(t, ok) + expected := codexOutboundIdentity{ + userAgent: profile.UserAgent(codexCLIVersion), + originator: codexTUIOriginator, + version: codexCLIVersion, + } + + for _, canonicalUA := range []string{ + "codex-tui/9.9.9_bad (Ubuntu 22.4.0; x86_64) xterm-256color", + "codex-tui/0.150.0\r\nx: value (Ubuntu 22.4.0; x86_64) xterm-256color", + } { + SetCodexCanonicalUserAgentResolver(func() string { return canonicalUA }) + require.Equal(t, codexCLIVersion, resolveCodexOutboundIdentity("").version) + require.Equal(t, expected, resolveCodexOutboundIdentityForAccount(account, "")) + headers := http.Header{"Originator": []string{"fixture-original"}} + enforceCodexIdentityHeadersForAccount(headers, account, "") + require.Equal(t, expected.userAgent, headers.Get("User-Agent")) + require.Equal(t, expected.originator, headers.Get("Originator")) + require.Equal(t, expected.version, headers.Get("Version")) + client := &identityRefreshingOAuthClientStub{} + _, err := NewOpenAIOAuthService(nil, client).RefreshAccountToken(context.Background(), account) + require.NoError(t, err) + require.Equal(t, expected.userAgent, client.userAgent) + require.Equal(t, expected.originator, client.originator) + } } type codexIdentityBackfillRepo struct { @@ -189,7 +189,7 @@ func TestEnforceCodexIdentityHeadersForAccountUsesDerivedTUIIdentity(t *testing. h := http.Header{} h.Set("originator", "codex_cli_rs") h.Set("user-agent", "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown") - require.NoError(t, enforceCodexIdentityHeadersForAccount(h, account, "")) + enforceCodexIdentityHeadersForAccount(h, account, "") version := resolveCodexOutboundIdentity("").version uaPattern := regexp.MustCompile(`^codex-tui/` + regexp.QuoteMeta(version) + @@ -228,28 +228,34 @@ func (id codexClientIdentity) withoutGeneratedAt() codexClientIdentity { return id } -// 同一种子派生同一身份;UA 的两处版本与 sandbox 都和身份自洽;带 CR/LF 的终端或版本号 -// 不得写进 User-Agent。 +// 同一种子派生同一身份,派生的每个分支都通过 schema 校验;UA 的两处版本与 sandbox 都和 +// 身份自洽;持久化的身份带 CR/LF 时视为缺失,账号改用按种子派生的身份。 func TestCodexClientIdentityPreservesDeterminismAndProtocolPairing(t *testing.T) { const seed = "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55" first := deriveCodexClientIdentity(seed) require.Equal(t, first, deriveCodexClientIdentity(seed)) require.True(t, first.valid()) + // 这 4096 个种子走到全部系统、系统版本、架构与终端分支。 + for i := 0; i < 4096; i++ { + derived := deriveCodexClientIdentity(fmt.Sprintf("00000000-0000-4000-8000-%012d", i)) + require.True(t, derived.valid(), "%+v", derived) + } - ua, err := first.buildUserAgent("0.150.0") - require.NoError(t, err) + ua := first.UserAgent("0.150.0") require.True(t, strings.HasPrefix(ua, "codex-tui/0.150.0 "), ua) require.True(t, strings.HasSuffix(ua, "(codex-tui; 0.150.0)"), ua) require.Equal(t, first.Sandbox, codexSandboxForUserAgent(ua)) - require.Equal(t, ua, first.UserAgent("0.150.0")) bad := first bad.Terminal = "terminal\r\nx-header: value" require.False(t, bad.valid(), "header injection accepted") - _, err = first.buildUserAgent("0.150.0\r\nx: value") - require.ErrorIs(t, err, errCodexClientVersionInvalid) - _, err = bad.buildUserAgent("0.150.0") - require.Error(t, err, "invalid profile accepted") + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{ + codexFingerprintSeedExtraKey: seed, + CodexClientIdentityExtraKey: codexClientIdentityExtraValue(bad, time.Unix(0, 0)), + }} + got, ok := account.CodexClientIdentity() + require.True(t, ok) + require.Equal(t, first, got, "invalid stored identity used") } // 凭据面与推理面共用显式 UA 策略:ForceCodexCLI 开启时忽略账号自定义 UA,刷新 token 也用派生身份。 @@ -270,9 +276,7 @@ func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) { stub = &identityRefreshingOAuthClientStub{} _, err = NewOpenAIOAuthService(nil, stub).RefreshAccountToken(context.Background(), account) require.NoError(t, err) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA") + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA") } // codexTestSeedForOS 返回一个派生出指定操作系统身份的合法种子(确定性搜索)。 diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index d18405d37db0..6fcd6ef567e0 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -272,42 +272,35 @@ func pairCodexIdentityHeaders(h http.Header) { // 优先级:管理员显式配置的账号级 User-Agent(只贡献客户端名与 OS / 架构 / 终端指纹, // 版本段仍由生效版本重建)> 账号持久化 / 种子派生的 Codex TUI 身份 > 全局规范身份。 // 版本号三处同源:UA 首段、UA 尾部括号组与 version 头都取当前生效的官方版本。 -// -// 唯一的失败原因:生效版本号无法写进该账号身份的 User-Agent。此时返回错误, -// 调用方必须让本次发送失败,而不是退回全局规范身份出站。 -func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) (codexOutboundIdentity, error) { +func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) codexOutboundIdentity { canonical := resolveCodexOutboundIdentity(overrideUA) if overrideUA != "" { - return canonical, nil + return canonical } identity, ok := account.CodexClientIdentity() if !ok { - return canonical, nil + return canonical } - userAgent, err := identity.buildUserAgent(canonical.version) - if err != nil { - return codexOutboundIdentity{}, err + return codexOutboundIdentity{ + userAgent: identity.UserAgent(canonical.version), + originator: codexTUIOriginator, + version: canonical.version, } - return codexOutboundIdentity{userAgent: userAgent, originator: codexTUIOriginator, version: canonical.version}, nil } // enforceCodexIdentityHeadersForAccount 与 enforceCodexIdentityHeadersWithUA 语义相同, // 但强制统一时使用账号级身份而不是全局规范身份,使同一账号的所有出站请求 -// 表现为同一台机器上的同一个 Codex TUI。账号身份解析失败时返回该错误,请求头保持原样。 -func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string) error { +// 表现为同一台机器上的同一个 Codex TUI。 +func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string) { if h == nil || h.Get("originator") == "" { - return nil + return } if !codexIdentityEnforcement.Load() { pairCodexIdentityHeaders(h) - return nil - } - identity, err := resolveCodexOutboundIdentityForAccount(account, overrideUA) - if err != nil { - return err + return } + identity := resolveCodexOutboundIdentityForAccount(account, overrideUA) h.Set("user-agent", identity.userAgent) h.Set("originator", identity.originator) h.Set("version", identity.version) - return nil } diff --git a/backend/internal/service/openai_codex_identity_snapshot.go b/backend/internal/service/openai_codex_identity_snapshot.go index 8ee24445a35a..e500bcd7fd95 100644 --- a/backend/internal/service/openai_codex_identity_snapshot.go +++ b/backend/internal/service/openai_codex_identity_snapshot.go @@ -90,9 +90,7 @@ func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string) co }() } - // The snapshot only reports. The send path resolves the same identity for - // the request and is where a failure to build it stops the send. - identity, _ := resolveCodexOutboundIdentityForAccount(source, overrideUA) + identity := resolveCodexOutboundIdentityForAccount(source, overrideUA) snapshot.UserAgent = identity.userAgent snapshot.Originator = identity.originator snapshot.Version = identity.version diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index caf02da8b8dd..4a71ffbd6a0b 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1792,10 +1792,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc identity := resolveCodexOutboundIdentity(overrideUA) if !useAPIKeyUpstream { overrideUA = codexAccountIdentityOverrideUA(credAccount) - identity, err = resolveCodexOutboundIdentityForAccount(credAccount, overrideUA) - if err != nil { - return nil, err - } + identity = resolveCodexOutboundIdentityForAccount(credAccount, overrideUA) } headers.Set("Originator", identity.originator) headers.Set("User-Agent", identity.userAgent) diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 94ed8e17aefd..604be7530758 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -1171,9 +1171,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.NoError(t, err) require.NotNil(t, firstResult) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) secondRec := httptest.NewRecorder() @@ -1187,7 +1185,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session-id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1238,9 +1236,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) { result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") require.NoError(t, err) require.NotNil(t, result) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) }) } } @@ -1283,9 +1279,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey firstSessionID := upstream.requests[0].Header.Get("session-id") require.NotEmpty(t, firstSessionID) require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) @@ -1300,7 +1294,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session-id")) require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } @@ -1455,9 +1449,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) { instructions := gjson.GetBytes(upstream.lastBody, "instructions") require.True(t, instructions.Exists()) require.Empty(t, instructions.String()) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], identity.userAgent, openai.CodexDefaultOriginator) + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, openai.CodexDefaultOriginator) } func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 2c368234d31b..ca7ec69c16a1 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1596,10 +1596,7 @@ func (s *OpenAIGatewayService) effectiveCodexOutboundUserAgent(c *gin.Context, a } overrideUA := s.codexIdentityOverrideUA(account) if codexIdentityEnforcement.Load() { - // 这里只预测终态 UA 供 sandbox 对齐,不决定请求能否发出:身份解析失败时预测为空 - // (sandbox 不改写),是否拒绝发送由终态收口 enforceCodexIdentityHeadersForAccount 决定。 - identity, _ := resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA) - return identity.userAgent + return resolveCodexOutboundIdentityForAccount(codexAccountIdentitySource(c, account), overrideUA).userAgent } candidate := overrideUA if candidate == "" { diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index daf10ef968e6..7f1d79237de8 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -579,9 +579,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali // 2) only auth is replaced; inbound auth/cookie are not forwarded require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization")) // 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游,出站是该账号的 Codex TUI 身份。 - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Empty(t, upstream.lastReq.Header.Get("Cookie")) require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key")) require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key")) @@ -1303,9 +1301,7 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t require.NotNil(t, upstream.lastReq) // 浏览器型复合 UA 被替换为该账号的 Codex TUI 身份 UA, // originator 随最终 UA 配套(issue #3901)。 - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator")) } @@ -2539,9 +2535,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.NoError(t, err) require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool()) require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool()) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) } // 透传模式的 OAuth 与非透传一致:官方客户端身份同样被强制统一为网关规范身份, @@ -2589,9 +2583,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testin _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } @@ -2635,9 +2627,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin _, err := svc.Forward(context.Background(), c, account, inputBody) require.NoError(t, err) require.NotNil(t, upstream.lastReq) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator")) require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version")) } diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 8749f36b6bf6..58ecc752142f 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -402,10 +402,7 @@ func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *A } clientID := account.GetCredential("client_id") - identity, err := resolveCodexOutboundIdentityForAccount(account, codexAccountIdentityOverrideUA(account)) - if err != nil { - return nil, err - } + identity := resolveCodexOutboundIdentityForAccount(account, codexAccountIdentityOverrideUA(account)) return s.refreshTokenWithIdentity(ctx, refreshToken, proxyURL, clientID, identity, account) } diff --git a/backend/internal/service/openai_setup_token_compat_test.go b/backend/internal/service/openai_setup_token_compat_test.go index 5eac0de75d0b..7aa52223b3e0 100644 --- a/backend/internal/service/openai_setup_token_compat_test.go +++ b/backend/internal/service/openai_setup_token_compat_test.go @@ -212,9 +212,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.Equal(t, chatgptCodexURL, upstream.requests[0].URL.String()) require.Equal(t, "Bearer setup-token-value", upstream.requests[0].Header.Get("Authorization")) require.Equal(t, "chatgpt-setup", upstream.requests[0].Header.Get("chatgpt-account-id")) - identity, err := resolveCodexOutboundIdentityForAccount(account, "") - require.NoError(t, err) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], identity.userAgent, "codex-tui") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"next"}],"stream":false}`) @@ -231,7 +229,7 @@ func TestOpenAISetupTokenMessagesUsesCodexBridgeAndTurnState(t *testing.T) { require.Equal(t, "turn_state_setup", upstream.requests[1].Header.Get("x-codex-turn-state")) require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session-id")) require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) - requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], identity.userAgent, "codex-tui") + requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], resolveCodexOutboundIdentityForAccount(account, "").userAgent, "codex-tui") } func openAISetupTokenCompatAccount(id int64) *Account { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 2722de345c5e..c57266bfdbb8 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -1400,19 +1400,14 @@ func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.C req.Header.Set("Authorization", "Bearer "+accessToken) } - identity, identityErr := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) - if identityErr != nil { - return nil, identityErr - } + identity := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) req.Header.Set("Accept", "application/json") req.Header.Set("Originator", identity.originator) req.Header.Set("User-Agent", identity.userAgent) req.Header.Set("Version", identity.version) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) credentialAccount.ApplyHeaderOverrides(req.Header) - if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil { - return nil, err - } + enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)) return req, nil } From 1a63faf9f73e8088441759b99edd6094fa11605e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 19:25:20 +0800 Subject: [PATCH 10/37] refactor(accounts): remove two throws the protocol-mode edits can never reach applyOpenAIProtocolModeEdits checked each edit against the table of allowed values and refused a Responses mode on an account that is not an API-key account. Both checks threw, and neither can be reached: - The edits come only from recordProtocolModeEdit in EditAccountModal, which checks the value against the same table before it records it, and the parameter type admits no other value. - The Responses mode select is rendered only for an OpenAI API-key account, and the recorded edits are cleared whenever the dialog opens or the account, its platform or its type changes. The function has one caller and no spec covers either throw. The type parameter stays: it still picks the OAuth key for the WebSocket mode. Four cases in EditAccountModal.spec.ts were titled with the retired account.edit slot name and its vocabulary. Each title now says what the case checks. No assertion changes. Co-Authored-By: Claude Opus 5.5 --- .../components/account/__tests__/EditAccountModal.spec.ts | 8 ++++---- frontend/src/utils/openaiProtocolModeEdits.ts | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 953447cbffdb..03b416346e0a 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -728,7 +728,7 @@ describe('EditAccountModal', () => { expect(bindingOffered(buildTypeSafeAccount())).toBe(false) }) - it('account.edit keeps an OpenAI API-key account on the Laxa endpoint editable like any other', async () => { + it('keeps an OpenAI API-key account on the Laxa endpoint editable and saves its stored passthrough and protocol-mode keys unchanged', async () => { const account = buildAccount() account.credentials.base_url = 'https://api.laxarouter.ai' account.credentials.model_mapping = { 'custom-source': 'custom-target' } @@ -756,14 +756,14 @@ describe('EditAccountModal', () => { [{ openai_compact_mode: 'auto', openai_compact_supported: true }, 'admin.accounts.openai.compactSupported'], [{ openai_compact_mode: 'auto', openai_compact_supported: false }, 'admin.accounts.openai.compactUnsupported'], [{}, 'admin.accounts.openai.compactAuto'] - ])('account.edit renders a finite compact status for canonical state %j', async (extra, expected) => { + ])('renders the compact status for stored extra %j', async (extra, expected) => { const wrapper = mountModal({ ...buildAccount(), extra }) await flushPromises() expect(wrapper.text()).toContain(expected) wrapper.unmount() }) - it('account.edit keeps ordinary mode intent and basic input across same-ID row replacement', async () => { + it('keeps unsaved notes and an edited Responses mode across a same-ID row replacement and saves that mode', async () => { const account = buildAccount() updateAccountMock.mockReset().mockResolvedValue(account) const wrapper = mountModal(account) @@ -780,7 +780,7 @@ describe('EditAccountModal', () => { wrapper.unmount() }) - it('account.edit preserves a custom __proto__ alias in model mappings', async () => { + it('keeps a model mapping named __proto__ as an own key when it is edited and saved', async () => { const account = { ...buildAccount(), credentials_status: { has_api_key: true }, credentials: { base_url: 'https://api.laxarouter.ai', model_mapping: JSON.parse('{"managed":"wire-managed","__proto__":"custom-target"}') } } updateAccountMock.mockReset().mockResolvedValue(account) const wrapper = mountModal(account) diff --git a/frontend/src/utils/openaiProtocolModeEdits.ts b/frontend/src/utils/openaiProtocolModeEdits.ts index a891f61b42a2..7ebc214215cf 100644 --- a/frontend/src/utils/openaiProtocolModeEdits.ts +++ b/frontend/src/utils/openaiProtocolModeEdits.ts @@ -28,8 +28,6 @@ export function applyOpenAIProtocolModeEdits( for (const mode of Object.keys(openAIProtocolModes) as OpenAIProtocolMode[]) { const value = edits[mode] if (!value) continue - if (!(openAIProtocolModes[mode].values as readonly string[]).includes(value)) throw new Error('Invalid protocol mode edit') - if (mode === 'responses_mode' && type !== 'apikey') throw new Error('Responses mode is not applicable') const key = mode === 'responses_websocket_mode' && type !== 'apikey' ? 'openai_oauth_responses_websockets_v2_mode' : openAIProtocolModes[mode].key next[key] = value From 69aa7508675a7e78e768829120596a892aa2a6b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 19:25:30 +0800 Subject: [PATCH 11/37] refactor(comments): say what the code does, not what the plugin era left - utils/accountTestModels.ts: the comment about presentation, a data contract and provider rules described the test plan that was shared with a provider plugin, and it sat above a function it did not describe. Removed. - service/codex_error_diagnostics.go and its test: both comments explained that a count limit and an account filter do not apply any more. They now state what the projection returns. Comments only. Co-Authored-By: Claude Opus 5.5 --- backend/internal/service/codex_error_diagnostics.go | 5 ++--- backend/internal/service/codex_error_diagnostics_test.go | 4 ++-- frontend/src/utils/accountTestModels.ts | 2 -- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/codex_error_diagnostics.go b/backend/internal/service/codex_error_diagnostics.go index 7823853f6045..1844346dbf84 100644 --- a/backend/internal/service/codex_error_diagnostics.go +++ b/backend/internal/service/codex_error_diagnostics.go @@ -21,9 +21,8 @@ type CodexErrorDiagnostic struct { Diagnostic *OpenAIContinuationDiagnostic `json:"diagnostic"` } -// ProjectCodexErrorDiagnostics returns every stored attempt diagnostic of one -// Ops error. The stored upstream_errors document is already bounded by the Ops -// queue limits, so no further count or account-state filter applies. +// ProjectCodexErrorDiagnostics returns every attempt diagnostic stored on one +// Ops error, each with its attempt's context. func ProjectCodexErrorDiagnostics(detail *OpsErrorLogDetail) []CodexErrorDiagnostic { result := make([]CodexErrorDiagnostic, 0) if detail == nil { diff --git a/backend/internal/service/codex_error_diagnostics_test.go b/backend/internal/service/codex_error_diagnostics_test.go index fe4a09747077..6e5aead9a260 100644 --- a/backend/internal/service/codex_error_diagnostics_test.go +++ b/backend/internal/service/codex_error_diagnostics_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" ) -// Every stored attempt diagnostic is projected with its own attempt context, -// including the bounded original values; account state no longer filters it. +// Every stored attempt diagnostic is projected, whatever its account or +// platform, with its own attempt context and the bounded original values. func TestCodexErrorDiagnosticsProjectEveryStoredAttempt(t *testing.T) { detail := &OpsErrorLogDetail{UpstreamErrors: `[ {"account_id":1,"account_name":"primary","platform":"openai","kind":"request_rejected","upstream_status_code":400,"upstream_request_id":"req_1","message":"Missing required parameter","continuation_diagnostic":{"classification":"thinking_signature_invalid","upstream_error":{"error_code":{"kind":"string","value":"thinking_signature_invalid"},"message":{"kind":"string","value":"signature invalid for input[3]"},"hints":["unlisted-hint","call_id"]},"incoming":{"instructions":{"kind":"string","value":"You are Codex"}}}}, diff --git a/frontend/src/utils/accountTestModels.ts b/frontend/src/utils/accountTestModels.ts index 610f75ca38a8..15c828d79b9c 100644 --- a/frontend/src/utils/accountTestModels.ts +++ b/frontend/src/utils/accountTestModels.ts @@ -1,7 +1,5 @@ import type { AccountAvailableModel, AccountTestPlanView } from '@/types' -// Presentation validates a data contract; provider/model selection rules are -// evaluated once by the server and never reconstructed in this bundle. export function isAccountTestReasoningValid(model: AccountAvailableModel | undefined, effort: string): boolean { return effort === '' || model?.reasoning_efforts?.includes(effort) === true } From 22ecdbb8646dd2aca0725b6f398cff1cb44b600d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 19:25:30 +0800 Subject: [PATCH 12/37] refactor(dto): put the account mappers' fields back where upstream has them When the plugin-era fields were deleted from the two account literals, the fields next to them stayed where they were: OpenCodeGoUsage at the end of AccountFromServiceShallow instead of after OllamaCloudUsage, and ManagementFolder and Tags at the head of AccountListItemFromAccount, which made gofmt re-align upstream's first line. OpenCodeGoUsage is back at upstream's position, and ManagementFolder and Tags follow the proxy fields as they do in the Account literal. mappers.go now differs from v0.2.13 only by added lines. Both literals are keyed and hold the same pairs as before, so they build the same values and the JSON is unchanged. dto/types.go is not touched: moving the OpenCodeGoUsage field there would change the key order of the account JSON. Co-Authored-By: Claude Opus 5.5 --- backend/internal/handler/dto/mappers.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 22ae6b34304c..2b8b91428761 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -259,6 +259,7 @@ func AccountFromServiceShallow(a *service.Account) *Account { CredentialsStatus: credsStatus, Extra: extra, OllamaCloudUsage: ollamaCloudUsage, + OpenCodeGoUsage: openCodeGoUsage, ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, @@ -288,7 +289,6 @@ func AccountFromServiceShallow(a *service.Account) *Account { GroupIDs: a.GroupIDs, ParentAccountID: a.ParentAccountID, QuotaDimension: a.QuotaDimension, - OpenCodeGoUsage: openCodeGoUsage, } // 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效) @@ -491,12 +491,11 @@ func AccountListItemFromAccount(a *Account) *AccountListItem { return nil } return &AccountListItem{ - ManagementFolder: a.ManagementFolder, - Tags: a.Tags, - ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, + ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, OllamaCloudUsage: a.OllamaCloudUsage, OpenCodeGoUsage: a.OpenCodeGoUsage, ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + ManagementFolder: a.ManagementFolder, Tags: a.Tags, Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, From 7a167e136edb769b951032d6175092fbb113aa16 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 19:34:12 +0800 Subject: [PATCH 13/37] refactor(account-tests): remove the plan's account guard that cannot trigger accountTestPlan refused a nil account and an account without an id with "account test target is unavailable". Its one caller, GetAvailableModels, reaches it only after GetAccount returned without an error, and the account repository returns an account or an error, never neither. The plain model list in the same handler uses the account without such a check, as upstream does. The check dates from the commit that shared the plan with a provider plugin. No test covers it and no response changes. Co-Authored-By: Claude Opus 5.5 --- backend/internal/handler/admin/account_test_plan.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/internal/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index 5643d9ea1211..f9ed1791edce 100644 --- a/backend/internal/handler/admin/account_test_plan.go +++ b/backend/internal/handler/admin/account_test_plan.go @@ -36,9 +36,6 @@ func accountTestPlanRequested(view string) (bool, error) { } func (h *AccountHandler) accountTestPlan(ctx context.Context, account *service.Account) (*accountTestPlanView, error) { - if account == nil || account.ID <= 0 { - return nil, errors.New("account test target is unavailable") - } raw, err := h.accountTestModels(ctx, account) if err != nil { return nil, err From b699710c38bff7f1e7e4d1ae751e6336b676c2a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 19:39:19 +0800 Subject: [PATCH 14/37] refactor(accounts): drop the request decoder for a removed field; keep upstream's field order UpdateAccountRequest.UnmarshalJSON only rejected "provider_edit": null, a field that no longer exists; upstream has no such method. Account's OpenCodeGoUsage is back where upstream declares it. Co-Authored-By: Claude Opus 5.5 --- .../internal/handler/admin/account_handler.go | 17 ----------------- backend/internal/handler/dto/types.go | 4 ++-- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 6f9cd777de3b..d59b2006d2e7 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -175,23 +175,6 @@ type UpdateAccountRequest struct { ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } -func (r *UpdateAccountRequest) UnmarshalJSON(raw []byte) error { - type plain UpdateAccountRequest - var decoded plain - if err := json.Unmarshal(raw, &decoded); err != nil { - return err - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return err - } - if value, present := fields["provider_edit"]; present && strings.TrimSpace(string(value)) == "null" { - return errors.New("invalid provider edit input") - } - *r = UpdateAccountRequest(decoded) - return nil -} - // BulkUpdateAccountsRequest represents the payload for bulk editing accounts type BulkUpdateAccountsRequest struct { AccountIDs []int64 `json:"account_ids"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index a623473b913a..9e913c511877 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -222,6 +222,7 @@ type Account struct { CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` Extra map[string]any `json:"extra"` OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` QuotaState *service.UpstreamQuotaState `json:"quota_state,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` @@ -240,8 +241,7 @@ type Account struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Schedulable bool `json:"schedulable"` - OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` + Schedulable bool `json:"schedulable"` RateLimitedAt *time.Time `json:"rate_limited_at"` RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` From 9ef3f8208a7ccb0d3d9e40ee8388f23139c6ef47 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 20:23:43 +0800 Subject: [PATCH 15/37] fix(accounts): key the drawer's usage cache and usage cell by account id AccountUsageCell built its cache key from the account-view context, and the details drawer was the only place that still provided one. Outside the drawer the key was a constant prefix on the account id. Inside it the key carried the drawer's open counter, so every open queried usage again, never reused what a card or compact row had just loaded, and left one more entry in the module cache. The cache and the in-flight request map are keyed by the account id again, as they were before cd8c18b54: - Reopening an account within five minutes shows the cached usage. - The drawer, the cards, the compact rows and the mobile table share one entry per account, in both directions. - The page's refresh button and the cell's own query buttons still force a request. The drawer keys its usage cell on the account id. When it goes straight from one account to another, the cell and the probe cells inside it are rebuilt: the new account loads on its own and a late answer for the previous one ends in an unmounted instance. Until now the cell was reused. An account that is not OpenAI OAuth kept the previous account's usage on screen, and if that account's request was still running the loading state never ended, because loadUsage resets it only for the account it started with. That check in loadUsage is unchanged. Every usage cell is now keyed by account id (table rows, cards, compact rows, the drawer), so it has no case left to catch. The view argument of the cell's getUsage calls, the cancellation it threw for a changed view and the watcher on the context key go with the context. The watcher could not fire: nothing reactive changed when the drawer changed account. Two cases in AccountDetailsDrawer.spec.ts cover the key and the shared cache. Both fail on the previous code. Co-Authored-By: Claude Opus 5.5 --- .../components/account/AccountUsageCell.vue | 81 ++++++------------- .../admin/account/AccountDetailsDrawer.vue | 9 +-- .../__tests__/AccountDetailsDrawer.spec.ts | 80 +++++++++++++++++- 3 files changed, 102 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 84c1c4248c2a..ca2f0204ec08 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -800,21 +800,18 @@ diff --git a/frontend/src/views/admin/__tests__/AccountTasksView.spec.ts b/frontend/src/views/admin/__tests__/AccountTasksView.spec.ts deleted file mode 100644 index e2532aadda59..000000000000 --- a/frontend/src/views/admin/__tests__/AccountTasksView.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' - -const api = vi.hoisted(() => ({ - list: vi.fn(), - get: vi.fn(), - listItems: vi.fn(), - cancel: vi.fn(), - retryFailed: vi.fn(), - reviewDuplicates: vi.fn(), - mergeDuplicates: vi.fn(), -})) - -vi.mock('@/api/admin/accountJobs', () => ({ default: api })) -vi.mock('vue-i18n', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useI18n: () => ({ t: (key: string) => key }) } -}) - -import router from '@/router' -import AccountTasksView from '../AccountTasksView.vue' -import { useAccountJobsStore } from '@/stores/accountJobs' - -const runningJob = { - id: 41, - created_by: 1, - kind: 'account_import', - status: 'running' as const, - metadata: {}, - target_count: 10, - processed_count: 4, - succeeded_count: 4, - failed_count: 0, - canceled_count: 0, - attempt: 1, - created_at: '2026-08-21T00:00:00Z', - updated_at: '2026-08-21T00:01:00Z', -} - -describe('AccountTasksView', () => { - beforeEach(() => { - setActivePinia(createPinia()) - vi.clearAllMocks() - api.list.mockResolvedValue({ items: [runningJob], total: 1, page: 1, page_size: 20 }) - api.get.mockResolvedValue(runningJob) - api.listItems.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20 }) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('is exposed as an authenticated administrator route', () => { - const route = router.getRoutes().find((candidate) => candidate.path === '/admin/tasks') - - expect(route?.meta).toMatchObject({ requiresAuth: true, requiresAdmin: true }) - }) - - it('loads the requested page and opens a selected task in the global drawer', async () => { - vi.useFakeTimers() - const wrapper = mount(AccountTasksView, { - global: { - stubs: { - AppLayout: { template: '
' }, - TablePageLayout: { template: '
' }, - DataTable: { - props: ['data'], - template: '
', - }, - Pagination: true, - Icon: true, - }, - }, - }) - await flushPromises() - - expect(api.list).toHaveBeenCalledWith( - { page: 1, page_size: 20, kind: undefined, status: undefined }, - { signal: expect.any(AbortSignal) }, - ) - await vi.advanceTimersByTimeAsync(10_000) - expect(api.list).toHaveBeenCalledTimes(1) - await wrapper.get('button.btn-secondary').trigger('click') - await flushPromises() - expect(api.list).toHaveBeenCalledTimes(2) - expect(wrapper.find('option[value="account_duplicate_merge"]').exists()).toBe(true) - await wrapper.get('[data-test="task-open-41"]').trigger('click') - await flushPromises() - - const store = useAccountJobsStore() - expect(store.drawerOpen).toBe(true) - expect(store.currentJob?.id).toBe(41) - expect(api.get).toHaveBeenCalledTimes(1) - expect(api.listItems).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(3_000) - expect(api.get).toHaveBeenCalledTimes(2) - expect(api.listItems).toHaveBeenCalledTimes(2) - expect(api.list).toHaveBeenCalledTimes(2) - api.get.mockResolvedValue({ ...runningJob, status: 'succeeded', processed_count: 10, succeeded_count: 10 }) - await vi.advanceTimersByTimeAsync(3_000) - expect(store.currentJob?.status).toBe('succeeded') - expect(api.get).toHaveBeenCalledTimes(3) - expect(api.listItems).toHaveBeenCalledTimes(3) - await vi.advanceTimersByTimeAsync(10_000) - expect(api.get).toHaveBeenCalledTimes(3) - expect(api.listItems).toHaveBeenCalledTimes(3) - expect(api.list).toHaveBeenCalledTimes(2) - wrapper.unmount() - }) -}) From 962a8d3ebf6b8aefe2c8a60e2dc0d5f0640b0adb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:31 +0800 Subject: [PATCH 20/37] refactor(accounts): read the OpenAI quota windows the server sends For an OpenAI OAuth account the usage endpoint answers through getOpenAIUsage, which builds quota_windows and fills five_hour and seven_day only from them. A response with those two fields and no quota_windows therefore does not exist. The helper that rebuilt windows from them for such a response is deleted and the usage cell reads quota_windows. Fifteen tests fed the cell that response and went through the helper. Their fixtures now carry quota_windows next to five_hour and seven_day, as the server's response does; no assertion changed. Co-Authored-By: Claude Opus 5.5 --- .../components/account/AccountUsageCell.vue | 3 +- .../__tests__/AccountUsageCell.list.spec.ts | 4 ++ .../__tests__/AccountUsageCell.spec.ts | 42 +++++++++++++++++++ frontend/src/utils/accountQuotaWindows.ts | 24 ----------- 4 files changed, 47 insertions(+), 26 deletions(-) delete mode 100644 frontend/src/utils/accountQuotaWindows.ts diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index ca2f0204ec08..c1682743f6d6 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -818,7 +818,6 @@ import { adminAPI } from '@/api/admin' import type { GrokQuotaProbeResult } from '@/api/admin/grok' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' -import { accountQuotaWindows } from '@/utils/accountQuotaWindows' import { enqueueUsageRequest } from '@/utils/usageLoadQueue' import { formatCompactNumber, formatRelativeTime } from '@/utils/format' import UsageProgressBar from './UsageProgressBar.vue' @@ -999,7 +998,7 @@ const geminiUsageAvailable = computed(() => { ) }) -const openAIQuotaWindows = computed(() => accountQuotaWindows(usageInfo.value)) +const openAIQuotaWindows = computed(() => usageInfo.value?.quota_windows ?? []) const hasOpenAIUsageFallback = computed(() => { if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false return openAIQuotaWindows.value.length > 0 diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.list.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.list.spec.ts index 3e9a1e4973a8..cd4018dfdbb3 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.list.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.list.spec.ts @@ -55,6 +55,10 @@ const makeOAuthUsage = ( ): AccountUsageInfo => ({ source: 'active', updated_at: updatedAt, + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: fiveHour, resets_at: '2026-07-29T17:00:00Z', expired: false, remaining_seconds: 18_000 }, + { id: '7d', window_minutes: 10080, utilization: sevenDay, resets_at: '2026-08-05T12:00:00Z', expired: false, remaining_seconds: 604_800 } + ], five_hour: { utilization: fiveHour, resets_at: '2026-07-29T17:00:00Z', diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index d4670cf4fd06..a34a18604fb4 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -437,6 +437,12 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 15, resets_at: '2026-03-08T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 3, tokens: 300, cost: 0.03, standard_cost: 0.03, user_cost: 0.03 } }, + { id: '7d', window_minutes: 10080, utilization: 77, resets_at: '2026-03-13T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 3, tokens: 300, cost: 0.03, standard_cost: 0.03, user_cost: 0.03 } } + ], five_hour: { utilization: 15, resets_at: '2026-03-08T12:00:00Z', @@ -498,6 +504,12 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 有 codex 快照时仍然使用 /usage API 数据渲染', async () => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 18, resets_at: '2099-03-07T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 9, tokens: 900, cost: 0.09, standard_cost: 0.09, user_cost: 0.09 } }, + { id: '7d', window_minutes: 10080, utilization: 36, resets_at: '2099-03-13T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 9, tokens: 900, cost: 0.09, standard_cost: 0.09, user_cost: 0.09 } } + ], five_hour: { utilization: 18, resets_at: '2099-03-07T12:00:00Z', @@ -610,6 +622,10 @@ describe('AccountUsageCell', () => { { id: 6805, utilization: 40, cost: Number.POSITIVE_INFINITY } ])('OpenAI OAuth 7d 输入无效时不显示预计总费用 (%o)', async ({ id, utilization, cost }) => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '7d', window_minutes: 10080, utilization, resets_at: null, expired: false, remaining_seconds: 0, + window_stats: { requests: 1, tokens: 100, cost }, estimate: { status: 'insufficient_data' } } + ], seven_day: { utilization, resets_at: null, @@ -645,6 +661,12 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 有现成快照时,手动刷新信号会触发 usage 重拉', async () => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 18, resets_at: '2099-03-07T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 9, tokens: 900, cost: 0.09, standard_cost: 0.09, user_cost: 0.09 } }, + { id: '7d', window_minutes: 10080, utilization: 36, resets_at: '2099-03-13T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 9, tokens: 900, cost: 0.09, standard_cost: 0.09, user_cost: 0.09 } } + ], five_hour: { utilization: 18, resets_at: '2099-03-07T12:00:00Z', @@ -715,6 +737,12 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 在无 codex 快照时会回退显示 usage 接口窗口', async () => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 0, resets_at: null, expired: false, remaining_seconds: 0, + window_stats: { requests: 2, tokens: 27700, cost: 0.06, standard_cost: 0.06, user_cost: 0.06 } }, + { id: '7d', window_minutes: 10080, utilization: 0, resets_at: null, expired: false, remaining_seconds: 0, + window_stats: { requests: 2, tokens: 27700, cost: 0.06, standard_cost: 0.06, user_cost: 0.06 } } + ], five_hour: { utilization: 0, resets_at: null, @@ -771,6 +799,10 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 在行数据刷新时复用 5 分钟 usage 缓存', async () => { getUsage .mockResolvedValueOnce({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 0, resets_at: null, expired: false, remaining_seconds: 0, + window_stats: { requests: 1, tokens: 100, cost: 0.01, standard_cost: 0.01, user_cost: 0.01 } } + ], five_hour: { utilization: 0, resets_at: null, @@ -786,6 +818,10 @@ describe('AccountUsageCell', () => { seven_day: null }) .mockResolvedValueOnce({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 0, resets_at: null, expired: false, remaining_seconds: 0, + window_stats: { requests: 2, tokens: 200, cost: 0.02, standard_cost: 0.02, user_cost: 0.02 } } + ], five_hour: { utilization: 0, resets_at: null, @@ -887,6 +923,12 @@ describe('AccountUsageCell', () => { it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => { getUsage.mockResolvedValue({ + quota_windows: [ + { id: '5h', window_minutes: 300, utilization: 100, resets_at: '2026-03-07T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 211, tokens: 106540000, cost: 38.13, standard_cost: 38.13, user_cost: 38.13 } }, + { id: '7d', window_minutes: 10080, utilization: 100, resets_at: '2026-03-13T12:00:00Z', expired: false, remaining_seconds: 3600, + window_stats: { requests: 211, tokens: 106540000, cost: 38.13, standard_cost: 38.13, user_cost: 38.13 } } + ], five_hour: { utilization: 100, resets_at: '2026-03-07T12:00:00Z', diff --git a/frontend/src/utils/accountQuotaWindows.ts b/frontend/src/utils/accountQuotaWindows.ts deleted file mode 100644 index da214388f7a3..000000000000 --- a/frontend/src/utils/accountQuotaWindows.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 -} From 92c9702491dc677c43776fea16e57d6ca3b2045e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:33 +0800 Subject: [PATCH 21/37] build(frontend): restore upstream's module resolution settings tsconfig.json is upstream's again, and vite.config.ts differs only by the SUB2API_ARTIFACT_BUILD switch. The removed paths, dedupe and 'vue' alias served sources outside frontend/, which no longer exist; 'vue' still resolves to dist/vue.runtime.esm-bundler.js for the client build. vitest.config.ts loses fs.allow, dedupe and the 'vue' alias. With all four of its added entries removed, the full unit suite differs in three SubscriptionPlanCard tests only: they assert translated text, and the runtime-only vue-i18n returns the key without __INTLIFY_JIT_COMPILATION__. That entry stays. Co-Authored-By: Claude Opus 5.5 --- frontend/tsconfig.json | 5 +---- frontend/vite.config.ts | 2 -- frontend/vitest.config.ts | 3 --- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cbc2c25d4f36..82ae3f9f10d3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,10 +16,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"], - "vue-draggable-plus": ["./node_modules/vue-draggable-plus"], - "vue": ["./node_modules/vue"], - "vue-i18n": ["./node_modules/vue-i18n"] + "@/*": ["./src/*"] }, "types": ["vite/client"] }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7118e0581c38..556e03e7df63 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -95,10 +95,8 @@ export default defineConfig(({ mode }) => { injectPublicSettings(backendUrl) ], resolve: { - dedupe: ['vue', 'vue-i18n'], alias: { '@': resolve(__dirname, 'src'), - 'vue': resolve(__dirname, 'node_modules/vue/dist/vue.runtime.esm-bundler.js'), // 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 7df084fcbe12..04e15c605492 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -6,12 +6,9 @@ export default defineConfig({ plugins: [vue()], // Match the production build: runtime-only i18n uses CSP-safe JIT. define: { __INTLIFY_JIT_COMPILATION__: true }, - server: { fs: { allow: [resolve(__dirname, '..')] } }, resolve: { - dedupe: ['vue', 'vue-i18n', '@vue/test-utils', 'vitest', 'vue-draggable-plus'], alias: { '@': resolve(__dirname, 'src'), - 'vue': resolve(__dirname, 'node_modules/vue/dist/vue.runtime.esm-bundler.js'), 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js' } }, From c1d2934dc47b86c61dd73fad0cff5f751ebee383 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:35 +0800 Subject: [PATCH 22/37] refactor(router): delete the /admin/tasks and /admin/cindy-accounts entries Both were redirects to the account page kept for old bookmarks; those addresses now reach the 404 page. The account page no longer opens the operation history for ?operations=history, a query only the /admin/tasks redirect produced. Co-Authored-By: Claude Opus 5.5 --- .../layout/__tests__/AppSidebar.spec.ts | 1 - frontend/src/router/index.ts | 24 ------------------- frontend/src/views/admin/AccountsView.vue | 1 - 3 files changed, 26 deletions(-) diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 36f43444974b..d600805ea3fe 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -66,7 +66,6 @@ describe('AppSidebar header styles', () => { describe('AppSidebar extensions section', () => { it('keeps downstream tools in a dedicated section', () => { expect(componentSource).toContain("{{ t('nav.extensions') }}") - expect(componentSource).not.toContain('/admin/cindy-accounts') expect(componentSource).toContain("{ path: '/admin/codex-runtime'") }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 48c19cd265af..e0f8b68dfc33 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -537,30 +537,6 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.plugins.description' } }, - { - path: '/admin/tasks', - name: 'AdminAccountTasks', - redirect: { path: '/admin/accounts', query: { operations: 'history' } }, - meta: { - requiresAuth: true, - requiresAdmin: true, - title: 'Account Tasks', - titleKey: 'admin.accountTasks.title', - descriptionKey: 'admin.accountTasks.description' - } - }, - { - // Former Cindy page: Cindy accounts are ordinary OpenAI API-key accounts filed in the "cindy" folder. - path: '/admin/cindy-accounts', - component: () => import('@/views/admin/AccountsView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, title: 'Account Management', titleKey: 'admin.accounts.title' }, - beforeEnter: async () => { - const { listFolders } = await import('@/api/admin/accounts') - const folders = await listFolders().catch(() => null) - const folder = folders?.find((item) => item.name.trim().toLowerCase() === 'cindy') - return { path: '/admin/accounts', query: folder ? { folder: String(folder.id) } : {} } - } - }, { path: '/admin/codex-runtime', name: 'AdminCodexRuntime', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 03b1ff31cad3..0d3fa71e30e4 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -3141,7 +3141,6 @@ const handleClickOutside = (event: MouseEvent) => { } onMounted(async () => { - if (route.query.operations === 'history') void accountJobsStore.openDrawer() if (typeof document !== 'undefined' && document.fonts) { document.fonts.addEventListener('loadingdone', remeasureAccountNames) void document.fonts.ready.then(remeasureAccountNames) From 9f9bae5a5a29c41bd2616a7aa6589d4f076825c8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:46 +0800 Subject: [PATCH 23/37] refactor(select): restore upstream's handling of a disabled select Select.vue differs from upstream only by the #overlay-root teleport and the console theme's dropdown anchor. The interactionDisabled alias of props.disabled is gone, and so are the four checks that closed an open dropdown and refused its options once the select became disabled; an open dropdown now stays open in that case, as upstream's does. Select.disabled.spec.ts tested only those checks. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/downstream-verify.yml | 1 - frontend/src/components/common/Select.vue | 17 ++--- .../common/__tests__/Select.disabled.spec.ts | 69 ------------------- 3 files changed, 5 insertions(+), 82 deletions(-) delete mode 100644 frontend/src/components/common/__tests__/Select.disabled.spec.ts diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index fbc41dc1e979..ca0f735322c0 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -113,7 +113,6 @@ jobs: src/views/admin/__tests__/SettingsView.spec.ts \ src/components/common/__tests__/VersionBadge.downstream.spec.ts \ src/components/common/__tests__/BaseDialog.spec.ts \ - src/components/common/__tests__/Select.disabled.spec.ts \ src/views/admin/__tests__/PluginsView.spec.ts \ src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts \ src/api/__tests__/downstreamStatus.spec.ts \ diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 09ef7cc8a7e6..415ad743c513 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -4,7 +4,7 @@ ref="triggerRef" type="button" @click="toggle" - :disabled="interactionDisabled" + :disabled="disabled" :aria-expanded="isOpen" :aria-haspopup="true" :id="id" @@ -14,7 +14,7 @@ 'select-trigger', isOpen && 'select-trigger-open', error && 'select-trigger-error', - interactionDisabled && 'select-trigger-disabled' + disabled && 'select-trigger-disabled' ]" @keydown.down.prevent="onTriggerKeyDown" @keydown.up.prevent="onTriggerKeyDown" @@ -25,7 +25,7 @@ (), { }) const emit = defineEmits() -const interactionDisabled = computed(() => props.disabled) const isOpen = ref(false) const searchQuery = ref('') @@ -379,13 +378,10 @@ const calculateDropdownPosition = () => { } const toggle = () => { - if (interactionDisabled.value) return + if (props.disabled) return isOpen.value = !isOpen.value } -// Close teleported options when disabled without changing the selected value. -watch(interactionDisabled, (disabled) => { if (disabled) isOpen.value = false }) - watch(isOpen, (open) => { if (open) { calculateDropdownPosition() @@ -432,7 +428,6 @@ watch(searchQuery, (query) => { }) const selectOption = (option: any) => { - if (interactionDisabled.value || isOptionDisabled(option)) return const value = getOptionValue(option) ?? null emit('update:modelValue', value) emit('change', value, option) @@ -441,21 +436,19 @@ const selectOption = (option: any) => { } const clearSelection = () => { - if (interactionDisabled.value) return + if (props.disabled) return emit('update:modelValue', null) emit('change', null, null) } // Keyboards const onTriggerKeyDown = () => { - if (interactionDisabled.value) return if (!isOpen.value) { isOpen.value = true } } const onDropdownKeyDown = (e: KeyboardEvent) => { - if (interactionDisabled.value) { e.preventDefault(); isOpen.value = false; return } switch (e.key) { case 'ArrowDown': e.preventDefault() diff --git a/frontend/src/components/common/__tests__/Select.disabled.spec.ts b/frontend/src/components/common/__tests__/Select.disabled.spec.ts deleted file mode 100644 index a132d40c8d35..000000000000 --- a/frontend/src/components/common/__tests__/Select.disabled.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { nextTick } from 'vue' -import { mount } from '@vue/test-utils' -import { afterEach, expect, it, vi } from 'vitest' -import { createI18n } from 'vue-i18n' -import Select from '../Select.vue' - -let cleanup: (() => void) | undefined -afterEach(() => { cleanup?.(); cleanup = undefined; vi.useRealTimers() }) - -it('closes a teleported selection when disabled without replacing its value', async () => { - const wrapper = mount(Select, { - attachTo: document.body, - props: { modelValue: 'kept', options: [{ value: 'kept', label: 'Kept selection' }, { value: 'other', label: 'Other' }] }, - global: { - stubs: { Transition: true }, - plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { common: { selectOption: 'Select', searchPlaceholder: 'Search', noOptionsFound: 'No options' } } } })] - } - }) - cleanup = () => wrapper.unmount() - await wrapper.get('button').trigger('click') - await nextTick() - expect(document.body.querySelector('[role="listbox"]')).not.toBeNull() - - await wrapper.setProps({ disabled: true }) - await nextTick() - - expect(document.body.querySelector('[role="listbox"]')).toBeNull() - expect(wrapper.get('button').attributes('disabled')).toBeDefined() - expect(wrapper.emitted('update:modelValue')).toBeUndefined() - expect(wrapper.text()).toContain('Kept selection') - await wrapper.setProps({ disabled: false }) - await wrapper.get('button').trigger('click') - await nextTick() - const selected = document.body.querySelector('[role="option"][aria-selected="true"]') - expect(selected?.textContent).toContain('Kept selection') -}) - -it('rejects queued portal clicks and cancels queued search while disabled, then permits selection after enabling', async () => { - vi.useFakeTimers() - const wrapper = mount(Select, { - attachTo: document.body, - props: { modelValue: 'kept', remote: true, options: [{ value: 'kept', label: 'Kept' }, { value: 'other', label: 'Other' }] }, - global: { - stubs: { Transition: true }, - plugins: [createI18n({ legacy: false, locale: 'en', messages: { en: { common: { searchPlaceholder: 'Search' } } } })] - } - }) - cleanup = () => wrapper.unmount() - await wrapper.get('button').trigger('click') - await nextTick() - const search = document.body.querySelector('input') as HTMLInputElement - search.value = 'queued query' - search.dispatchEvent(new Event('input', { bubbles: true })) - await nextTick() - const other = document.body.querySelectorAll('[role="option"]')[1]! - await wrapper.setProps({ disabled: true }) - other.click() - await nextTick() - await vi.advanceTimersByTimeAsync(350) - expect(wrapper.emitted('update:modelValue')).toBeUndefined() - expect(wrapper.emitted('search')).toBeUndefined() - await wrapper.setProps({ disabled: false }) - await nextTick() - await wrapper.get('button').trigger('click') - await nextTick() - document.body.querySelectorAll('[role="option"]')[1]!.click() - await nextTick() - expect(wrapper.emitted('update:modelValue')).toEqual([['other']]) -}) From c21e0909dae6ea1e97d84d52b64e79ffe3953f52 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:58 +0800 Subject: [PATCH 24/37] fix(ops): describe reasoning recovery in the error details without "host" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sentences of the continuation diagnostics named the gateway "the host" (宿主). They now state what happened: a bounded recovery request was dispatched, replay is not allowed after semantic output, recovery across sources is not allowed. Co-Authored-By: Claude Opus 5.5 --- .../components/admin/codex/CodexContinuationDiagnostics.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue b/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue index bd55a29ade61..725d9afba6bf 100644 --- a/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue +++ b/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue @@ -175,11 +175,11 @@ function explain(diagnostic: Diagnostic): string[] { if (changed(incoming.instructions, wire.instructions)) lines.push(text('入口与实际出站的指令不同,对照下表的原文。', 'Instructions differ between entry and dispatch; compare the values below.')) if (changed(incoming.session, wire.session) || changed(incoming.previous_response, wire.previous_response)) lines.push(text('入口与实际出站的会话或前序响应引用发生了变化。', 'Session or previous-response references changed between entry and dispatch.')) if (incoming.inspection_limited || wire.inspection_limited || history.scan_limited || error.inspection_limited) lines.push(text('本次结构检查受大小或可读性限制;未检查部分不能当作不存在。', 'Structural inspection was limited by size or readability; uninspected fields are not evidence of absence.')) - if (recovery.retry_attempted) lines.push(text('宿主已执行一次受限恢复请求;恢复是否成功须查看后续结果。', 'The host dispatched one bounded recovery request; check the subsequent result for its outcome.')) + if (recovery.retry_attempted) lines.push(text('已执行一次受限恢复请求;恢复是否成功须查看后续结果。', 'One bounded recovery request was dispatched; check the subsequent result for its outcome.')) const reasons: Record = { disabled: ['推理恢复开关已关闭,或该账号类型、请求路径不在恢复适用范围。', 'Reasoning recovery is switched off, or this account type or request path is outside what it covers.'], - semantic_output_committed: ['已向客户端发送业务输出,宿主禁止重放请求。', 'Semantic output was already committed; the host prohibits replay.'], - source_changed: ['请求来源已变化,宿主禁止跨来源恢复。', 'The source changed; the host prohibits recovery across sources.'], + semantic_output_committed: ['已向客户端发送业务输出,不允许重放请求。', 'Semantic output was already committed; replay is not allowed.'], + source_changed: ['请求来源已变化,不允许跨来源恢复。', 'The source changed; recovery across sources is not allowed.'], request_cancelled: ['请求已取消,未继续恢复。', 'The request was canceled before further recovery.'] } const reason = reasons[recovery.not_attempted_reason || ''] From 6bbace01871e82602bf6c6cda8a7c5ef13738662 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 21:22:59 +0800 Subject: [PATCH 25/37] test(frontend): name three specs after what they cover - CodexRuntime.native.spec.ts becomes CodexRuntime.spec.ts; its suite and its diagnostics test no longer say "native". - SettingsView.spec.ts: the mocks and the test of the observability settings and the model catalog no longer say "native". - features/account-jobs/__tests__/integrationSurface.spec.ts moves next to the layout it mounts, as AppLayout.accountTaskDrawer.spec.ts, and stops asserting that a removed header button is absent. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/downstream-verify.yml | 2 +- ...odexRuntime.native.spec.ts => CodexRuntime.spec.ts} | 4 ++-- .../__tests__/AppLayout.accountTaskDrawer.spec.ts} | 8 +++----- .../src/views/admin/__tests__/SettingsView.spec.ts | 10 +++++----- 4 files changed, 11 insertions(+), 13 deletions(-) rename frontend/src/components/admin/codex/__tests__/{CodexRuntime.native.spec.ts => CodexRuntime.spec.ts} (95%) rename frontend/src/{features/account-jobs/__tests__/integrationSurface.spec.ts => components/layout/__tests__/AppLayout.accountTaskDrawer.spec.ts} (89%) diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index ca0f735322c0..09ab20dcb0b3 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -114,7 +114,7 @@ jobs: src/components/common/__tests__/VersionBadge.downstream.spec.ts \ src/components/common/__tests__/BaseDialog.spec.ts \ src/views/admin/__tests__/PluginsView.spec.ts \ - src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts \ + src/components/admin/codex/__tests__/CodexRuntime.spec.ts \ src/api/__tests__/downstreamStatus.spec.ts \ src/i18n/__tests__/accountConsoleLocales.spec.ts \ src/__tests__/integration/data-import.spec.ts \ diff --git a/frontend/src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts b/frontend/src/components/admin/codex/__tests__/CodexRuntime.spec.ts similarity index 95% rename from frontend/src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts rename to frontend/src/components/admin/codex/__tests__/CodexRuntime.spec.ts index 39005b4f8803..393ee181f256 100644 --- a/frontend/src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts +++ b/frontend/src/components/admin/codex/__tests__/CodexRuntime.spec.ts @@ -30,7 +30,7 @@ beforeEach(() => { }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) -describe('native Codex controls', () => { +describe('Codex runtime settings and continuation diagnostics', () => { it('reads request compression and saves request_zstd through step-up', async () => { get.mockResolvedValue({ data: { request_zstd: false } }) const wrapper = mount(CodexRuntimeSettings, { global }); wrappers.push(wrapper) @@ -63,7 +63,7 @@ describe('native Codex controls', () => { expect(wrapper.text()).not.toContain('Settings saved') }) - it('uses native diagnostics and ignores the result for an older error ID', async () => { + it('reads the diagnostics of the current error and ignores the result for an older error ID', async () => { const earlier = deferred<{ data: unknown }>() get.mockReturnValueOnce(earlier.promise).mockResolvedValueOnce({ data: [{ account_id: 11, attempt: 1, diagnostic: { recovery: { not_attempted_reason: 'source_changed' } } }] }) const wrapper = mount(CodexContinuationDiagnostics, { props: { errorId: 41 } }); wrappers.push(wrapper) diff --git a/frontend/src/features/account-jobs/__tests__/integrationSurface.spec.ts b/frontend/src/components/layout/__tests__/AppLayout.accountTaskDrawer.spec.ts similarity index 89% rename from frontend/src/features/account-jobs/__tests__/integrationSurface.spec.ts rename to frontend/src/components/layout/__tests__/AppLayout.accountTaskDrawer.spec.ts index aa9a81e0de4e..96f66d6d1bf7 100644 --- a/frontend/src/features/account-jobs/__tests__/integrationSurface.spec.ts +++ b/frontend/src/components/layout/__tests__/AppLayout.accountTaskDrawer.spec.ts @@ -29,8 +29,8 @@ import { i18n } from '@/i18n' import { useAuthStore } from '@/stores/auth' import { useAccountJobsStore } from '@/stores/accountJobs' -describe('account job global surface', () => { - it('mounts one global drawer for account-page operation history without a header task entry', async () => { +describe('AppLayout account task drawer', () => { + it('mounts one global drawer for account-page operation history, for administrators only', async () => { const pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() @@ -59,12 +59,11 @@ describe('account job global surface', () => { }) expect(wrapper.findAllComponents(AccountTaskDrawer)).toHaveLength(1) - expect(wrapper.find('[data-test="account-task-button"]').exists()).toBe(false) expect(wrapper.find('[role="dialog"]').exists()).toBe(false) expect(listJobs).not.toHaveBeenCalled() // AccountsView.lite.spec mounts and clicks the real operation-history button. - // Its shared store action must open this single host-owned history drawer. + // Its shared store action must open this single history drawer. const jobs = useAccountJobsStore(pinia) await jobs.openDrawer() await flushPromises() @@ -78,7 +77,6 @@ describe('account job global surface', () => { ) auth.user = { ...auth.user!, role: 'user' } await wrapper.vm.$nextTick() - expect(wrapper.find('[data-test="account-task-button"]').exists()).toBe(false) expect(wrapper.findAllComponents(AccountTaskDrawer)).toHaveLength(0) expect(wrapper.find('[role="dialog"]').exists()).toBe(false) expect(listJobs).toHaveBeenCalledTimes(1) diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index f8195b83e261..4f603cb10076 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -81,7 +81,7 @@ const { const localeRef = vi.hoisted(() => ({ value: "zh-CN" })); -const nativeSettings = vi.hoisted(() => ({ +const panelSettings = vi.hoisted(() => ({ observability: { telemetry_enabled: true, theme_enabled: true }, })); @@ -91,12 +91,12 @@ vi.mock("@/stores/auth", () => ({ vi.mock("@/api/admin/settings", async () => { const actual = await vi.importActual("@/api/admin/settings"); - const native = { - getObservabilitySettings: vi.fn(async () => ({ ...nativeSettings.observability })), + const panelAPI = { + getObservabilitySettings: vi.fn(async () => ({ ...panelSettings.observability })), updateObservabilitySettings: vi.fn(async (value) => value), getOfficialModelCapacityCatalog: vi.fn(async () => ({ entries: [] })), }; - return { ...actual, ...native, settingsAPI: { ...actual.settingsAPI, ...native }, default: { ...actual.default, ...native } }; + return { ...actual, ...panelAPI, settingsAPI: { ...actual.settingsAPI, ...panelAPI }, default: { ...actual.default, ...panelAPI } }; }); vi.mock("@/api", () => ({ @@ -759,7 +759,7 @@ describe("admin SettingsView payment visible method controls", () => { adminSettingsFetch.mockResolvedValue(undefined); }); - it("mounts native settings without resubmitting their configuration through the general settings form", async () => { + it("mounts the observability settings and the official model catalog without resubmitting their configuration through the general settings form", async () => { const wrapper = mountView(); await flushPromises(); await openGatewayTab(wrapper); From 6d108e009f0dedb30720af726e9440b52fc2f2a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 22:01:01 +0800 Subject: [PATCH 26/37] refactor(prompt-audit): restore upstream's page, view model and spec PromptAuditView.vue is upstream v0.2.13 plus the console theme's page hook (data-ui="prompt-audit-page" on the root); viewModel.ts and PromptAuditView.spec.ts are upstream's bytes. EventWorkspace.vue keeps its one theme hook (data-ui="log-table"). What the page did beyond upstream is gone with it: - the save bar sticking to the end of the content column: it is fixed to the window's bottom again, from the sidebar to the right edge; - reconcileSavedDraft: a save's response replaces the draft, so edits made while the save was in flight are dropped; - the request generations of the event list and the event detail: the response that arrives last is shown, also when it is the older one; - the generation that tied a filter-deletion preview to its criteria and to the open dialog: a late preview is shown after the criteria changed, and a one-click deletion goes ahead after the dialog was closed; - the unmount guard: a request that fails after leaving the page still raises its error toast. Co-Authored-By: Claude Opus 5.5 --- .../features/prompt-audit/PromptAuditView.vue | 91 ++--- .../__tests__/PromptAuditView.spec.ts | 334 +----------------- .../src/features/prompt-audit/viewModel.ts | 27 -- 3 files changed, 26 insertions(+), 426 deletions(-) diff --git a/frontend/src/features/prompt-audit/PromptAuditView.vue b/frontend/src/features/prompt-audit/PromptAuditView.vue index a49843f85c98..15eaa0fae3ff 100644 --- a/frontend/src/features/prompt-audit/PromptAuditView.vue +++ b/frontend/src/features/prompt-audit/PromptAuditView.vue @@ -90,7 +90,7 @@
-
+
@@ -144,7 +144,7 @@ diff --git a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts index 8897e081542b..c68fa06f4f61 100644 --- a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import type { PromptAuditConfig, PromptAuditDraft, PromptAuditEvent, PromptAuditRuntime, PromptDeletePreview } from '../types' -import { emptyEventFilters, SCANNER_CATALOG } from '../viewModel' +import type { PromptAuditConfig, PromptAuditRuntime } from '../types' +import { SCANNER_CATALOG } from '../viewModel' import PromptAuditView from '../PromptAuditView.vue' const mocks = vi.hoisted(() => ({ @@ -58,35 +58,6 @@ function mountView() { }) } -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((done, fail) => { resolve = done; reject = fail }) - return { promise, resolve, reject } -} - -const deletionCriteria = (keyword: string) => ({ - ...emptyEventFilters(), keyword, start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00', - api_key_id: '23', request_id: 'request-fixture', prompt_hash: 'f'.repeat(64), -}) -const deletionPreview = (token: string): PromptDeletePreview => ({ - matched_count: 2, filter_summary: {}, snapshot_max_id: token === 'old' ? 10 : 20, - filter_hash: (token === 'old' ? 'a' : 'b').repeat(64), confirmation_token: token, - expires_at: '2026-07-16T00:05:00Z', -}) - -const eventPage = (page: number, id: number) => ({ items: [{ id } as PromptAuditEvent], total: 3, page, page_size: 20, pages: 3 }) - -function draftFrom(wrapper: ReturnType): PromptAuditDraft { - return JSON.parse(JSON.stringify(wrapper.getComponent(PolicyStub).props('draft'))) -} -async function editDraft(wrapper: ReturnType, change: (draft: PromptAuditDraft) => void) { - const draft = draftFrom(wrapper) - change(draft) - wrapper.getComponent(PolicyStub).vm.$emit('update:draft', draft) - await flushPromises() -} - describe('PromptAuditView', () => { beforeEach(() => { Object.values(mocks).forEach((mock) => mock.mockReset()) @@ -202,18 +173,15 @@ describe('PromptAuditView', () => { expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('none') }) - it('uses native labeled switches and a responsive sticky save surface', async () => { + it('uses native labeled switches and a responsive fixed save surface', async () => { const wrapper = mountView() await flushPromises() - expect(wrapper.find('div.sticky.bottom-0').exists()).toBe(false) await wrapper.get('[data-test="tab-config"]').trigger('click') const switches = wrapper.findAll('[role="switch"]') expect(switches).toHaveLength(4) expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true) - const saveSurface = wrapper.get('div.sticky.bottom-0') - expect(saveSurface.classes()).not.toContain('fixed') - expect(wrapper.get('[data-test="save-config"]').element.closest('.sticky')).toBe(saveSurface.element) - expect(saveSurface.find('div.flex-wrap').exists()).toBe(true) + expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0') + expect(wrapper.html()).toContain('flex-wrap') }) it('executes single, selected-batch, and preview-confirmed filter deletion flows', async () => { @@ -270,296 +238,4 @@ describe('PromptAuditView', () => { })) expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(false) }) - - describe('filter deletion generation', () => { - it('discards a late preview after criteria changes and confirms only the new full filter', async () => { - const old = deferred() - mocks.previewDelete.mockReturnValueOnce(old.promise).mockResolvedValueOnce(deletionPreview('new')) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - const dialog = wrapper.getComponent(FilterDeleteStub) - dialog.vm.$emit('preview', deletionCriteria('old')); await flushPromises() - dialog.vm.$emit('criteria-change'); await flushPromises() - old.resolve(deletionPreview('old')); await flushPromises() - - expect(dialog.props('preview')).toBeNull() - dialog.vm.$emit('confirm', deletionCriteria('new')); await flushPromises() - expect(mocks.previewDelete).toHaveBeenCalledTimes(2) - expect(mocks.previewDelete).toHaveBeenLastCalledWith(deletionCriteria('new')) - expect(mocks.deleteEventsByFilter).toHaveBeenCalledTimes(1) - expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(deletionCriteria('new'), deletionPreview('new')) - wrapper.unmount() - }) - - it.each(['success', 'error'])('ignores late preview %s and finalization after closing and starting a new preview', async (outcome) => { - const old = deferred(), current = deferred() - mocks.previewDelete.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - const dialog = wrapper.getComponent(FilterDeleteStub) - dialog.vm.$emit('preview', deletionCriteria('old')); await flushPromises() - dialog.vm.$emit('close'); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - dialog.vm.$emit('preview', deletionCriteria('new')); await flushPromises() - if (outcome === 'success') old.resolve(deletionPreview('old')) - else old.reject(new Error('superseded preview')) - await flushPromises() - - expect(dialog.props('preview')).toBeNull() - expect(dialog.props('previewing')).toBe(true) - expect(mocks.showError).not.toHaveBeenCalled() - current.resolve(deletionPreview('new')); await flushPromises() - dialog.vm.$emit('confirm', deletionCriteria('new')); await flushPromises() - expect(mocks.previewDelete).toHaveBeenCalledTimes(2) - expect(mocks.deleteEventsByFilter).toHaveBeenCalledTimes(1) - expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(deletionCriteria('new'), deletionPreview('new')) - wrapper.unmount() - }) - - it.each(['criteria-change', 'close'])('does not submit a one-click deletion after %s invalidates its pending preview', async (invalidate) => { - const preview = deferred() - mocks.previewDelete.mockReturnValueOnce(preview.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - const dialog = wrapper.getComponent(FilterDeleteStub) - dialog.vm.$emit('confirm', deletionCriteria('old')); await flushPromises() - dialog.vm.$emit(invalidate); await flushPromises() - preview.resolve(deletionPreview('old')); await flushPromises() - - expect(mocks.deleteEventsByFilter).toHaveBeenCalledTimes(0) - expect(mocks.showSuccess).not.toHaveBeenCalled() - expect(dialog.props('deleting')).toBe(false) - wrapper.unmount() - }) - - it('reports an already submitted deletion without clearing a newer confirmation preview', async () => { - const deletion = deferred<{ deleted_events: number; deleted_jobs: number }>() - mocks.previewDelete.mockResolvedValueOnce(deletionPreview('old')).mockResolvedValueOnce(deletionPreview('new')) - mocks.deleteEventsByFilter.mockReturnValueOnce(deletion.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - const dialog = wrapper.getComponent(FilterDeleteStub) - dialog.vm.$emit('preview', deletionCriteria('old')); await flushPromises() - dialog.vm.$emit('confirm', deletionCriteria('old')); await flushPromises() - expect(mocks.deleteEventsByFilter).toHaveBeenCalledTimes(1) - dialog.vm.$emit('close'); await flushPromises() - await wrapper.get('[data-test="preview"]').trigger('click') - dialog.vm.$emit('preview', deletionCriteria('new')); await flushPromises() - deletion.resolve({ deleted_events: 2, deleted_jobs: 2 }); await flushPromises() - - expect(mocks.showSuccess).toHaveBeenCalledWith('admin.promptAudit.messages.deleted') - expect(dialog.props('show')).toBe(true) - expect(dialog.props('preview')).toEqual(deletionPreview('new')) - expect(mocks.deleteEventsByFilter).toHaveBeenCalledTimes(1) - wrapper.unmount() - }) - }) - - describe('save response reconciliation', () => { - it('preserves later fields and new tokens while retiring only sent credential actions', async () => { - const config = baseConfig() - config.endpoints = ['guard-1', 'guard-2', 'guard-3'].map(id => ({ ...config.endpoints[0], id, name: id })) - mocks.getConfig.mockResolvedValue(config) - const save = deferred() - mocks.updateConfig.mockReturnValueOnce(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="tab-config"]').trigger('click') - await editDraft(wrapper, draft => { - draft.worker_count = 6 - draft.endpoints[0].token = 'sent-token-one' - draft.endpoints[1].clear_token = true - draft.endpoints[2].token = 'sent-token-three' - }) - await wrapper.get('[data-test="save-config"]').trigger('click') - await editDraft(wrapper, draft => { - draft.worker_count = 9 - draft.endpoints[0].token = 'later-token-one' - draft.endpoints[1].token = 'later-token-two' - draft.endpoints[1].clear_token = false - draft.endpoints.reverse() - }) - expect(mocks.updateConfig.mock.calls[0][0]).toMatchObject({ - expected_config_version: 7, worker_count: 6, - endpoints: [expect.objectContaining({ token: 'sent-token-one' }), expect.objectContaining({ clear_token: true }), expect.objectContaining({ token: 'sent-token-three' })], - }) - const saved = { ...config, worker_count: 6, config_version: 8, - endpoints: config.endpoints.map(endpoint => endpoint.id === 'guard-2' ? { ...endpoint, has_token: false, token_status: 'missing' } : endpoint) } - save.resolve(saved); await flushPromises() - - const current = draftFrom(wrapper) - expect(current).toMatchObject({ worker_count: 9, config_version: 8 }) - expect(current.endpoints.map(endpoint => endpoint.id)).toEqual(['guard-3', 'guard-2', 'guard-1']) - expect(current.endpoints.map(endpoint => [endpoint.token, endpoint.clear_token])).toEqual([ - ['', false], ['later-token-two', false], ['later-token-one', false], - ]) - expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty') - expect(mocks.updateConfig).toHaveBeenCalledTimes(1) - mocks.updateConfig.mockResolvedValueOnce({ ...config, config_version: 9, worker_count: 9, endpoints: [...config.endpoints].reverse() }) - await wrapper.get('[data-test="save-config"]').trigger('click'); await flushPromises() - expect(mocks.updateConfig).toHaveBeenCalledTimes(2) - expect(mocks.updateConfig.mock.calls[1][0]).toMatchObject({ - expected_config_version: 8, worker_count: 9, - endpoints: [expect.objectContaining({ id: 'guard-3', token: undefined, clear_token: false }), expect.objectContaining({ token: 'later-token-two' }), expect.objectContaining({ token: 'later-token-one' })], - }) - expect(draftFrom(wrapper).endpoints.every(endpoint => endpoint.token === '' && !endpoint.clear_token)).toBe(true) - expect(wrapper.text()).toContain('admin.promptAudit.saveBar.synced') - wrapper.unmount() - }) - - it('preserves a later explicit clear without repeating it after its own save succeeds', async () => { - const save = deferred() - mocks.updateConfig.mockReturnValueOnce(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="tab-config"]').trigger('click') - await editDraft(wrapper, draft => { draft.endpoints[0].token = 'sent-token' }) - await wrapper.get('[data-test="save-config"]').trigger('click') - await editDraft(wrapper, draft => { draft.endpoints[0].token = ''; draft.endpoints[0].clear_token = true }) - save.resolve({ ...baseConfig(), config_version: 8 }); await flushPromises() - - expect(draftFrom(wrapper).endpoints[0]).toMatchObject({ token: '', clear_token: true }) - mocks.updateConfig.mockResolvedValueOnce({ ...baseConfig(), config_version: 9, endpoints: [{ ...baseConfig().endpoints[0], has_token: false, token_status: 'missing' }] }) - await wrapper.get('[data-test="save-config"]').trigger('click'); await flushPromises() - expect(mocks.updateConfig.mock.calls[1][0]).toMatchObject({ expected_config_version: 8, endpoints: [expect.objectContaining({ token: undefined, clear_token: true })] }) - expect(draftFrom(wrapper).endpoints[0]).toMatchObject({ token: '', clear_token: false, has_token: false }) - await editDraft(wrapper, draft => { draft.worker_count = 5 }) - await wrapper.get('[data-test="save-config"]').trigger('click'); await flushPromises() - expect(mocks.updateConfig.mock.calls[2][0]).toMatchObject({ expected_config_version: 9, endpoints: [expect.objectContaining({ token: undefined, clear_token: false })] }) - wrapper.unmount() - }) - - it('does not duplicate an in-flight save and accepts normalization when no later edit exists', async () => { - const save = deferred() - mocks.updateConfig.mockReturnValue(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="tab-config"]').trigger('click') - await editDraft(wrapper, draft => { draft.endpoints[0].name = ' Normalized '; draft.endpoints[0].token = 'sent-token' }) - const button = wrapper.get('[data-test="save-config"]').element - button.dispatchEvent(new MouseEvent('click', { bubbles: true })) - button.dispatchEvent(new MouseEvent('click', { bubbles: true })) - await flushPromises() - expect(mocks.updateConfig).toHaveBeenCalledTimes(1) - expect(mocks.updateConfig.mock.calls[0][0]).toMatchObject({ expected_config_version: 7, endpoints: [expect.objectContaining({ name: 'Normalized' })] }) - save.resolve({ ...baseConfig(), config_version: 8, endpoints: [{ ...baseConfig().endpoints[0], name: 'Normalized' }] }); await flushPromises() - expect(draftFrom(wrapper).endpoints[0]).toMatchObject({ name: 'Normalized', token: '', clear_token: false }) - expect(wrapper.text()).toContain('admin.promptAudit.saveBar.synced') - wrapper.unmount() - }) - - it('keeps failed-save drafts and their original CAS version for an explicit retry', async () => { - mocks.updateConfig.mockRejectedValueOnce({ code: 'prompt_audit_config_conflict', message: 'fixture conflict' }) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="tab-config"]').trigger('click') - await editDraft(wrapper, draft => { draft.queue_capacity = 123; draft.endpoints[0].token = 'retry-token' }) - await wrapper.get('[data-test="save-config"]').trigger('click'); await flushPromises() - expect(draftFrom(wrapper)).toMatchObject({ config_version: 7, queue_capacity: 123, endpoints: [expect.objectContaining({ token: 'retry-token' })] }) - expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty') - expect(mocks.updateConfig).toHaveBeenCalledTimes(1) - expect(mocks.getConfig).toHaveBeenCalledTimes(1) - expect(mocks.showSuccess).not.toHaveBeenCalled() - expect(mocks.showError).toHaveBeenCalledTimes(1) - wrapper.unmount() - }) - - it('reports a submitted save after unmount without starting another runtime request', async () => { - const save = deferred() - mocks.updateConfig.mockReturnValueOnce(save.promise) - const wrapper = mountView(); await flushPromises() - await wrapper.get('[data-test="tab-config"]').trigger('click') - await editDraft(wrapper, draft => { draft.worker_count = 5 }) - await wrapper.get('[data-test="save-config"]').trigger('click') - wrapper.unmount() - save.resolve({ ...baseConfig(), config_version: 8, worker_count: 5 }); await flushPromises() - expect(mocks.updateConfig).toHaveBeenCalledTimes(1) - expect(mocks.showSuccess).toHaveBeenCalledWith('admin.promptAudit.messages.saved') - expect(mocks.getRuntime).toHaveBeenCalledTimes(1) - }) - }) - - describe('read request generations', () => { - it('keeps the newer event page and selection when an older list response arrives last', async () => { - const wrapper = mountView(); await flushPromises() - const old = deferred>(), current = deferred>() - mocks.listEvents.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const workspace = wrapper.getComponent(EventsStub) - workspace.vm.$emit('page', 2); await flushPromises() - workspace.vm.$emit('page', 3); await flushPromises() - current.resolve(eventPage(3, 30)); await flushPromises() - workspace.vm.$emit('selection', [30]); await flushPromises() - old.resolve(eventPage(2, 20)); await flushPromises() - expect(workspace.props()).toMatchObject({ page: 3, events: [{ id: 30 }], selectedIds: [30], loading: false, error: '' }) - wrapper.unmount() - }) - - it('ignores an older list error and finally while the newer request is still loading', async () => { - const wrapper = mountView(); await flushPromises() - const old = deferred>(), current = deferred>() - mocks.listEvents.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const workspace = wrapper.getComponent(EventsStub) - workspace.vm.$emit('search', { ...emptyEventFilters(), keyword: 'old' }); await flushPromises() - workspace.vm.$emit('search', { ...emptyEventFilters(), keyword: 'current' }); await flushPromises() - old.reject(new Error('old list error')); await flushPromises() - expect(workspace.props()).toMatchObject({ loading: true, error: '' }) - current.reject(new Error('current list error')); await flushPromises() - expect(workspace.props()).toMatchObject({ loading: false, error: 'current list error' }) - expect(mocks.showError).not.toHaveBeenCalled() - wrapper.unmount() - }) - - it('keeps the newer detail when an older detail succeeds last', async () => { - const wrapper = mountView(); await flushPromises() - const old = deferred(), current = deferred() - mocks.getEvent.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const workspace = wrapper.getComponent(EventsStub), detail = wrapper.getComponent(DetailStub) - workspace.vm.$emit('view', 10); await flushPromises() - workspace.vm.$emit('view', 20); await flushPromises() - current.resolve({ id: 20 } as PromptAuditEvent); await flushPromises() - old.resolve({ id: 10 } as PromptAuditEvent); await flushPromises() - expect(detail.props()).toMatchObject({ show: true, event: { id: 20 }, loading: false }) - wrapper.unmount() - }) - - it('ignores an older detail error and finally while the newer detail is loading', async () => { - const wrapper = mountView(); await flushPromises() - const old = deferred(), current = deferred() - mocks.getEvent.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) - const workspace = wrapper.getComponent(EventsStub), detail = wrapper.getComponent(DetailStub) - workspace.vm.$emit('view', 10); await flushPromises() - workspace.vm.$emit('view', 20); await flushPromises() - old.reject(new Error('old detail error')); await flushPromises() - expect(detail.props()).toMatchObject({ show: true, event: null, loading: true }) - expect(mocks.showError).not.toHaveBeenCalled() - current.resolve({ id: 20 } as PromptAuditEvent); await flushPromises() - expect(detail.props()).toMatchObject({ show: true, event: { id: 20 }, loading: false }) - wrapper.unmount() - }) - - it.each(['success', 'error'])('discards a closed detail request %s without restoring its state or warning', async (outcome) => { - const wrapper = mountView(); await flushPromises() - const request = deferred() - mocks.getEvent.mockReturnValueOnce(request.promise) - wrapper.getComponent(EventsStub).vm.$emit('view', 10); await flushPromises() - const detail = wrapper.getComponent(DetailStub) - detail.vm.$emit('close'); await flushPromises() - if (outcome === 'success') request.resolve({ id: 10 } as PromptAuditEvent) - else request.reject(new Error('closed detail error')) - await flushPromises() - expect(detail.props()).toMatchObject({ show: false, event: null, loading: false }) - expect(mocks.showError).not.toHaveBeenCalled() - wrapper.unmount() - }) - - it('drops pending read callbacks on unmount without triggering further calls or errors', async () => { - const wrapper = mountView(); await flushPromises() - const list = deferred>(), detail = deferred() - mocks.listEvents.mockReturnValueOnce(list.promise) - mocks.getEvent.mockReturnValueOnce(detail.promise) - const workspace = wrapper.getComponent(EventsStub) - workspace.vm.$emit('page', 2); workspace.vm.$emit('view', 10); await flushPromises() - wrapper.unmount() - list.reject(new Error('closed list')); detail.reject(new Error('closed detail')); await flushPromises() - expect(mocks.showError).not.toHaveBeenCalled() - expect(mocks.listEvents).toHaveBeenCalledTimes(2) - expect(mocks.getEvent).toHaveBeenCalledTimes(1) - }) - }) }) diff --git a/frontend/src/features/prompt-audit/viewModel.ts b/frontend/src/features/prompt-audit/viewModel.ts index aecc1173f26a..2762a94b5766 100644 --- a/frontend/src/features/prompt-audit/viewModel.ts +++ b/frontend/src/features/prompt-audit/viewModel.ts @@ -40,33 +40,6 @@ export function configToDraft(config: PromptAuditConfig): PromptAuditDraft { } } -export function reconcileSavedDraft( - submitted: PromptAuditDraft, - current: PromptAuditDraft, - confirmed: PromptAuditDraft, -): PromptAuditDraft { - if (JSON.stringify(current) === JSON.stringify(submitted)) return cloneData(confirmed) - const next = cloneData(current) - next.config_version = confirmed.config_version - next.effective_mode = confirmed.effective_mode - next.updated_at = confirmed.updated_at - next.updated_by = confirmed.updated_by - next.change_summary = confirmed.change_summary - const sentByID = new Map(submitted.endpoints.map(endpoint => [endpoint.id.trim(), endpoint])) - const confirmedByID = new Map(confirmed.endpoints.map(endpoint => [endpoint.id, endpoint])) - for (const endpoint of next.endpoints) { - const sent = sentByID.get(endpoint.id.trim()) - const saved = confirmedByID.get(endpoint.id.trim()) - if (!sent || !saved) continue - endpoint.has_token = saved.has_token - endpoint.token_status = saved.token_status - // 只消费本次已确认且未被后写改动的凭据动作,不清掉新输入。 - if (!sent.clear_token && endpoint.token === sent.token) endpoint.token = '' - if (endpoint.clear_token === sent.clear_token) endpoint.clear_token = false - } - return next -} - export function createDefaultEndpoint(index = 1): PromptAuditEndpointDraft { return { id: `guard-${Date.now()}-${index}`, From 932d9c2fe567234f999eeb04832e742b9ffa1fc0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 5 Oct 2026 22:01:35 +0800 Subject: [PATCH 27/37] fix(theme): style the prompt audit save bar that upstream draws The console theme's rule for the save bar selected the sticky bar the fork's page had (div.sticky.bottom-0 after the page root). Upstream's bar is fixed to the window's bottom, so the rule matched nothing and the bar showed upstream's translucent fill, blur and drop shadow under the theme. The rule now selects upstream's bar and keeps what it drew before: the surface fill on the structural line, no shadow; the blur behind the now opaque fill is switched off as for the header row. Upstream starts the bar at its own 16rem sidebar (lg:left-64). The theme's sidebar is clamp(13rem, 20vw, 15rem) wide, so from 1024px the bar starts at the shell's sidebar width instead; without this it began 16px to 48px right of the sidebar's edge. Co-Authored-By: Claude Opus 5.5 --- frontend/src/styles/console/pages/ops-channels.css | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/console/pages/ops-channels.css b/frontend/src/styles/console/pages/ops-channels.css index a0eab0901655..50caa44eeeff 100644 --- a/frontend/src/styles/console/pages/ops-channels.css +++ b/frontend/src/styles/console/pages/ops-channels.css @@ -4175,9 +4175,19 @@ html.flat-theme [data-ui~="prompt-audit-page"] div[class~="overflow-x-auto"][cla border-radius: var(--ui-radius-lg); } -/* the save bar (config tab): a surface band on the structural line, the reference's quiet footer (no drop shadow) */ -html.flat-theme [data-ui~="prompt-audit-page"] + div[class~="sticky"][class~="bottom-0"] { +/* the save bar (config tab; upstream fixes it to the window's bottom): a surface band on the structural line, the + reference's quiet footer (no drop shadow, no blur behind the opaque fill) */ +html.flat-theme [data-ui~="prompt-audit-page"] + div[class~="fixed"][class~="bottom-0"] { border-color: rgb(var(--ui-line)); background-color: rgb(var(--ui-surface)); box-shadow: none; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* from 1024px upstream starts the bar at its own 16rem sidebar (lg:left-64): here at the shell's (shell.css) */ +@media (min-width: 1024px) { + html.flat-theme [data-ui~="prompt-audit-page"] + div[class~="fixed"][class~="bottom-0"] { + left: var(--ui-shell-sidebar-w); + } } From 4c49982059adf25b7f151ebe4e6303af67a0054f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 6 Oct 2026 20:31:45 +0800 Subject: [PATCH 28/37] refactor(gateway): delete account traffic telemetry The per-account traffic counters had writers only. Every OpenAI gateway entry point counted its forwarded attempts in Redis, and the one reader, GET /admin/accounts/:id/traffic-telemetry, was never called by any page. Removed: - the Begin and Finish calls at the seven entry points: Responses, Messages, Chat Completions, Embeddings, Images, alpha search and the Responses WebSocket; - the observer, its Redis cache with the Lua script, the outcome decision table and its rules, and the snapshot handler with its route; - the wire providers (wire_gen.go regenerated); - OpenAIForwardResult.UpstreamTerminalStatus with its three WebSocket writers: only the outcome classifier read it; - the tests of all of the above. The six HTTP forward closures are upstream's bytes again. Slot release and every other call around a forward keep their order; each forwarded attempt makes two Redis round trips fewer. The telemetry switch in the observability settings and gateway.account_traffic_telemetry_disabled switch nothing after this commit; the next one removes them. Co-Authored-By: Claude Opus 5.5 --- backend/cmd/server/wire_gen.go | 6 +- .../internal/handler/admin/account_handler.go | 55 ----- .../account_handler_traffic_telemetry_test.go | 119 --------- .../internal/handler/openai_alpha_search.go | 6 +- .../handler/openai_chat_completions.go | 4 +- backend/internal/handler/openai_embeddings.go | 4 +- .../handler/openai_gateway_handler.go | 47 +--- backend/internal/handler/openai_images.go | 4 +- backend/internal/handler/wire.go | 4 - .../account_traffic_observe_cache.go | 198 --------------- .../repository/concurrency_cache_live_test.go | 42 ---- backend/internal/repository/wire.go | 1 - backend/internal/server/routes/admin.go | 1 - .../service/account_traffic_decision.go | 81 ------ .../service/account_traffic_observe.go | 233 ------------------ .../internal/service/admin_observability.go | 44 ---- .../service/admin_observability_test.go | 107 -------- .../service/openai_gateway_service.go | 30 +-- .../openai_ws_forwarder_client_cancel_test.go | 25 -- .../service/openai_ws_forwarder_ingress.go | 1 - .../service/openai_ws_forwarder_v2.go | 3 - .../internal/service/openai_ws_http_bridge.go | 4 - backend/internal/service/wire.go | 1 - 23 files changed, 22 insertions(+), 998 deletions(-) delete mode 100644 backend/internal/handler/admin/account_handler_traffic_telemetry_test.go delete mode 100644 backend/internal/repository/account_traffic_observe_cache.go delete mode 100644 backend/internal/service/account_traffic_decision.go delete mode 100644 backend/internal/service/account_traffic_observe.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index fddf3dbca8cf..e5182729a420 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -305,19 +305,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { systemPromptHandler := admin.NewSystemPromptHandler(systemPromptService) reasoningRecoveryHandler := admin.NewReasoningRecoveryHandler(reasoningRecoveryService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) - accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) - accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache) openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache) - 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, reasoningRecoveryHandler, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, openCodeGoUsageService, claudeResetCreditService) + 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, reasoningRecoveryHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator, compositeRouteResolver) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index d59b2006d2e7..453f9e7b64b4 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -69,7 +69,6 @@ type AccountHandler struct { grokImportProber grokImportProber upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService - trafficObserver *service.AccountTrafficObserver accountJobs *service.AccountJobService cfg *config.Config @@ -87,11 +86,6 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs h.ollamaCloudUsage = usage } -// SetAccountTrafficObserver attaches the optional observe-only traffic telemetry. -func (h *AccountHandler) SetAccountTrafficObserver(observer *service.AccountTrafficObserver) { - h.trafficObserver = observer -} - func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) { h.opencodeGoUsage = usage } @@ -2620,55 +2614,6 @@ func (h *AccountHandler) GetTodayStats(c *gin.Context) { response.Success(c, stats) } -// GetTrafficTelemetry returns the observe-only per-account traffic counters. -// GET /api/v1/admin/accounts/:id/traffic-telemetry -// -// Read-only Redis snapshot (24h TTL). Never 5xx on telemetry trouble: a disabled -// or unavailable observer yields state_available=false. peak_in_flight only -// carries meaning when configured_concurrency > 0 (unlimited accounts write no -// slot members, so it reads 0 for them). -func (h *AccountHandler) GetTrafficTelemetry(c *gin.Context) { - c.Header("Cache-Control", "no-store") - accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil { - response.BadRequest(c, "Invalid account ID") - return - } - - account, err := h.adminService.GetAccount(c.Request.Context(), accountID) - if err != nil { - response.ErrorFrom(c, err) - return - } - - payload := gin.H{ - "account_id": account.ID, - "configured_concurrency": account.Concurrency, - "state_available": false, - } - if h.trafficObserver == nil { - response.Success(c, payload) - return - } - snapshot, err := h.trafficObserver.Snapshot(c.Request.Context(), account) - if err != nil { - if !errors.Is(err, service.ErrAccountTrafficTelemetryUnavailable) { - payload["error"] = "telemetry_unavailable" - } - response.Success(c, payload) - return - } - payload["state_available"] = true - payload["ttl_seconds"] = service.AccountTrafficObserveTTLSeconds - payload["protocols"] = snapshot - if h.concurrencyService != nil { - if counts, err := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), []int64{account.ID}); err == nil { - payload["in_flight_now"] = counts[account.ID] - } - } - response.Success(c, payload) -} - // BatchTodayStatsRequest 批量今日统计请求体。 type BatchTodayStatsRequest struct { AccountIDs []int64 `json:"account_ids" binding:"required"` diff --git a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go b/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go deleted file mode 100644 index 17f63c0feb17..000000000000 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package admin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type stubAccountTrafficObserveCache struct { - snapshot map[service.AccountTrafficProtocol]service.AccountTrafficObserveState - snapshotCalls int -} - -func (s *stubAccountTrafficObserveCache) Begin(context.Context, int64, service.AccountTrafficProtocol) error { - return nil -} - -func (s *stubAccountTrafficObserveCache) Finish(context.Context, int64, service.AccountTrafficProtocol, service.AccountTrafficOutcome) error { - return nil -} - -func (s *stubAccountTrafficObserveCache) Snapshot(context.Context, int64) (map[service.AccountTrafficProtocol]service.AccountTrafficObserveState, error) { - s.snapshotCalls++ - return s.snapshot, nil -} - -type scopedTrafficAdminService struct { - *stubAdminService - reads int -} - -func (s *scopedTrafficAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { - s.reads++ - return s.stubAdminService.GetAccount(ctx, id) -} - -func TestAccountHandlerTrafficTelemetryFollowsTelemetrySwitch(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, enabled := range []bool{false, true} { - t.Run(strconv.FormatBool(enabled), func(t *testing.T) { - t.Cleanup(func() { service.ConfigureAdminObservability(nil) }) - service.ConfigureAdminObservability(&service.AdminObservabilityConfig{TelemetryEnabled: enabled}) - stub := &scopedTrafficAdminService{stubAdminService: newStubAdminService()} - stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, Credentials: map[string]any{"api_key": "never-return-secret"}} - cache := &stubAccountTrafficObserveCache{snapshot: map[service.AccountTrafficProtocol]service.AccountTrafficObserveState{service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2}}} - handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - handler.SetAccountTrafficObserver(service.NewAccountTrafficObserver(cache)) - router := gin.New() - router.GET("/accounts/:id/traffic-telemetry", handler.GetTrafficTelemetry) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/accounts/18/traffic-telemetry", nil)) - require.Equal(t, http.StatusOK, recorder.Code) - require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) - var response struct { - Data map[string]any `json:"data"` - } - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) - require.Equal(t, enabled, response.Data["state_available"]) - require.EqualValues(t, 18, response.Data["account_id"]) - require.Equal(t, 1, stub.reads) - require.NotContains(t, recorder.Body.String(), "never-return-secret") - if enabled { - require.Equal(t, 1, cache.snapshotCalls) - require.Contains(t, response.Data, "protocols") - } else { - require.Zero(t, cache.snapshotCalls) - require.NotContains(t, response.Data, "protocols") - require.NotContains(t, response.Data, "error", "disabled telemetry keeps the no-data response") - } - }) - } -} - -func TestAccountHandlerGetTrafficTelemetryReturnsSnapshot(t *testing.T) { - gin.SetMode(gin.TestMode) - stub := newStubAdminService() - stub.getAccountResult = &service.Account{ID: 18, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, Concurrency: 3, - Credentials: map[string]any{"api_key": "never-return-secret"}} - handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - handler.SetAccountTrafficObserver(service.NewAccountTrafficObserver(&stubAccountTrafficObserveCache{ - snapshot: map[service.AccountTrafficProtocol]service.AccountTrafficObserveState{ - service.AccountTrafficProtocolHTTP: {Started: 3, Completed2xx: 2, Upstream429: 1, PeakInFlight: 2, RequestsLast60s: 1}, - service.AccountTrafficProtocolWS: {}, - }, - })) - router := gin.New() - router.GET("/accounts/:id/traffic-telemetry", handler.GetTrafficTelemetry) - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/accounts/18/traffic-telemetry", nil)) - require.Equal(t, http.StatusOK, rec.Code) - require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) - var payload struct { - Data struct { - AccountID int64 `json:"account_id"` - ConfiguredConcurrency int `json:"configured_concurrency"` - StateAvailable bool `json:"state_available"` - TTLSeconds int `json:"ttl_seconds"` - Protocols map[string]service.AccountTrafficObserveState `json:"protocols"` - } `json:"data"` - } - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) - require.EqualValues(t, 18, payload.Data.AccountID) - require.Equal(t, 3, payload.Data.ConfiguredConcurrency) - require.True(t, payload.Data.StateAvailable) - require.Equal(t, service.AccountTrafficObserveTTLSeconds, payload.Data.TTLSeconds) - require.EqualValues(t, 3, payload.Data.Protocols["http"].Started) - require.EqualValues(t, 1, payload.Data.Protocols["http"].Upstream429) - require.Equal(t, 2, payload.Data.Protocols["http"].PeakInFlight) - require.Contains(t, payload.Data.Protocols, "ws") - require.NotContains(t, rec.Body.String(), "never-return-secret") -} diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index 0df7da6ef2f2..2b5904daaa43 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -215,11 +215,7 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { var result *service.OpenAIForwardResult service.SetActualOpenAIUpstreamEndpoint(c, "") setActualUpstreamEndpoint(c, "") - trafficTurn := h.trafficObserver.Begin(c.Request.Context(), account, service.AccountTrafficProtocolHTTP) - result, err = func() (res *service.OpenAIForwardResult, ferr error) { - defer func() { - trafficTurn.Finish(res, ferr, c.Request.Context().Err() != nil) - }() + result, err = func() (*service.OpenAIForwardResult, error) { if accountRelease != nil { defer accountRelease() } diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index c7d0e53154d9..edcd56786d83 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -283,13 +283,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { forwardBody := openAIModelMappedBody(body, forwardMapped, forwardMappedModel, h.gatewayService.ReplaceModelInBody) writerSizeBeforeForward := c.Writer.Size() - trafficTurn := h.trafficObserver.Begin(c.Request.Context(), account, service.AccountTrafficProtocolHTTP) - result, err := func() (res *service.OpenAIForwardResult, ferr error) { + result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } - trafficTurn.Finish(res, ferr, c.Request.Context().Err() != nil) }() return h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "") }() diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 27ccb2172005..bd43d46387ae 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -228,13 +228,11 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel) } writerSizeBeforeForward := c.Writer.Size() - trafficTurn := h.trafficObserver.Begin(c.Request.Context(), account, service.AccountTrafficProtocolHTTP) - result, err := func() (res *service.OpenAIForwardResult, ferr error) { + result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } - trafficTurn.Finish(res, ferr, c.Request.Context().Err() != nil) }() return h.gatewayService.ForwardEmbeddings(c.Request.Context(), c, account, forwardBody, "") }() diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 5751e568bf5a..69eacbd2e153 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -47,7 +47,6 @@ type OpenAIGatewayHandler struct { grokMediaEligibilityProber grokMediaEligibilityProber opsService *service.OpsService concurrencyHelper *ConcurrencyHelper - trafficObserver *service.AccountTrafficObserver imageLimiter *imageConcurrencyLimiter maxAccountSwitches int cfg *config.Config @@ -62,14 +61,6 @@ func (h *OpenAIGatewayHandler) SetNativeAnthropicGatewayService(gateway *service } } -// SetAccountTrafficObserver attaches the observe-only per-account traffic -// telemetry. Nil is allowed: every seam is nil-safe and simply records nothing. -func (h *OpenAIGatewayHandler) SetAccountTrafficObserver(observer *service.AccountTrafficObserver) { - if h != nil { - h.trafficObserver = observer - } -} - type openAIWSTurnChannelMappingSnapshot struct { turn int mapping service.ChannelMappingResult @@ -914,13 +905,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 从不可变的 canonical forwardBody 派生本次尝试 body 并整块剔除上游私有的加密 // reasoning item(含耦合的 id/summary),避免非透传上游 400 拒绝 Kiro reasoning 形态。 attemptBody := h.deriveOpenAIForwardAttemptBody(reqLog, forwardBody, account, &passthroughFailoverState) - trafficTurn := h.trafficObserver.Begin(c.Request.Context(), account, service.AccountTrafficProtocolHTTP) - result, err := func() (res *service.OpenAIForwardResult, ferr error) { + result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } - trafficTurn.Finish(res, ferr, c.Request.Context().Err() != nil) }() return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody) }() @@ -1600,13 +1589,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { // 应用渠道模型映射到请求体 forwardBody := mappedBodyForMessages(channelMappingMsg.Mapped, channelMappingMsg.MappedModel) writerSizeBeforeForward := c.Writer.Size() - trafficTurn := h.trafficObserver.Begin(c.Request.Context(), account, service.AccountTrafficProtocolHTTP) - result, err := func() (res *service.OpenAIForwardResult, ferr error) { + result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } - trafficTurn.Finish(res, ferr, c.Request.Context().Err() != nil) }() return h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel) }() @@ -2838,27 +2825,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { var currentUserRelease func() var currentAccountRelease func() var currentAccountSelection *service.AccountSelectionResult - // Observe-only traffic telemetry: exactly one open turn per connection. Turn 1 - // begins right before ProxyResponsesWebSocketFromClient (passthrough never calls - // BeforeTurn(1)), later turns begin in BeforeTurn, and AfterTurn finishes the - // turn as its first statement. Relay ordering guarantees AfterTurn(prev) runs - // before BeforeTurn(next) can be admitted, so a single pointer suffices. - // releaseAccountSlot is the safety net for the passthrough zero-turn returns - // (retry-same-account / transport error) that return without AfterTurn. - var wsTrafficTurn atomic.Pointer[service.AccountTrafficTurn] - finishOpenWSTrafficTurn := func(result *service.OpenAIForwardResult, turnErr error) { - if turn := wsTrafficTurn.Swap(nil); turn != nil { - turn.Finish(result, turnErr, clientLifecycleCtx.Err() != nil) - } - } - beginWSTrafficTurn := func(account *service.Account) { - // A turn still open here never reported; reconcile it first so that - // started == sum(outcomes) always holds. - finishOpenWSTrafficTurn(nil, nil) - wsTrafficTurn.Store(h.trafficObserver.Begin(ctx, account, service.AccountTrafficProtocolWS)) - } releaseAccountSlot := func() { - finishOpenWSTrafficTurn(nil, nil) if currentAccountSelection != nil { h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(currentAccountSelection) currentAccountSelection = nil @@ -3347,17 +3314,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc) currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc) - if err := checkSimpleModeTurnBilling(); err != nil { - return err - } - beginWSTrafficTurn(account) - return nil + return checkSimpleModeTurnBilling() }, AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) { turnUsageCtx := quotaUsageCtx - // Telemetry first: the deferred releaseTurnSlots safety net below - // must find no open turn for a normally reported turn. - finishOpenWSTrafficTurn(result, turnErr) turnStart := getTurnStart(turn) cyberBlockBody := takeCyberTurnBody(turn) // 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard, @@ -3479,7 +3439,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。 requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage) - beginWSTrafficTurn(account) if err := h.gatewayService.ProxyResponsesWebSocketFromClient(ctx, c, wsConn, account, token, wsFirstMessage, hooks); err != nil { if closeErr, postOutputCyber := openAIWSPostOutputCyberClose(err); postOutputCyber { reqLog.Info("openai.websocket_post_output_cyber_closed", diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index bc65201df714..aa0e58729884 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -290,13 +290,11 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { } forwardStart := time.Now() writerSizeBeforeForward := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) - trafficTurn := h.trafficObserver.Begin(requestCtx, account, service.AccountTrafficProtocolHTTP) - result, err := func() (res *service.OpenAIForwardResult, ferr error) { + result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } - trafficTurn.Finish(res, ferr, requestCtx.Err() != nil) }() return h.gatewayService.ForwardImages(requestCtx, c, account, body, parsed, channelMapping.MappedModel) }() diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index cf0cfc4a1e48..1a30dfb4f2a0 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -52,13 +52,11 @@ func ProvideAdminHandlers( reasoningRecoveryHandler *admin.ReasoningRecoveryHandler, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, - accountTrafficObserver *service.AccountTrafficObserver, opencodeGoUsage *service.OpenCodeGoUsageService, claudeResetCredits *service.ClaudeResetCreditService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) - accountHandler.SetAccountTrafficObserver(accountTrafficObserver) accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage) accountHandler.SetClaudeResetCreditService(claudeResetCredits) return &AdminHandlers{ @@ -141,7 +139,6 @@ func ProvideOpenAIGatewayHandler( contentModerationService *service.ContentModerationService, opsService *service.OpsService, grokQuotaService *service.GrokQuotaService, - trafficObserver *service.AccountTrafficObserver, cfg *config.Config, coordinator *securityaudit.Coordinator, compositeResolver *service.CompositeRouteResolver, @@ -150,7 +147,6 @@ func ProvideOpenAIGatewayHandler( h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.SetNativeAnthropicGatewayService(nativeAnthropicGatewayService) - h.SetAccountTrafficObserver(trafficObserver) h.compositeResolver = compositeResolver h.securityAuditCoordinator = coordinator h.grokMediaEligibilityProber = grokQuotaService diff --git a/backend/internal/repository/account_traffic_observe_cache.go b/backend/internal/repository/account_traffic_observe_cache.go deleted file mode 100644 index 9fa2c01dd2b4..000000000000 --- a/backend/internal/repository/account_traffic_observe_cache.go +++ /dev/null @@ -1,198 +0,0 @@ -package repository - -import ( - "context" - "errors" - "fmt" - "strconv" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/redis/go-redis/v9" -) - -// 账号级流量遥测(只观察)缓存。 -// -// 键布局(全部 24h TTL,每次写入都刷新): -// -// account_traffic_observe:{}: HASH started / completed_2xx / upstream_429 / -// upstream_5xx / cancelled / failed_other / -// peak_in_flight / observed_since_ms -// account_traffic_observe:{}::minute ZSET member ":",score now_ms; -// Begin 时裁掉 60s 之前的成员 → 滚动 60s 计数 -// -// protocol ∈ {http, ws},字段名来自 service 层的封闭常量集合(写入前校验),键与字段集合有界。 -// -// peak_in_flight 在 Begin 时只读采样:ZCOUNT concurrency:account:{id}(score ≥ now-slotTTL) -// + ZCOUNT concurrency:live:account:{id}(score ≥ now-60),边界与 acquireScript 的过期规则一致, -// 且永不写这两个并发 ZSET。Concurrency<=0(不限并发)的账号从不写槽位成员,因此其 peak 恒为 0。 -const ( - accountTrafficObserveKeyPrefix = "account_traffic_observe:" - accountTrafficObserveTTL = time.Duration(service.AccountTrafficObserveTTLSeconds) * time.Second - accountTrafficObserveWindowMs = 60_000 -) - -// accountTrafficObserveBeginScript 原子完成:started+1、滚动窗口成员维护、只读采样并发槽位、 -// 刷新 peak_in_flight 与 TTL。返回 {inflight, 窗口内成员数}。 -// KEYS[1]=HASH KEYS[2]=minute ZSET KEYS[3]=concurrency:account:{id} KEYS[4]=concurrency:live:account:{id} -// ARGV[1]=slotTTLSeconds ARGV[2]=ttlSeconds ARGV[3]=windowMs -var accountTrafficObserveBeginScript = redis.NewScript(` - redis.replicate_commands() - local stateKey = KEYS[1] - local minuteKey = KEYS[2] - local slotKey = KEYS[3] - local liveSlotKey = KEYS[4] - local slotTTL = tonumber(ARGV[1]) - local ttl = tonumber(ARGV[2]) - local windowMs = tonumber(ARGV[3]) - - local t = redis.call('TIME') - local nowS = tonumber(t[1]) - local nowMs = nowS * 1000 + math.floor(tonumber(t[2]) / 1000) - - local seq = redis.call('HINCRBY', stateKey, 'started', 1) - redis.call('HSETNX', stateKey, 'observed_since_ms', nowMs) - - redis.call('ZREMRANGEBYSCORE', minuteKey, '-inf', nowMs - windowMs) - redis.call('ZADD', minuteKey, nowMs, nowMs .. ':' .. seq) - - -- 只读采样:与 acquireScript 的过期边界一致,绝不修改并发键。 - local inflight = redis.call('ZCOUNT', slotKey, nowS - slotTTL, '+inf') + redis.call('ZCOUNT', liveSlotKey, nowS - 60, '+inf') - local peak = tonumber(redis.call('HGET', stateKey, 'peak_in_flight') or '0') or 0 - if inflight > peak then - redis.call('HSET', stateKey, 'peak_in_flight', inflight) - end - - redis.call('EXPIRE', stateKey, ttl) - redis.call('EXPIRE', minuteKey, ttl) - return {inflight, redis.call('ZCARD', minuteKey)} -`) - -type accountTrafficObserveCache struct { - rdb *redis.Client - slotTTLSeconds int -} - -// NewAccountTrafficObserveCache 创建遥测缓存;slotTTLMinutes 与并发缓存共用同一配置, -// 保证 in-flight 采样与 acquireScript 使用相同的过期边界。 -func NewAccountTrafficObserveCache(rdb *redis.Client, slotTTLMinutes int) service.AccountTrafficObserveCache { - if slotTTLMinutes <= 0 { - slotTTLMinutes = defaultSlotTTLMinutes - } - return &accountTrafficObserveCache{rdb: rdb, slotTTLSeconds: slotTTLMinutes * 60} -} - -// ProvideAccountTrafficObserveCache 从配置读取槽位 TTL(与 ProvideConcurrencyCache 一致)。 -func ProvideAccountTrafficObserveCache(rdb *redis.Client, cfg *config.Config) service.AccountTrafficObserveCache { - slotTTLMinutes := 0 - if cfg != nil { - slotTTLMinutes = cfg.Gateway.ConcurrencySlotTTLMinutes - } - return NewAccountTrafficObserveCache(rdb, slotTTLMinutes) -} - -func accountTrafficObserveStateKey(accountID int64, protocol service.AccountTrafficProtocol) string { - return fmt.Sprintf("%s{%d}:%s", accountTrafficObserveKeyPrefix, accountID, protocol) -} - -func accountTrafficObserveMinuteKey(accountID int64, protocol service.AccountTrafficProtocol) string { - return accountTrafficObserveStateKey(accountID, protocol) + ":minute" -} - -func (c *accountTrafficObserveCache) ready() error { - if c == nil || c.rdb == nil { - return errors.New("account traffic observe cache is not configured") - } - return nil -} - -func (c *accountTrafficObserveCache) Begin(ctx context.Context, accountID int64, protocol service.AccountTrafficProtocol) error { - if err := c.ready(); err != nil { - return err - } - if !protocol.Valid() { - return fmt.Errorf("account traffic observe: unknown protocol %q", string(protocol)) - } - keys := []string{ - accountTrafficObserveStateKey(accountID, protocol), - accountTrafficObserveMinuteKey(accountID, protocol), - accountSlotKey(accountID), - liveAccountSlotKey(accountID), - } - _, _, err := runScriptInt64Pair(ctx, c.rdb, accountTrafficObserveBeginScript, keys, - c.slotTTLSeconds, service.AccountTrafficObserveTTLSeconds, accountTrafficObserveWindowMs) - return err -} - -// Finish 与 rpm_cache 同形:TxPipeline HINCRBY + EXPIRE,无需 Lua。 -func (c *accountTrafficObserveCache) Finish(ctx context.Context, accountID int64, protocol service.AccountTrafficProtocol, outcome service.AccountTrafficOutcome) error { - if err := c.ready(); err != nil { - return err - } - if !protocol.Valid() { - return fmt.Errorf("account traffic observe: unknown protocol %q", string(protocol)) - } - if !outcome.Valid() { - return fmt.Errorf("account traffic observe: unknown outcome %q", string(outcome)) - } - stateKey := accountTrafficObserveStateKey(accountID, protocol) - pipe := c.rdb.TxPipeline() - pipe.HIncrBy(ctx, stateKey, string(outcome), 1) - pipe.Expire(ctx, stateKey, accountTrafficObserveTTL) - _, err := pipe.Exec(ctx) - return err -} - -// Snapshot 读取两个协议的 HASH 与滚动 60s 计数;时间取 Redis TIME,避免多实例时钟偏差。 -func (c *accountTrafficObserveCache) Snapshot(ctx context.Context, accountID int64) (map[service.AccountTrafficProtocol]service.AccountTrafficObserveState, error) { - if err := c.ready(); err != nil { - return nil, err - } - serverTime, err := c.rdb.Time(ctx).Result() - if err != nil { - return nil, fmt.Errorf("account traffic observe snapshot: redis TIME: %w", err) - } - windowStart := "(" + strconv.FormatInt(serverTime.UnixMilli()-accountTrafficObserveWindowMs, 10) - - protocols := service.AccountTrafficProtocols() - hashCmds := make(map[service.AccountTrafficProtocol]*redis.MapStringStringCmd, len(protocols)) - countCmds := make(map[service.AccountTrafficProtocol]*redis.IntCmd, len(protocols)) - pipe := c.rdb.Pipeline() - for _, protocol := range protocols { - hashCmds[protocol] = pipe.HGetAll(ctx, accountTrafficObserveStateKey(accountID, protocol)) - countCmds[protocol] = pipe.ZCount(ctx, accountTrafficObserveMinuteKey(accountID, protocol), windowStart, "+inf") - } - if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("account traffic observe snapshot: %w", err) - } - - result := make(map[service.AccountTrafficProtocol]service.AccountTrafficObserveState, len(protocols)) - for _, protocol := range protocols { - fields, _ := hashCmds[protocol].Result() - last60s, _ := countCmds[protocol].Result() - result[protocol] = parseAccountTrafficObserveState(fields, last60s) - } - return result, nil -} - -func parseAccountTrafficObserveState(fields map[string]string, last60s int64) service.AccountTrafficObserveState { - get := func(name string) int64 { - value, err := strconv.ParseInt(fields[name], 10, 64) - if err != nil { - return 0 - } - return value - } - return service.AccountTrafficObserveState{ - Started: get("started"), - Completed2xx: get(string(service.AccountTrafficOutcomeCompleted2xx)), - Upstream429: get(string(service.AccountTrafficOutcomeUpstream429)), - Upstream5xx: get(string(service.AccountTrafficOutcomeUpstream5xx)), - Cancelled: get(string(service.AccountTrafficOutcomeCancelled)), - FailedOther: get(string(service.AccountTrafficOutcomeFailedOther)), - PeakInFlight: int(get("peak_in_flight")), - RequestsLast60s: int(last60s), - ObservedSinceMs: get("observed_since_ms"), - } -} diff --git a/backend/internal/repository/concurrency_cache_live_test.go b/backend/internal/repository/concurrency_cache_live_test.go index e9a3b744f8ce..6f8a143165ae 100644 --- a/backend/internal/repository/concurrency_cache_live_test.go +++ b/backend/internal/repository/concurrency_cache_live_test.go @@ -71,45 +71,3 @@ func TestLiveLeaseExpiresWithoutRefresh(t *testing.T) { require.NoError(t, err) require.False(t, refreshed) } - -func TestAccountTrafficObserveBeginReadsSlotsAndRollingMinute(t *testing.T) { - redisServer := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) - regular := NewConcurrencyCache(client, 15, 900) - observe := NewAccountTrafficObserveCache(client, 15) - ctx := context.Background() - - for _, requestID := range []string{"slot-a", "slot-b"} { - acquired, err := regular.AcquireAccountSlot(ctx, 10, 5, requestID) - require.NoError(t, err) - require.True(t, acquired) - } - require.NoError(t, observe.Begin(ctx, 10, service.AccountTrafficProtocolHTTP)) - require.NoError(t, observe.Begin(ctx, 10, service.AccountTrafficProtocolHTTP)) - // Both the Lua script and Snapshot read Redis TIME: moving it past the window - // drops the two earlier members from the rolling count while the 24h counter - // keeps them. - redisServer.SetTime(time.Now().Add(61 * time.Second)) - require.NoError(t, observe.Begin(ctx, 10, service.AccountTrafficProtocolHTTP)) - - snapshot, err := observe.Snapshot(ctx, 10) - require.NoError(t, err) - httpState := snapshot[service.AccountTrafficProtocolHTTP] - require.EqualValues(t, 3, httpState.Started) - require.Equal(t, 2, httpState.PeakInFlight) - require.Equal(t, 1, httpState.RequestsLast60s) - require.Zero(t, snapshot[service.AccountTrafficProtocolWS].Started) - // Sampling must leave the concurrency ZSET untouched. - count, err := regular.GetAccountConcurrency(ctx, 10) - require.NoError(t, err) - require.Equal(t, 2, count) - - turn := service.NewAccountTrafficObserver(observe).Begin(ctx, &service.Account{ID: 10, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, service.AccountTrafficProtocolHTTP) - require.NotNil(t, turn) - turn.Finish(nil, &service.UpstreamFailoverError{StatusCode: 429}, false) - turn.Finish(nil, &service.UpstreamFailoverError{StatusCode: 429}, false) - snapshot, err = observe.Snapshot(ctx, 10) - require.NoError(t, err) - require.EqualValues(t, 4, snapshot[service.AccountTrafficProtocolHTTP].Started) - require.EqualValues(t, 1, snapshot[service.AccountTrafficProtocolHTTP].Upstream429) -} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index f3d7a51896c9..0065c61f3c50 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -119,7 +119,6 @@ var ProviderSet = wire.NewSet( NewOpenAI403CounterCache, NewInternal500CounterCache, ProvideConcurrencyCache, - ProvideAccountTrafficObserveCache, ProvideSessionLimitCache, NewRPMCache, NewUserRPMCache, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 3c5319b20786..8c1e0688458a 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -454,7 +454,6 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/:id/revert-proxy-fallback", h.Admin.Account.RevertProxyFallback) accounts.GET("/:id/usage", h.Admin.Account.GetUsage) accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats) - accounts.GET("/:id/traffic-telemetry", h.Admin.Account.GetTrafficTelemetry) accounts.POST("/usage/batch", h.Admin.Account.GetBatchUsage) accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats) accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit) diff --git a/backend/internal/service/account_traffic_decision.go b/backend/internal/service/account_traffic_decision.go deleted file mode 100644 index 477afc1aa627..000000000000 --- a/backend/internal/service/account_traffic_decision.go +++ /dev/null @@ -1,81 +0,0 @@ -package service - -import ( - "errors" - "strconv" -) - -// DecisionTable is a bounded ordered list of conjunctions. It has no loops, -// code execution, IO, or implicit inputs; domains supply their own rules. -type DecisionTable struct { - Rules []DecisionRule `json:"rules"` - Default string `json:"default"` -} -type DecisionRule struct { - When []DecisionCondition `json:"when"` - Result string `json:"result"` -} -type DecisionCondition struct { - Field string `json:"field"` - Operator string `json:"operator"` - Value string `json:"value"` -} - -func (t DecisionTable) Validate() error { - if len(t.Rules) > 64 || t.Default == "" || len(t.Default) > 64 { - return errors.New("invalid decision table") - } - for _, rule := range t.Rules { - if len(rule.When) == 0 || len(rule.When) > 16 || rule.Result == "" || len(rule.Result) > 64 { - return errors.New("invalid decision rule") - } - for _, condition := range rule.When { - if condition.Field == "" || len(condition.Field) > 64 || len(condition.Value) > 256 { - return errors.New("invalid decision condition") - } - switch condition.Operator { - case "eq", "ne": - case "gte", "lt": - if _, err := strconv.ParseInt(condition.Value, 10, 64); err != nil { - return errors.New("invalid decision number") - } - default: - return errors.New("unknown decision operator") - } - } - } - return nil -} - -func (t DecisionTable) Evaluate(facts map[string]string) (string, error) { - if err := t.Validate(); err != nil { - return "", err - } - for _, rule := range t.Rules { - matched := true - for _, condition := range rule.When { - actual, exists := facts[condition.Field] - if !exists { - matched = false - break - } - switch condition.Operator { - case "eq": - matched = actual == condition.Value - case "ne": - matched = actual != condition.Value - case "gte", "lt": - number, err := strconv.ParseInt(actual, 10, 64) - wanted, _ := strconv.ParseInt(condition.Value, 10, 64) - matched = err == nil && ((condition.Operator == "gte" && number >= wanted) || (condition.Operator == "lt" && number < wanted)) - } - if !matched { - break - } - } - if matched { - return rule.Result, nil - } - } - return t.Default, nil -} diff --git a/backend/internal/service/account_traffic_observe.go b/backend/internal/service/account_traffic_observe.go deleted file mode 100644 index 50c80896207d..000000000000 --- a/backend/internal/service/account_traffic_observe.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "context" - "errors" - "strconv" - "sync" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "go.uber.org/zap" -) - -// Observe-only per-account traffic telemetry for the OpenAI-family gateway. -// -// Scope: every business entry of OpenAIGatewayHandler that binds an account -// slot before forwarding — Responses HTTP, chat-completions compat, Messages -// compat, Images, Embeddings, alpha-search, and the Responses WebSocket ingress -// in all modes (ctx_pool / shared / dedicated / http_bridge / passthrough). Each -// forwarded attempt on an account is one "started" turn on that account and is -// finished exactly once with a single outcome bucket, so per protocol -// started == sum(outcomes) once every open turn has finished. -// -// Explicitly EXCLUDED (intentional scope exceptions, stated for reviewers): -// - Grok media / audio / native web-search (grok_media.go, grok_audio.go, -// gateway_web_search.go) — Grok-specific transports; -// - Live sessions — they hold their own live lease (LiveConcurrencyCache) and -// write their own usage rows; -// - admin account test probes (account_test_service) — the reviewer contract -// forbids observing through probes, and probe traffic is not client traffic; -// - the Codex models manifest fetch (openai_codex_models_handler.go) — a -// metadata lookup, not a model turn; -// - the non-OpenAI (Anthropic-protocol) gateway handler (gateway_handler.go). -// -// This never admits, rejects, reschedules or cools anything down, and persists -// nothing but bounded counters: no tokens, cookies, seeds, bodies or upstream -// error text. Telemetry failures are debug-logged and never gate a request. - -// AccountTrafficProtocol is the client-facing ingress protocol of a turn. -type AccountTrafficProtocol string - -const ( - AccountTrafficProtocolHTTP AccountTrafficProtocol = "http" - AccountTrafficProtocolWS AccountTrafficProtocol = "ws" -) - -// AccountTrafficProtocols lists every protocol label the cache may carry. -func AccountTrafficProtocols() []AccountTrafficProtocol { - return []AccountTrafficProtocol{AccountTrafficProtocolHTTP, AccountTrafficProtocolWS} -} - -// Valid reports whether p is one of the bounded protocol labels. -func (p AccountTrafficProtocol) Valid() bool { - switch p { - case AccountTrafficProtocolHTTP, AccountTrafficProtocolWS: - return true - default: - return false - } -} - -// AccountTrafficOutcome is the single bucket a finished turn lands in. -type AccountTrafficOutcome string - -const ( - AccountTrafficOutcomeCompleted2xx AccountTrafficOutcome = "completed_2xx" - AccountTrafficOutcomeUpstream429 AccountTrafficOutcome = "upstream_429" - AccountTrafficOutcomeUpstream5xx AccountTrafficOutcome = "upstream_5xx" - // AccountTrafficOutcomeCancelled covers client cancellation / disconnect and - // the upstream WS terminals response.cancelled and response.incomplete - // (max_output_tokens, content filter): the turn ended without a complete - // answer, but not because the upstream failed. - AccountTrafficOutcomeCancelled AccountTrafficOutcome = "cancelled" - // AccountTrafficOutcomeFailedOther is the reconciliation bucket: non-429/5xx - // failures, WS turns without a demonstrable terminal, and safety-net - // finishes for turns that never reported. - AccountTrafficOutcomeFailedOther AccountTrafficOutcome = "failed_other" -) - -// Valid reports whether o is one of the bounded outcome labels (Redis field -// names are derived from it, so the set must stay closed). -func (o AccountTrafficOutcome) Valid() bool { - switch o { - case AccountTrafficOutcomeCompleted2xx, AccountTrafficOutcomeUpstream429, AccountTrafficOutcomeUpstream5xx, - AccountTrafficOutcomeCancelled, AccountTrafficOutcomeFailedOther: - return true - default: - return false - } -} - -// AccountTrafficObserveTTLSeconds is the Redis TTL applied to every telemetry -// key on every write (24h). -const AccountTrafficObserveTTLSeconds = 24 * 60 * 60 - -// accountTrafficObserveRedisTimeout bounds each telemetry round-trip so a slow -// Redis can never hold a request or a slot release hostage. -const accountTrafficObserveRedisTimeout = 2 * time.Second - -// AccountTrafficObserveState is the per-protocol counter snapshot of one account. -type AccountTrafficObserveState struct { - Started int64 `json:"started"` - Completed2xx int64 `json:"completed_2xx"` - Upstream429 int64 `json:"upstream_429"` - Upstream5xx int64 `json:"upstream_5xx"` - Cancelled int64 `json:"cancelled"` - FailedOther int64 `json:"failed_other"` - PeakInFlight int `json:"peak_in_flight"` - RequestsLast60s int `json:"requests_last_60s"` - ObservedSinceMs int64 `json:"observed_since_ms"` -} - -// AccountTrafficObserveCache is the Redis-backed counter store (repository). -type AccountTrafficObserveCache interface { - Begin(ctx context.Context, accountID int64, protocol AccountTrafficProtocol) error - Finish(ctx context.Context, accountID int64, protocol AccountTrafficProtocol, outcome AccountTrafficOutcome) error - Snapshot(ctx context.Context, accountID int64) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) -} - -// ErrAccountTrafficTelemetryUnavailable is returned by Snapshot when telemetry -// is disabled or not wired; callers must treat it as "no data", never as 5xx. -var ErrAccountTrafficTelemetryUnavailable = errors.New("account traffic telemetry is unavailable") - -// AccountTrafficObserver hands out turns to the gateway seams. A nil observer, -// a disabled one, or one without a cache is a no-op everywhere. -type AccountTrafficObserver struct { - cache AccountTrafficObserveCache -} - -// Whether turns are recorded follows the admin_observability_config setting, -// whose default follows gateway.account_traffic_telemetry_disabled. -func NewAccountTrafficObserver(cache AccountTrafficObserveCache) *AccountTrafficObserver { - return &AccountTrafficObserver{ - cache: cache, - } -} - -// Begin records one started turn for account/protocol and samples in-flight -// slots. It never returns an error: on failure it debug-logs and returns nil, -// whose Finish is a no-op, so started and outcomes stay reconciled. -func (o *AccountTrafficObserver) Begin(ctx context.Context, account *Account, protocol AccountTrafficProtocol) *AccountTrafficTurn { - if o == nil || o.cache == nil || !protocol.Valid() { - return nil - } - if ctx == nil { - ctx = context.Background() - } - policy, available := currentAccountTrafficObservationPolicy(account) - if !available || !policy.Enabled { - return nil - } - accountID := account.ID - beginCtx, cancel := context.WithTimeout(ctx, accountTrafficObserveRedisTimeout) - defer cancel() - if err := o.cache.Begin(beginCtx, accountID, protocol); err != nil { - logger.L().Debug("account_traffic_observe.begin_failed", - zap.Int64("account_id", accountID), - zap.String("protocol", string(protocol)), - zap.Error(err), - ) - return nil - } - return &AccountTrafficTurn{cache: o.cache, accountID: accountID, protocol: protocol, classification: policy.Classification} -} - -// Snapshot returns the per-protocol counters of one account. -func (o *AccountTrafficObserver) Snapshot(ctx context.Context, account *Account) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) { - if o == nil || o.cache == nil { - return nil, ErrAccountTrafficTelemetryUnavailable - } - if ctx == nil { - ctx = context.Background() - } - policy, available := currentAccountTrafficObservationPolicy(account) - if !available || !policy.Enabled { - return nil, ErrAccountTrafficTelemetryUnavailable - } - snapshotCtx, cancel := context.WithTimeout(ctx, accountTrafficObserveRedisTimeout) - defer cancel() - return o.cache.Snapshot(snapshotCtx, account.ID) -} - -// AccountTrafficTurn is one started turn awaiting its single Finish. -type AccountTrafficTurn struct { - classification DecisionTable - cache AccountTrafficObserveCache - accountID int64 - protocol AccountTrafficProtocol - once sync.Once -} - -// Finish classifies the turn and increments exactly one outcome bucket. It is -// idempotent (later calls are ignored) and nil-safe. clientCancelled is the -// client-side signal (request context / connection lifecycle already ended); a -// nil result with a nil err is the safety-net reconciliation and lands in -// failed_other unless clientCancelled. -func (t *AccountTrafficTurn) Finish(result *OpenAIForwardResult, err error, clientCancelled bool) { - if t == nil || t.cache == nil { - return - } - t.once.Do(func() { - outcome := evaluateAccountTrafficOutcome(t.classification, result, err, clientCancelled) - // Detached like the ConcurrencyService ReleaseFunc: the request context - // is usually already cancelled when a cancelled turn finishes. - finishCtx, cancel := context.WithTimeout(context.Background(), accountTrafficObserveRedisTimeout) - defer cancel() - if ferr := t.cache.Finish(finishCtx, t.accountID, t.protocol, outcome); ferr != nil { - logger.L().Debug("account_traffic_observe.finish_failed", - zap.Int64("account_id", t.accountID), - zap.String("protocol", string(t.protocol)), - zap.String("outcome", string(outcome)), - zap.Error(ferr), - ) - } - }) -} - -func evaluateAccountTrafficOutcome(table DecisionTable, result *OpenAIForwardResult, err error, clientCancelled bool) AccountTrafficOutcome { - facts := map[string]string{"client_cancelled": strconv.FormatBool(clientCancelled || (result != nil && result.ClientDisconnect)), "has_result": strconv.FormatBool(result != nil), "has_error": strconv.FormatBool(err != nil), "ws": "false", "terminal": "", "terminal_status": "0", "error_status": "0"} - if result != nil { - facts["ws"] = strconv.FormatBool(result.OpenAIWSMode) - facts["terminal"], facts["terminal_status"] = result.UpstreamTerminalEvent, strconv.Itoa(result.UpstreamTerminalStatus) - } - var failover *UpstreamFailoverError - if errors.As(err, &failover) && failover != nil { - facts["error_status"] = strconv.Itoa(failover.StatusCode) - } - value, evalErr := table.Evaluate(facts) - if evalErr != nil || !AccountTrafficOutcome(value).Valid() { - return AccountTrafficOutcomeFailedOther - } - return AccountTrafficOutcome(value) -} diff --git a/backend/internal/service/admin_observability.go b/backend/internal/service/admin_observability.go index 58ce84f9d8ca..c46c346aad93 100644 --- a/backend/internal/service/admin_observability.go +++ b/backend/internal/service/admin_observability.go @@ -34,47 +34,3 @@ func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { func FlatThemeEnabled() bool { return EffectiveAdminObservabilityConfig().ThemeEnabled } - -type TrafficObservationPolicy struct { - Enabled bool `json:"enabled"` - Classification DecisionTable `json:"classification"` -} - -// currentAccountTrafficObservationPolicy returns the telemetry switch and the -// outcome rules for one account. Accounts without a concrete identity are never -// observed. -func currentAccountTrafficObservationPolicy(account *Account) (TrafficObservationPolicy, bool) { - if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { - return TrafficObservationPolicy{}, false - } - return TrafficObservationPolicy{Enabled: EffectiveAdminObservabilityConfig().TelemetryEnabled, Classification: accountTrafficOutcomeRules}, true -} - -// accountTrafficOutcomeRules classifies a finished turn. Client cancellation -// outranks errors, and a WebSocket turn counts as completed only with a -// demonstrable terminal event. Every result is an AccountTrafficOutcome. -var accountTrafficOutcomeRules = func() DecisionTable { - eq := func(field, value string) DecisionCondition { - return DecisionCondition{Field: field, Operator: "eq", Value: value} - } - num := func(field, op, value string) DecisionCondition { - return DecisionCondition{Field: field, Operator: op, Value: value} - } - rule := func(result AccountTrafficOutcome, when ...DecisionCondition) DecisionRule { - return DecisionRule{When: when, Result: string(result)} - } - return DecisionTable{Default: string(AccountTrafficOutcomeFailedOther), Rules: []DecisionRule{ - rule(AccountTrafficOutcomeCancelled, eq("client_cancelled", "true")), - rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.cancelled")), - rule(AccountTrafficOutcomeCancelled, eq("ws", "true"), eq("terminal", "response.incomplete")), - rule(AccountTrafficOutcomeUpstream429, eq("has_error", "true"), eq("error_status", "429")), - rule(AccountTrafficOutcomeUpstream5xx, eq("has_error", "true"), num("error_status", "gte", "500"), num("error_status", "lt", "600")), - rule(AccountTrafficOutcomeFailedOther, eq("has_error", "true")), - rule(AccountTrafficOutcomeFailedOther, eq("has_result", "false")), - rule(AccountTrafficOutcomeCompleted2xx, eq("ws", "false")), - rule(AccountTrafficOutcomeCompleted2xx, eq("terminal", "response.completed")), - rule(AccountTrafficOutcomeCompleted2xx, eq("terminal", "response.done")), - rule(AccountTrafficOutcomeUpstream429, eq("terminal", "response.failed"), eq("terminal_status", "429")), - rule(AccountTrafficOutcomeUpstream5xx, eq("terminal", "response.failed"), num("terminal_status", "gte", "500"), num("terminal_status", "lt", "600")), - }} -}() diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go index 6a1e8c54e29a..f8b0e6950827 100644 --- a/backend/internal/service/admin_observability_test.go +++ b/backend/internal/service/admin_observability_test.go @@ -1,118 +1,11 @@ package service import ( - "context" - "strconv" "testing" "github.com/stretchr/testify/require" ) -type trafficScopeCache struct { - beginIDs []int64 - snapshotIDs []int64 - outcomes []AccountTrafficOutcome -} - -func (c *trafficScopeCache) Begin(_ context.Context, id int64, _ AccountTrafficProtocol) error { - c.beginIDs = append(c.beginIDs, id) - return nil -} -func (c *trafficScopeCache) Finish(_ context.Context, _ int64, _ AccountTrafficProtocol, outcome AccountTrafficOutcome) error { - c.outcomes = append(c.outcomes, outcome) - return nil -} -func (c *trafficScopeCache) Snapshot(_ context.Context, id int64) (map[AccountTrafficProtocol]AccountTrafficObserveState, error) { - c.snapshotIDs = append(c.snapshotIDs, id) - return map[AccountTrafficProtocol]AccountTrafficObserveState{AccountTrafficProtocolHTTP: {Started: 1}}, nil -} - -func useAdminObservability(t *testing.T, config AdminObservabilityConfig) { - t.Helper() - previous := adminObservabilityConfig.Load() - t.Cleanup(func() { adminObservabilityConfig.Store(previous) }) - ConfigureAdminObservability(&config) -} - -func TestTrafficObservationFollowsTelemetrySwitch(t *testing.T) { - account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - for _, enabled := range []bool{false, true} { - t.Run(strconv.FormatBool(enabled), func(t *testing.T) { - useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: enabled}) - cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache) - turn := observer.Begin(context.Background(), account, AccountTrafficProtocolWS) - snapshot, err := observer.Snapshot(context.Background(), account) - if !enabled { - require.Nil(t, turn) - require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) - require.Empty(t, cache.beginIDs) - require.Empty(t, cache.snapshotIDs) - return - } - require.NotNil(t, turn) - require.NoError(t, err) - require.EqualValues(t, 1, snapshot[AccountTrafficProtocolHTTP].Started) - require.Equal(t, []int64{2}, cache.beginIDs) - require.Equal(t, []int64{2}, cache.snapshotIDs) - }) - } -} - -func TestTrafficObservationRejectsMissingAccountIdentity(t *testing.T) { - useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: true}) - cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache) - for _, account := range []*Account{ - nil, - {Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - {ID: 2, Type: AccountTypeOAuth}, - {ID: 2, Platform: PlatformOpenAI}, - {ID: 2, Platform: "*", Type: AccountTypeOAuth}, - {ID: 2, Platform: PlatformOpenAI, Type: "*"}, - } { - require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) - snapshot, err := observer.Snapshot(context.Background(), account) - require.ErrorIs(t, err, ErrAccountTrafficTelemetryUnavailable) - require.Nil(t, snapshot) - } - require.Empty(t, cache.beginIDs) - require.Empty(t, cache.snapshotIDs) -} - -func TestTrafficObservationFinishesStartedTurnAfterTelemetryIsSwitchedOff(t *testing.T) { - useAdminObservability(t, AdminObservabilityConfig{TelemetryEnabled: true}) - cache := &trafficScopeCache{} - observer := NewAccountTrafficObserver(cache) - account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth} - turn := observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP) - require.NotNil(t, turn) - ConfigureAdminObservability(&AdminObservabilityConfig{}) - turn.Finish(&OpenAIForwardResult{}, nil, false) - turn.Finish(nil, nil, false) - require.Equal(t, []AccountTrafficOutcome{AccountTrafficOutcomeCompleted2xx}, cache.outcomes) - require.Nil(t, observer.Begin(context.Background(), account, AccountTrafficProtocolHTTP)) - require.Len(t, cache.beginIDs, 1) -} - -func TestAccountTrafficOutcomeRulesKeepCancellationAboveErrorsAndRequireWSTerminals(t *testing.T) { - require.NoError(t, accountTrafficOutcomeRules.Validate()) - for _, tc := range []struct { - facts map[string]string - want AccountTrafficOutcome - }{ - {map[string]string{"client_cancelled": "true", "has_error": "true", "error_status": "503"}, AccountTrafficOutcomeCancelled}, - {map[string]string{"ws": "true", "terminal": "response.incomplete", "has_error": "true", "error_status": "429"}, AccountTrafficOutcomeCancelled}, - {map[string]string{"ws": "true", "has_result": "true", "has_error": "false", "terminal": ""}, AccountTrafficOutcomeFailedOther}, - {map[string]string{"ws": "true", "has_result": "true", "has_error": "false", "terminal": "response.failed", "terminal_status": "503"}, AccountTrafficOutcomeUpstream5xx}, - {map[string]string{"ws": "false", "has_result": "true", "has_error": "false"}, AccountTrafficOutcomeCompleted2xx}, - } { - got, err := accountTrafficOutcomeRules.Evaluate(tc.facts) - require.NoError(t, err) - require.Equal(t, string(tc.want), got) - } -} - func TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues(t *testing.T) { config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} require.NoError(t, DecodeSwitchSettings([]byte(`{"theme_enabled":false}`), &config, "telemetry_enabled", "theme_enabled")) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 93ca13d31fdc..775467fa3cd4 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -279,23 +279,19 @@ type OpenAIForwardResult struct { // UpstreamTerminalEvent is the normalized terminal event observed on an // upstream Responses WebSocket turn. Empty preserves legacy/non-WS success. UpstreamTerminalEvent string - // UpstreamTerminalStatus is the HTTP-style status carried by the WS terminal - // payload (response.error.status_code and friends), 0 when absent. It lets - // observe-only telemetry bucket post-output response.failed turns as 429/5xx. - UpstreamTerminalStatus int - ResponseHeaders http.Header - Duration time.Duration - FirstTokenMs *int - ClientDisconnect bool - ImageCount int - ImageSize string - ImageInputSize string - ImageOutputSize string - ImageOutputSizes []string - ImageSizeSource string - ImageSizeBreakdown map[string]int - VideoCount int - VideoResolution string + ResponseHeaders http.Header + Duration time.Duration + FirstTokenMs *int + ClientDisconnect bool + ImageCount int + ImageSize string + ImageInputSize string + ImageOutputSize string + ImageOutputSizes []string + ImageSizeSource string + ImageSizeBreakdown map[string]int + VideoCount int + VideoResolution string // VideoDurationSeconds 是提交时请求的生成时长(xAI 按输出秒数计费),已归一化到 1-15 秒。 VideoDurationSeconds int // WebSearchCalls 是 Codex alpha/search 网页搜索调用次数(每次成功请求为 1)。 diff --git a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go index 523632d77cca..568c90565454 100644 --- a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go +++ b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go @@ -3,7 +3,6 @@ package service import ( "bytes" "context" - "errors" "io" "net/http" "net/http/httptest" @@ -303,27 +302,3 @@ func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error } return event, nil } - -func TestClassifyAccountTrafficOutcome(t *testing.T) { - for _, tc := range []struct { - name string - result *OpenAIForwardResult - err error - clientCancelled bool - want AccountTrafficOutcome - }{ - {name: "http_success", result: &OpenAIForwardResult{Stream: true}, want: AccountTrafficOutcomeCompleted2xx}, - {name: "failover_429", err: &UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}, want: AccountTrafficOutcomeUpstream429}, - {name: "failover_503", err: &UpstreamFailoverError{StatusCode: http.StatusServiceUnavailable}, want: AccountTrafficOutcomeUpstream5xx}, - {name: "client_cancelled_wins", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.completed"}, clientCancelled: true, want: AccountTrafficOutcomeCancelled}, - {name: "ws_failed_terminal_status_429", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.failed", UpstreamTerminalStatus: http.StatusTooManyRequests}, want: AccountTrafficOutcomeUpstream429}, - {name: "ws_incomplete_is_cancelled", result: &OpenAIForwardResult{OpenAIWSMode: true, UpstreamTerminalEvent: "response.incomplete"}, want: AccountTrafficOutcomeCancelled}, - {name: "ws_empty_terminal_is_not_success", result: &OpenAIForwardResult{OpenAIWSMode: true}, want: AccountTrafficOutcomeFailedOther}, - {name: "unknown_error", err: errors.New("boom"), want: AccountTrafficOutcomeFailedOther}, - {name: "safety_net_nil_nil", want: AccountTrafficOutcomeFailedOther}, - } { - t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, evaluateAccountTrafficOutcome(accountTrafficOutcomeRules, tc.result, tc.err, tc.clientCancelled)) - }) - } -} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 9c2986b01f63..be7b8adfcddc 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -1475,7 +1475,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: terminalEvent, - UpstreamTerminalStatus: openAIWSPayloadStatus(upstreamMessage), ResponseHeaders: lease.HandshakeHeaders(), Duration: time.Since(turnStart), FirstTokenMs: firstTokenMs, diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 4275f1d80a5d..263527f7f322 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -417,7 +417,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( firstEventType := "" lastEventType := "" upstreamTerminalEvent := "" - upstreamTerminalStatus := 0 clientDisconnected := false clientDisconnectDrainStartedAt := time.Time{} readTimeout := s.openAIWSReadTimeout() @@ -466,7 +465,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: upstreamTerminalEvent, - UpstreamTerminalStatus: upstreamTerminalStatus, ResponseHeaders: lease.HandshakeHeaders(), Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, @@ -948,7 +946,6 @@ readLoop: markOpenAIWSClientVisibleFailure(c, eventType, message) } upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), message) - upstreamTerminalStatus = openAIWSPayloadStatus(message) // A terminal event must be the final JSON document in its WS message. // Ignore any tail for the completed client turn, but never reuse the // ambiguous upstream connection for another request. diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 33d635651638..d104fa16fbc0 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -696,7 +696,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( firstEventType := "" lastEventType := "" upstreamTerminalEvent := "" - upstreamTerminalStatus := 0 sawDone := false wroteDownstream := false semanticOutputStarted := false @@ -739,7 +738,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: upstreamTerminalEvent, - UpstreamTerminalStatus: upstreamTerminalStatus, ResponseHeaders: cloneHeader(resp.Header), Duration: time.Since(turnStart), FirstTokenMs: firstTokenMs, @@ -764,7 +762,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( failureAccountSideEffectsApplied = s.handleOpenAIWSFailureAccountSideEffects(ctx, account, mappedModel, resp.Header, bareErrorPayload) } upstreamTerminalEvent = "response.failed" - upstreamTerminalStatus = openAIWSPayloadStatus(bareErrorPayload) if clientDisconnected { return nil } @@ -1058,7 +1055,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } if isOpenAIWSTerminalEvent(eventType) && !bareErrorPending { upstreamTerminalEvent = s.handleOpenAIWSTerminalTransientFailure(ctx, account, canonicalOpenAIAccountSchedulingModel(account, originalModel), resp.Header, rawUpstreamMessage) - upstreamTerminalStatus = openAIWSPayloadStatus(rawUpstreamMessage) terminalEventCount++ firstTokenMsValue := -1 if firstTokenMs != nil { diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 85f9953ab148..aa87a26d3c62 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -1081,7 +1081,6 @@ var ProviderSet = wire.NewSet( NewSubscriptionService, wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)), ProvideConcurrencyService, - NewAccountTrafficObserver, ProvideUserMessageQueueService, NewUsageRecordWorkerPool, ProvideSchedulerSnapshotService, From d4d388f379f6ac3d40dfafecaef9e117c1278ab8 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 6 Oct 2026 21:02:43 +0800 Subject: [PATCH 29/37] refactor(settings): delete the telemetry switch, its stored key and its config option With the traffic counters gone the telemetry switch switched nothing. - The observability settings hold the flat theme switch only. GET and PUT /admin/settings/observability carry {"theme_enabled": }; a PUT that still sends telemetry_enabled is refused like any unknown key. The settings panel loses the telemetry checkbox and the sentence that described the counters. The theme checkbox and its save are unchanged. - gateway.account_traffic_telemetry_disabled is no longer a config key. The files under deploy/ still name it; the server ignores the variable. - Migration 273 removes telemetry_enabled from the stored admin_observability_config row. The reader of that row is strict and a key it does not know stops startup. theme_enabled keeps its value. - What was left of admin_observability.go, the switch holder and FlatThemeEnabled, moves into setting_admin_observability.go. The decoder test moves to setting_switch_load_test.go with a two-switch type of its own, so it still shows that an omitted switch keeps its default. Co-Authored-By: Claude Opus 5.5 --- .downstream/native-domains.md | 15 +- backend/internal/config/config.go | 4 - .../internal/handler/admin/setting_handler.go | 11 +- ...switch_purge_migration_integration_test.go | 131 ++++++++++++++++++ .../internal/service/admin_observability.go | 36 ----- .../service/admin_observability_test.go | 16 --- .../service/setting_admin_observability.go | 43 ++++-- .../service/setting_switch_load_test.go | 17 ++- ...purge_account_traffic_telemetry_switch.sql | 30 ++++ frontend/src/api/admin/settings.ts | 3 +- .../admin/ObservabilitySettingsPanel.vue | 5 - .../src/i18n/locales/en/admin/settings.ts | 2 - .../src/i18n/locales/zh/admin/settings.ts | 2 - .../admin/__tests__/SettingsView.spec.ts | 2 +- 14 files changed, 227 insertions(+), 90 deletions(-) create mode 100644 backend/internal/repository/account_traffic_telemetry_switch_purge_migration_integration_test.go delete mode 100644 backend/internal/service/admin_observability.go delete mode 100644 backend/internal/service/admin_observability_test.go create mode 100644 backend/migrations/273_purge_account_traffic_telemetry_switch.sql diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index c9bf9c7d746c..c46c558b8424 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -13,7 +13,7 @@ authentication and the existing step-up policy. | Interface | Contract | | --- | --- | | `GET/PUT /admin/settings/codex-runtime` | Codex request body compression switch (`request_zstd`; a missing key is on). | -| `GET/PUT /admin/settings/observability` | Telemetry and native theme switches. | +| `GET/PUT /admin/settings/observability` | Native theme switch (`theme_enabled`; a missing key is on). | Each of the two is stored as one `settings` row holding a JSON object of boolean switches; `GET` and `PUT` answer with the switches in the standard @@ -128,6 +128,19 @@ Going back to an earlier release is therefore not an image change: it requires a database dump taken before the migrations that release does not have. Such a rollback has not been run and is not an automatic failure path. +Migration 273 removes the key `telemetry_enabled` from the stored +`admin_observability_config` row; `theme_enabled` keeps its value. The key +switched the per-account traffic counters, which are deleted with their route +`GET /admin/accounts/:id/traffic-telemetry`. The counters were the Redis keys +`account_traffic_observe:{}:` and the same with the +suffix `:minute`; each expires 24 hours after its last write and nothing +writes them any more. v0.2.13-codexrip.10 and .11 start on the migrated row and +take the missing switch as on. Saving the observability settings in one of +them stores the key again, and 273 is recorded by then: this release then +exits at start with `invalid setting admin_observability_config` until the key +is removed by hand +(`UPDATE settings SET value = (value::jsonb - 'telemetry_enabled')::text WHERE key = 'admin_observability_config'`). + ## Verification Normal required PR checks validate the native domain packages, account scope, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5c9f8fdf97db..f85506817ff5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1087,9 +1087,6 @@ type GatewayConfig struct { // ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟) // 应大于最长 LLM 请求时间,防止请求完成前槽位过期 ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"` - // AccountTrafficTelemetryDisabled: 关闭 OpenAI 系网关的账号级流量遥测(只观察:仅写 Redis 计数器 - // account_traffic_observe:{id}:*,不改变调度/准入)。零值为开启,手工构造的 Config 也能观测。 - AccountTrafficTelemetryDisabled bool `mapstructure:"account_traffic_telemetry_disabled"` // SessionIdleTimeoutMinutes: 会话空闲超时时间(分钟),默认 5 分钟 // 用于 Anthropic OAuth/SetupToken 账号的会话数量限制功能 // 空闲超过此时间的会话将被自动释放 @@ -2449,7 +2446,6 @@ func setDefaults() { viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.force_codex_cli", false) viper.SetDefault("gateway.openai_codex_request_zstd", true) - viper.SetDefault("gateway.account_traffic_telemetry_disabled", false) viper.SetDefault("gateway.openai_request_integrity_mode", "off") viper.SetDefault("gateway.disable_codex_identity_enforcement", false) viper.SetDefault("gateway.disable_codex_originator_normalization", false) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 6ae17eb5831e..4cae69c6e93a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -108,21 +108,20 @@ func (h *SettingHandler) GetOfficialModelContextCatalog(c *gin.Context) { }) } -// GetObservabilitySettings returns the account traffic telemetry and flat theme -// switches this process applies. +// GetObservabilitySettings returns the flat theme switch this process applies. // GET /api/v1/admin/settings/observability func (h *SettingHandler) GetObservabilitySettings(c *gin.Context) { response.Success(c, service.EffectiveAdminObservabilityConfig()) } -// UpdateObservabilitySettings saves the account traffic telemetry and flat theme -// switches. Omitted switches stay on. +// UpdateObservabilitySettings saves the flat theme switch. An omitted switch is +// on. // PUT /api/v1/admin/settings/observability func (h *SettingHandler) UpdateObservabilitySettings(c *gin.Context) { - req := service.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} + req := service.AdminObservabilityConfig{ThemeEnabled: true} raw, err := c.GetRawData() if err == nil { - err = service.DecodeSwitchSettings(raw, &req, "telemetry_enabled", "theme_enabled") + err = service.DecodeSwitchSettings(raw, &req, "theme_enabled") } if err != nil { response.BadRequest(c, "Invalid observability settings: "+err.Error()) diff --git a/backend/internal/repository/account_traffic_telemetry_switch_purge_migration_integration_test.go b/backend/internal/repository/account_traffic_telemetry_switch_purge_migration_integration_test.go new file mode 100644 index 000000000000..75f8a74b0e6e --- /dev/null +++ b/backend/internal/repository/account_traffic_telemetry_switch_purge_migration_integration_test.go @@ -0,0 +1,131 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const accountTrafficTelemetrySwitchPurgeMigration = "273_purge_account_traffic_telemetry_switch.sql" + +// TestMain applied every migration, 273 included, to an empty database, which +// stores no observability row. Each case stores the row as a database of an +// earlier release can hold it next to settings that must stay, applies 273, +// checks which key went and which rows were written, reads what is left as the +// application does at start, and applies 273 again. +func TestMigration273PurgesAccountTrafficTelemetrySwitch(t *testing.T) { + ctx := context.Background() + content, err := dbmigrations.FS.ReadFile(accountTrafficTelemetrySwitchPurgeMigration) + require.NoError(t, err) + migration := string(content) + + const observability = "admin_observability_config" + // The rows 273 must not write: the other switch setting, an object under + // another key that holds the same switch name, and text that is not JSON. + kept := map[string]string{ + "codex_runtime_config": `{"request_zstd":true}`, + "migration_273_other_object": `{"telemetry_enabled":true,"theme_enabled":true}`, + "migration_273_text_that_is_kept": `telemetry_enabled`, + } + + for _, tc := range []struct { + name string + // stored is the observability row before 273; absent stores none. + stored string + absent bool + // left is the JSON 273 leaves in a row it writes. A row it does not + // write has no left and keeps its bytes. + left string + // readable rows are JSON objects; themeOn is what their reader yields. + readable, themeOn bool + }{ + {name: "both switches on", stored: `{"telemetry_enabled":true,"theme_enabled":true}`, left: `{"theme_enabled":true}`, readable: true, themeOn: true}, + {name: "both switches off", stored: `{"telemetry_enabled":false,"theme_enabled":false}`, left: `{"theme_enabled":false}`, readable: true}, + {name: "telemetry off and theme on", stored: `{"telemetry_enabled":false,"theme_enabled":true}`, left: `{"theme_enabled":true}`, readable: true, themeOn: true}, + {name: "only the telemetry switch", stored: `{"telemetry_enabled":true}`, left: `{}`, readable: true, themeOn: true}, + {name: "only the theme switch", stored: `{"theme_enabled":false}`, readable: true}, + {name: "no switch", stored: `{}`, readable: true, themeOn: true}, + {name: "an array that names the switch", stored: `["telemetry_enabled"]`}, + {name: "text that is not JSON", stored: `telemetry_enabled`}, + {name: "no row", absent: true}, + } { + t.Run(tc.name, func(t *testing.T) { + tx := testTx(t) + exec := func(query string, args ...any) { + t.Helper() + _, err := tx.ExecContext(ctx, query, args...) + require.NoError(t, err) + } + text := func(query string, args ...any) string { + t.Helper() + var value string + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(query string, args ...any) int { + t.Helper() + var value int + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + put := func(key, value string) { + t.Helper() + exec(`INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, key, value) + } + // The row version and its bytes: equal as long as the row is not written. + row := func(key string) string { + t.Helper() + return text(`SELECT ctid::text || '/' || value FROM settings WHERE key = $1`, key) + } + + require.Zero(t, count(`SELECT count(*) FROM settings WHERE key = $1`, observability)) + if !tc.absent { + put(observability, tc.stored) + } + untouched := map[string]string{} + for key, value := range kept { + put(key, value) + untouched[key] = row(key) + } + rows := count(`SELECT count(*) FROM settings`) + before := "" + if !tc.absent { + before = row(observability) + } + + after := "" + for run := 1; run <= 2; run++ { + exec(migration) + + require.Equal(t, rows, count(`SELECT count(*) FROM settings`), "run %d adds and deletes no setting", run) + for key := range kept { + require.Equal(t, untouched[key], row(key), "run %d writes no other setting: %s", run, key) + } + if tc.absent { + continue + } + value := text(`SELECT value FROM settings WHERE key = $1`, observability) + if run == 1 { + after = row(observability) + require.Equal(t, tc.left != "", after != before, "only a row that held the switch is written") + } + require.Equal(t, after, row(observability), "run %d: the second run writes no row", run) + if tc.left != "" { + require.JSONEq(t, tc.left, value) + } else { + require.Equal(t, tc.stored, value) + } + if tc.readable { + config := service.AdminObservabilityConfig{ThemeEnabled: true} + require.NoError(t, service.DecodeSwitchSettings([]byte(value), &config, "theme_enabled"), "the strict reader accepts the row") + require.Equal(t, tc.themeOn, config.ThemeEnabled) + } + } + }) + } +} diff --git a/backend/internal/service/admin_observability.go b/backend/internal/service/admin_observability.go deleted file mode 100644 index c46c346aad93..000000000000 --- a/backend/internal/service/admin_observability.go +++ /dev/null @@ -1,36 +0,0 @@ -package service - -import ( - "sync/atomic" - - "github.com/Wei-Shaw/sub2api/internal/config" -) - -var adminObservabilityConfig atomic.Pointer[AdminObservabilityConfig] - -// defaultAdminObservabilityConfig is the deploy-time default: account traffic -// telemetry follows gateway.account_traffic_telemetry_disabled and the flat -// theme is on. -func defaultAdminObservabilityConfig(cfg *config.Config) AdminObservabilityConfig { - return AdminObservabilityConfig{TelemetryEnabled: cfg == nil || !cfg.Gateway.AccountTrafficTelemetryDisabled, ThemeEnabled: true} -} - -// ConfigureAdminObservability installs the effective telemetry and theme -// switches (startup load, admin update, tests). A nil value restores the -// built-in default. -func ConfigureAdminObservability(config *AdminObservabilityConfig) { - adminObservabilityConfig.Store(config) -} - -// EffectiveAdminObservabilityConfig returns the switches this process applies. -func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { - if config := adminObservabilityConfig.Load(); config != nil { - return *config - } - return defaultAdminObservabilityConfig(nil) -} - -// FlatThemeEnabled reports whether the flat site theme is switched on. -func FlatThemeEnabled() bool { - return EffectiveAdminObservabilityConfig().ThemeEnabled -} diff --git a/backend/internal/service/admin_observability_test.go b/backend/internal/service/admin_observability_test.go deleted file mode 100644 index f8b0e6950827..000000000000 --- a/backend/internal/service/admin_observability_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package service - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues(t *testing.T) { - config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} - require.NoError(t, DecodeSwitchSettings([]byte(`{"theme_enabled":false}`), &config, "telemetry_enabled", "theme_enabled")) - require.Equal(t, AdminObservabilityConfig{TelemetryEnabled: true}, config, "omitted switches keep their defaults") - for _, raw := range []string{`{"theme_enabled":null}`, `{"theme":true}`, `{"theme_enabled":"false"}`, `[]`, `null`} { - require.Error(t, DecodeSwitchSettings([]byte(raw), &config, "telemetry_enabled", "theme_enabled"), raw) - } -} diff --git a/backend/internal/service/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go index 2c61b2b284c5..c9fb9dc388a0 100644 --- a/backend/internal/service/setting_admin_observability.go +++ b/backend/internal/service/setting_admin_observability.go @@ -2,36 +2,53 @@ package service import ( "context" + "sync/atomic" ) -// SettingKeyAdminObservabilityConfig stores the account traffic telemetry and -// flat theme switches. +// SettingKeyAdminObservabilityConfig stores the flat theme switch. const SettingKeyAdminObservabilityConfig = "admin_observability_config" // AdminObservabilityConfig is the stored value of // SettingKeyAdminObservabilityConfig and the body of the observability settings // endpoints. type AdminObservabilityConfig struct { - TelemetryEnabled bool `json:"telemetry_enabled"` - ThemeEnabled bool `json:"theme_enabled"` + ThemeEnabled bool `json:"theme_enabled"` } -// LoadAdminObservabilityConfig installs the effective switches for this process -// at startup: the stored value, or the deploy-time default when none is stored. +var adminObservabilityConfig atomic.Pointer[AdminObservabilityConfig] + +// ConfigureAdminObservability installs the effective theme switch (startup +// load, admin update, tests). A nil value restores the built-in default. +func ConfigureAdminObservability(config *AdminObservabilityConfig) { + adminObservabilityConfig.Store(config) +} + +// EffectiveAdminObservabilityConfig returns the switch this process applies. +// The built-in default is the flat theme on. +func EffectiveAdminObservabilityConfig() AdminObservabilityConfig { + if config := adminObservabilityConfig.Load(); config != nil { + return *config + } + return AdminObservabilityConfig{ThemeEnabled: true} +} + +// FlatThemeEnabled reports whether the flat site theme is switched on. +func FlatThemeEnabled() bool { + return EffectiveAdminObservabilityConfig().ThemeEnabled +} + +// LoadAdminObservabilityConfig installs the effective switch for this process +// at startup: the stored value, or the flat theme on when none is stored. func (s *SettingService) LoadAdminObservabilityConfig(ctx context.Context) error { - config := AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} - found, err := s.readSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "telemetry_enabled", "theme_enabled") - if err != nil { + config := AdminObservabilityConfig{ThemeEnabled: true} + if _, err := s.readSwitchSetting(ctx, SettingKeyAdminObservabilityConfig, &config, "theme_enabled"); err != nil { return err } - if !found { - config = defaultAdminObservabilityConfig(s.cfg) - } ConfigureAdminObservability(&config) return nil } -// UpdateAdminObservabilityConfig persists the switches and applies them to this +// UpdateAdminObservabilityConfig persists the switch and applies it to this // process. func (s *SettingService) UpdateAdminObservabilityConfig(ctx context.Context, config AdminObservabilityConfig) error { if err := s.writeJSONSetting(ctx, SettingKeyAdminObservabilityConfig, config); err != nil { diff --git a/backend/internal/service/setting_switch_load_test.go b/backend/internal/service/setting_switch_load_test.go index a1a07de8403a..927e6b10cad6 100644 --- a/backend/internal/service/setting_switch_load_test.go +++ b/backend/internal/service/setting_switch_load_test.go @@ -36,7 +36,7 @@ func TestSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { }{ {name: "database error", err: errors.New("fixture database unavailable")}, {name: "invalid JSON", value: `{"broken"`}, - {name: "invalid value type", value: `{"telemetry_enabled":"false"}`}, + {name: "invalid value type", value: `{"theme_enabled":"false"}`}, {name: "empty existing key", value: ""}, } { t.Run(test.name, func(t *testing.T) { @@ -50,11 +50,24 @@ func TestSettingLoadFailureNeverReenablesSavedSwitches(t *testing.T) { } svc := NewSettingService(&switchReadRepository{err: ErrSettingNotFound}, nil) var value map[string]json.RawMessage - found, err := svc.readSwitchSetting(context.Background(), SettingKeyAdminObservabilityConfig, &value, "telemetry_enabled", "theme_enabled") + found, err := svc.readSwitchSetting(context.Background(), SettingKeyAdminObservabilityConfig, &value, "theme_enabled") require.NoError(t, err) require.False(t, found) } +func TestDecodeSwitchSettingsRejectsUnknownAndNonBooleanValues(t *testing.T) { + type switches struct { + Kept bool `json:"kept"` + Changed bool `json:"changed"` + } + config := switches{Kept: true, Changed: true} + require.NoError(t, DecodeSwitchSettings([]byte(`{"changed":false}`), &config, "kept", "changed")) + require.Equal(t, switches{Kept: true}, config, "omitted switches keep their defaults") + for _, raw := range []string{`{"changed":null}`, `{"change":true}`, `{"changed":"false"}`, `[]`, `null`} { + require.Error(t, DecodeSwitchSettings([]byte(raw), &config, "kept", "changed"), raw) + } +} + type codexRuntimeSettingRepository struct { SettingRepository values map[string]string diff --git a/backend/migrations/273_purge_account_traffic_telemetry_switch.sql b/backend/migrations/273_purge_account_traffic_telemetry_switch.sql new file mode 100644 index 000000000000..e5f1f246c6c4 --- /dev/null +++ b/backend/migrations/273_purge_account_traffic_telemetry_switch.sql @@ -0,0 +1,30 @@ +-- Remove the account traffic telemetry switch from the observability settings. +-- +-- settings.admin_observability_config is one JSON object of boolean switches. +-- Earlier releases stored two in it: telemetry_enabled, the switch of the +-- per-account traffic counters, and theme_enabled, the flat theme switch. The +-- counters and their switch are deleted. The reader of the row refuses a key +-- it does not know and stops startup, so the stored key goes with the switch. +-- +-- Only telemetry_enabled is removed, from that one row. theme_enabled keeps +-- its stored value; the object is written back as PostgreSQL prints jsonb, +-- which the reader accepts. A row without the key is not written, a value +-- that is not a JSON object is not written either (the reader refuses it, as +-- before), and a database without the row stays without it: the flat theme is +-- then on. No other setting is touched. The counters were Redis keys that +-- expire on their own; a migration does not reach them. +-- +-- An earlier image reads the missing switch as on. Saving the observability +-- settings in such an image stores the key again, and this migration is +-- recorded by then and does not run a second time: before returning to this +-- release, run the statement below by hand. +-- +-- The statement is a no-op once the key is gone, so the migration can run +-- again. No explicit BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +UPDATE settings +SET value = (value::jsonb - 'telemetry_enabled')::text, updated_at = NOW() +WHERE key = 'admin_observability_config' + AND CASE WHEN value IS JSON OBJECT THEN value::jsonb ? 'telemetry_enabled' ELSE FALSE END; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index e8be297d0dbd..05804b48322c 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1141,11 +1141,10 @@ export interface OfficialModelCapacityCatalog { } export interface ObservabilitySettings { - telemetry_enabled: boolean theme_enabled: boolean } -/** Account traffic telemetry and flat site theme switches. */ +/** Flat site theme switch. */ export async function getObservabilitySettings(): Promise { const { data } = await apiClient.get("/admin/settings/observability"); return data; diff --git a/frontend/src/components/admin/ObservabilitySettingsPanel.vue b/frontend/src/components/admin/ObservabilitySettingsPanel.vue index c74fea342d05..d04e6edc6bbd 100644 --- a/frontend/src/components/admin/ObservabilitySettingsPanel.vue +++ b/frontend/src/components/admin/ObservabilitySettingsPanel.vue @@ -4,14 +4,9 @@

{{ t('admin.settings.observability.title') }}

-

{{ t('admin.settings.observability.description') }}

{{ t('admin.settings.observability.loadFailed') }}: {{ loadError }}