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/.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..09ab20dcb0b3 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 @@ -110,14 +108,13 @@ 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 \ 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/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 \ @@ -141,7 +138,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/README.md b/README.md index 2b29ea23796f..8ec0b7f59c50 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -200,7 +200,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.26.6, Gin, Ent | +| Backend | Go 1.27.0, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | diff --git a/README_CN.md b/README_CN.md index 31e85705aa2c..70eef1990ddc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -202,7 +202,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.26.6, Gin, Ent | +| 后端 | Go 1.27.0, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/README_JA.md b/README_JA.md index 68e5583a165d..ca94ba55d39b 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.26.6-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -201,7 +201,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.26.6, Gin, Ent | +| バックエンド | Go 1.27.0, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | 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/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/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/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_handler.go b/backend/internal/handler/admin/account_handler.go index 6f9cd777de3b..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 } @@ -175,23 +169,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"` @@ -2637,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 eebe90a656a4..000000000000 --- a/backend/internal/handler/admin/account_handler_traffic_telemetry_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package admin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "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" -) - -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(&extensionv1.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/admin/account_job_handler.go b/backend/internal/handler/admin/account_job_handler.go index 8a5fa32840de..02c0a366e377 100644 --- a/backend/internal/handler/admin/account_job_handler.go +++ b/backend/internal/handler/admin/account_job_handler.go @@ -22,7 +22,7 @@ func NewAccountJobHandler(jobs *service.AccountJobService) *AccountJobHandler { func (h *AccountJobHandler) List(c *gin.Context) { page := positiveAccountJobQuery(c.Query("page"), 1) pageSize := positiveAccountJobQuery(c.Query("page_size"), 20) - jobs, err := h.jobs.List(c.Request.Context(), 0, c.Query("kind"), c.Query("status"), page, pageSize) + jobs, err := h.jobs.List(c.Request.Context(), c.Query("status"), page, pageSize) if err != nil { response.ErrorFrom(c, accountJobHTTPError(err)) return diff --git a/backend/internal/handler/admin/account_job_submit_test.go b/backend/internal/handler/admin/account_job_submit_test.go index ac54a9b98abe..cf7aa5238213 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{} @@ -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/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/handler/admin/account_test_plan.go b/backend/internal/handler/admin/account_test_plan.go index e3828dcb5834..f9ed1791edce 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) { @@ -38,14 +36,11 @@ 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 } - return ordinaryAccountTestPlan(account, raw) + return buildAccountTestPlan(account, raw) } // accountTestModelRows decodes a typed model catalog into its JSON rows. @@ -110,19 +105,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 +117,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 +144,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/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/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 870e7d7ad1ae..4cae69c6e93a 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" @@ -109,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 := extensionv1.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()) @@ -147,7 +145,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/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..2b8b91428761 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, @@ -260,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, @@ -289,7 +289,6 @@ func AccountFromServiceShallow(a *service.Account) *Account { GroupIDs: a.GroupIDs, ParentAccountID: a.ParentAccountID, QuotaDimension: a.QuotaDimension, - OpenCodeGoUsage: openCodeGoUsage, } // 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效) @@ -492,13 +491,11 @@ 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, + 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, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index cd65c900410e..9e913c511877 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 原文,其它敏感键仍不返回。 @@ -223,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"` @@ -241,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"` @@ -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/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/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/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/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/decision.go b/backend/internal/nativeapi/decision.go deleted file mode 100644 index e14b2f9c2cb8..000000000000 --- a/backend/internal/nativeapi/decision.go +++ /dev/null @@ -1,81 +0,0 @@ -package nativeapi - -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/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/repository/account_job_repo.go b/backend/internal/repository/account_job_repo.go index 7672a0987ec0..b5074807d3cd 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 } } @@ -225,29 +225,20 @@ func normalizeAccountJobPage(page, size int) (int, int) { return page, size } -func (r *accountJobRepository) List(ctx context.Context, createdBy int64, kind, status string, page, pageSize int) (*service.AccountJobList, error) { +func (r *accountJobRepository) List(ctx context.Context, status string, page, pageSize int) (*service.AccountJobList, error) { page, pageSize = normalizeAccountJobPage(page, pageSize) - where := []string{"1=1"} - args := make([]any, 0, 5) - if createdBy > 0 { - args = append(args, createdBy) - where = append(where, "created_by=$"+strconv.Itoa(len(args))) - } - if kind = strings.TrimSpace(kind); kind != "" { - args = append(args, kind) - where = append(where, "kind=$"+strconv.Itoa(len(args))) - } + args := make([]any, 0, 3) + where := "1=1" if status = strings.TrimSpace(status); status != "" { args = append(args, status) - where = append(where, "status=$"+strconv.Itoa(len(args))) + where += " AND status=$1" } - whereSQL := strings.Join(where, " AND ") var total int64 - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM admin_account_jobs WHERE "+whereSQL, args...).Scan(&total); err != nil { + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM admin_account_jobs WHERE "+where, args...).Scan(&total); err != nil { return nil, err } args = append(args, pageSize, (page-1)*pageSize) - rows, err := r.db.QueryContext(ctx, `SELECT `+accountJobSelectColumns+` FROM admin_account_jobs WHERE `+whereSQL+ + rows, err := r.db.QueryContext(ctx, `SELECT `+accountJobSelectColumns+` FROM admin_account_jobs WHERE `+where+ ` ORDER BY created_at DESC, id DESC LIMIT $`+strconv.Itoa(len(args)-1)+` OFFSET $`+strconv.Itoa(len(args)), args...) if err != nil { return nil, err @@ -610,7 +601,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 +615,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/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/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/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/api_contract_test.go b/backend/internal/server/api_contract_test.go index bb8ec546a842..0865dd261ae5 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1623,7 +1623,7 @@ func (*contractAccountJobRepo) FindIdempotent(context.Context, int64, string, st func (*contractAccountJobRepo) Get(context.Context, int64) (*service.AccountJob, error) { return nil, service.ErrAccountJobNotFound } -func (*contractAccountJobRepo) List(context.Context, int64, string, string, int, int) (*service.AccountJobList, error) { +func (*contractAccountJobRepo) List(context.Context, string, int, int) (*service.AccountJobList, error) { return &service.AccountJobList{}, nil } func (*contractAccountJobRepo) ResultAccountIDs(context.Context, int64) ([]int64, error) { 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/server/routes/admin.go b/backend/internal/server/routes/admin.go index c59bc6f66024..8c1e0688458a 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) @@ -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_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/account_job.go b/backend/internal/service/account_job.go index 98526dfad8b6..d9bf61f683d0 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 } @@ -145,7 +143,7 @@ type AccountJobRepository interface { Create(context.Context, CreateAccountJobParams) (*AccountJob, bool, error) FindIdempotent(context.Context, int64, string, string) (*AccountJob, error) Get(context.Context, int64) (*AccountJob, error) - List(context.Context, int64, string, string, int, int) (*AccountJobList, error) + List(context.Context, string, int, int) (*AccountJobList, error) ListItems(context.Context, int64, string, int, int) (*AccountJobItemList, error) MarkInterrupted(context.Context) error Claim(context.Context) (*AccountJob, error) @@ -260,8 +258,8 @@ func (s *AccountJobService) Get(ctx context.Context, jobID int64) (*AccountJob, return job, err } -func (s *AccountJobService) List(ctx context.Context, createdBy int64, kind, status string, page, pageSize int) (*AccountJobList, error) { - list, err := s.repo.List(ctx, createdBy, kind, status, page, pageSize) +func (s *AccountJobService) List(ctx context.Context, status string, page, pageSize int) (*AccountJobList, error) { + list, err := s.repo.List(ctx, status, page, pageSize) if err == nil && list != nil { for i := range list.Items { s.decorateRetryEligibility(ctx, &list.Items[i]) diff --git a/backend/internal/service/account_job_test.go b/backend/internal/service/account_job_test.go index ab83e46f623a..ec169b6d426c 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 @@ -111,7 +111,7 @@ func (r *accountJobTestRepo) Get(_ context.Context, id int64) (*AccountJob, erro return cloneAccountJob(job), nil } -func (r *accountJobTestRepo) List(context.Context, int64, string, string, int, int) (*AccountJobList, error) { +func (r *accountJobTestRepo) List(context.Context, string, int, int) (*AccountJobList, error) { return &AccountJobList{}, nil } @@ -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/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_traffic_observe.go b/backend/internal/service/account_traffic_observe.go deleted file mode 100644 index 87afa355aac8..000000000000 --- a/backend/internal/service/account_traffic_observe.go +++ /dev/null @@ -1,224 +0,0 @@ -package service - -import ( - "context" - "errors" - "strconv" - "sync" - "time" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "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 = extensionv1.AccountTrafficCounters - -// 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 is the legacy gateway flag. -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 extensionv1.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 extensionv1.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/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/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/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/admin_observability.go b/backend/internal/service/admin_observability.go deleted file mode 100644 index ebbabc08ede9..000000000000 --- a/backend/internal/service/admin_observability.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "sync/atomic" - - "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" -) - -var adminObservabilityConfigOverride atomic.Pointer[extensionv1.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} -} - -// 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) { - adminObservabilityConfigOverride.Store(config) -} - -func currentAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { - if config := adminObservabilityConfigOverride.Load(); config != nil { - return *config - } - return LegacyAdminObservabilityConfig(nil) -} - -// FlatThemeEnabled reports whether the flat site theme is switched on. -func FlatThemeEnabled() bool { - return currentAdminObservabilityConfig().ThemeEnabled -} - -// 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) { - if account == nil || account.ID <= 0 || account.Platform == "" || account.Platform == "*" || account.Type == "" || account.Type == "*" { - return extensionv1.TrafficObservationPolicy{}, false - } - return extensionv1.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} - } - num := func(field, op, value string) extensionv1.DecisionCondition { - return extensionv1.DecisionCondition{Field: field, Operator: op, Value: value} - } - rule := func(result AccountTrafficOutcome, when ...extensionv1.DecisionCondition) extensionv1.DecisionRule { - return extensionv1.DecisionRule{When: when, Result: string(result)} - } - return extensionv1.DecisionTable{Default: string(AccountTrafficOutcomeFailedOther), Rules: []extensionv1.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 deleted file mode 100644 index eca865d56eff..000000000000 --- a/backend/internal/service/admin_observability_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package service - -import ( - "context" - "strconv" - "testing" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "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 extensionv1.AdminObservabilityConfig) { - t.Helper() - previous := adminObservabilityConfigOverride.Load() - t.Cleanup(func() { adminObservabilityConfigOverride.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, extensionv1.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, extensionv1.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, extensionv1.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{}) - 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 := extensionv1.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") - 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/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 9f24b55c3da2..6e5aead9a260 100644 --- a/backend/internal/service/codex_error_diagnostics_test.go +++ b/backend/internal/service/codex_error_diagnostics_test.go @@ -6,9 +6,9 @@ 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. -func TestNativeCodexErrorDiagnosticsProjectEveryStoredAttempt(t *testing.T) { +// 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"}}}}, {"account_id":2,"platform":"anthropic","continuation_diagnostic":{"classification":"invalid_encrypted_content"}}, 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/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..2c2fbfc2dea1 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 @@ -26,20 +24,14 @@ 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 { 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 +98,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/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_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/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 d391269dbe8b..85540c8c7cde 100644 --- a/backend/internal/service/openai_codex_client_identity.go +++ b/backend/internal/service/openai_codex_client_identity.go @@ -2,14 +2,14 @@ package service import ( "context" + "crypto/sha256" "encoding/json" "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 +18,167 @@ 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) } -// UserAgent 返回该身份在给定版本号下的 Codex TUI User-Agent;身份或版本号不合法时返回空串。 -func (id codexClientIdentity) UserAgent(version string) string { - userAgent, _ := codexprofile.UserAgent(extensionv1.CodexClientProfile(id), version) - return userAgent -} +// codexSandboxForUserAgent 从有效出站 UA 的 `( ; )` 段推出与之配套的 +// sandbox 标签;无法解析时返回空串(调用方不改写 sandbox)。turn metadata 声明的沙箱 +// 必须跟随最终发出的 UA,而不是账号身份本身:管理员显式 UA 覆盖时二者可能不同。 +var codexUserAgentPlatformPattern = regexp.MustCompile(`\(([^();]+?) [^ ();]+; [^();]+\)`) func codexSandboxForUserAgent(userAgent string) string { - return codexprofile.SandboxForUserAgent(userAgent) + 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 { + 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 { - 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..4b7f4dec773f 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" @@ -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, codexprofile.ErrInvalidVersion) - headers := http.Header{"Originator": []string{"fixture-original"}} - err = enforceCodexIdentityHeadersForAccount(headers, account, "") - require.ErrorIs(t, err, codexprofile.ErrInvalidVersion) - 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.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,6 +228,36 @@ func (id codexClientIdentity) withoutGeneratedAt() codexClientIdentity { return id } +// 同一种子派生同一身份,派生的每个分支都通过 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 := 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)) + + bad := first + bad.Terminal = "terminal\r\nx-header: value" + require.False(t, bad.valid(), "header injection 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 也用派生身份。 func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) { t.Cleanup(func() { SetCodexForceCLIEnabled(false) }) @@ -246,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 bd0e9d425ce2..6fcd6ef567e0 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" ) @@ -274,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 := codexprofile.UserAgent(extensionv1.CodexClientProfile(identity), 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_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_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_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/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/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/setting_admin_observability.go b/backend/internal/service/setting_admin_observability.go index 78af5ac845dc..c9fb9dc388a0 100644 --- a/backend/internal/service/setting_admin_observability.go +++ b/backend/internal/service/setting_admin_observability.go @@ -2,41 +2,55 @@ package service import ( "context" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + "sync/atomic" ) -// SettingKeyAdminObservabilityConfig stores the account traffic telemetry and -// flat theme switches. +// SettingKeyAdminObservabilityConfig stores the flat theme switch. const SettingKeyAdminObservabilityConfig = "admin_observability_config" -// EffectiveAdminObservabilityConfig returns the switches this process applies. -func EffectiveAdminObservabilityConfig() extensionv1.AdminObservabilityConfig { - return currentAdminObservabilityConfig() +// AdminObservabilityConfig is the stored value of +// SettingKeyAdminObservabilityConfig and the body of the observability settings +// endpoints. +type AdminObservabilityConfig struct { + ThemeEnabled bool `json:"theme_enabled"` +} + +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} } -// LoadAdminObservabilityConfig installs the effective switches for this process -// at startup: the stored value, or the deploy-time default when none is stored. +// 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 := extensionv1.AdminObservabilityConfig{TelemetryEnabled: true, ThemeEnabled: true} - found, err := s.readNativeSwitchSetting(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 { - if s == nil { - config = LegacyAdminObservabilityConfig(nil) - } else { - config = LegacyAdminObservabilityConfig(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 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..cdf5aaa13ba8 100644 --- a/backend/internal/service/setting_codex_runtime.go +++ b/backend/internal/service/setting_codex_runtime.go @@ -2,25 +2,29 @@ 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} - found, err := s.readNativeSwitchSetting(ctx, SettingKeyCodexRuntimeConfig, &config, "request_zstd") + config := CodexRuntimeConfig{RequestZstd: true} + found, err := s.readSwitchSetting(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 } 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/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/native_setting_load_test.go b/backend/internal/service/setting_switch_load_test.go similarity index 69% rename from backend/internal/service/native_setting_load_test.go rename to backend/internal/service/setting_switch_load_test.go index c7fda2ce243f..927e6b10cad6 100644 --- a/backend/internal/service/native_setting_load_test.go +++ b/backend/internal/service/setting_switch_load_test.go @@ -7,28 +7,27 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "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() { ConfigureAdminObservability(&oldObservability) SetCodexRequestZstdEnabled(oldCodexRuntime.RequestZstd) }) - ConfigureAdminObservability(&extensionv1.AdminObservabilityConfig{}) + ConfigureAdminObservability(&AdminObservabilityConfig{}) SetCodexRequestZstdEnabled(false) for _, test := range []struct { name string @@ -37,25 +36,38 @@ func TestNativeSettingLoadFailureNeverReenablesSavedSwitches(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) { - 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())) } - 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) + 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, "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 @@ -81,7 +93,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 +119,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 +131,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/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"` 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 } 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, 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/docs/channel-monitor-v2-safe-defaults.md b/docs/channel-monitor-v2-safe-defaults.md index 81fe9f5f917d..75a9c93969fe 100644 --- a/docs/channel-monitor-v2-safe-defaults.md +++ b/docs/channel-monitor-v2-safe-defaults.md @@ -1,7 +1,7 @@ # Channel Monitor V2 Safe Defaults & Gentle Backfill -**Date:** 2026-08-08 -**Status:** Approved for implementation +**Date:** 2026-08-08 +**Status:** Approved for implementation **Branch:** `fix/channel-monitor-v2-ops-ui-blockers` (onto channel-monitor-v2) ## Problem @@ -47,7 +47,7 @@ ### Error dedup -- Bound the `request_id IN candidate_ids` branch with +- Bound the `request_id IN candidate_ids` branch with `created_at >= $1 - INTERVAL '90 minutes' AND created_at < $2`. ## Out of scope diff --git a/frontend/src/api/accountOperationIdempotency.ts b/frontend/src/api/accountOperationIdempotency.ts index 2a91e97b8088..01cf474ea79c 100644 --- a/frontend/src/api/accountOperationIdempotency.ts +++ b/frontend/src/api/accountOperationIdempotency.ts @@ -42,9 +42,3 @@ export function settleAccountOperation(config?: OperationConfig): void { if (config?.accountOperationSignature) unresolved.delete(config.accountOperationSignature) } export function clearAccountOperationKeys(): void { unresolved.clear(); session++ } - -// Reuse the same actor/session boundary as pending submission keys. Token -// refresh does not invalidate a session; logout/reset does, even for one actor. -export function captureAccountOperationSession(): string { - return `${session}:${currentAccountOperationActor() ?? 'anonymous'}` -} diff --git a/frontend/src/api/admin/__tests__/accountJobs.spec.ts b/frontend/src/api/admin/__tests__/accountJobs.spec.ts index b034adb8cdab..87ef2f6b0ffc 100644 --- a/frontend/src/api/admin/__tests__/accountJobs.spec.ts +++ b/frontend/src/api/admin/__tests__/accountJobs.spec.ts @@ -21,7 +21,7 @@ describe('accountJobsAPI', () => { const controller = new AbortController() await accountJobsAPI.list( - { kind: 'account_import', status: 'running', page: 2, page_size: 25 }, + { status: 'running', page: 2, page_size: 25 }, { signal: controller.signal }, ) await accountJobsAPI.get(41, { signal: controller.signal }) @@ -33,7 +33,7 @@ describe('accountJobsAPI', () => { await accountJobsAPI.cancel(41) expect(get).toHaveBeenNthCalledWith(1, '/admin/account-jobs', { - params: { kind: 'account_import', status: 'running', page: 2, page_size: 25 }, + params: { status: 'running', page: 2, page_size: 25 }, signal: controller.signal, }) expect(get).toHaveBeenNthCalledWith(2, '/admin/account-jobs/41', { @@ -46,39 +46,20 @@ describe('accountJobsAPI', () => { expect(post).toHaveBeenNthCalledWith(1, '/admin/account-jobs/41/cancel') }) - it('adds a fresh Idempotency-Key to retry and duplicate job submissions', async () => { + it('adds a fresh Idempotency-Key to each retry submission', async () => { + await accountJobsAPI.retryFailed(41) await accountJobsAPI.retryFailed(41) - await accountJobsAPI.reviewDuplicates([7, 8]) - await accountJobsAPI.mergeDuplicates({ - survivor_account_id: 7, - loser_account_ids: [8], - confirmation_hash: 'a'.repeat(64), - }) - expect(post).toHaveBeenNthCalledWith( - 1, - '/admin/account-jobs/41/retry-failed', - undefined, - { headers: { 'Idempotency-Key': expect.stringMatching(/^account_job_retry-/) } }, - ) - expect(post).toHaveBeenNthCalledWith( - 2, - '/admin/accounts/duplicates/review', - { account_ids: [7, 8] }, - { headers: { 'Idempotency-Key': expect.stringMatching(/^account_duplicate_review-/) } }, - ) - expect(post).toHaveBeenNthCalledWith( - 3, - '/admin/accounts/duplicates/merge', - { - survivor_account_id: 7, - loser_account_ids: [8], - confirmation_hash: 'a'.repeat(64), - }, - { headers: { 'Idempotency-Key': expect.stringMatching(/^account_duplicate_merge-/) } }, - ) + for (const call of [1, 2]) { + expect(post).toHaveBeenNthCalledWith( + call, + '/admin/account-jobs/41/retry-failed', + undefined, + { headers: { 'Idempotency-Key': expect.stringMatching(/^account_job_retry-/) } }, + ) + } const keys = post.mock.calls.map((call) => call[2]?.headers?.['Idempotency-Key']) - expect(new Set(keys).size).toBe(3) + expect(new Set(keys).size).toBe(2) }) }) diff --git a/frontend/src/api/admin/accountJobs.ts b/frontend/src/api/admin/accountJobs.ts index 894123a9341b..b7a49f797bc1 100644 --- a/frontend/src/api/admin/accountJobs.ts +++ b/frontend/src/api/admin/accountJobs.ts @@ -1,6 +1,4 @@ import { apiClient } from '../client' -import { accountViewClient } from './accountViewClient' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' const BASE_PATH = '/admin/account-jobs' @@ -42,7 +40,6 @@ export interface AccountJobItem { id: number job_id: number ordinal: number - action?: string target_account_id?: number status: AccountJobItemStatus metadata: Record @@ -69,7 +66,6 @@ export interface AccountJobItemPage { } export interface AccountJobListParams { - kind?: string status?: string page?: number page_size?: number @@ -81,25 +77,6 @@ export interface AccountJobItemListParams { page_size?: number } -export interface DuplicateMergeRequest { - survivor_account_id: number - loser_account_ids: number[] - confirmation_hash: string -} - -export interface DuplicateReviewAccount { - account_id: number - name: string - group_count: number - tag_count: number - configuration_score: number -} - -export interface DuplicateReviewMetadata { - confirmation_hash: string - accounts: DuplicateReviewAccount[] -} - export function createAccountJobIdempotencyKey(scope: string): string { const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}` @@ -158,24 +135,6 @@ async function retryFailed(jobID: number): Promise { return data } -async function reviewDuplicates(accountIDs: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( - '/admin/accounts/duplicates/review', - { account_ids: accountIDs }, - accountJobIdempotencyHeaders('account_duplicate_review'), - ) - return data -} - -async function mergeDuplicates(request: DuplicateMergeRequest): Promise { - const { data } = await apiClient.post( - '/admin/accounts/duplicates/merge', - request, - accountJobIdempotencyHeaders('account_duplicate_merge'), - ) - return data -} - const accountJobsAPI = { async resultAccountIDs(jobID: number): Promise { const { data } = await apiClient.get<{ account_ids: number[] }>(`${BASE_PATH}/${jobID}/result-account-ids`) @@ -186,8 +145,6 @@ const accountJobsAPI = { listItems, cancel, retryFailed, - reviewDuplicates, - mergeDuplicates, } export default accountJobsAPI diff --git a/frontend/src/api/admin/accountViewClient.ts b/frontend/src/api/admin/accountViewClient.ts deleted file mode 100644 index 9316c6f4a08b..000000000000 --- a/frontend/src/api/admin/accountViewClient.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AxiosRequestConfig, AxiosResponse } from 'axios' -import { apiClient } from '../client' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' - -export interface AccountViewRequestOptions { view?: CapturedAccountView; signal?: AbortSignal; etag?: string | null } -export interface AccountHTTPClient { - request(config: AxiosRequestConfig): Promise> - get(url: string, config?: AxiosRequestConfig): Promise> - post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise> - put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise> - delete(url: string, config?: AxiosRequestConfig): Promise> -} - -/** Streaming callers share the same native read guard without adding view metadata. */ -export function accountViewRequestConfig(scope: CapturedAccountView, config: AxiosRequestConfig): AxiosRequestConfig { - scope.assertCurrent() - return config -} - -/** Per-call client only: no route/global Axios interceptor or mutable authority. */ -export function accountViewClient(scope?: CapturedAccountView): AccountHTTPClient { - if (!scope) return apiClient - const request = async (config: AxiosRequestConfig): Promise> => { - scope.assertCurrent() - const response = await apiClient.request(config) - scope.assertCurrent() - return response - } - return { - request, - get: (url: string, config?: AxiosRequestConfig) => request({ ...config, url, method: 'GET' }), - post: (url: string, data?: unknown, config?: AxiosRequestConfig) => request({ ...config, url, data, method: 'POST' }), - put: (url: string, data?: unknown, config?: AxiosRequestConfig) => request({ ...config, url, data, method: 'PUT' }), - delete: (url: string, config?: AxiosRequestConfig) => request({ ...config, url, method: 'DELETE' }) - } -} diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index df548b6e0e73..3a23e81d2a0f 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -5,8 +5,6 @@ import { apiClient, buildApiUrl } from '../client' import { ADMIN_UI_REQUEST_HEADER } from '../adminUIRequest' -import { accountViewClient } from './accountViewClient' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' import { accountJobIdempotencyHeaders, type AccountJob @@ -79,9 +77,9 @@ export async function list( filters?: AccountListFilters, options?: { signal?: AbortSignal - }, view?: CapturedAccountView + } ): Promise> { - const { data } = await accountViewClient(view).get>('/admin/accounts', { + const { data } = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -97,8 +95,8 @@ export async function list( * @param digestTerm - "sha256:" search term of the key (utils/accountKeySearch); the key itself is not sent * @returns Whether at least one account has that key, whatever the list is filtered by */ -export async function hasAPIKeyDigest(digestTerm: string, view?: CapturedAccountView): Promise { - const { total } = await list(1, 1, { search: digestTerm, lite: '1' }, undefined, view) +export async function hasAPIKeyDigest(digestTerm: string): Promise { + const { total } = await list(1, 1, { search: digestTerm, lite: '1' }) return total > 0 } @@ -121,12 +119,12 @@ export async function getUpstreamBillingRatesWithEtag( options?: { signal?: AbortSignal etag?: string | null - }, view?: CapturedAccountView + } ): Promise { const headers: Record = {} if (options?.etag) headers['If-None-Match'] = options.etag - const response = await accountViewClient(view).get('/admin/accounts/upstream-billing-rates', { + const response = await apiClient.get('/admin/accounts/upstream-billing-rates', { params: { page, page_size: pageSize, ...filters }, headers, signal: options?.signal, @@ -145,14 +143,14 @@ export async function listWithEtag( options?: { signal?: AbortSignal etag?: string | null - }, view?: CapturedAccountView + } ): Promise { const headers: Record = {} if (options?.etag) { headers['If-None-Match'] = options.etag } - const response = await accountViewClient(view).get>('/admin/accounts', { + const response = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -184,8 +182,8 @@ export async function listWithEtag( * @param id - Account ID * @returns Account details */ -export async function getById(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}`) +export async function getById(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}`) return data } @@ -193,16 +191,16 @@ export interface AccountAPIKeyVisibility { enabled: boolean } -export async function getAPIKeyVisibility(view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/api-key-visibility') +export async function getAPIKeyVisibility(): Promise { + const { data } = await apiClient.get('/admin/accounts/api-key-visibility') return data } export async function setAPIKeyVisibility(payload: { enabled: boolean password?: string -}, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).put('/admin/accounts/api-key-visibility', payload) +}): Promise { + const { data } = await apiClient.put('/admin/accounts/api-key-visibility', payload) return data } @@ -244,7 +242,7 @@ function storeDuplicateOperationKey(id: number, key: string | null): void { } } -export async function duplicate(id: number, view?: CapturedAccountView): Promise { +export async function duplicate(id: number): Promise { let idempotencyKey = duplicateOperationKeys.get(id) ?? getStoredDuplicateOperationKey(id) if (!idempotencyKey) { const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` @@ -252,7 +250,7 @@ export async function duplicate(id: number, view?: CapturedAccountView): Promise } duplicateOperationKeys.set(id, idempotencyKey) storeDuplicateOperationKey(id, idempotencyKey) - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/duplicate`, undefined, { + const { data } = await apiClient.post(`/admin/accounts/${id}/duplicate`, undefined, { headers: { 'Idempotency-Key': idempotencyKey } }) duplicateOperationKeys.delete(id) @@ -266,13 +264,13 @@ export async function duplicate(id: number, view?: CapturedAccountView): Promise * @param updates - Fields to update * @returns Updated account */ -export async function update(id: number, updates: UpdateAccountRequest, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).put(`/admin/accounts/${id}`, updates) +export async function update(id: number, updates: UpdateAccountRequest): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}`, updates) return data } -export async function getGrokMediaEligibility(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function getGrokMediaEligibility(id: number): Promise { + const { data } = await apiClient.get( `/admin/accounts/${id}/grok-media-eligibility` ) return data @@ -280,9 +278,9 @@ export async function getGrokMediaEligibility(id: number, view?: CapturedAccount export async function updateGrokMediaEligibility( id: number, - mode: GrokMediaEligibilityMode, view?: CapturedAccountView + mode: GrokMediaEligibilityMode ): Promise { - const { data } = await accountViewClient(view).put( + const { data } = await apiClient.put( `/admin/accounts/${id}/grok-media-eligibility`, { mode } ) @@ -293,9 +291,9 @@ export async function updateGrokMediaEligibility( * Check mixed-channel risk for account-group binding. */ export async function checkMixedChannelRisk( - payload: CheckMixedChannelRequest, view?: CapturedAccountView + payload: CheckMixedChannelRequest ): Promise { - const { data } = await accountViewClient(view).post('/admin/accounts/check-mixed-channel', payload) + const { data } = await apiClient.post('/admin/accounts/check-mixed-channel', payload) return data } @@ -304,8 +302,8 @@ export async function checkMixedChannelRisk( * @param id - Account ID * @returns Success confirmation */ -export async function deleteAccount(id: number, view?: CapturedAccountView): Promise<{ message: string }> { - const { data } = await accountViewClient(view).delete<{ message: string }>(`/admin/accounts/${id}`) +export async function deleteAccount(id: number): Promise<{ message: string }> { + const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`) return data } @@ -315,8 +313,8 @@ export async function deleteAccount(id: number, view?: CapturedAccountView): Pro * @param status - New status * @returns Updated account */ -export async function toggleStatus(id: number, status: 'active' | 'inactive', view?: CapturedAccountView): Promise { - return update(id, { status }, view) +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { + return update(id, { status }) } /** @@ -324,12 +322,12 @@ export async function toggleStatus(id: number, status: 'active' | 'inactive', vi * @param id - Account ID * @returns Test result */ -export async function testAccount(id: number, view?: CapturedAccountView): Promise<{ +export async function testAccount(id: number): Promise<{ success: boolean message: string latency_ms?: number }> { - const { data } = await accountViewClient(view).post<{ + const { data } = await apiClient.post<{ success: boolean message: string latency_ms?: number @@ -400,8 +398,8 @@ export type RefreshCredentialsResult = | { account: Account; message: string; warning: 'missing_project_id_temporary' } | { account: Account; message?: never; warning?: never } -export async function refreshCredentials(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/refresh`) +export async function refreshCredentials(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/refresh`) return 'account' in data ? data : { account: data } } @@ -420,9 +418,9 @@ export async function applyOAuthCredentials( type: 'oauth' | 'setup-token' credentials: Record extra?: Record - }, view?: CapturedAccountView + } ): Promise { - const { data } = await accountViewClient(view).post( + const { data } = await apiClient.post( `/admin/accounts/${id}/apply-oauth-credentials`, payload ) @@ -435,8 +433,8 @@ export async function applyOAuthCredentials( * @param days - Number of days (default: 30) * @returns Account usage statistics with history, summary, and models */ -export async function getStats(id: number, days: number = 30, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/stats`, { +export async function getStats(id: number, days: number = 30): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/stats`, { params: { days } }) return data @@ -447,8 +445,8 @@ export async function getStats(id: number, days: number = 30, view?: CapturedAcc * @param id - Account ID * @returns Updated account */ -export async function clearError(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/clear-error`) +export async function clearError(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/clear-error`) return data } @@ -457,11 +455,11 @@ export async function clearError(id: number, view?: CapturedAccountView): Promis * @param id - Account ID * @returns Account usage info */ -export async function getUsage(id: number, source?: 'passive' | 'active', force?: boolean, view?: CapturedAccountView): Promise { +export async function getUsage(id: number, source?: 'passive' | 'active', force?: boolean): Promise { const params: Record = {} if (source) params.source = source if (force) params.force = 'true' - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/usage`, { + const { data } = await apiClient.get(`/admin/accounts/${id}/usage`, { params: Object.keys(params).length > 0 ? params : undefined }) return data @@ -472,8 +470,8 @@ export interface BatchAccountUsageResponse { errors: Record } -export async function getBatchUsage(accountIds: number[], force?: boolean, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post('/admin/accounts/usage/batch', { +export async function getBatchUsage(accountIds: number[], force?: boolean): Promise { + const { data } = await apiClient.post('/admin/accounts/usage/batch', { account_ids: accountIds, force: force === true }) @@ -485,8 +483,8 @@ export async function getBatchUsage(accountIds: number[], force?: boolean, view? * @param id - Account ID * @returns Updated account */ -export async function clearRateLimit(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function clearRateLimit(id: number): Promise { + const { data } = await apiClient.post( `/admin/accounts/${id}/clear-rate-limit` ) return data @@ -497,8 +495,8 @@ export async function clearRateLimit(id: number, view?: CapturedAccountView): Pr * @param id - Account ID * @returns Updated account */ -export async function recoverState(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/recover-state`) +export async function recoverState(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/recover-state`) return data } @@ -507,8 +505,8 @@ export async function recoverState(id: number, view?: CapturedAccountView): Prom * @param id - Account ID * @returns Updated account */ -export async function resetAccountQuota(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function resetAccountQuota(id: number): Promise { + const { data } = await apiClient.post( `/admin/accounts/${id}/reset-quota` ) return data @@ -519,8 +517,8 @@ export async function resetAccountQuota(id: number, view?: CapturedAccountView): * @param id - Account ID * @returns Status with detail state if active */ -export async function getTempUnschedulableStatus(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function getTempUnschedulableStatus(id: number): Promise { + const { data } = await apiClient.get( `/admin/accounts/${id}/temp-unschedulable` ) return data @@ -531,8 +529,8 @@ export async function getTempUnschedulableStatus(id: number, view?: CapturedAcco * @param id - Account ID * @returns Success confirmation */ -export async function resetTempUnschedulable(id: number, view?: CapturedAccountView): Promise<{ message: string }> { - const { data } = await accountViewClient(view).delete<{ message: string }>( +export async function resetTempUnschedulable(id: number): Promise<{ message: string }> { + const { data } = await apiClient.delete<{ message: string }>( `/admin/accounts/${id}/temp-unschedulable` ) return data @@ -589,8 +587,8 @@ export async function batchUpdateCredentials(request: { account_ids: number[] field: string value: any -}, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +}): Promise { + const { data } = await apiClient.post( '/admin/accounts/batch-update-credentials', request, accountJobIdempotencyHeaders('account_batch_update_credentials') @@ -606,7 +604,7 @@ export async function batchUpdateCredentials(request: { */ export async function bulkUpdate( accountIdsOrPayload: number[] | Record, - updates?: Record, view?: CapturedAccountView + updates?: Record ): Promise { const payload = Array.isArray(accountIdsOrPayload) ? { @@ -614,7 +612,7 @@ export async function bulkUpdate( ...(updates ?? {}) } : accountIdsOrPayload - const { data } = await accountViewClient(view).post( + const { data } = await apiClient.post( '/admin/accounts/bulk-update', payload, accountJobIdempotencyHeaders('account_bulk_update') @@ -627,8 +625,8 @@ export async function bulkUpdate( * @param id - Account ID * @returns Today's stats (requests, tokens, cost) */ -export async function getTodayStats(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/today-stats`) +export async function getTodayStats(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/today-stats`) return data } @@ -641,8 +639,8 @@ export interface BatchTodayStatsResponse { * @param accountIds - 账号 ID 列表 * @returns 以账号 ID(字符串)为键的统计映射 */ -export async function getBatchTodayStats(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post('/admin/accounts/today-stats/batch', { +export async function getBatchTodayStats(accountIds: number[]): Promise { + const { data } = await apiClient.post('/admin/accounts/today-stats/batch', { account_ids: accountIds }) return data @@ -654,8 +652,8 @@ export async function getBatchTodayStats(accountIds: number[], view?: CapturedAc * @param schedulable - Whether the account should participate in scheduling * @returns Updated account */ -export async function setSchedulable(id: number, schedulable: boolean, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/schedulable`, { +export async function setSchedulable(id: number, schedulable: boolean): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/schedulable`, { schedulable }) return data @@ -666,13 +664,13 @@ export async function setSchedulable(id: number, schedulable: boolean, view?: Ca * @param id - Account ID * @returns List of available models for this account */ -export async function getAvailableModels(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/models`) +export async function getAvailableModels(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/models`) return data } -export async function getAccountTestPlan(id: number, signal?: AbortSignal, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/models`, { +export async function getAccountTestPlan(id: number, signal?: AbortSignal): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/models`, { params: { view: 'account-test-plan-v1' }, signal, }) return data @@ -743,8 +741,8 @@ export interface ModelContextCapacitiesResult { } /** Read persisted observations and the local official catalog without contacting upstream. */ -export async function getModelContextCapacities(id: number, signal?: AbortSignal, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/models/context-capacities`, { signal }) +export async function getModelContextCapacities(id: number, signal?: AbortSignal): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/models/context-capacities`, { signal }) return data } @@ -792,8 +790,8 @@ export interface UpstreamModelMetadata { * @param id - Account ID * @returns List of model IDs returned by the upstream */ -export async function syncUpstreamModels(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/models/sync-upstream`) +export async function syncUpstreamModels(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/models/sync-upstream`) return data } @@ -881,7 +879,7 @@ export async function exportData(options?: { ids?: number[] filters?: AccountListFilters includeProxies?: boolean -}, view?: CapturedAccountView): Promise { +}): Promise { const params: Record = {} if (options?.ids && options.ids.length > 0) { params.ids = options.ids.join(',') @@ -899,7 +897,7 @@ export async function exportData(options?: { if (options?.includeProxies === false) { params.include_proxies = 'false' } - const { data } = await accountViewClient(view).get('/admin/accounts/data', { params }) + const { data } = await apiClient.get('/admin/accounts/data', { params }) return data } @@ -936,8 +934,8 @@ export async function previewImportData(payload: { return data } -export async function listFolders(view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/folders') +export async function listFolders(): Promise { + const { data } = await apiClient.get('/admin/accounts/folders') return data } @@ -968,8 +966,8 @@ export async function reorderFolders(orderedIds: number[]): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/tags') +export async function listTags(): Promise { + const { data } = await apiClient.get('/admin/accounts/tags') return data } @@ -1000,21 +998,21 @@ export async function reorderTags(orderedIds: number[]): Promise { - const { data } = await accountViewClient(view).put(`/admin/accounts/${id}/taxonomy`, { +export async function setTaxonomy(id: number, folderId: number | null, tagIds: number[]): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/taxonomy`, { folder_id: folderId, tag_ids: tagIds }) return data } -export async function getFacets(filters?: AccountListFilters, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/facets', { params: filters }) +export async function getFacets(filters?: AccountListFilters): Promise { + const { data } = await apiClient.get('/admin/accounts/facets', { params: filters }) return data } -export async function bulkUpdateTaxonomy(payload: AccountBulkTaxonomyRequest, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function bulkUpdateTaxonomy(payload: AccountBulkTaxonomyRequest): Promise { + const { data } = await apiClient.post( '/admin/accounts/bulk-taxonomy', payload, accountJobIdempotencyHeaders('account_bulk_taxonomy') @@ -1077,16 +1075,16 @@ export async function refreshOpenAIToken( * @param id - Account ID * @returns Success confirmation */ -export async function revertProxyFallback(id: number, view?: CapturedAccountView): Promise<{ message: string }> { - const { data } = await accountViewClient(view).post<{ message: string }>(`/admin/accounts/${id}/revert-proxy-fallback`) +export async function revertProxyFallback(id: number): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>(`/admin/accounts/${id}/revert-proxy-fallback`) return data } /** * Delete multiple accounts with bounded server-side concurrency. */ -export async function batchDelete(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function batchDelete(accountIds: number[]): Promise { + const { data } = await apiClient.post( '/admin/accounts/batch-delete', { account_ids: accountIds }, accountJobIdempotencyHeaders('account_batch_delete') @@ -1099,8 +1097,8 @@ export async function batchDelete(accountIds: number[], view?: CapturedAccountVi * @param accountIds - Array of account IDs * @returns Batch operation result */ -export async function batchClearError(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function batchClearError(accountIds: number[]): Promise { + const { data } = await apiClient.post( '/admin/accounts/batch-clear-error', { account_ids: accountIds }, accountJobIdempotencyHeaders('account_batch_clear_error') @@ -1113,8 +1111,8 @@ export async function batchClearError(accountIds: number[], view?: CapturedAccou * @param accountIds - Array of account IDs * @returns Batch operation result */ -export async function batchRefresh(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function batchRefresh(accountIds: number[]): Promise { + const { data } = await apiClient.post( '/admin/accounts/batch-refresh', { account_ids: accountIds }, accountJobIdempotencyHeaders('account_batch_refresh') @@ -1122,8 +1120,8 @@ export async function batchRefresh(accountIds: number[], view?: CapturedAccountV return data } -export async function batchRefreshTier(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function batchRefreshTier(accountIds: number[]): Promise { + const { data } = await apiClient.post( '/admin/accounts/batch-refresh-tier', { account_ids: accountIds }, accountJobIdempotencyHeaders('account_batch_refresh_tier') @@ -1136,8 +1134,8 @@ export async function batchRefreshTier(accountIds: number[], view?: CapturedAcco * @param id - Account ID * @returns Updated account */ -export async function setPrivacy(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/set-privacy`) +export async function setPrivacy(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/set-privacy`) return data } @@ -1230,8 +1228,8 @@ export interface OpenAIQuotaRefreshResult extends OpenAIQuotaUsage { * API consumers; the panel always wants the snapshot persisted, so it has no * client binding here. */ -export async function refreshOpenAIQuota(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function refreshOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( `/admin/openai/accounts/${id}/quota/refresh` ) return data @@ -1262,8 +1260,8 @@ export async function sendOpenAIReferralInvite( * timeout: aborting locally would report a successful consumption as a failure * and invite a retry that spends a second credit. */ -export async function resetOpenAIQuota(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function resetOpenAIQuota(id: number): Promise { + const { data } = await apiClient.post( `/admin/openai/accounts/${id}/reset-quota`, undefined, { timeout: 90_000 } @@ -1278,13 +1276,13 @@ export interface SparkShadowCreatePayload { group_ids?: number[] } -export async function createSparkShadow(parentId: number, payload: SparkShadowCreatePayload, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${parentId}/shadow`, payload) +export async function createSparkShadow(parentId: number, payload: SparkShadowCreatePayload): Promise { + const { data } = await apiClient.post(`/admin/accounts/${parentId}/shadow`, payload) return data } -export async function getUpstreamBillingProbeSettings(view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/upstream-billing-probe/settings') +export async function getUpstreamBillingProbeSettings(): Promise { + const { data } = await apiClient.get('/admin/accounts/upstream-billing-probe/settings') return data } @@ -1298,25 +1296,25 @@ export async function updateUpstreamBillingProbeSettings( return data } -export async function setUpstreamBillingProbeEnabled(id: number, enabled: boolean, view?: CapturedAccountView): Promise { - await accountViewClient(view).put(`/admin/accounts/${id}/upstream-billing-probe`, { enabled }) +export async function setUpstreamBillingProbeEnabled(id: number, enabled: boolean): Promise { + await apiClient.put(`/admin/accounts/${id}/upstream-billing-probe`, { enabled }) } -export async function probeUpstreamBilling(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/upstream-billing-probe`) +export async function probeUpstreamBilling(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/upstream-billing-probe`) return data } -export async function probeUpstreamBillingBatch(accountIds: number[], view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post<{ results: UpstreamBillingProbeResult[] }>( +export async function probeUpstreamBillingBatch(accountIds: number[]): Promise { + const { data } = await apiClient.post<{ results: UpstreamBillingProbeResult[] }>( '/admin/accounts/upstream-billing-probe/batch', { account_ids: accountIds } ) return data.results } -export async function getOllamaCloudUsageSettings(view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get('/admin/accounts/ollama-cloud-usage/settings') +export async function getOllamaCloudUsageSettings(): Promise { + const { data } = await apiClient.get('/admin/accounts/ollama-cloud-usage/settings') return data } @@ -1330,32 +1328,32 @@ export async function updateOllamaCloudUsageSettings( return data } -export async function getOllamaCloudUsage(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/accounts/${id}/ollama-cloud-usage`) +export async function getOllamaCloudUsage(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/ollama-cloud-usage`) return data } -export async function saveOllamaCloudUsageSession(id: number, session: string, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).put(`/admin/accounts/${id}/ollama-cloud-usage/session`, { +export async function saveOllamaCloudUsageSession(id: number, session: string): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/ollama-cloud-usage/session`, { session }) return data } -export async function deleteOllamaCloudUsageSession(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).delete(`/admin/accounts/${id}/ollama-cloud-usage/session`) +export async function deleteOllamaCloudUsageSession(id: number): Promise { + const { data } = await apiClient.delete(`/admin/accounts/${id}/ollama-cloud-usage/session`) return data } -export async function setOllamaCloudUsageAutoRefresh(id: number, enabled: boolean, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).put(`/admin/accounts/${id}/ollama-cloud-usage/auto-refresh`, { +export async function setOllamaCloudUsageAutoRefresh(id: number, enabled: boolean): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/ollama-cloud-usage/auto-refresh`, { enabled }) return data } -export async function refreshOllamaCloudUsage(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post(`/admin/accounts/${id}/ollama-cloud-usage/refresh`) +export async function refreshOllamaCloudUsage(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/ollama-cloud-usage/refresh`) return data } @@ -1484,96 +1482,4 @@ export const accountsAPI = { refreshOpenCodeGoUsage } - -/** Only fixed native account operations accept an origin-view context. */ -const viewArgumentCounts: Partial> = { - list: 4, - hasAPIKeyDigest: 1, - getUpstreamBillingRatesWithEtag: 4, - listWithEtag: 4, - getById: 1, - getAPIKeyVisibility: 0, - setAPIKeyVisibility: 1, - duplicate: 1, - update: 2, - getGrokMediaEligibility: 1, - updateGrokMediaEligibility: 2, - checkMixedChannelRisk: 1, - delete: 1, - toggleStatus: 2, - testAccount: 1, - refreshCredentials: 1, - applyOAuthCredentials: 2, - getStats: 2, - clearError: 1, - getUsage: 3, - getBatchUsage: 2, - clearRateLimit: 1, - recoverState: 1, - resetAccountQuota: 1, - getTempUnschedulableStatus: 1, - resetTempUnschedulable: 1, - batchUpdateCredentials: 1, - bulkUpdate: 2, - getTodayStats: 1, - getBatchTodayStats: 1, - setSchedulable: 2, - getAvailableModels: 1, - getAccountTestPlan: 2, - getModelContextCapacities: 2, - syncUpstreamModels: 1, - exportData: 1, - listFolders: 0, - listTags: 0, - setTaxonomy: 3, - getFacets: 1, - bulkUpdateTaxonomy: 1, - revertProxyFallback: 1, - batchDelete: 1, - batchClearError: 1, - batchRefresh: 1, - batchRefreshTier: 1, - setPrivacy: 1, - refreshOpenAIQuota: 1, - resetOpenAIQuota: 1, - createSparkShadow: 2, - getUpstreamBillingProbeSettings: 0, - setUpstreamBillingProbeEnabled: 2, - probeUpstreamBilling: 1, - probeUpstreamBillingBatch: 1, - getOllamaCloudUsageSettings: 0, - getOllamaCloudUsage: 1, - saveOllamaCloudUsageSession: 2, - deleteOllamaCloudUsageSession: 1, - setOllamaCloudUsageAutoRefresh: 2, - refreshOllamaCloudUsage: 1, -} -const independentCredentialHelpers = new Set([ - 'generateAuthUrl', 'exchangeCode', 'refreshOpenAIToken', 'getAntigravityDefaultModelMapping', - 'previewModelContextCapacities', 'syncUpstreamModelsPreview' -]) - -/** A captured per-operation facade; never reads mutable route state. */ -export function accountAPIForView(view?: CapturedAccountView, core = accountsAPI): typeof accountsAPI { - if (!view) return core - return Object.fromEntries(Object.entries(core).map(([rawName, action]) => { - const name = rawName as keyof typeof accountsAPI - return [name, async (...args: unknown[]) => { - view.assertCurrent() - const count = viewArgumentCounts[name] - if (count !== undefined) { - const input = args.slice(0, count) - while (input.length < count) input.push(undefined) - return (action as (...input: unknown[]) => Promise)(...input, view) - } - // These private credential-form helpers have no existing account target. - // Their final account mutation still uses the captured scoped client. - if (!independentCredentialHelpers.has(name)) throw new Error('Operation unavailable in an account view') - const result = await (action as (...input: unknown[]) => Promise)(...args) - view.assertCurrent() - return result - }] - })) as typeof accountsAPI -} - export default accountsAPI diff --git a/frontend/src/api/admin/cnProviders.ts b/frontend/src/api/admin/cnProviders.ts index 3dea2e0bf6a9..7a4d3f18c5b2 100644 --- a/frontend/src/api/admin/cnProviders.ts +++ b/frontend/src/api/admin/cnProviders.ts @@ -3,8 +3,7 @@ * Coding-plan rolling-window quota probe + payg balance probe. */ -import { accountViewClient } from './accountViewClient' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' +import { apiClient } from '../client' /** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */ export interface CNQuotaTier { @@ -50,16 +49,16 @@ export interface CNProviderBalanceResult { } /** 查询 Coding Plan 滚动窗口用量(5h + weekly)。 */ -export async function queryQuota(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function queryQuota(id: number): Promise { + const { data } = await apiClient.get( `/admin/cn-providers/accounts/${id}/quota` ) return data } /** 查询 payg 账号余额。 */ -export async function queryBalance(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function queryBalance(id: number): Promise { + const { data } = await apiClient.get( `/admin/cn-providers/accounts/${id}/balance` ) return data diff --git a/frontend/src/api/admin/grok.ts b/frontend/src/api/admin/grok.ts index 35f4c94ff37f..c1dc1cbaa268 100644 --- a/frontend/src/api/admin/grok.ts +++ b/frontend/src/api/admin/grok.ts @@ -4,8 +4,6 @@ */ import { apiClient } from '../client' -import { accountViewClient } from './accountViewClient' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' import type { GrokBillingSummary, GrokQuotaWindow, WindowStats } from '@/types' export type { GrokBillingSummary, GrokQuotaWindow } from '@/types' @@ -164,8 +162,8 @@ export async function refreshGrokToken( return data } -export async function queryQuota(id: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get(`/admin/grok/accounts/${id}/quota`) +export async function queryQuota(id: number): Promise { + const { data } = await apiClient.get(`/admin/grok/accounts/${id}/quota`) return data } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index ce27e583846d..5b70512dbdf7 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -130,6 +130,11 @@ export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from export type { BackupAgentHealth, DataManagementConfig } from './dataManagement' export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile' export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' -export type { PluginInstallation, PluginCompatibility, PluginUISession, PluginTestResult } from './plugins' +export type { + PluginInstallation, + PluginCompatibility, + PluginUISession, + PluginTestResult +} from './plugins' export type { SystemPrompt, SystemPromptBinding, SystemPromptConfig, SystemPromptState } from './systemPrompts' export type { ReasoningRecoveryConfig } from './reasoningRecovery' diff --git a/frontend/src/api/admin/scheduledTests.ts b/frontend/src/api/admin/scheduledTests.ts index 601a7cc4c35f..9603e99c1c38 100644 --- a/frontend/src/api/admin/scheduledTests.ts +++ b/frontend/src/api/admin/scheduledTests.ts @@ -3,8 +3,7 @@ * Handles scheduled test plan management for account connectivity monitoring */ -import { accountViewClient } from './accountViewClient' -import type { CapturedAccountView } from '@/composables/useAccountViewContext' +import { apiClient } from '../client' import type { ScheduledTestPlan, ScheduledTestResult, @@ -17,8 +16,8 @@ import type { * @param accountId - Account ID * @returns List of scheduled test plans */ -export async function listByAccount(accountId: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function listByAccount(accountId: number): Promise { + const { data } = await apiClient.get( `/admin/accounts/${accountId}/scheduled-test-plans` ) return data ?? [] @@ -29,8 +28,8 @@ export async function listByAccount(accountId: number, view?: CapturedAccountVie * @param req - Plan creation request * @returns Created plan */ -export async function create(req: CreateScheduledTestPlanRequest, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).post( +export async function create(req: CreateScheduledTestPlanRequest): Promise { + const { data } = await apiClient.post( '/admin/scheduled-test-plans', req ) @@ -43,8 +42,8 @@ export async function create(req: CreateScheduledTestPlanRequest, view?: Capture * @param req - Fields to update * @returns Updated plan */ -export async function update(id: number, req: UpdateScheduledTestPlanRequest, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).put( +export async function update(id: number, req: UpdateScheduledTestPlanRequest): Promise { + const { data } = await apiClient.put( `/admin/scheduled-test-plans/${id}`, req ) @@ -55,8 +54,8 @@ export async function update(id: number, req: UpdateScheduledTestPlanRequest, vi * Delete a scheduled test plan * @param id - Plan ID */ -export async function deletePlan(id: number, view?: CapturedAccountView): Promise { - await accountViewClient(view).delete(`/admin/scheduled-test-plans/${id}`) +export async function deletePlan(id: number): Promise { + await apiClient.delete(`/admin/scheduled-test-plans/${id}`) } /** @@ -65,8 +64,8 @@ export async function deletePlan(id: number, view?: CapturedAccountView): Promis * @param limit - Optional max number of results to return * @returns List of test results */ -export async function listResults(planId: number, limit?: number, view?: CapturedAccountView): Promise { - const { data } = await accountViewClient(view).get( +export async function listResults(planId: number, limit?: number): Promise { + const { data } = await apiClient.get( `/admin/scheduled-test-plans/${planId}/results`, { params: limit ? { limit } : undefined @@ -84,14 +83,3 @@ export const scheduledTestsAPI = { } export default scheduledTestsAPI - -export function scheduledTestsForView(view?: CapturedAccountView, core = scheduledTestsAPI): typeof scheduledTestsAPI { - if (!view) return core - return { - listByAccount: id => core.listByAccount(id, view), - create: request => core.create(request, view), - update: (id, request) => core.update(id, request, view), - delete: id => core.delete(id, view), - listResults: (id, limit) => core.listResults(id, limit, view) - } -} 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/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/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 84c1c4248c2a..9d8206e3cee5 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -800,28 +800,24 @@ 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}`, diff --git a/frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts b/frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts index 0bff6c30a51a..d5aabade3425 100644 --- a/frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts +++ b/frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts @@ -8,13 +8,11 @@ describe('account console locale keys', () => { it('defines every label introduced by the cockpit account console', () => { expect(enAccounts.accounts.columns.classificationRoute).toBe('Management Classification / Request Routing') expect(enAccounts.accounts.columns.usage).toBe('Usage') - expect(enAccounts.accounts.columns.capacityUsage).toBe('Capacity / Usage') expect(enAccounts.accounts.usageNoData).toBe('No quota data') expect(enCommon.common.clear).toBe('Clear') expect(zhAccounts.accounts.columns.classificationRoute).toBe('管理分类 / 请求路由') expect(zhAccounts.accounts.columns.usage).toBe('用量') - expect(zhAccounts.accounts.columns.capacityUsage).toBe('容量 / 用量') expect(zhAccounts.accounts.usageNoData).toBe('暂无额度数据') expect(zhCommon.common.clear).toBe('清除') }) diff --git a/frontend/src/i18n/locales/en/admin/accountTasks.ts b/frontend/src/i18n/locales/en/admin/accountTasks.ts index 02aa78d80269..cb605829f466 100644 --- a/frontend/src/i18n/locales/en/admin/accountTasks.ts +++ b/frontend/src/i18n/locales/en/admin/accountTasks.ts @@ -20,10 +20,7 @@ export default { historyAction: "Operation history", title: 'Operation history', description: 'Track account imports and bulk operations.', - drawerTitle: 'Account tasks', - viewAll: 'View all tasks', noTasks: 'No account tasks yet', - allKinds: 'All operations', allStatuses: 'All statuses', details: 'Operation results', results: 'Item results', @@ -32,20 +29,9 @@ export default { previousPage: 'Previous', nextPage: 'Next', progress: '{processed} of {total}', - progressMetrics: 'Elapsed {elapsed} · {rate} items/s · ETA {eta}', - etaDone: 'done', - jobProgress: 'Progress for task #{id}', resultSummary: '{succeeded} succeeded, {failed} failed, {canceled} canceled', account: 'Account #{id}', item: 'Item {ordinal}', - columns: { - task: 'Task', - status: 'Status', - progress: 'Progress', - result: 'Result', - createdAt: 'Created', - actions: 'Actions', - }, kinds: { account_import: 'Data import', account_import_codex: 'Codex import', @@ -57,8 +43,6 @@ export default { account_batch_refresh: 'Refresh accounts', account_batch_refresh_tier: 'Refresh tiers', account_batch_update_credentials: 'Update credentials', - account_duplicate_review: 'Duplicate review', - account_duplicate_merge: 'Duplicate merge', }, statuses: { pending: 'Pending', @@ -76,15 +60,5 @@ export default { }, loadFailed: 'Failed to load account tasks', actionFailed: 'Task action failed', - duplicate: { - reviewMerge: 'Review merge', - confirmMerge: 'Confirm to merge the reviewed accounts into the selected account.', - title: 'Duplicate review', - selectSurvivor: 'Choose the account to keep. The other reviewed accounts will be merged into it.', - summary: 'Account #{id} · {groups} groups · {tags} tags · score {score}', - merge: 'Confirm merge', - mergeFailed: 'Failed to create the duplicate merge task', - reviewFailed: 'Failed to create the duplicate review task', - }, }, } diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index b1e232d6cbff..e1f482fdf387 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -9,14 +9,7 @@ export default {accounts: { description: 'Manage AI platform accounts and credentials', importSessionFilter: 'Imported accounts ({count})', contextCapacity: { - title: 'Model context capacities', - description: 'Priority: custom > applicable official API specification > this account\'s valid upstream declaration > models.dev registry; without any of them the capacity is unknown and is not advertised. Shared aliases use the same real upstream model setting; this does not change routing or increase upstream limits.', - units: 'Decimal input: 258K = 258000, 1.05M = 1050000 tokens. Values retain every token.', - saveBeforeSync: 'Save this account\'s endpoint, product or credential changes before syncing upstream models.', - syncSuccess: 'Synced capacity information for {count} model(s); the whitelist and mappings were not changed.', loading: 'Loading locally saved capacities…', - empty: 'Sync upstream models to view their capacities and sources.', - aliases: 'Public aliases', effective: 'Adopted capacity', exactTokens: '{value} tokens', upstream: 'Upstream declaration', @@ -25,8 +18,6 @@ export default {accounts: { unbound: 'Observation has no bound upstream identity; shown for diagnosis and ignored when resolving capacity.', source_mismatch: 'Observation belongs to a different upstream identity; shown for diagnosis and ignored when resolving capacity.' }, - official: 'Official specification', - custom: 'Custom capacity', edit: 'Edit context capacity for {model}', editHint: 'Enter or leave this field to confirm; Esc cancels. Leave empty to restore automatic priority. Save the account to apply changes.', unavailable: 'No unambiguous capacity is available for this model yet.', @@ -35,17 +26,11 @@ export default {accounts: { max_input_tokens: 'Input limit', max_output_tokens: 'Output limit', observedAt: 'Observed', - verifiedAt: 'Verified', - officialEvidence: 'Source and interpretation', officialSource: 'Official source', matchedReference: 'Matched reference model: {model} ({product})', referenceWindows: 'Original Codex {release} reference: default {context}, maximum {maximum}.', products: { api: 'API specification', codex_subscription: 'Codex subscription catalog' }, - normalization: 'Unit interpretation', unknown: 'Unknown', - notMatched: 'No applicable verified specification', - clearHint: 'Leave empty to use automatic priority.', - clear: 'Clear custom value', invalid: 'Enter a positive safe integer in tokens or decimal K/M. Fractional tokens and rounding are not allowed.', readonly: 'Read-only; overrides are not supported.', exceedsUpstream: 'The adopted value exceeds the upstream declaration under the configured priority. It is a client planning value, not an increase to the upstream limit.', @@ -55,11 +40,6 @@ export default {accounts: { official: 'Official API', registry: 'models.dev', invalid: 'Invalid draft' - }, - basis: { - total_context: 'Total context window', - input_limit: 'Input-window planning budget', - max_context_window: 'Maximum context window' } }, createAccount: 'Create Account', @@ -112,7 +92,6 @@ export default {accounts: { dataImportPreviewItems: 'Preview items', dataImportPreviewAction: 'Action', dataImportMatchedAccounts: 'Matched existing accounts: {ids}', - dataImportPreviewRequired: 'Preview the import before submitting the task.', dataImportStalePreview: 'The preview is no longer current. A fresh preview is required.', importUniformSettings: 'Uniform Settings', importProxyStrategy: 'Batch proxy strategy', @@ -131,7 +110,6 @@ export default {accounts: { viewModeTable: 'Table', viewModeCompact: 'Compact', viewModeCards: 'Cards', - folderAll: 'All Accounts', allAccounts: 'All Accounts', folderUncategorized: 'Uncategorized', folders: 'Folders', @@ -407,7 +385,6 @@ export default {accounts: { groups: 'Routing Groups', classificationRoute: 'Management Classification / Request Routing', usage: 'Usage', - capacityUsage: 'Capacity / Usage', usageWindows: 'Usage Windows', proxy: 'Proxy', lastUsed: 'Last Used', @@ -665,7 +642,6 @@ export default {accounts: { resetStatus: 'Reset Status', refreshToken: 'Refresh Token', refreshTier: 'Refresh Tier', - duplicateReview: 'Review Duplicates', probeUpstreamBilling: 'Probe Upstream Rate', testConnection: 'Batch Test Connection', resetStatusSuccess: 'Successfully reset {count} account(s) status', @@ -676,7 +652,6 @@ export default {accounts: { title: 'Batch Account Connection Test', model: 'Test model', loadingModels: 'Loading account models...', - selectModel: 'Select a model', start: 'Start test', testing: 'Testing...', viewSnapshot: 'View last snapshot', @@ -690,7 +665,6 @@ export default {accounts: { }, account: 'Account', modelStatus: 'Model support', - modelResult: 'Model result', upstreamResponse: 'Upstream response', modelMismatch: 'Model mismatch', result: 'Result', @@ -754,8 +728,7 @@ export default {accounts: { summaryClear: 'Move to Uncategorized', summaryAddTags: 'Add {count} tag(s)', summaryRemoveTags: 'Remove {count} tag(s)', - targetChanged: 'The filtered result count changed. The list was refreshed; review and confirm again.', - success: 'Updated classification for {count} account(s).' + targetChanged: 'The filtered result count changed. The list was refreshed; review and confirm again.' }, bulkDeleteTitle: 'Bulk Delete Accounts', bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index b32f687b860e..a083c9642f67 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -516,13 +516,11 @@ export default { saveFailed: 'Failed to save OpenCode Go usage refresh settings' }, observability: { - title: 'Appearance and observations', - description: 'Account traffic observations record real gateway attempts without running test requests. They do not restrict accounts or change billing.', - telemetryEnabled: 'Enable account traffic observations', + title: 'Appearance', themeEnabled: 'Enable flat theme', save: 'Save settings', saved: 'Settings saved', - loadFailed: 'Failed to load appearance and observation settings', + loadFailed: 'Failed to load appearance settings', saveFailed: 'Save failed' }, officialModelCatalog: { @@ -530,7 +528,6 @@ export default { description: 'Capacity priority: account override > applicable official API specification > valid upstream declaration > models.dev registry. Values missing from the selected source remain unknown.', search: 'Search model or provider', count: '{count} matches.', - context: 'Context', maxOutput: 'Max output', subscriptionMaximum: 'Subscription reference maximum', verified: 'Verified', diff --git a/frontend/src/i18n/locales/zh/admin/accountTasks.ts b/frontend/src/i18n/locales/zh/admin/accountTasks.ts index 35adee3de1e2..7cbaa050542a 100644 --- a/frontend/src/i18n/locales/zh/admin/accountTasks.ts +++ b/frontend/src/i18n/locales/zh/admin/accountTasks.ts @@ -20,10 +20,7 @@ export default { historyAction: "操作记录", title: '操作记录', description: '查看近期账号操作及其执行结果。', - drawerTitle: '账号操作', - viewAll: '查看全部任务', noTasks: '暂无操作记录', - allKinds: '全部操作', allStatuses: '全部状态', details: '任务详情', results: '逐项结果', @@ -32,20 +29,9 @@ export default { previousPage: '上一页', nextPage: '下一页', progress: '已处理 {processed} / {total}', - progressMetrics: '耗时 {elapsed} · {rate} 项/秒 · 预计剩余 {eta}', - etaDone: '已完成', - jobProgress: '任务 #{id} 的进度', resultSummary: '成功 {succeeded},失败 {failed},取消 {canceled}', account: '账号 #{id}', item: '第 {ordinal} 项', - columns: { - task: '任务', - status: '状态', - progress: '进度', - result: '结果', - createdAt: '创建时间', - actions: '操作', - }, kinds: { account_import: '数据导入', account_import_codex: 'Codex 导入', @@ -57,8 +43,6 @@ export default { account_batch_refresh: '刷新账号', account_batch_refresh_tier: '刷新等级', account_batch_update_credentials: '更新凭据', - account_duplicate_review: '重复账号复核', - account_duplicate_merge: '重复账号合并', }, statuses: { pending: '等待中', @@ -76,15 +60,5 @@ export default { }, loadFailed: '加载账号任务失败', actionFailed: '任务操作失败', - duplicate: { - reviewMerge: '检查合并', - confirmMerge: '确认后将合并其他已复核账号,仅保留所选账号。', - title: '重复账号复核', - selectSurvivor: '选择要保留的账号,其余已复核账号将合并到该账号。', - summary: '账号 #{id} · {groups} 个分组 · {tags} 个标签 · 完整度 {score}', - merge: '确认合并', - mergeFailed: '创建重复账号合并任务失败', - reviewFailed: '创建重复账号复核任务失败', - }, }, } diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 733b9b4b51c0..b0415043422a 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -9,14 +9,7 @@ export default {accounts: { description: '管理 AI 平台账号和 Cookie', importSessionFilter: '本次导入({count})', contextCapacity: { - title: '模型上下文容量', - description: '优先级:自定义 > 适用的官方 API 规格 > 本账号有效上游声明 > models.dev 目录;都没有时为未知,不对外宣告容量。同一真实上游模型的公开别名共用设置;不改变路由,也不会扩大上游限制。', - units: '输入采用十进制:258K = 258000、1.05M = 1050000 tokens。显示和保存均保留精确值。', - saveBeforeSync: '请先保存此账号的端点、产品或凭据变更,再同步上游模型。', - syncSuccess: '已同步 {count} 个模型的容量信息,未更改白名单或映射。', loading: '正在读取本地保存的容量…', - empty: '同步上游模型后,可查看容量及其来源。', - aliases: '公开别名', effective: '最终采用容量', exactTokens: '{value} tokens', upstream: '上游返回', @@ -25,8 +18,6 @@ export default {accounts: { unbound: '观测未绑定上游身份,仅保留诊断展示,不参与容量解析。', source_mismatch: '观测来自不同上游身份,仅保留诊断展示,不参与容量解析。' }, - official: '官方规格', - custom: '自定义容量', edit: '编辑 {model} 的上下文容量', editHint: '回车或移开焦点确认,Esc 取消;留空恢复自动优先级。保存账号后生效。', unavailable: '此模型暂时没有可明确对应的容量信息。', @@ -35,17 +26,11 @@ export default {accounts: { max_input_tokens: '输入上限', max_output_tokens: '输出上限', observedAt: '观测时间', - verifiedAt: '核实日期', - officialEvidence: '来源和口径依据', officialSource: '官方来源', matchedReference: '匹配参考型号:{model}({product})', referenceWindows: 'Codex {release} 原始参考:默认 {context},最大 {maximum}。', products: { api: 'API 规格', codex_subscription: 'Codex 订阅目录' }, - normalization: '单位归一依据', unknown: '未知', - notMatched: '无适用的已核实规格', - clearHint: '留空恢复自动优先级。', - clear: '清除自定义', invalid: '请输入正安全整数 tokens 或十进制 K/M;不能含小数 token,也不会自动舍入。', readonly: '只读展示,不支持自定义覆盖。', exceedsUpstream: '按当前优先级采用的容量高于上游声明。此值仅供客户端规划,不代表上游真实限制已扩大。', @@ -55,11 +40,6 @@ export default {accounts: { official: '官方 API', registry: 'models.dev', invalid: '草稿无效' - }, - basis: { - total_context: '上下文总窗口', - input_limit: '输入窗口规划预算', - max_context_window: '最大上下文窗口' } }, createAccount: '添加账号', @@ -112,7 +92,6 @@ export default {accounts: { dataImportPreviewItems: '预览明细', dataImportPreviewAction: '动作', dataImportMatchedAccounts: '匹配的现有账号:{ids}', - dataImportPreviewRequired: '提交任务前请先生成导入预览。', dataImportStalePreview: '预览已过期或数据发生变化,请重新生成预览。', importUniformSettings: '统一设置', importProxyStrategy: '整批代理策略', @@ -131,7 +110,6 @@ export default {accounts: { viewModeTable: '表格', viewModeCompact: '紧凑', viewModeCards: '卡片', - folderAll: '全部账号', allAccounts: '全部账号', folderUncategorized: '未分类', folders: '文件夹', @@ -246,7 +224,6 @@ export default {accounts: { groups: '路由分组', classificationRoute: '管理分类 / 请求路由', usage: '用量', - capacityUsage: '容量 / 用量', usageWindows: '用量窗口', proxy: '代理', lastUsed: '最近使用', @@ -813,7 +790,6 @@ export default {accounts: { resetStatus: '批量重置状态', refreshToken: '批量刷新令牌', refreshTier: '批量刷新等级', - duplicateReview: '检查重复账号', probeUpstreamBilling: '探测上游倍率', testConnection: '批量测试连接', resetStatusSuccess: '已成功重置 {count} 个账号状态', @@ -824,7 +800,6 @@ export default {accounts: { title: '批量测试账号连接', model: '测试模型', loadingModels: '正在读取账号模型...', - selectModel: '请选择模型', start: '开始测试', testing: '测试中...', viewSnapshot: '查看上次快照', @@ -838,7 +813,6 @@ export default {accounts: { }, account: '账号', modelStatus: '模型支持', - modelResult: '模型结果', upstreamResponse: '上游响应', modelMismatch: '模型不一致', result: '结果', @@ -901,8 +875,7 @@ export default {accounts: { summaryClear: '移至未分类', summaryAddTags: '增加 {count} 个标签', summaryRemoveTags: '移除 {count} 个标签', - targetChanged: '筛选结果数量已变化,列表已刷新,请重新检查并确认。', - success: '已更新 {count} 个账号的分类。' + targetChanged: '筛选结果数量已变化,列表已刷新,请重新检查并确认。' }, bulkDeleteTitle: '批量删除账号', bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 02a82538561d..b433665d90af 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -509,13 +509,11 @@ export default { saveFailed: '保存 OpenCode Go 用量刷新设置失败' }, observability: { - title: '界面与观测', - description: '账号流量观测只记录实际业务转发的结果,不触发测试请求,也不用于限制账号或改变计费。', - telemetryEnabled: '启用账号流量观测', + title: '界面', themeEnabled: '启用平面主题', save: '保存设置', saved: '设置已保存', - loadFailed: '界面与观测设置加载失败', + loadFailed: '界面设置加载失败', saveFailed: '保存失败' }, officialModelCatalog: { @@ -523,7 +521,6 @@ export default { description: '容量优先级:账号自定义 > 适用的官方 API 规格 > 有效上游声明 > models.dev 目录。选中来源未声明的字段保持未知。', search: '搜索模型或供应商', count: '共 {count} 项匹配。', - context: '上下文', maxOutput: '最大输出', subscriptionMaximum: '订阅参考最大窗口', verified: '核对日期', 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/stores/__tests__/accountJobs.spec.ts b/frontend/src/stores/__tests__/accountJobs.spec.ts index 0eda65050c0b..d4df294bed13 100644 --- a/frontend/src/stores/__tests__/accountJobs.spec.ts +++ b/frontend/src/stores/__tests__/accountJobs.spec.ts @@ -63,8 +63,8 @@ describe('useAccountJobsStore', () => { .mockResolvedValueOnce({ items: [job('succeeded')], total: 1, page: 1, page_size: 20 }) .mockResolvedValueOnce({ items: [job('succeeded')], total: 1, page: 1, page_size: 20 }) - await store.loadRecent() - await store.loadRecent() + await store.loadRecent({ page: 1, status: '' }) + await store.loadRecent({ page: 1, status: '' }) expect(app.toasts).toHaveLength(1) expect(app.toasts[0].type).toBe('success') @@ -75,7 +75,7 @@ describe('useAccountJobsStore', () => { const store = useAccountJobsStore() const app = useAppStore() - await store.loadRecent() + await store.loadRecent({ page: 1, status: '' }) expect(app.toasts).toHaveLength(0) }) @@ -104,7 +104,7 @@ describe('useAccountJobsStore', () => { }) const store = useAccountJobsStore() - await store.loadRecent({ page: 2, page_size: 2 }) + await store.loadRecent({ page: 2, status: '' }) await store.openJob(19, { page: 2, page_size: 1 }) expect(store.recentJobs.map((item) => item.id)).toEqual([19, 20]) @@ -154,7 +154,7 @@ describe('useAccountJobsStore', () => { await vi.advanceTimersByTimeAsync(3_000) expect(get).toHaveBeenCalledTimes(1) expect(store.completedJobs.map(j => j.id)).toEqual([19]) - expect(store.activeCount).toBe(0) + expect(store.activeJobs).toHaveLength(0) expect(store.recentJobs.map(j => j.id)).toEqual([80]) await vi.advanceTimersByTimeAsync(6_000) expect(get).toHaveBeenCalledTimes(1) @@ -192,7 +192,7 @@ describe('useAccountJobsStore', () => { get.mockResolvedValue(job('succeeded')) await vi.advanceTimersByTimeAsync(3000) expect(store.connectionLost).toBe(false) - expect(store.activeCount).toBe(0) + expect(store.activeJobs).toHaveLength(0) expect(retryFailed).not.toHaveBeenCalled() }) @@ -201,11 +201,11 @@ describe('useAccountJobsStore', () => { store.startPolling() await vi.advanceTimersByTimeAsync(0) await store.openJob(19) - expect(store.activeCount).toBe(1) + expect(store.activeJobs).toHaveLength(1) store.closeDrawer() get.mockResolvedValue(job('succeeded')) await vi.advanceTimersByTimeAsync(3000) - expect(store.activeCount).toBe(0) + expect(store.activeJobs).toHaveLength(0) expect(store.completedJobs.map(j => j.id)).toEqual([19]) }) @@ -222,19 +222,18 @@ describe('useAccountJobsStore', () => { expect(store.itemPage.page).toBe(2) }) - it('refreshes the selected page and filters on an explicit request', async () => { + it('requests the given page and status filter with the page size the server last reported', async () => { const store = useAccountJobsStore() list.mockResolvedValue({ items: [], total: 0, page: 3, page_size: 25 }) - await store.loadRecent({ page: 3, page_size: 25, kind: 'account_import', status: 'running' }) + await store.loadRecent({ page: 3, status: 'running' }) list.mockClear() - await store.loadRecent() + await store.loadRecent({ page: 4, status: 'failed' }) expect(list).toHaveBeenCalledWith({ - page: 3, + page: 4, page_size: 25, - kind: 'account_import', - status: 'running', + status: 'failed', }, { signal: expect.any(AbortSignal) }) }) @@ -261,10 +260,10 @@ describe('useAccountJobsStore', () => { list.mockImplementationOnce((_params, { signal }: { signal: AbortSignal }) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject({ code: 'ERR_CANCELED' })) })) - const canceled = store.loadRecent({ page: 2 }) + const canceled = store.loadRecent({ page: 2, status: '' }) let resolveNew!: (value: unknown) => void list.mockImplementationOnce(() => new Promise(resolve => { resolveNew = resolve })) - const current = store.loadRecent({ page: 3 }) + const current = store.loadRecent({ page: 3, status: '' }) await canceled expect(store.loadingJobs).toBe(true) resolveNew({ items: [], total: 0, page: 3, page_size: 20 }) @@ -310,14 +309,14 @@ describe('useAccountJobsStore', () => { expect(store.itemPage.page).toBe(1) expect(store.loadingCurrent).toBe(false) get.mockClear() - await store.refreshDrawer() + await vi.advanceTimersByTimeAsync(3_000) expect(get).not.toHaveBeenCalled() }) it('does not insert off-page detail or submitted tasks into a filtered list', async () => { const store = useAccountJobsStore() list.mockResolvedValue({ items: [job('failed', { id: 22 })], total: 4, page: 2, page_size: 1 }) - await store.loadRecent({ page: 2, page_size: 1, status: 'failed' }) + await store.loadRecent({ page: 2, status: 'failed' }) await store.openJob(19) store.track(job('pending', { id: 23 })) @@ -329,7 +328,7 @@ describe('useAccountJobsStore', () => { const store = useAccountJobsStore() let resolveOld!: (value: unknown) => void list.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) - const pending = store.loadRecent() + const pending = store.loadRecent({ page: 1, status: '' }) const signal = list.mock.calls[0][1].signal as AbortSignal store.clear() resolveOld({ items: [job('running')], total: 1, page: 1, page_size: 20 }) diff --git a/frontend/src/stores/accountJobs.ts b/frontend/src/stores/accountJobs.ts index 604a6f0675d3..2c74c5d2fb50 100644 --- a/frontend/src/stores/accountJobs.ts +++ b/frontend/src/stores/accountJobs.ts @@ -4,8 +4,6 @@ import accountJobsAPI, { type AccountJob, type AccountJobItem, type AccountJobItemListParams, - type AccountJobListParams, - type DuplicateMergeRequest, } from '@/api/admin/accountJobs' import { useAppStore } from '@/stores/app' import { i18n } from '@/i18n' @@ -43,7 +41,7 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { const loadingCurrent = ref(false) const jobPage = reactive({ total: 0, page: 1, pageSize: 20 }) const itemPage = reactive({ total: 0, page: 1, pageSize: 20 }) - const listFilters = reactive({ kind: '', status: '' }) + let listStatus = '' const trackedStatuses = new Map() const notifiedJobs = new Set() const failuresFocused = new Set() @@ -57,7 +55,6 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { let currentRequestSerial = 0 const activeJobs = computed(() => Object.values(trackedJobs.value).filter((job) => !isTerminalAccountJob(job))) - const activeCount = computed(() => activeJobs.value.length) const visibleJobs = computed(() => Object.values(trackedJobs.value) .filter(job => !dismissedJobs.value.has(job.id)) .sort((a, b) => b.id - a.id)) @@ -118,18 +115,17 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { } function updateRecent(job: AccountJob, allowInsert = false): void { - const matchesFilters = (!listFilters.kind || listFilters.kind === job.kind) - && (!listFilters.status || listFilters.status === job.status) + const matchesStatus = !listStatus || listStatus === job.status const index = recentJobs.value.findIndex((candidate) => candidate.id === job.id) if (index >= 0) { - if (matchesFilters) recentJobs.value[index] = job + if (matchesStatus) recentJobs.value[index] = job else { recentJobs.value.splice(index, 1) jobPage.total = Math.max(0, jobPage.total - 1) } return } - if (allowInsert && matchesFilters && jobPage.page === 1) { + if (allowInsert && matchesStatus && jobPage.page === 1) { recentJobs.value = [job, ...recentJobs.value].slice(0, jobPage.pageSize) jobPage.total += 1 } @@ -158,22 +154,20 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { if (!isTerminalAccountJob(job)) startPolling(false) } - async function loadRecent(params: AccountJobListParams = {}): Promise { + async function loadRecent(params: { page: number; status: string }): Promise { const requestGeneration = generation const detailSerial = currentRequestSerial const requestSerial = ++listRequestSerial listRequest?.abort() const controller = new AbortController() listRequest = controller - if (Object.prototype.hasOwnProperty.call(params, 'kind')) listFilters.kind = params.kind ?? '' - if (Object.prototype.hasOwnProperty.call(params, 'status')) listFilters.status = params.status ?? '' + listStatus = params.status loadingJobs.value = true try { const page = await accountJobsAPI.list({ - page: params.page ?? jobPage.page, - page_size: params.page_size ?? jobPage.pageSize, - kind: listFilters.kind || undefined, - status: listFilters.status || undefined, + page: params.page, + page_size: jobPage.pageSize, + status: listStatus || undefined, }, { signal: controller.signal }) if (generation !== requestGeneration || requestSerial !== listRequestSerial) return for (const job of page.items) if (trackedJobs.value[job.id]) observeTrackedTransition(job) @@ -315,18 +309,6 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { if (epoch === generation) recoveryNeeded = false } - async function refreshDrawer(): Promise { - try { - const jobID = selectedJobID.value - await Promise.all([ - loadRecent(), - jobID === null ? Promise.resolve() : loadCurrent(jobID), - ]) - } catch (error) { - showLoadFailure(error) - } - } - async function poll(): Promise { if (!pollingEnabled || pollInFlight) return const epoch = generation @@ -380,7 +362,7 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { async function openDrawer(): Promise { historyOpen.value = true - try { await loadRecent({ page: 1, kind: '', status: '' }) } + try { await loadRecent({ page: 1, status: '' }) } catch (error) { showLoadFailure(error) } } @@ -399,20 +381,6 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { return replacement } - async function reviewDuplicates(accountIDs: number[]): Promise { - const job = await accountJobsAPI.reviewDuplicates(accountIDs) - track(job, { embedded: embeddedOpen.value }) - void loadCurrent(job.id).catch(() => { connectionLost.value = true }) - return job - } - - async function mergeDuplicates(request: DuplicateMergeRequest): Promise { - const job = await accountJobsAPI.mergeDuplicates(request) - track(job, { embedded: embeddedOpen.value }) - void loadCurrent(job.id).catch(() => { connectionLost.value = true }) - return job - } - function clear(): void { clearAccountOperationKeys() stopPolling() @@ -444,8 +412,7 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { itemPage.total = 0 itemPage.page = 1 itemPage.pageSize = 20 - listFilters.kind = '' - listFilters.status = '' + listStatus = '' trackedStatuses.clear() notifiedJobs.clear() failuresFocused.clear() @@ -455,7 +422,6 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { recentJobs, completedJobs, activeJobs, - activeCount, currentJob, items, drawerOpen, @@ -474,12 +440,9 @@ export const useAccountJobsStore = defineStore('accountJobs', () => { loadCurrent, openJob, openDrawer, - refreshDrawer, closeDrawer, cancelJob, retryJob, - reviewDuplicates, - mergeDuplicates, startPolling, stopPolling, clear, 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); + } } 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/types/index.ts b/frontend/src/types/index.ts index bbb8ad1c4287..63ac810b4969 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 @@ -1312,13 +1300,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 @@ -1419,7 +1400,6 @@ export interface AccountFacetOption { } export interface AccountConsoleFacets { - view_preset_counts?: Record total: number uncategorized_count: number platforms: AccountFacetOption[] 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/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 -} diff --git a/frontend/src/utils/accountTestModels.ts b/frontend/src/utils/accountTestModels.ts index f334f045f86c..15c828d79b9c 100644 --- a/frontend/src/utils/accountTestModels.ts +++ b/frontend/src/utils/accountTestModels.ts @@ -1,15 +1,12 @@ 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 } 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() diff --git a/frontend/src/utils/browserPreferences.ts b/frontend/src/utils/browserPreferences.ts deleted file mode 100644 index e8972ebae0f1..000000000000 --- a/frontend/src/utils/browserPreferences.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const browserPreferenceEvent = 'sub2api:ui-preference' - -export function writeBrowserPreference(key: string, value: string) { - try { localStorage.setItem(key, value) } catch { /* A browser can disable storage. */ } - window.dispatchEvent(new CustomEvent(browserPreferenceEvent, { detail: { key, value } })) -} diff --git a/frontend/src/utils/openaiProtocolModeEdits.ts b/frontend/src/utils/openaiProtocolModeEdits.ts new file mode 100644 index 000000000000..7ebc214215cf --- /dev/null +++ b/frontend/src/utils/openaiProtocolModeEdits.ts @@ -0,0 +1,35 @@ +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 + const key = mode === 'responses_websocket_mode' && type !== 'apikey' + ? 'openai_oauth_responses_websockets_v2_mode' : openAIProtocolModes[mode].key + next[key] = value + } +} diff --git a/frontend/src/views/admin/AccountTasksView.vue b/frontend/src/views/admin/AccountTasksView.vue deleted file mode 100644 index 64bba8bbea6a..000000000000 --- a/frontend/src/views/admin/AccountTasksView.vue +++ /dev/null @@ -1,169 +0,0 @@ - - - diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 03b1ff31cad3..044ef136135e 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -254,7 +254,6 @@ @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @refresh-tier="handleBulkRefreshTier" - @duplicate-review="handleDuplicateReview" @probe-upstream-billing="handleBulkProbeUpstreamBilling" @test-connection="openBatchTest" @edit-selected="openBulkEditSelected" @@ -581,7 +580,6 @@ @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @refresh-tier="handleBulkRefreshTier" - @duplicate-review="handleDuplicateReview" @probe-upstream-billing="handleBulkProbeUpstreamBilling" @test-connection="openBatchTest" @edit-selected="openBulkEditSelected" @@ -2469,11 +2467,6 @@ const handleBulkRefreshTier = async () => { const ids = [...selIds.value] confirmAccountOperation('account_batch_refresh_tier', ids, () => adminAPI.accounts.batchRefreshTier(ids)) } -const handleDuplicateReview = async () => { - const accountIDs = [...selIds.value] - if (accountIDs.length < 2 || accountIDs.length > 100) return - confirmAccountOperation('account_duplicate_review', accountIDs, () => accountJobsAPI.reviewDuplicates(accountIDs)) -} const handleBulkProbeUpstreamBilling = async () => { const accountIDs = [...selIds.value] if (accountIDs.length === 0) { @@ -2684,8 +2677,8 @@ watch( for (const job of observedAccountJobs.value) { if (isTerminalAccountJob(job) && accountJobsStore.completedJobs?.some(done => done.id === job.id) && !refreshedOperations.has(job.id)) { refreshedOperations.add(job.id) - if (!pendingDataImportJobIDs.has(job.id) && job.kind !== 'account_duplicate_review') { - void reloadChangedAccounts(load, ['account_bulk_taxonomy', 'account_bulk_update', 'account_batch_delete', 'account_duplicate_merge'].includes(job.kind)) + if (!pendingDataImportJobIDs.has(job.id)) { + void reloadChangedAccounts(load, ['account_bulk_taxonomy', 'account_bulk_update', 'account_batch_delete'].includes(job.kind)) } } const revision = pendingDataImportJobIDs.get(job.id) @@ -3141,7 +3134,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) 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() - }) -}) diff --git a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts index 40f74740c05c..23b39e3c05fd 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts @@ -6,7 +6,7 @@ import AccountsView from '../AccountsView.vue' import { flattenStackedColumns } from '@/components/common/columnStack' vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: vi.fn(), reviewDuplicates: vi.fn() }) + useAccountJobsStore: () => ({ track: vi.fn() }) })) const { diff --git a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts index c27ede44207c..2bbcb277078c 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.console.spec.ts @@ -23,7 +23,6 @@ const { showError, showSuccess, jobTrack, - reviewDuplicates, getAllProxies, getAllGroups, updateAccount, @@ -43,7 +42,6 @@ const { showError: vi.fn(), showSuccess: vi.fn(), jobTrack: vi.fn(), - reviewDuplicates: vi.fn(), getAllProxies: vi.fn(), getAllGroups: vi.fn(), updateAccount: vi.fn(), @@ -58,7 +56,7 @@ vi.mock('@/api/admin/accounts', async (importOriginal) => ({ vi.mock('@/stores/accountJobs', async () => { const { reactive } = await vi.importActual('vue') - const store = reactive({ recentJobs: [] as any[], track: jobTrack, reviewDuplicates }) + const store = reactive({ recentJobs: [] as any[], track: jobTrack }) accountJobsState.store = store return { isTerminalAccountJob: (job: { status?: string }) => ['succeeded', 'partially_succeeded', 'failed', 'canceled'].includes(job.status || ''), @@ -338,7 +336,6 @@ describe('admin AccountsView Cockpit console', () => { showError.mockReset() showSuccess.mockReset() jobTrack.mockReset() - reviewDuplicates.mockReset() accountJobsState.store.recentJobs.splice(0) getAllProxies.mockReset().mockResolvedValue([]) getAllGroups.mockReset().mockResolvedValue([]) diff --git a/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts index ab07e88afed0..d9e1d66ddf36 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts @@ -49,8 +49,7 @@ vi.mock('@/stores/accountJobs', () => ({ isTerminalAccountJob: () => false, useAccountJobsStore: () => ({ recentJobs: [], - track: vi.fn(), - reviewDuplicates: vi.fn() + track: vi.fn() }) })) diff --git a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts index b6b8b4f5e2bd..d42f3008aa6c 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts @@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils' import AccountsView from '../AccountsView.vue' vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: vi.fn(), reviewDuplicates: vi.fn() }) + useAccountJobsStore: () => ({ track: vi.fn() }) })) const { diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts index 9506a00d3222..8d2d88e9e36a 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts @@ -16,7 +16,6 @@ const { showError, showSuccess, jobTrack, - reviewDuplicates, batchRefreshTier } = vi.hoisted(() => ({ listAccounts: vi.fn(), @@ -29,14 +28,11 @@ const { showError: vi.fn(), showSuccess: vi.fn(), jobTrack: vi.fn(), - reviewDuplicates: vi.fn(), batchRefreshTier: vi.fn() })) -vi.mock('@/api/admin/accountJobs', async () => ({ ...await vi.importActual('@/api/admin/accountJobs'), default: { reviewDuplicates } })) - vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: jobTrack, reviewDuplicates }) + useAccountJobsStore: () => ({ track: jobTrack }) })) vi.mock('@/api/admin', () => ({ @@ -101,7 +97,7 @@ const makeAccounts = (count: number) => Array.from({ length: count }, (_, index) const AccountBulkActionsBarStub = { props: ['selectedIds', 'totalResults', 'selectingAll', 'allResultsSelected'], - emits: ['select-all-results', 'select-page', 'clear', 'duplicate-review', 'refresh-tier', 'refresh-token'], + emits: ['select-all-results', 'select-page', 'clear', 'refresh-tier', 'refresh-token'], template: `
{{ selectedIds.length }} @@ -110,7 +106,6 @@ const AccountBulkActionsBarStub = { -
@@ -178,7 +173,6 @@ describe('admin AccountsView select all filtered results', () => { showError.mockReset() showSuccess.mockReset() jobTrack.mockReset() - reviewDuplicates.mockReset().mockResolvedValue({ id: 91, status: 'pending' }) batchRefreshTier.mockReset().mockResolvedValue({ id: 92, kind: 'account_batch_refresh_tier', status: 'pending' }) listWithEtag.mockResolvedValue({ @@ -312,7 +306,7 @@ describe('admin AccountsView select all filtered results', () => { expect(showError).toHaveBeenCalledWith('admin.accounts.bulkActions.selectAllFailed') }) - it('submits selected duplicate review and tier refresh jobs then clears selection', async () => { + it('submits the selected tier refresh job then clears selection', async () => { const currentPage = makeAccounts(20) listAccounts.mockResolvedValue({ items: currentPage, @@ -325,15 +319,6 @@ describe('admin AccountsView select all filtered results', () => { const wrapper = mountView() await flushPromises() - await wrapper.get('[data-test="select-page"]').trigger('click') - await wrapper.get('[data-test="duplicate-review"]').trigger('click') - await flushPromises() - await wrapper.get('[data-test="confirm-dialog-submit"]').trigger('click') - await flushPromises() - - expect(reviewDuplicates).toHaveBeenCalledWith(currentPage.map((account) => account.id)) - expect(wrapper.get('[data-test="selected-count"]').text()).toBe('0') - await wrapper.get('[data-test="select-page"]').trigger('click') await wrapper.get('[data-test="refresh-tier"]').trigger('click') await flushPromises() diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts index 06a34b0842f5..f66cf14c6a76 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts @@ -43,7 +43,7 @@ vi.mock('@/api/admin', async () => { }) vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: trackJob, reviewDuplicates: vi.fn() }) + useAccountJobsStore: () => ({ track: trackJob }) })) vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError, showSuccess: vi.fn(), showInfo: vi.fn() }) diff --git a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts index c2bc79647aff..f9af8251ccea 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts @@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils' import AccountsView from '../AccountsView.vue' vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: vi.fn(), reviewDuplicates: vi.fn() }) + useAccountJobsStore: () => ({ track: vi.fn() }) })) import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' diff --git a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts index 8a87b813aaa7..e1ccb6f04c17 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts @@ -6,7 +6,7 @@ import AccountsView from '../AccountsView.vue' import type { Column } from '@/components/common/types' vi.mock('@/stores/accountJobs', () => ({ - useAccountJobsStore: () => ({ track: vi.fn(), reviewDuplicates: vi.fn() }) + useAccountJobsStore: () => ({ track: vi.fn() }) })) const { diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index f8195b83e261..d0d46fd33594 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -81,8 +81,8 @@ const { const localeRef = vi.hoisted(() => ({ value: "zh-CN" })); -const nativeSettings = vi.hoisted(() => ({ - observability: { telemetry_enabled: true, theme_enabled: true }, +const panelSettings = vi.hoisted(() => ({ + observability: { theme_enabled: true }, })); vi.mock("@/stores/auth", () => ({ @@ -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); 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..8c45a9ff39ac 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -4,14 +4,9 @@ import vue from '@vitejs/plugin-vue' 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' } },