diff --git a/.downstream/gpt6-models-and-prompts.md b/.downstream/gpt6-models-and-prompts.md index e9cb2e0850ca..6fa0a7e5e7f1 100644 --- a/.downstream/gpt6-models-and-prompts.md +++ b/.downstream/gpt6-models-and-prompts.md @@ -32,7 +32,14 @@ ChatGPT 边缘自 2026-09-23 起不再为 `/backend-api/codex/responses` 的流 侧栏“扩展功能 → 系统提示词”:一个全局开关、一个站点默认提示词和提示词库(名称、正文 ≤64 KiB、位置 prepend/append、角色 auto/system/developer,最多 50 条)。整份配置存为 `settings.system_prompts` 一行 JSON;`GET/PUT /api/v1/admin/system-prompts` 读写整份配置并返回账号使用统计,删除仍被自定义账号使用的提示词返回 409。 -账号绑定存于 `accounts.extra.system_prompt`(`inherit`、`off`、`custom` + `prompt_id`,缺省为继承)。账号编辑、行菜单和批量栏(≤1000 个)通过 `PUT /api/v1/admin/system-prompts/bindings` 写入,普通账号编辑保留该键。继承的账号在所有平台使用站点默认;全局开关关闭时一律不注入。 +账号绑定存于 `accounts.extra.system_prompt`(`inherit`、`off`、`custom` + `prompt_id`,缺省为继承)。账号编辑、行菜单和批量栏(≤1000 个)通过 `PUT /api/v1/admin/system-prompts/bindings` 写入,普通账号编辑保留该键。继承的账号使用站点默认;全局开关关闭时一律不注入。 + +绑定只属于请求有注入点的平台,即 `service.SystemPromptPlatforms` 的十个平台(下表四种最终协议)。TypeSafe 只走 System One,请求没有 system / instructions 字段,转发时原样发送,所以它的账号不注入、不计入使用统计、不持有绑定: + +- 账号编辑和行菜单不提供该入口;批量栏在选中的账号都已加载且都不可注入时也不提供。选中的是一组 ID,可以包含其他页、全部结果或导入结果里未加载的账号,这类选择保留入口,批量弹窗提示 TypeSafe 账号会被跳过。 +- 绑定接口跳过这类账号并返回实际写入的账号数。 +- 创建账号(数据导入、复制账号同此路径)时丢弃这类账号带入的 `extra.system_prompt`;批量编辑和 extra 合并接口在任何平台都不写该键,已有账号的绑定只由绑定接口写入。 +- 迁移 `268_purge_system_prompt_bindings_without_insertion_point.sql` 一次性删除它们已存的绑定。 请求路径只读内存快照(保存即替换,60 秒后台刷新以同步多实例)和调度账号自带的绑定,不查询数据库;配置不可用时不注入并告警,不向客户端返回错误。每个最终协议只注入一次: @@ -53,6 +60,14 @@ Claude OAuth 伪装系统块只由上游设置决定:设置 → 网关的 `ena 侧栏“Codex 运行设置”(`/admin/codex-runtime`)有一个开关“压缩 Codex Responses 请求体”:开启时 OAuth 账号发往 ChatGPT Codex 后端的流式 `/responses` 请求体以 zstd 压缩发送,compact、models 等其他请求保持明文。保存沿用 TOTP 二次验证,对应 `GET/PUT /api/v1/admin/settings/codex-runtime` 的 `{"request_zstd": }`;接口契约与未保存时的默认值见 [native domains](native-domains.md)。 +## 失效推理密文恢复 + +侧栏“扩展功能 → 推理恢复”(`/admin/reasoning-recovery`):一个全局开关和保存按钮,默认开启,没有账号级选项;页面打开时读取一次,读取失败时显示错误、不显示开关。存为 `settings.reasoning_recovery_config` 一行 JSON(`{"enabled": }`),没有该行即开启;`GET/PUT /api/v1/admin/reasoning-recovery` 以标准响应封装读写 `{"enabled": }`,管理员认证、无二次验证,保存记入操作审计,请求体不是严格的 `{"enabled": }` 时返回 400 且不保存。请求路径只读内存中的布尔值(保存即替换,60 秒后台刷新以同步多实例),不查询数据库,每次转发开始时读取一次。启动时读取失败或存储值不是严格的 `{"enabled": }` 则停止启动;运行中刷新失败保留当前值。 + +开启时,OpenAI 平台的 API Key、OAuth 和 Setup Token 账号(含自动透传)在原生 Responses(含 compact)与 Chat Completions 转 Responses 的 HTTP 请求上生效:上游以结构化错误码 `invalid_encrypted_content` 或 `thinking_signature_invalid` 拒绝、且尚无语义输出时,去掉被拒推理项的 `encrypted_content`,在同一账号重发一次;被拒密文按来源在 Redis 记忆 24 小时,期间同源请求发送前即去掉。Messages 桥接与 WebSocket 不恢复。关闭时不重发也不预先去除,签名拒绝仍作为请求级终态返回,不切换账号。 + +迁移 `267_purge_account_reasoning_options.sql` 一次性删除各账号 `extra` 中原有的两个账号级推理选项键(键名见该文件);此后没有代码读写它们,已存的账号级取值不带入全局开关。Chat Completions 转 Responses 的请求只由客户端消息转换而来,网关不保存响应中的推理项。 + ## 账号连接测试与批量测试 单账号、批量和定时测试共用同一测试路径,错误按上游 v0.2.8 原样显示:`API returned <状态码>: <原始响应>`、`Request failed: <错误>` 及流内 `error`/`response.failed` 的上游 message;服务日志 `Account test error:` 与界面文本相同。成功仍要求有效协议终态和可见文本,失败终态附上游原始内容,例如 `stream ended before terminal (last event: …)`、`completed without visible text (finish_reason=…)`、`finish_reason=…`。 diff --git a/.downstream/upstream-review-v0.2.13.md b/.downstream/upstream-review-v0.2.13.md index cc02577bdef2..f80b9a6c560e 100644 --- a/.downstream/upstream-review-v0.2.13.md +++ b/.downstream/upstream-review-v0.2.13.md @@ -2,11 +2,12 @@ ## Source -- Downstream base: `291d25e7f8a8c82d45072a1eee742c21ba3b8d21` (`v0.2.11-codexrip.9`, `origin/main`). The integration was cut on `06e865f6e850da5b64b7c2a4d51a325446e5a4e1` (`v0.2.11-codexrip.8`); `origin/main` was merged into it after #233 (removal of the native Codex runtime host, the fingerprint panel and the Codex quality runs) landed. +- Downstream base: `291d25e7f8a8c82d45072a1eee742c21ba3b8d21` (`v0.2.11-codexrip.9`). The integration was cut on `06e865f6e850da5b64b7c2a4d51a325446e5a4e1` (`v0.2.11-codexrip.8`), took `main` in after #233 (removal of the native Codex runtime host, the fingerprint panel and the Codex quality runs) landed, and merged as #232. - Upstream: stable tag `v0.2.13` (tag object `7d0c0067f406c380f0a94cfc3879cdae7049b467`), commit `3040209f205472038c1ba745a1bedd2edd9053b1`; merged at `upstream/main` `b8dece9000c68815a5b867ca5a1e6f236e173905`, which only adds the VERSION sync (the tagged commit still says `0.2.12`). `v0.2.12` (`5106065716e494204fc0e8db16f68f6e9d576be0`) lies inside the range and is integrated with it. - v0.2.11 was integrated by a squash (#220), so git did not know it was merged. A first `merge -s ours 42bc7f6cffe24bcb471608e48e66b4a0afa1f882` commit records it without changing the tree, which makes that commit (`v0.2.11` plus its VERSION sync) the real shared ancestor. - Upstream delta: 26 commits (39 with merges), 157 paths (28 added, 129 modified), +6027/-314. 64 paths overlap downstream changes (65 in the tag-to-tag risk manifest, which also counts `VERSION`); 16 of them conflicted textually. -- Upstream brings two SQL migrations and two ent schema changes (`payment_orders.bonus_amount`, `typesafe` in the quota platform validator). This integration adds one downstream tail migration (see Migrations). No Go dependency changed. Upstream's only frontend dependency change, axios `^1.20.0`, was already downstream: `frontend/package.json`, `frontend/pnpm-lock.yaml`, `backend/go.mod` and `backend/go.sum` are identical to the downstream base. +- Upstream brings two SQL migrations and two ent schema changes (`payment_orders.bonus_amount`, `typesafe` in the quota platform validator). The integration adds one downstream tail migration (265), and the follow-ups add three downstream data migrations (266, 267 and 268; see Migrations). No Go dependency changed. Upstream's only frontend dependency change, axios `^1.20.0`, was already downstream: `frontend/package.json`, `frontend/pnpm-lock.yaml`, `backend/go.mod` and `backend/go.sum` are identical to the downstream base. +- `v0.2.13-codexrip.1` ships the integration together with the downstream follow-ups the owner decided on 2026-10-03, on one tree: group profit control is upstream's again; reasoning recovery is one global switch, and Chat reasoning replay and the per-account reasoning options are deleted; the two retired OpenAI API-key compatibility settings are deleted; the TypeSafe wording and the upstream model sync rule of the account dialogs are fixed; a system prompt binding exists only where a prompt can be injected. No release lies between the integration and the follow-ups. This document describes that tree. ## Integration decisions @@ -18,25 +19,57 @@ Every official commit is adopted with its official behaviour. Where upstream and - Connection test and batch test: the model resolver returns `jev-latest` for a TypeSafe account (`connectionTestDefaultModels`), so the batch test no longer falls back to a Claude model name. The reasoning-effort resolver offers no effort (the System One wire has none). The quality check stays limited to OpenAI OAuth accounts. - API-key model catalog auto-refresh: TypeSafe accounts are not eligible (`UpstreamModelCatalogAutoSyncEligible`). System One has no model list and the sync request builder rejects the platform, so a refresh could only fail and back off. - Group capacity catalog: the composite pool is `matchingPlatforms(PlatformComposite)` instead of a second hand-written platform list, so TypeSafe accounts of a composite group contribute their rows. `jev-latest` has no capacity unless an account override or observation supplies one. - - System prompt injection: not applied. System One has no system or instructions slot and its forwarder calls no injector. + - System prompts: not applied, and no binding (owner decision of 2026-10-03). System One has no system or instructions slot and its forwarder calls no injector, so a TypeSafe account can never receive a prompt. The rule is one list, `service.SystemPromptPlatforms`: the ten platforms forwarded by the four gateways that call the prompt service on the final body (Messages, Chat Completions, Responses, Gemini). An account on any other platform takes no binding: + - `PUT /api/v1/admin/system-prompts/bindings`, which the account edit dialog, the row menu and the bulk bar all use, skips it and answers with the number of accounts it wrote, as it already did for IDs that match no account; + - the usage counts of 系统提示词 leave it out, so a TypeSafe account is no longer counted as inheriting; + - the account edit dialog and the row menu do not offer 系统提示词 for it (`takesSystemPrompt` in `utils/systemPromptBinding.ts`, the frontend copy of the list). The bulk bar offers it while the selection can hold an account that takes a prompt: `AccountsView` gives the bar the selected rows the list holds, and the button is absent when every selected account is among them and none takes a prompt (one selected TypeSafe row, or several). A selection is a set of IDs that can also name accounts of other pages, of every result or of an import; such a selection keeps the button, and the binding request skips the accounts that take none. The bulk dialog says that TypeSafe accounts are skipped whenever a selected account may be skipped, for one selected account as for many; it states what the binding puts in effect only when a selected account is known to take a prompt, and it reports the number actually updated; + - the texts of 系统提示词 name the exception under the default prompt and in the notes, and the description no longer says every platform; + - a binding already stored on such an account is removed by migration 268 (see Migrations), and nothing stores one again. Account creation, which data import and duplicate go through (`buildAccountForCreate`), drops a given `extra.system_prompt` unless the platform takes a prompt. The two key-level writers of `extra`, bulk update (`BulkUpdateAccounts`) and `UpdateAccountExtra` (which re-authorization reaches with the caller's `extra`), write the key on no platform. An account edit keeps the stored value, as before, so on an existing account the binding endpoint is the only writer of the key. - **Recharge bonus tiers (upstream #7802).** Adopted: `payment_recharge_bonus_tiers`, `payment_recharge_bonus_mode` (`bonus` or `discount`) and `payment_recharge_bonus_notice` in the payment settings, `payment_orders.bonus_amount`, promotion tags and the notice on the recharge page, the bonus in order lists, details and the payment result, and the affiliate rebate base without the bonus. No tier is configured by default. - **Account priority quick adjust (upstream #7630).** Adopted: the stepper and inline input in the accounts table save a priority-only update 450 ms after the last change, without confirmation, and revert on failure. Downstream table rows open the details drawer on a click, so the cell stops click propagation; without it every stepper click also opened the drawer. - **Security fixes.** Adopted: the anonymous `POST /api/v1/payment/public/orders/verify` is limited to 20 requests per minute per client IP and fails open on Redis errors (#7673); the Antigravity Gemini path returns a scrubbed Gemini-style error body to the client (#7674); verification-code attempts are counted atomically and password reset tokens are stored as SHA-256 hashes and consumed once (#7676). A reset link issued by the previous version stops working after the upgrade, because its stored token is not a hash; a new reset email is needed. - **Also adopted as upstream:** Grok CLI identity headers and version gate (#7780), usage settlement when the API key was deleted before billing completes (#7816), API keys sortable by group (#7803), the corrected custom-error-code warning text (#7773), `.github/SECURITY.md` (#7814). +- **Group profit control (upstream #4925) is upstream's again.** Earlier releases kept the feature switched off: a compile-time kill switch (`profitControlRuntimeEnabled`), a 400 on any `profit_*` field in group create and update, the constants `false/0/0` in the API-key auth snapshot and a read-only block in the group edit dialog. All of it is removed (owner decision of 2026-10-03). Groups carry `profit_control_enabled`, `profit_min_margin` and `profit_safety_buffer` as upstream does, editable in the create and edit dialogs, off by default. A group that leaves it off gets no gate, so nothing is filtered for it. + - Identical to v0.2.13 again: `service/openai_profit_control.go`, `service/gateway_profit_control.go`, `service/group.go`, `service/admin_group_duplicate.go`, `handler/admin/group_handler.go`, the `profit-preview` command with `service/profit_preview.go`, `views/admin/groupsProfitControl.ts` with its spec, and upstream's profit tests in `service`, `handler` and `handler/dto`. The downstream rewrites of those tests to the switched-off state, `profit_control_dormant_regression_test.go` and `TestGroupHandlerRejectsLegacyProfitControlWrites` are gone. + - Restored inside files the fork also changes: the editable section and its form logic in `GroupsView.vue` (in place of the read-only block), the `profitControl` texts of both locales (in place of `retiredProfitControl`), the request and response types, the stored values in `snapshotFromAPIKey` and `snapshotToAPIKey`, the two profit counters of the legacy selection failure summary in `gateway_scheduling.go`, and upstream's comments at the gate call sites. The summary therefore ends with `profit_threshold=0 profit_invalid_account_rate=0` for a group without a gate; it is logged, and a client reads it in the `No available accounts` message only where `gateway.scheduling.load_batch_enabled` is off. + - Auth snapshot version 28. Entries written by earlier releases hold the constants, not the stored values, so every cached key is reloaded once after the upgrade, as after each earlier version change. + - No migration. The three columns come from 192, and `enqueue_group_auth_cache_invalidation` as last defined by 260 has the body of 193, which compares the three columns. + - Console theme: no rule and no hook was added. The section has the markup of the peak-rate block above it, and its divider, checkbox, label, hint, field labels and inputs resolve to the same computed styles as their neighbours in light and dark. + - A group whose stored `profit_control_enabled` is true is gated from the first start of this release; the stored values were not read here. +- **The two retired OpenAI API-key compatibility settings are deleted.** `openai_apikey_alpha_search_responses_bridge_enabled` and `openai_apikey_prompt_cache_key_normalization_enabled` lost their gates and write fields on 2026-09-01 (`cce166e21`). The previous release (`v0.2.11-codexrip.9`) only loaded them, showed them in 系统设置 as 已弃用设置(只读,不生效) and wrote them back unchanged on every save. All of it is removed (owner decision of 2026-10-03): the block and its locale keys, the `alphaSearchMode*` and `promptCacheKeyMode*` account locale keys that no component used, the `deprecated_settings` field of `GET /admin/settings` (the handler ends as upstream's again), the two setting keys with their defaults, parse, update and audit code, the two never-read fields of `OpenAIRefusalRecoveryRuntime`, and the stored rows (migration 266, see Migrations). The account option `openai_prompt_cache_key_mode` and the PAT-only Alpha Search bridge are live and not touched. +- **Reasoning recovery is one global switch; Chat reasoning replay and the per-account reasoning options are deleted (owner decisions of 2026-10-03).** Recovery and replay are downstream additions. + - Chat reasoning replay is removed. On Chat Completions the request sent to a Responses upstream is the plain conversion of the client's messages again, as in v0.2.13, and nothing is recorded from the response. The replay engine, its recorder hooks in the Chat and Messages handlers, the replay rules and the batch half of the reasoning state cache are gone. The batches the previous release stored in Redis are not deleted by the upgrade; they expire there (see Release target). + - The recovery of a rejected reasoning ciphertext keeps its logic: on a structured `invalid_encrypted_content` or `thinking_signature_invalid` rejection, before any semantic output, the gateway strips the ciphertext of the rejected reasoning items, retries once on the same account and remembers the rejected ciphertext in Redis for 24 hours. It applies to OpenAI API-key, OAuth and Setup Token accounts, automatic passthrough included, on native Responses, compact and Chat-to-Responses requests over HTTP; Messages and WebSocket are not covered. Whether it runs is one settings row, `reasoning_recovery_config` = `{"enabled": }`, on while no row is stored. A forwarding attempt reads an in-memory value once and never queries the database; a save replaces that value at once and a background refresh renews it after 60 seconds, so other instances follow. A failed read at startup stops the start; a failed refresh keeps the value in use. + - `GET` and `PUT /api/v1/admin/reasoning-recovery` read and save `{"enabled": }` in the standard response envelope: administrator authentication, no step-up, the save audited as `admin.reasoning_recovery.update`. A body that is not exactly that object is answered with 400 and stores nothing. + - The page 推理恢复 (`/admin/reasoning-recovery`, sidebar section 扩展功能, after Codex 运行设置) holds the switch and Save. The value is read once when the page is opened: a failed read shows its error and no switch, with Save disabled, until the page is opened again, as on Codex 运行设置 and 系统设置. The sidebar entry has an icon no other entry uses, the turn-back arrow (`RecoveryIcon`, Icon.vue `undo`, Lucide `undo-2` under the console theme). + - The account create, edit and bulk edit dialogs no longer show the two options, and nothing reads, writes or validates them. Deleted: `OpenAIReasoningPolicyFields.vue`, `utils/openaiReasoningPolicy.ts`, their specs and locale keys; the two `extra` key constants with their getters; the validation (`OPENAI_REASONING_POLICY_INVALID`) and the preservation of omitted values in the admin service; the handler checks, the import merge, the bulk-edit preflight and the scheduler cache projection. Migration 267 removes the stored keys (see Migrations). A stored per-account value is not carried over: an account that had recovery switched off follows the global switch after the upgrade. + - The rejected-cipher memory runs on one long-lived Redis client of its own, built with the gateway cache from the shared options: every read and write ends at the deadline of the operation's 100 ms budget, a refused dial fails the operation at once, and application cleanup closes the client before the shared one. + - The stop reason `policy_unavailable` is gone on both sides: nothing produces it since recovery became a direct call, the continuation diagnostic no longer accepts it, and the Ops error detail has no text for it. The text for `disabled` there names the switch, the account type and the request path instead of a per-account scope. + - The dated live recovery diagnostic is deleted (owner decision of 2026-10-04). It was the fork side of one sealed study of Chat reasoning replay and signature recovery (run `reasoning-replay-recovery-20260907`): a broker outside this repository ran one pinned binary once and passed only when the Chat replay cases completed, and those cases no longer exist. Deleted here: the live test, its offline control test and its support file, and the second command of `.github/scripts/test_reasoning_recovery.sh`. The broker scripts and their command entry outside this repository are deleted with it; the study's ledger on the server is not touched. The frozen gateway helper it shared with the reasoning fidelity harness (`ReasoningFidelityGatewayForTest`) now loads the recovery switch from the same frozen settings snapshot: a missing row keeps the default, a stored off stays off, an invalid row fails the bootstrap. The fidelity harness itself is unchanged. The deleted command was the only CI step that compiled files with the `reasoning_fidelity` build tag, so CI no longer compiles that harness. + - Console theme: the page joins the selectors of the 系统提示词 rules through its own static `data-ui="reasoning-recovery"` hook (`pages/settings-ops-public.css`, and the narrow-screen heading rule of `pages/user.css`); it has no rule of its own. - **Console theme for the new upstream UI.** Behaviour is upstream's; only styling was added under `frontend/src/styles/console`: the eleventh platform segment of the account dialog takes the `sky` identity glyph (`controls.css` 7.1a); the priority control sits as the value of the stacked priority line with label-toned steppers (`pages/accounts.css` 5, reached through the component's own static `data-testid` attributes); the mode toggle of `RechargeBonusTierEditor` carries the static `data-ui="seg-box"` hook. No `:has()`, no runtime-bound hook. - One existing rule also reaches the new UI without a change: the `RechargeBonusTierEditor` card has the same header and `p-4` body as the payment provider card beside it, so the positional body rule of `pages/settings-ops-public.css` 2.2 gives it the settings card padding. The comment of that rule now names both cards. ## Migrations -- Upstream files: `241_add_payment_order_bonus_amount.sql`, byte-identical to upstream (adds `payment_orders.bonus_amount DECIMAL(20,2) NOT NULL DEFAULT 0` with `IF NOT EXISTS`), and `241_add_typesafe_platform.sql` (re-creates `user_platform_quotas_platform_check` and `composite_model_routes_target_platform_check` with eleven platforms, `typesafe` included). +- **Files of this release, in the order the runner applies them.** The runner applies unrecorded files in filename order, each in a transaction of its own. + 1. `241_add_payment_order_bonus_amount.sql`, byte-identical to upstream: adds `payment_orders.bonus_amount DECIMAL(20,2) NOT NULL DEFAULT 0` with `IF NOT EXISTS`. + 2. `241_add_typesafe_platform.sql`, upstream's file with `cindy` kept in the quota list: re-creates `user_platform_quotas_platform_check` and `composite_model_routes_target_platform_check` with eleven platforms, `typesafe` included. + 3. `265_user_platform_quota_typesafe_union.sql`: states the eleven-platform quota CHECK once more at the tail. + 4. `266_purge_retired_openai_apikey_compat_settings.sql`: deletes the two retired settings rows. + 5. `267_purge_account_reasoning_options.sql`: removes the two per-account reasoning option keys from `accounts.extra`. + 6. `268_purge_system_prompt_bindings_without_insertion_point.sql`: removes `extra.system_prompt` from accounts on a platform without an insertion point. +- **On production.** A database that already ran `v0.2.11-codexrip.9` has recorded every file through `264_purge_codex_runtime_plugin_data.sql`, which belongs to that release (#233). The six files above are the unrecorded ones and run at the first start, in that order, after everything already applied. The quota CHECK left by 260 lists ten platforms and 260 deleted every `cindy` quota row; the composite CHECK left by `238_opencode_go_platform.sql` lists the same ten. Every existing row therefore satisfies both new constraints, and no downstream migration touches `payment_orders.bonus_amount`. This path was read, not executed: no database in the state of that release was used here. +- **On a new database** every file runs in filename order. Both upstream 241 files sort before the downstream `241_minimax_cindy_platform_quota_compat.sql`, `251_user_platform_quota_platform_union.sql` and `260_cindy_openai_apikey_preset.sql`, which re-create the quota CHECK without `typesafe`, and 264 runs before 265. This path was executed (see Validation): all 349 files applied to an empty PostgreSQL 18.1 database, which ended with the eleven platforms and no `cindy` in the quota CHECK, the same eleven in the composite CHECK and `payment_orders.bonus_amount` with default 0. - **`cindy` in the quota list of `241_add_typesafe_platform.sql`.** The downstream copy adds `cindy` to the quota list (not to the composite list) and a comment saying so, as the downstream copies of `237_add_minimax_platform.sql` and `238_opencode_go_platform.sql` do. Upstream's file relies on its list being a superset of 238's, and the downstream 238 lists `cindy`. By reading: with upstream's bytes, a downstream database that has not reached 260 and still holds `cindy` quota rows would fail on this file (SQLSTATE 23514, the file's transaction rolls back and startup stops), although the same database upgrades in one start on `v0.2.11-codexrip.8`, where 260 deletes those rows before it re-creates the CHECK. With `cindy` in the list that upgrade stays a single start: the file passes, 260 removes the rows and the value, 265 states the final list. `TestTypeSafePlatformMigration` pins the list. No downstream database has recorded this file yet; from the first release on its checksum is fixed. -- The runner applies unrecorded files in filename order. Both upstream files sort before the downstream `241_minimax_cindy_platform_quota_compat.sql`, `251_user_platform_quota_platform_union.sql` and `260_cindy_openai_apikey_preset.sql`, which re-create the quota CHECK without `typesafe`. -- **Production path (ledger through 263).** The two upstream files are unrecorded and run at the next start, after everything already applied. The quota CHECK left by 260 lists ten platforms and 260 deleted every `cindy` quota row; the composite CHECK left by `238_opencode_go_platform.sql` lists the same ten. Every existing row therefore satisfies both new constraints, and no downstream migration touches `payment_orders.bonus_amount`. Both files succeed by reading; neither was executed against a database here. - **Final constraints without a downstream tail.** Production would end with the eleven platforms in both CHECKs, plus the retired `cindy` value in the quota CHECK. A new database would run upstream's 241 first and then 241 compat, 251 and 260, ending with ten platforms in the quota CHECK and `typesafe` missing, while the composite CHECK keeps eleven on both paths (no downstream file re-creates it after upstream's 241). With `typesafe` missing, a default TypeSafe quota makes the multi-row quota snapshot of a new user violate the CHECK. - **Fix.** `265_user_platform_quota_typesafe_union.sql` states the eleven-platform quota CHECK once more at the tail (264 is `264_purge_codex_runtime_plugin_data.sql` from #233), so every path ends with the same eleven platforms and without `cindy`: `SET LOCAL` lock and statement timeouts, `DROP CONSTRAINT IF EXISTS` plus `ADD CONSTRAINT`, no row written, no explicit transaction control, a no-op when run again. `TestTypeSafeStaysInTheFinalPlatformChecks` pins the last file that defines each constraint (265 for the quota CHECK, upstream's 241 for the composite CHECK), the eleven platforms and the shape of the tail file. - **Standing hazard.** Each future upstream migration that re-creates a platform CHECK with a number below the downstream tail needs another tail migration of this kind, and `cindy` in its quota list for as long as a database older than 260 has to upgrade in one start. -- Rollback boundary, by reading: an older image ignores ledger rows of files it does not embed, the extra column has a default and the wider CHECKs accept every older value. TypeSafe accounts, groups and quota rows created after the upgrade are not understood by an older image. -- Generated code: `go generate ./ent` reproduces the merged generated code without a difference. `wire.go` and `wire_gen.go` are unchanged; upstream changed no provider input (the Redis client for the new rate limit is passed inside `router.go`). +- **Settings purge.** `266_purge_retired_openai_apikey_compat_settings.sql` deletes the two retired settings rows by key and nothing else: `SET LOCAL` lock and statement timeouts, one `DELETE FROM settings`, no explicit transaction control, a no-op when the rows are absent. It adds no constraint, so 265 stays the last file that states the quota CHECK. `TestMigration266PurgesRetiredOpenAIAPIKeyCompatSettings` (tag `integration`) restores the two rows next to settings and an account option that must stay and applies the file twice. By reading, an older image loses nothing with the rows: it reads an absent row as off, and no gate read either value since 2026-09-01. +- **Reasoning options purge.** `267_purge_account_reasoning_options.sql` removes the two per-account reasoning option keys (named in the file) from the `extra` of every account row that holds one of them, soft-deleted rows and other platforms included, and nothing else: `SET LOCAL` lock and statement timeouts, one `UPDATE accounts`, no explicit transaction control, a no-op when run again. A stored value is not carried over to the global switch, and a row that holds neither key is not written. Writing `extra` fires the long-context billing triggers of 175, which keep the stored flag and queue no scheduler event; the scheduler rebuild at startup reloads the cached account payloads, whose projection no longer lists the keys. `TestMigration267PurgesAccountReasoningOptions` (tag `integration`) stores accounts with both keys, with one key whose value is not a boolean, a soft-deleted one, one of another platform, one without the keys and one that holds a key name only inside a nested object, applies the file, checks which keys went, that only rows holding a key were written and that no scheduler event was queued, and applies it again. +- **Binding purge.** `268_purge_system_prompt_bindings_without_insertion_point.sql` removes `extra.system_prompt` from every account whose platform is not one of the ten in `service.SystemPromptPlatforms` (TypeSafe, and rows of a platform without a forwarder), soft-deleted rows included, and nothing else: `SET LOCAL` lock and statement timeouts, one `UPDATE accounts`, no explicit transaction control, a no-op when run again. Such a row could hold a binding because the binding endpoint wrote to every selected account whatever its platform, because account creation (data import and duplicate included), bulk update and the extra merge stored a given key as it was, and because 259 turned a stored `prompt_skills` "off" into a binding on any platform; it never changed a request. From this release on none of those four writers stores one (see TypeSafe in downstream account flows) and 259 ran once, so the file corrects the stored data a single time and no code ignores a stored value. No other row is written and no scheduler event is queued: of the two `extra` triggers of 175, one returns a row of another platform than `openai` unchanged and the other fires for `openai` only. `TestMigration268PurgesSystemPromptBindingsWithoutInsertionPoint` (tag `integration`) stores bindings on accounts with and without an insertion point, among them a soft-deleted row and a row whose `extra` is not an object, and applies the file twice. +- Rollback boundary, by reading: an older image ignores ledger rows of files it does not embed, the extra column has a default and the wider CHECKs accept every older value. TypeSafe accounts, groups and quota rows created after the upgrade are not understood by an older image. 266, 267 and 268 have no down path: an older image reads the absent settings rows as off and finds no binding where 268 removed one, neither of which had an effect, but it reads an account without the two reasoning options as its default, so Chat reasoning replay and recovery are on again for every account, also for those that had one switched off. It does not read the `reasoning_recovery_config` row. +- Generated code: `go generate ./ent` reproduces the merged generated code without a difference. Upstream changed no provider input (the Redis client for the new rate limit is passed inside `router.go`). The follow-ups add the reasoning recovery service and its admin handler as providers and give the cleanup the gateway cache to close; `wire.go` and `wire_gen.go` carry both. ## Deliberate downstream deviations kept @@ -44,10 +77,26 @@ Every official commit is adopted with its official behaviour. Where upstream and - **A connection test passes only on a valid answer.** Upstream's TypeSafe test passes on any HTTP 200 JSON object; its `TestTypeSafeAccountTestUsesPromptAsState` pinned `{"answers":{}}` as a pass. Downstream a connection test needs a valid protocol terminal, and a passed test recovers the account's error and rate-limit state (`RecoverAccountAfterSuccessfulTest` after the single and batch tests, and after scheduled tests with auto-recover), so a relay or a wrong base URL that answers 200 with any JSON must not pass. The TypeSafe test passes only when the response answers the question it asked: `answers.connection_test` of type `noul` with a probability between 0 and 1. `typesafe.NoulScore` checks it with the answer validator `typesafe.Evaluate` applies (now one shared function); the test does not require a `model` field. That answer shape is the one upstream's own client already requires of every content moderation answer; it was not checked against the live API here (no real TypeSafe call). Any other 200 body fails as `Invalid System One response: : ` and changes no account state; a body that cannot be read fails as `Failed to read response: `. The two upstream fixtures that passed without a valid answer were given one, and `TestTypeSafeAccountTestRequiresAnswerToItsQuestion` pins the failures. - **Ops keeps the upstream body of a failed System One request.** Upstream's `handleSystemOneErrorResponse` recorded only the extracted message, which is empty for any body without `error.message`, `detail` or `message`, and its event for a 2xx response the gateway cannot relay recorded only the decoder's error text (`typesafe invalid response` or the size limit error); no log line carried the body, while the client gets a fixed sentence. Downstream both events carry the body bounded by `gateway.log_upstream_error_body` and `gateway.log_upstream_error_body_max_bytes`, as `Detail` and, the events being passthrough, as `UpstreamResponseBody`; the top-level Ops detail follows from the last event, and skip-monitoring passthrough rules see the body as on every other path. The non-2xx path also writes the body log line of `handleErrorResponse`. For the 2xx case `typesafe.DecodeSystemOneResponse` returns the body it read inside a `SystemOneResponseError`; the error text is unchanged and `errors.Is(err, ErrSystemOneResponseTooLarge)` still holds. The client response is unchanged. `TestForwardSystemOneOpsRecordKeepsUpstreamBody` pins the three cases and the switch. - **Administrators keep the raw upstream error.** The Antigravity masking applies to the client response only. `setOpsUpstreamError` and `appendOpsUpstreamError` still record the upstream message and the raw body as the upstream error detail before the scrubbed body is written. That detail exists while `gateway.log_upstream_error_body` is on (the default) and is cut at `gateway.log_upstream_error_body_max_bytes` (2048 by default). The client error body stored with the same Ops entry is now the scrubbed one. +- **TypeSafe wording in the account dialogs (owner decision of 2026-10-03).** Upstream's dialogs show the generic texts for a TypeSafe account, which name Anthropic: the Base URL hint `Leave default for official Anthropic API` in the create and edit dialogs, the API Key hint `Your Claude Console API Key` in the create dialog, and the placeholders `https://api.anthropic.com` and `sk-ant-...` in the edit dialog. Downstream the Base URL and API Key hints come from the per-platform keys `admin.accounts.typesafe.baseUrlHint` and `apiKeyHint` of both locales, through the `baseUrlHint` and `apiKeyHint` computeds that already serve OpenAI and Gemini, and the edit dialog's placeholders are `https://api.typesafe.ai` and `ts-...`, the values upstream's create dialog already has. +- **An upstream model sync is offered and started only where the backend can list models (owner decision of 2026-10-03).** Upstream decides by platform alone, with a platform set inside `ModelWhitelistSelector`, and starts the sync after a create for every API-key account with a concrete mapping target. A TypeSafe account always has one (`jev-latest`), so each create was followed by a sync the backend rejects (400) and by the warning toast `同步上游模型失败`. Downstream one predicate answers for every place, `supportsUpstreamModelSync(platform, type, credentials)` in `utils/upstreamModelSync.ts`: + - the create dialog builds the preview credentials of 同步上游支持的模型 and starts the sync after a create only when it holds; a preview also needs the API-key form, whose key authenticates it; + - the edit dialog gives the account ID to its model selectors and shows the Antigravity button only when it holds for the saved account; + - `ModelWhitelistSelector` offers the button for the target it is given and has no rule of its own; bulk edit gives it none, as before. + - The predicate is the set `fetchUpstreamModelList` and its request builders (`service/upstream_models.go`) accept before any upstream call: Anthropic `oauth`, `setup-token`, `apikey`; OpenAI `oauth`, `apikey`; Gemini `apikey`, and `oauth` without a `project_id` (Code Assist has no model list); Antigravity `oauth`, `upstream`, and `apikey` with a base URL ending in `/antigravity`; Grok `oauth`, `apikey`; Kimi, Zhipu, DeepSeek, MiniMax and OpenCode `apikey`; TypeSafe none. It was compared with the backend by running both over the eleven platforms, the six account types and four credential variants (264 cells, no difference); that comparison is not part of the repository. + - What changes besides TypeSafe: a Vertex service-account account no longer shows the button in the edit dialog (the backend answered 400); an Antigravity API-key account whose base URL does not end in `/antigravity` shows no button and gets no sync after a create (400, and after a create the same warning toast); a key left in the API-key form no longer offers a preview in the OAuth and Bedrock sections. The backend is unchanged. - **Generic platform lists.** `api/admin/settings.ts`, `api/admin/users.ts`, `UserPlatformQuotaCell.vue`, `UserDashboardStats.vue` and `SettingsView.vue` keep the lists derived from `CONCRETE_PLATFORM_VALUES`. The dashboard cards therefore follow the platform catalog order (TypeSafe after the other known platforms, unknown platforms last); upstream's new dashboard test encoded its literal order and was rewritten to the catalog order. - **Capacity stays manifest-derived.** Upstream adds no capacity literal in this range and none was added for `jev-latest`. - **Codex catalogs of composite groups exclude TypeSafe**, as upstream: `compositeAvailableModels(..., includeSystemOne=false)` and the static composite defaults never list `jev-latest`. A group whose own platform is `typesafe` is not covered by that exclusion, also as upstream: `GET /v1/models?client_version=...` reaches `GatewayHandler.CodexModels` for every non-OpenAI group, and its manifest lists `jev-latest` (from the account mappings or `defaultModelIDsForPlatform`), with no capacity unless an account override or observation supplies one. -- Image Studio and the Responses image bridge stay off, the public model page stays retired, the profit control kill switch and the per-account reasoning policy switches are untouched; no upstream change in this range touches them. +- **Profit control inside downstream mechanisms.** The profit code differs from v0.2.13 only where a downstream contract wraps it: + - a selection result also carries the runtime-breaker probe (`attachSelectionProfitGate(ctx, attachSelectionRuntimeBreakerProbe(...))`), and a vetoed slot releases that probe together with the slot; + - a same-account retry does not select again: the downstream `ReacquireOpenAISameAccountSelection` returns a selection that carries no gate, and the check after the slot then runs against the request-level gate, which every text handler installs with `WithOpenAIRequestPricingContext` for the group it schedules; + - the previous-response lookup reads the stored owner from the repository once, so the veto is checked once instead of upstream's twice, and a veto is the downstream policy miss: reuse is skipped, the binding stays, ordinary scheduling follows; + - the WebSocket turn veto closes the turn with the downstream local close error; + - token counting selects through the scheduler under the handler's suppress marker, so upstream's `SelectAccountForTokenCount` is not used; + - the Codex models manifest selects through the scheduler as well, where upstream uses `SelectAccountForModelWithExclusions`, which installs no gate; its handler sets the suppress marker before the selection loop, so the manifest stays outside the gate as upstream (`TestCodexModelsIgnoresGroupProfitControl`); + - the eligibility helpers return `(bool, reason)` for the downstream selection diagnostics, and the handlers pass the pricing instant through the downstream usage snapshot; + - the auth snapshot version is the downstream counter (28; upstream 24). +- Image Studio and the Responses image bridge stay off, the public model page stays retired and reasoning recovery stays a downstream addition (one global switch, see Integration decisions); no upstream change in this range touches them. ## Conflict resolutions @@ -55,7 +104,7 @@ Every official commit is adopted with its official behaviour. Where upstream and - `backend/internal/handler/gateway_handler.go`: `Models` keeps the downstream generic fallback (any non-Anthropic platform lists `defaultModelIDsForPlatform`), which now serves TypeSafe; upstream's dedicated TypeSafe branch is not added. `defaultModelIDsForPlatform` keeps the downstream DeepSeek, MiniMax, Kimi and Zhipu cases and gains upstream's TypeSafe case. - `backend/internal/server/api_contract_test.go` (two hunks): the settings contract lists `typesafe` in `default_platform_quotas` once. - `backend/internal/service/account_service.go`: `Create` runs upstream's API-key-only check for TypeSafe before the downstream base URL normalization. -- `backend/internal/service/admin_account.go` (two hunks): the same check in `buildAccountForCreate`, before the downstream clone of `extra`, and in `UpdateAccount`, before the downstream credential normalization and capacity override validation. +- `backend/internal/service/admin_account.go` (two hunks): the same check at the start of `buildAccountForCreate`, before the downstream handling of `extra`, and in `UpdateAccount`, before the downstream credential normalization and capacity override validation. - `backend/internal/service/account_test_service.go`: the TypeSafe dispatch is added to the downstream dispatcher, after the relocated OpenCode branch and before the Claude fallback. - `backend/internal/service/billing_service.go`: upstream's `jev-latest` fallback price is added; the downstream removal of the DeepSeek fallback block stays. - `frontend/src/api/admin/settings.ts` (two hunks), `frontend/src/api/admin/users.ts`, `frontend/src/components/user/UserPlatformQuotaCell.vue`, `frontend/src/components/user/dashboard/UserDashboardStats.vue` (two hunks): the downstream generic lists and `resolvePlatformLabel` stay; `typesafe` arrives through `constants/platforms.ts` and `platformColors.ts`. @@ -74,25 +123,36 @@ Every official commit is adopted with its official behaviour. Where upstream and - `typesafe_platform_migration_test.go`: the quota list of the 241 file carries `cindy`, and `TestTypeSafeStaysInTheFinalPlatformChecks` was added for the tail migration. - Fixtures that pinned ten platforms now pin eleven: `platforms.spec.ts`, `settings.authSourceDefaults.spec.ts`, `channelPlatformOptions.spec.ts` (upstream's `ChannelsView` lists gained `typesafe`). Two test titles lost their platform count, and the name filter of the `MiniMax quota and Grok save compatibility` step in `.github/workflows/downstream-verify.yml` was updated with them, so the step still selects the same tests. - TypeSafe in the remaining platform lists: `matchingPlatforms`, the scheduler platform buckets, `AllowedQuotaPlatforms`, `isConcreteRequestPlatform` and `compositeAvailableModels` include it; a composite group's Codex catalog and the static composite defaults (the composite case of `defaultModelIDsForPlatform` and `compositeDefaultModelsListCandidateIDs`) exclude it, as upstream. -- Upstream warts kept as they are, identical in upstream v0.2.13: creating a TypeSafe account with a model whitelist triggers the post-create upstream model sync, which the backend rejects for this platform, and a warning toast follows although the account is saved; the account dialogs show the Anthropic wording of the generic API-key hints and base URL placeholder for TypeSafe; `PlatformTypeBadge` and `GroupBadge` have no TypeSafe colour case and use their default. +- Upstream wart kept as it is, identical in upstream v0.2.13: `PlatformTypeBadge` and `GroupBadge` have no TypeSafe colour case and use their default. The two TypeSafe defects of the account dialogs that stood here (the rejected model sync after a create, the Anthropic wording) are fixed downstream; see Deliberate downstream deviations kept. ## Validation -- Every bullet except "After the review fixes" describes the checks of the integration before its review; that bullet lists what was run again on the final tree. -- `go build ./...` passes for windows and for `GOOS=linux GOARCH=amd64`. `go vet ./...` passes plain and with each of the `unit`, `integration` and `e2e` tags, so every merged test file compiles. `gofmt -l` is empty for the Go files that differ from the downstream base. -- Focused `go test -tags=unit` runs pass: `migrations` (both TypeSafe migration tests); `service` (TypeSafe connection test, connection test model, catalog auto-refresh eligibility, composite capacity pool, `jev-latest` pricing, `ForwardSystemOne`, Antigravity client error body, TypeSafe account creation with the billing probe, platform lists, Grok headers, recharge bonus quote and rebate base, reset token); `handler` (System One, protocol rejection, model lists); `handler/admin` (available models, group platform binding, pricing sync); `server` (admin settings contract); `server/routes` (public order verify rate limit, prompt audit route coverage, group model allowlist routes); `repository` (API key sort, deleted-key usage settlement, email cache, Grok CLI identity); `model` (platform list). -- `golangci-lint` 2.13 reports 0 issues on `ent`, `domain`, `handler`, `model`, `pkg/typesafe`, `pkg/xai`, `repository`, `securityaudit`, `server`, `service` and `migrations`. -- The merge commit was also checked on its own, from an export of its tree: `go build ./...`, `go vet -tags=unit` on `service`, `handler`, `server` and `migrations`, and the TypeSafe connection tests pass there. The frontend checks ran on the final tree only. -- `vue-tsc --noEmit` passes. Focused vitest runs pass for the conflicted, new and modified-component specs (quota modal and cell, settings view, dashboard stats, recharge bonus, amount input, payment views, keys view, account dialogs, `AccountPriorityCell`, all `AccountsView` specs, platform catalogs, channel platform options, use-key modal, model whitelist, locale key completeness). Tests in three fixture files (`settings.authSourceDefaults.spec.ts`, `platforms.spec.ts`, `channelPlatformOptions.spec.ts`) failed first because they still pinned ten platforms; they pass after the update described above. The exact command of the name-filtered CI step selects 9 tests and passes. ESLint passes on the changed frontend files. -- `.downstream/upstream-risk.json` was regenerated with `.github/scripts/upstream_risk.py` (`review_required`, 157 upstream paths, 65 overlapping, 31 critical, `merge_conflicts: []`, the 16 initially conflicted paths recorded); recomputing it reproduces every key the gate compares. `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. -- A local preview of the new upstream UI under the console theme was captured in light and dark from a scratch production build against a mock API: the priority control (idle, hover, changed, editing), a TypeSafe account row, the platform segment and the TypeSafe form of the create dialog, the recharge tier editor in both modes, the default quota rows and the recharge page. The captures are not part of the repository. -- The frontend checks ran against a shared dependency tree that holds axios 1.18.1, while the lockfile pins 1.20.0. -- After the review fixes (System One Ops body, connection test success rule, `cindy` in the 241 quota list, the CSS comment), on the final tree: `go build ./...` (windows) and `go vet -tags=unit` on `pkg/typesafe`, `service` and `migrations` pass, and `gofmt -l` is empty for the six changed Go files. Focused tests pass: `pkg/typesafe` (`TestEvaluateContract` and both `DecodeSystemOneResponse` tests), `migrations` (both TypeSafe migration tests), `service` (the five TypeSafe connection tests; the `ForwardSystemOne` error policy, request error, account-level failover, oversized response and response-without-model tests; the new Ops record test). `golangci-lint` 2.13 reports 0 issues on `pkg/typesafe`, `migrations` and `service`. Not repeated: the linux build, the `integration` and `e2e` vet runs, the other packages and every frontend check. The fixes changed no platform-specific code, no tagged test file and no other package, and in the frontend only a CSS comment. -- After merging `origin/main` (`291d25e7f`, #233): one conflict, `backend/internal/service/admin_account.go`, where upstream's TypeSafe API-key-only check stays and the `maps.Clone(accountExtra)` line that #233 removed stays removed. `.downstream/upstream-risk.json` was regenerated against the new downstream commit (1420 downstream paths; 157 upstream, 65 overlapping and 31 critical as before). On that tree `go build ./...` and `go vet -tags=unit` on `service`, `handler`, `repository`, `server` and `migrations` pass, the two TypeSafe migration tests and the `service` tests matching `TypeSafe` pass, and `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. No frontend check was repeated locally. -- Not run: full suites, race tests, integration tests against a database, a new-database, older-database or production migration rehearsal, real model or TypeSafe calls, deploy. Source integration does not claim a successful deployment. +- **The tree.** The follow-ups were written on two branches cut before the integration was squashed and were replayed onto `main` (#232, #233 and #234 included), commit by commit. Where #233 and a follow-up changed the same file, both changes are kept; in `CreateAccountModal.spec.ts` the Codex fingerprint case of #233 stays where the per-account reasoning cases went. Everything in this section was run on that assembled tree unless it says otherwise. +- **Go.** `go build ./...` (windows) passes. `go vet` passes with the `unit` tag and with the `integration` tag on the fourteen packages the follow-ups touch (`cmd/profit-preview`, `cmd/server`, `codexruntime/recovery`, `handler`, `handler/admin`, `handler/dto`, `nativeapi`, `pkg/apicompat`, `repository`, `server`, `server/middleware`, `server/routes`, `service`, `migrations`), and with the `unit` tag on the four other packages whose tests import them (`accounttools/policy`, `codexruntime/profile`, `handler/quotaview`, `securityaudit`). `gofmt -l` is empty for the 102 changed Go files. `golangci-lint` 2.13 reports 0 issues on the fourteen packages. +- **Go tests by name, tag `unit`: 120 in ten packages, all passing.** + - `service`, 78: the 43 tests of upstream's eight profit test files and the two simple-mode group tests; the 26 Alpha Search and refusal runtime tests of the two files the settings purge changed; the four binding tests (`TestSystemPromptSetBindingsSkipsAccountsWithoutInsertionPoint`, `TestSystemPromptBindingHasNoOtherWriter`, `TestAdminAccountExtraAndBulkCannotInjectModelContextRuntime`, `TestBuildAccountForCreateAcceptsTypeSafeAPIKeyWithProbeEnabled`); three recovery tests outside the gate. + - `handler`, 14: upstream's ten profit veto tests, the two Alpha Search failover tests the settings purge changed, and `TestCodexModelsIgnoresGroupProfitControl` with `TestCodexModelsHonorsAccountSwitchLimit`. + - `handler/admin`, 14: `TestGroupHandlerEndpoints` and the thirteen `TestUpdateSettings*` tests of the partial payload file. + - `handler/dto`, 2, and `cmd/profit-preview`, 3: upstream's profit tests there. `cmd/server`, 3: the cleanup tests, the gateway cache close among them. + - `server`, 1 (`TestAPIContracts`); `server/middleware`, 2 (the audit actions of 系统提示词 and 推理恢复); `server/routes`, 1 (`TestReasoningRecoveryRoutesRequireAdminAuthenticationWithoutStepUp`); `migrations`, 2 (both TypeSafe migration tests; the second reads every file and finds 265 as the last one that states the quota CHECK). +- **The reasoning recovery gate.** `.github/scripts/test_reasoning_recovery.sh` passes as CI runs it: 60 tests with the `unit` tag in `pkg/apicompat`, `repository`, `service` and `handler/admin`, `TestReasoningRecoveryEndpointsReadAndSaveTheSwitch` among them. +- **Migrations against a database.** A disposable PostgreSQL 18.1 on the loopback interface, started from local binaries and deleted afterwards. The integration harness applied all 349 migration files to the empty database, and `TestMigration266PurgesRetiredOpenAIAPIKeyCompatSettings`, `TestMigration267PurgesAccountReasoningOptions` and `TestMigration268PurgesSystemPromptBindingsWithoutInsertionPoint` (tag `integration`) pass against it. The database ended with all 349 files recorded and with the constraints and the column described under Migrations. +- **Frontend.** `vue-tsc --noEmit` passes. ESLint passes on the 48 changed frontend files. Vitest (`--no-cache`) passes for 13 files with 279 tests: the eleven specs the follow-ups changed (`BulkEditAccountModal`, `CreateAccountModal`, `EditAccountModal`, `AccountActionMenu.spark_shadow`, `AccountBulkActionsBar`, `AppSidebar`, `AppSidebar.simple-mode`, `useModelContextCapacities`, `AccountsView.selectionScope`, `ReasoningRecoveryView`, `groupsProfitControl`), locale key completeness, and `CodexRuntime.native`, which mounts the continuation diagnostics. These ran against a local dependency tree that holds axios 1.18.1 and dompurify 3.3.1, while the lockfile pins 1.20.0 and 3.4.14. +- **推理恢复 on both sides.** The page's client and its spec use `GET` and `PUT` on `/admin/reasoning-recovery` under the `/api/v1` base with `{"enabled": }` and read the answer through the envelope the API client unwraps. The handler test pins the path `/api/v1/admin/reasoning-recovery`, the envelope `{"code":0,"message":"success","data":{"enabled": …}}` and the 400 for any other body; the route test pins administrator authentication without step-up, and the page uses none. +- **Workflow references.** Every spec and script `.github/workflows/downstream-verify.yml` names exists. Its `Reasoning recovery contracts` step runs the renamed gate script, and its vitest list names `ReasoningRecoveryView.spec.ts` in place of the two deleted reasoning specs. The name filters of its Go steps select what they selected before, less the deleted tests (the retired settings test of the refusal runtime and the three scheduler cache projection tests of the per-account options); the two renamed Alpha Search tests still match. The critical list of the root `Makefile` no longer names the Cindy spec that #206 deleted. `test_upstream_automation_contract.py` (16 tests), `test_upstream_risk.py` (4) and `test_release_preflight.py` (12) pass. +- `.downstream/upstream-risk.json` is unchanged. It is computed from the two upstream tags and the downstream commit of the integration (`291d25e7f`: 157 upstream paths, 65 overlapping, 31 critical, 1420 downstream paths), so it also lists files the follow-ups delete. The gate that recomputes it (`.github/workflows/upstream-risk-gate.yml`) runs for pull requests from `sync/upstream-*` branches and on a manual dispatch for a pull request number. By reading, it recomputes from the tags and the downstream commit the manifest records, not from the head of the pull request, so a run dispatched for the follow-ups reproduces this manifest, deleted files included, and then requires the `upstream-reviewed` label because the class is `review_required`. +- **Changed after the runs above.** Five changes followed the runs above: the comment headers of 266 and 267 now cite the previous release (`v0.2.11-codexrip.9`) and its line numbers, with no statement changed, so the migration tests applied the same SQL; `TestOpenAIReasoningRecoveryChatToolRoundKeepsNoReasoningState` lost its last assertion, a search of the gin context for a key only the deleted replay set; the zh label of the recovery line in the Ops continuation diagnostics is 推理恢复, the name the reason text of the same panel already used; the live recovery diagnostic and the second command of the gate were deleted; and `ReasoningFidelityGatewayForTest` loads the recovery switch from its frozen snapshot. The zh texts of the 推理恢复 page are the ones the owner approved in the preview of 2026-10-03. Run on the final tree: `go build ./...`; `go vet` on `service` with the `unit` tag and with the `reasoning_fidelity` tag; `.github/scripts/test_reasoning_recovery.sh`, which passes with its one remaining command; `TestReasoningFidelityControl` and `TestReasoningFidelityFixtures` with the `reasoning_fidelity` tag; `test_upstream_automation_contract.py`; `vue-tsc --noEmit`; ESLint on the two changed frontend files; and vitest (`--no-cache`) for six files with 20 tests (`ReasoningRecoveryView`, locale key completeness, locale message compile, locale key collision, `CodexRuntime.native`, `OpsErrorDetailModal`). +- **Checked for the integration before it merged as #232 and not repeated here:** the build for `GOOS=linux GOARCH=amd64`; `go vet` plain, with the `e2e` tag and on the packages the follow-ups do not touch; `go generate ./ent`; the TypeSafe, System One, recharge bonus, security fix and platform list tests; the vitest specs of the new upstream UI. The previews the owner saw on 2026-10-03 (the new upstream UI under the console theme, the profit control section, 推理恢复, the TypeSafe dialogs) were captured from the separate branches against a mock API; the captures are not part of the repository. +- **Not run:** full suites, race tests, any other integration test, the production migration path against a database in the state of `v0.2.11-codexrip.9` (read only, see Migrations), a browser preview of the assembled tree, an owner preview of two zh texts of the Ops continuation diagnostics written after 2026-10-03 (the label 推理恢复 and the reason shown for `disabled`), real model or TypeSafe calls, deploy. Source integration does not claim a successful deployment. ## Release target -- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). The first start applies three migrations: the two upstream 241 files and 265. No setting changes on upgrade: no recharge tier and no TypeSafe account or group exists until an administrator creates one. -- Not part of this integration: the system prompt binding selector of the account edit dialog is shown for TypeSafe accounts although System One requests are never injected. The owner decided on 2026-10-03 that it is not offered there; the follow-up change does that. +- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). On a database that already ran `v0.2.11-codexrip.9` the first start applies six migrations: the two upstream 241 files, then 265, 266, 267 and 268. +- What changes with the upgrade before an administrator does anything: + - reasoning recovery follows the global switch, which is on: an account that had recovery switched off is recovered like every other account, and Chat reasoning replay stops for every account; + - a group whose stored `profit_control_enabled` is true is gated from the first start; the stored values were not read here; + - every cached API key is reloaded once (auth snapshot version 28); + - a password reset link issued before the upgrade stops working. +- Nothing else takes a new value: 266 only deletes the two retired rows, which had no effect, and 268 only removes bindings that had none; no recharge tier and no TypeSafe account or group exists until an administrator creates one, and profit control stays off for every group that has not enabled it. +- Stored data of the removed replay that the upgrade does not delete: the batches the previous image wrote to Redis under `openai_reasoning_state:v1:{openai_reasoning_batches}:`. A batch holds the output items of one upstream response (reasoning with its ciphertext, messages, function calls) and what the client was shown of them, at most 256 KiB; the store holds at most 8192 batches and 64 MiB, plus its index keys. This release has no code that names the prefix, so it neither reads nor removes them. Every key of the prefix carries a 24-hour expiry, set by the previous image when it stored a batch (on the batch, on the set of its API key and on the five index keys) and never extended by a read, so the last key is gone 24 hours after that image stored its last batch. - Final source SHA, PR and check results, image digest and production outcome belong to the operational delivery evidence. diff --git a/.github/scripts/test_reasoning_recovery.sh b/.github/scripts/test_reasoning_recovery.sh new file mode 100644 index 000000000000..b30e4de78ab8 --- /dev/null +++ b/.github/scripts/test_reasoning_recovery.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/../.." + +# Offline contracts only; no account credentials or model calls. +go -C backend test -p=1 -tags unit ./internal/pkg/apicompat ./internal/repository ./internal/service ./internal/handler/admin \ + -run 'Test(OpenAIReasoning|OpenAIHTTPTerminal|GatewayCacheReasoningState|ResponsesReasoningConfiguration|ReasoningRecovery)' -count=1 diff --git a/.github/scripts/test_reasoning_replay.sh b/.github/scripts/test_reasoning_replay.sh deleted file mode 100644 index 91b993e371ed..000000000000 --- a/.github/scripts/test_reasoning_replay.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/../.." - -# Offline contracts only. The live diagnostic entry point is excluded -# explicitly; this gate cannot consume account credentials or model requests. -go -C backend test -p=1 -tags unit ./internal/pkg/apicompat ./internal/repository ./internal/service ./internal/handler/admin \ - -run 'Test(OpenAIChatReasoning|OpenAIReasoning|OpenAIHTTPTerminal|GatewayCacheReasoningState|SchedulerCacheReasoningPolicy|ResponsesReasoningConfiguration)' -count=1 -go -C backend test -p=1 -tags reasoning_fidelity,reasoning_replay_diagnostic ./internal/service \ - -run '^TestReasoningReplayDiagnostic(Control|ChatParse)$' -count=1 diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index f4b4e4b57720..ce3439b4c810 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -36,8 +36,8 @@ jobs: run: bash .github/scripts/test_agent_protocol.sh - name: Native domain and retirement contracts run: bash .github/scripts/test_native_domains.sh - - name: Reasoning replay and recovery contracts - run: bash .github/scripts/test_reasoning_replay.sh + - name: Reasoning recovery contracts + run: bash .github/scripts/test_reasoning_recovery.sh - name: Model context capacity contracts run: bash .github/scripts/test_model_context_capacity.sh - name: Service tests @@ -144,8 +144,7 @@ jobs: src/components/account/__tests__/CreateAccountModal.spec.ts \ src/components/payment/__tests__/StripePaymentInline.contrast.spec.ts \ src/views/user/__tests__/StripePaymentView.spec.ts \ - src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts \ - src/utils/__tests__/openaiReasoningPolicy.spec.ts \ + src/views/admin/__tests__/ReasoningRecoveryView.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ src/utils/__tests__/accountEditCodec.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ diff --git a/Makefile b/Makefile index a91b6a9dc304..bc978c70f1b6 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,6 @@ FRONTEND_CRITICAL_VITEST := \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ src/composables/__tests__/useModelWhitelist.spec.ts \ src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts \ - src/features/cindy-group-split/__tests__/CindyGroupAuditDialog.spec.ts \ src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \ src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts diff --git a/backend/cmd/profit-preview/main.go b/backend/cmd/profit-preview/main.go new file mode 100644 index 000000000000..2c351410c922 --- /dev/null +++ b/backend/cmd/profit-preview/main.go @@ -0,0 +1,237 @@ +// profit-preview 读取生产只读导出的 JSON(分组利润配置、账号倍率与探测状态、 +// 用户覆盖倍率、主力模型清单),复用线上 U/D/阈值判定做五平台离线预演。 +// +// 用法: +// +// go run ./cmd/profit-preview -input dump.json [-assume-enabled] [-json] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type inputGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + RateMultiplier float64 `json:"rate_multiplier"` + SubscriptionType string `json:"subscription_type"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` +} + +type inputAccount struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + RateMultiplier *float64 `json:"rate_multiplier"` + Extra map[string]any `json:"extra"` + ModelMapping map[string]string `json:"model_mapping"` +} + +type inputEntry struct { + Group inputGroup `json:"group"` + Accounts []inputAccount `json:"accounts"` + UserOverrides map[string]*float64 `json:"user_overrides"` + Models []string `json:"models"` +} + +type inputDoc struct { + Groups []inputEntry `json:"groups"` +} + +func main() { + inputPath := flag.String("input", "", "生产只读导出 JSON 路径") + assumeEnabled := flag.Bool("assume-enabled", false, "把当前关闭的支持平台分组按保存配置视为已启用") + jsonOut := flag.Bool("json", false, "以 JSON 输出完整报告(默认输出可读表格)") + flag.Parse() + if *inputPath == "" { + fmt.Fprintln(os.Stderr, "usage: profit-preview -input dump.json [-assume-enabled] [-json]") + os.Exit(2) + } + raw, err := os.ReadFile(*inputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "read input: %v\n", err) + os.Exit(1) + } + inputs, err := parsePreviewInputs(raw, *assumeEnabled) + if err != nil { + fmt.Fprintf(os.Stderr, "parse input: %v\n", err) + os.Exit(1) + } + + evalAt := time.Now() + reports := service.PreviewProfitAdmission(inputs, evalAt) + if len(reports) == 0 { + fmt.Fprintln(os.Stderr, "input produced no preview reports") + os.Exit(1) + } + + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"evaluated_at": evalAt, "reports": reports}); err != nil { + fmt.Fprintf(os.Stderr, "write output: %v\n", err) + os.Exit(1) + } + return + } + + fmt.Printf("利润门预演 @ %s(U=账号倍率;探测状态仅告警)\n", evalAt.Format(time.RFC3339)) + for _, report := range reports { + fmt.Printf("\n== 分组 %d %s [%s] ==\n", report.GroupID, report.GroupName, report.Platform) + fmt.Printf(" 利润门生效=%v 假定启用=%v | 默认 D=%.4f 阈值=%.4f | 最低有效 D=%.4f 阈值=%.4f\n", + report.EffectiveGate, report.AssumedEnabled, + report.DefaultD, report.ThresholdDefault, report.MinEffectiveD, report.ThresholdMinD) + counts := map[string]int{} + for _, v := range report.Verdicts { + counts[v.Class]++ + rate := "-" + if v.AccountRate != nil { + rate = fmt.Sprintf("%.4f", *v.AccountRate) + } + flags := make([]string, 0, 2) + if v.RejectedUnderMinD { + flags = append(flags, "最低有效D下拒绝") + } + if len(v.Warnings) > 0 { + flags = append(flags, strings.Join(v.Warnings, ",")) + } + suffix := "" + if len(flags) > 0 { + suffix = " [" + strings.Join(flags, "; ") + "]" + } + fmt.Printf(" 账号 %-4d %-24s 平台=%-12s U=%-8s 来源=%-19s %s%s\n", + v.AccountID, v.Name, v.Platform, rate, v.RateSource, v.Class, suffix) + } + fmt.Printf(" 分类合计: 准入=%d 利润不足=%d 倍率非法=%d\n", + counts[service.ProfitPreviewClassAdmitted], + counts[service.ProfitPreviewClassRejectedThreshold], + counts[service.ProfitPreviewClassRejectedInvalidRate]) + models := make([]string, 0, len(report.RemainingByModel)) + for model := range report.RemainingByModel { + models = append(models, model) + } + sort.Strings(models) + for _, model := range models { + fmt.Printf(" 模型 %-20s 利润门准入账号: 默认D=%d 最低有效D=%d\n", + model, report.RemainingByModel[model], report.RemainingByModelMinD[model]) + } + for _, model := range modelsWithZeroRemaining(report) { + fmt.Printf(" 警告: 模型 %s 启用后利润门准入账号为 0\n", model) + } + for _, model := range modelsWithZeroRemainingUnderMinD(report) { + fmt.Printf(" 警告: 模型 %s 在最低有效D(存在低倍率用户覆盖)下利润门准入账号为 0\n", model) + } + } +} + +func parsePreviewInputs(raw []byte, assumeEnabled bool) ([]service.ProfitPreviewGroupInput, error) { + var doc inputDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if len(doc.Groups) == 0 { + return nil, fmt.Errorf("input contains no groups; check the export query and target configuration") + } + + inputs := make([]service.ProfitPreviewGroupInput, 0, len(doc.Groups)) + for i, entry := range doc.Groups { + if entry.Group.ID <= 0 || strings.TrimSpace(entry.Group.Platform) == "" { + return nil, fmt.Errorf("invalid group at index %d: id and platform are required", i) + } + group := &service.Group{ + ID: entry.Group.ID, + Name: entry.Group.Name, + Platform: entry.Group.Platform, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: entry.Group.RateMultiplier, + SubscriptionType: entry.Group.SubscriptionType, + ProfitControlEnabled: entry.Group.ProfitControlEnabled, + ProfitMinMargin: entry.Group.ProfitMinMargin, + ProfitSafetyBuffer: entry.Group.ProfitSafetyBuffer, + PeakRateEnabled: entry.Group.PeakRateEnabled, + PeakStart: entry.Group.PeakStart, + PeakEnd: entry.Group.PeakEnd, + PeakRateMultiplier: entry.Group.PeakRateMultiplier, + } + accounts := make([]*service.Account, 0, len(entry.Accounts)) + for _, a := range entry.Accounts { + account := &service.Account{ + ID: a.ID, + Name: a.Name, + Platform: a.Platform, + Type: a.Type, + RateMultiplier: a.RateMultiplier, + Extra: a.Extra, + } + if len(a.ModelMapping) > 0 { + mapping := make(map[string]any, len(a.ModelMapping)) + for k, v := range a.ModelMapping { + mapping[k] = v + } + account.Credentials = map[string]any{"model_mapping": mapping} + } + accounts = append(accounts, account) + } + overrides := make(map[int64]float64, len(entry.UserOverrides)) + for userID, rate := range entry.UserOverrides { + if rate == nil { + continue + } + var id int64 + if _, err := fmt.Sscan(userID, &id); err == nil && id > 0 { + overrides[id] = *rate + } + } + inputs = append(inputs, service.ProfitPreviewGroupInput{ + Group: group, + Accounts: accounts, + UserOverrides: overrides, + Models: entry.Models, + AssumeEnabled: assumeEnabled, + }) + } + return inputs, nil +} + +func modelsWithZeroRemaining(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModel { + if count == 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} + +// modelsWithZeroRemainingUnderMinD 返回默认 D 下仍有准入账号、但在最低有效 D +// 下会归零的模型。最低有效 D 来自分组内最低的用户级倍率覆盖:这些模型对那部分 +// 用户是全黑的,而只看默认 D 的告警完全看不出来。 +// 两档都为 0 的模型由 modelsWithZeroRemaining 报告,这里不重复。 +func modelsWithZeroRemainingUnderMinD(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModelMinD { + if count == 0 && report.RemainingByModel[model] > 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} diff --git a/backend/cmd/profit-preview/main_test.go b/backend/cmd/profit-preview/main_test.go new file mode 100644 index 000000000000..9b6d185c5b01 --- /dev/null +++ b/backend/cmd/profit-preview/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestParsePreviewInputsIgnoresNullUserOverride(t *testing.T) { + raw := []byte(`{ + "groups": [{ + "group": { + "id": 50, + "name": "preview", + "platform": "openai", + "rate_multiplier": 0.5, + "subscription_type": "standard", + "profit_control_enabled": false, + "profit_min_margin": 0.1, + "profit_safety_buffer": 0 + }, + "accounts": [{ + "id": 1, + "name": "cheap", + "platform": "openai", + "type": "apikey", + "rate_multiplier": 0.2 + }], + "user_overrides": {"40": null, "41": 0.4}, + "models": ["gpt-test"] + }] + }`) + + inputs, err := parsePreviewInputs(raw, true) + require.NoError(t, err) + require.Len(t, inputs, 1) + require.Equal(t, map[int64]float64{41: 0.4}, inputs[0].UserOverrides) + require.True(t, inputs[0].AssumeEnabled) + + report := service.PreviewProfitAdmission(inputs, time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC))[0] + require.InDelta(t, 0.4, report.MinEffectiveD, 1e-12, "null 覆盖不能被解码成 0 倍率") + require.InDelta(t, 0.36, report.ThresholdMinD, 1e-12) +} + +func TestParsePreviewInputsRejectsEmptyGroups(t *testing.T) { + for _, raw := range [][]byte{ + []byte(`{"groups":null}`), + []byte(`{"groups":[]}`), + } { + inputs, err := parsePreviewInputs(raw, false) + require.ErrorContains(t, err, "input contains no groups") + require.Nil(t, inputs) + } +} + +// TestModelsWithZeroRemainingWarnings 钉死两档 D 的归零告警分工: +// 默认 D 归零由 modelsWithZeroRemaining 报告;默认 D 仍有账号但最低有效 D +// (分组内存在更低的用户级倍率覆盖)归零的模型必须单独告警——那些用户的该 +// 模型会全黑,只看默认 D 完全看不出来。两档都为 0 时不重复告警。 +func TestModelsWithZeroRemainingWarnings(t *testing.T) { + report := service.ProfitPreviewGroupReport{ + RemainingByModel: map[string]int{ + "both-zero": 0, + "min-d-zero": 2, + "healthy": 3, + "min-d-zero-alt": 1, + }, + RemainingByModelMinD: map[string]int{ + "both-zero": 0, + "min-d-zero": 0, + "healthy": 3, + "min-d-zero-alt": 0, + }, + } + + if got := modelsWithZeroRemaining(report); len(got) != 1 || got[0] != "both-zero" { + t.Fatalf("默认D归零告警应只覆盖 both-zero,got %v", got) + } + + got := modelsWithZeroRemainingUnderMinD(report) + want := []string{"min-d-zero", "min-d-zero-alt"} + if len(got) != len(want) { + t.Fatalf("最低有效D归零告警不符: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("最低有效D归零告警不符(应按模型名排序): got %v want %v", got, want) + } + } +} diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 1398b2b79f4e..65d2e98e5929 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -5,6 +5,7 @@ package main import ( "context" + "io" "log" "net/http" "sync" @@ -95,6 +96,7 @@ func provideUsageCommitObserver() service.UsageCommitObserver { func provideCleanup( entClient *ent.Client, rdb *redis.Client, + gatewayCache service.GatewayCache, opsMetricsCollector *service.OpsMetricsCollector, opsAggregation *service.OpsAggregationService, opsAlertEvaluator *service.OpsAlertEvaluatorService, @@ -424,7 +426,14 @@ func provideCleanup( }}, } + // The production gateway cache owns the Redis client of its rejected-cipher memory. infraSteps := []cleanupStep{ + {"GatewayCache", func() error { + if closer, ok := gatewayCache.(io.Closer); ok { + return closer.Close() + } + return nil + }}, {"Redis", func() error { if rdb == nil { return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index cb405bb22d92..994b3a5ee6b8 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -19,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/redis/go-redis/v9" + "io" "log" "net/http" "sync" @@ -164,7 +165,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { grokOAuthClient := repository.NewGrokOAuthClient() grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient) grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache) - openAIGatewayService := service.ProvideOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository, systemPromptService) + reasoningRecoveryService, err := service.ProvideReasoningRecoveryService(settingRepository) + if err != nil { + return nil, err + } + openAIGatewayService := service.ProvideOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository, systemPromptService, reasoningRecoveryService) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() driveClient := repository.NewGeminiDriveClient() @@ -303,6 +308,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) systemPromptHandler := admin.NewSystemPromptHandler(systemPromptService) + reasoningRecoveryHandler := admin.NewReasoningRecoveryHandler(reasoningRecoveryService) accountJobRuntime, err := handler.ProvideAccountJobRuntime(accountJobService, accountHandler) if err != nil { return nil, err @@ -313,7 +319,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { 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, accountJobRuntime, 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, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, openCodeGoUsageService, claudeResetCreditService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -387,7 +393,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, imageStudioRuntime, pluginManager) + v := provideCleanup(client, redisClient, gatewayCache, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, imageStudioRuntime, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -439,6 +445,7 @@ func provideUsageCommitObserver() service.UsageCommitObserver { func provideCleanup( entClient *ent.Client, rdb *redis.Client, + gatewayCache service.GatewayCache, opsMetricsCollector *service.OpsMetricsCollector, opsAggregation *service.OpsAggregationService, opsAlertEvaluator *service.OpsAlertEvaluatorService, @@ -768,6 +775,12 @@ func provideCleanup( } infraSteps := []cleanupStep{ + {"GatewayCache", func() error { + if closer, ok := gatewayCache.(io.Closer); ok { + return closer.Close() + } + return nil + }}, {"Redis", func() error { if rdb == nil { return nil diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 99e59704226d..23be0a8b7676 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -24,11 +24,11 @@ func TestProvideServiceBuildInfo(t *testing.T) { } func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { - cleanup := minimalDependencyCleanup(nil) + cleanup := minimalDependencyCleanup(nil, nil) require.NotPanics(t, cleanup) } -func minimalDependencyCleanup(autoReset *service.OpenAIQuotaAutoResetService) func() { +func minimalDependencyCleanup(gatewayCache service.GatewayCache, autoReset *service.OpenAIQuotaAutoResetService) func() { cfg := &config.Config{} oauthSvc := service.NewOAuthService(nil, nil) @@ -62,6 +62,7 @@ func minimalDependencyCleanup(autoReset *service.OpenAIQuotaAutoResetService) fu return provideCleanup( nil, // entClient nil, // redis + gatewayCache, &service.OpsMetricsCollector{}, &service.OpsAggregationService{}, &service.OpsAlertEvaluatorService{}, @@ -165,6 +166,22 @@ func TestProvideCleanupStopsExistingQuotaAutoResetWorker(t *testing.T) { t.Fatal("the actual auto-reset worker did not reach the synthetic repository") } require.NoError(t, work.Err()) - minimalDependencyCleanup(autoReset)() + minimalDependencyCleanup(nil, autoReset)() require.ErrorIs(t, work.Err(), context.Canceled, "application cleanup must stop the existing upstream worker before infrastructure closes") } + +type cleanupGatewayCache struct { + service.GatewayCache + closed bool +} + +func (c *cleanupGatewayCache) Close() error { + c.closed = true + return nil +} + +func TestProvideCleanupClosesGatewayCache(t *testing.T) { + cache := &cleanupGatewayCache{} + minimalDependencyCleanup(cache, nil)() + require.True(t, cache.closed, "application cleanup must close the Redis client the gateway cache owns") +} diff --git a/backend/internal/codexruntime/recovery/policy.go b/backend/internal/codexruntime/recovery/policy.go index 4ba4eebec6e9..aba210c6a0c8 100644 --- a/backend/internal/codexruntime/recovery/policy.go +++ b/backend/internal/codexruntime/recovery/policy.go @@ -9,11 +9,6 @@ import ( var inputParam = regexp.MustCompile(`^input(?:\[(\d+)\]|\.(\d+))(?:\.encrypted_content)?$`) -func ReplayPolicy() extensionv1.ReplayRules { - return extensionv1.ReplayRules{Version: "chat-tools-v1", Enabled: true, MaxToolCalls: 32, AllowOmittedReasoning: true, - ChatFields: []string{"role", "content", "reasoning_content", "reasoning", "tool_calls", "refusal"}, OutputKinds: []string{"reasoning", "message", "function_call"}} -} - func Rejection(in extensionv1.RecoveryEnvelope) extensionv1.RecoveryRejection { if !extensionv1.ValidReasoningRejectionEnvelope(in) { return extensionv1.RecoveryRejection{} @@ -60,9 +55,3 @@ func Select(in extensionv1.RecoverySelectionQuery) extensionv1.RecoverySelection } return extensionv1.RecoverySelection{Indices: append([]int(nil), in.CipherIndices...), Reason: "recovery_not_dispatched"} } - -// Enabled reports an account's reasoning policy setting: on unless it is -// configured to anything but true. -func Enabled(in extensionv1.RecoverySetting) bool { - return !in.Configured || (in.Valid && in.Value) -} diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 90c1ea51c0da..8dec35eecbab 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -121,10 +121,6 @@ func (h *AccountHandler) ImportCodexSession(c *gin.Context) { response.BadRequest(c, "Invalid Codex session import request: "+err.Error()) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } entries, err := parseCodexSessionImportEntries(req) if err != nil { response.BadRequest(c, "Invalid Codex session import content: "+err.Error()) @@ -257,7 +253,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi autoPauseOnExpired = nil } mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) - mergedExtra := mergeAccountUpdateExtra(existing.Extra, extra) + mergedExtra := mergeCodexImportMap(existing.Extra, extra) updateInput := &service.UpdateAccountInput{ Credentials: mergedCredentials, Extra: mergedExtra, diff --git a/backend/internal/handler/admin/account_data_import_v2.go b/backend/internal/handler/admin/account_data_import_v2.go index aee091fb617e..3d4e8fa619bc 100644 --- a/backend/internal/handler/admin/account_data_import_v2.go +++ b/backend/internal/handler/admin/account_data_import_v2.go @@ -330,7 +330,7 @@ func (h *AccountHandler) updateImportedDataAccount( ) (*service.Account, []string, error) { input := &service.UpdateAccountInput{Type: item.Type, Credentials: item.Credentials, ExpiresAt: item.ExpiresAt} if len(item.Extra) > 0 { - input.Extra = mergeAccountUpdateExtra(existing.Extra, item.Extra) + input.Extra = mergeDataImportMaps(existing.Extra, item.Extra) } if overrides.Name != nil { input.Name = strings.TrimSpace(*overrides.Name) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 83863cd492e9..e70f9ffbc695 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1091,10 +1091,6 @@ func (h *AccountHandler) Create(c *gin.Context) { response.ErrorFrom(c, err) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } if req.RateMultiplier != nil && *req.RateMultiplier < 0 { response.BadRequest(c, "rate_multiplier must be >= 0") return @@ -1234,10 +1230,6 @@ func (h *AccountHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } if req.RateMultiplier != nil && *req.RateMultiplier < 0 { response.BadRequest(c, "rate_multiplier must be >= 0") return @@ -1909,10 +1901,6 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { response.ErrorFrom(c, err) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { response.ErrorFrom(c, err) return @@ -2127,10 +2115,6 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { response.ErrorFrom(c, err) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(item.Extra); err != nil { - response.ErrorFrom(c, err) - return - } } groupIDs := make([]int64, 0) for _, item := range req.Accounts { @@ -2187,10 +2171,6 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } if req.RateMultiplier != nil && *req.RateMultiplier < 0 { response.BadRequest(c, "rate_multiplier must be >= 0") return @@ -2240,31 +2220,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { if h.replayAccountJob(c, service.AccountJobKindBulkUpdate, submission) { return } - if service.HasOpenAIReasoningPolicyUpdates(req.Extra) { - // A bulk worker processes individual items. Validate the complete set - // before submitting any item, not only the UI's current metadata page. - resolvedIDs, err := h.resolveAccountJobTargetIDs(c.Request.Context(), ids, req.Filters) - if err != nil { - response.ErrorFrom(c, err) - return - } - if len(resolvedIDs) == 0 { - response.BadRequest(c, "No matching accounts for reasoning policy update") - return - } - accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), resolvedIDs) - if err != nil { - response.ErrorFrom(c, err) - return - } - if err := service.ValidateOpenAIReasoningPolicyTargets(resolvedIDs, accounts); err != nil { - response.ErrorFrom(c, err) - return - } - // Freeze the validated selection in item seeds. The original request - // remains the replay source; workers execute only each seed's target. - ids = resolvedIDs - } else if len(ids) == 0 { + if len(ids) == 0 { if h.accountJobs == nil { response.ErrorFrom(c, infraerrors.New(503, "ACCOUNT_JOBS_UNAVAILABLE", "account jobs are unavailable")) return diff --git a/backend/internal/handler/admin/account_job_execution.go b/backend/internal/handler/admin/account_job_execution.go index a02480395fd8..4d9145b941f1 100644 --- a/backend/internal/handler/admin/account_job_execution.go +++ b/backend/internal/handler/admin/account_job_execution.go @@ -155,9 +155,6 @@ func (h *AccountHandler) createAccountJobAccount(ctx context.Context, item Creat if err := service.ValidateOpenAILongContextBillingExtra(item.Platform, item.Extra); err != nil { return nil, err } - if err := service.ValidateOpenAIReasoningPolicyExtra(item.Extra); err != nil { - return nil, err - } if item.RateMultiplier != nil && *item.RateMultiplier < 0 { return nil, errors.New("rate_multiplier must be >= 0") } @@ -510,9 +507,6 @@ func (h *AccountHandler) executeCodexImportJob(ctx context.Context, raw json.Raw if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil { return accountJobFailedWithError(item.ID, "payload_invalid", err) } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - return accountJobFailedWithError(item.ID, "payload_invalid", err) - } entries, err := parseCodexSessionImportEntries(req) if err != nil { return accountJobFailedWithError(item.ID, "payload_invalid", err) diff --git a/backend/internal/handler/admin/account_reasoning_policy.go b/backend/internal/handler/admin/account_reasoning_policy.go deleted file mode 100644 index bdd73ef18056..000000000000 --- a/backend/internal/handler/admin/account_reasoning_policy.go +++ /dev/null @@ -1,21 +0,0 @@ -package admin - -import "github.com/Wei-Shaw/sub2api/internal/service" - -func mergeAccountUpdateExtra(existing, incoming map[string]any) map[string]any { - merged := make(map[string]any, len(existing)+len(incoming)) - for key, value := range existing { - merged[key] = value - } - for key, value := range incoming { - merged[key] = value - } - for _, key := range [...]string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if _, provided := incoming[key]; !provided { - // UpdateAccount owns preservation of omitted policies. Do not present a - // legacy stored value as newly submitted admin input during re-import. - delete(merged, key) - } - } - return merged -} diff --git a/backend/internal/handler/admin/account_reasoning_policy_test.go b/backend/internal/handler/admin/account_reasoning_policy_test.go deleted file mode 100644 index c932fe9446a6..000000000000 --- a/backend/internal/handler/admin/account_reasoning_policy_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package admin - -import ( - "bytes" - "context" - "encoding/json" - "maps" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestOpenAIReasoningPolicyAdminBoundariesRejectMalformedValuesBeforeMutation(t *testing.T) { - for _, key := range []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - malformedExtra := `"extra":{"` + key + `":"rejected-sensitive-marker"}` - tests := []struct { - name, method, path, body string - mount func(*gin.Engine, *AccountHandler) - }{ - { - name: "create", method: http.MethodPost, path: "/accounts", - body: `{"name":"account","platform":"openai","type":"apikey","credentials":{},` + malformedExtra + `}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.POST("/accounts", h.Create) }, - }, - { - name: "update", method: http.MethodPut, path: "/accounts/1", body: `{` + malformedExtra + `}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.PUT("/accounts/:id", h.Update) }, - }, - { - name: "bulk", method: http.MethodPost, path: "/accounts/bulk", body: `{"account_ids":[1],` + malformedExtra + `}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.POST("/accounts/bulk", h.BulkUpdate) }, - }, - { - name: "batch create", method: http.MethodPost, path: "/accounts/batch", - body: `{"accounts":[{"name":"account","platform":"openai","type":"apikey","credentials":{},` + malformedExtra + `}]}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.POST("/accounts/batch", h.BatchCreate) }, - }, - { - name: "Codex import", method: http.MethodPost, path: "/accounts/import", body: `{"content":"unused-test-token",` + malformedExtra + `}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.POST("/accounts/import", h.ImportCodexSession) }, - }, - { - name: "OAuth apply", method: http.MethodPost, path: "/accounts/1/apply", - body: `{"type":"oauth","credentials":{"access_token":"unused-test-token"},` + malformedExtra + `}`, - mount: func(r *gin.Engine, h *AccountHandler) { r.POST("/accounts/:id/apply", h.ApplyOAuthCredentials) }, - }, - } - for _, tt := range tests { - t.Run(key+"/"+tt.name, func(t *testing.T) { - gin.SetMode(gin.TestMode) - stub := newStubAdminService() - stub.getAccountResult = &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth} - handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - router := gin.New() - tt.mount(router, handler) - recorder := httptest.NewRecorder() - request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) - request.Header.Set("Content-Type", "application/json") - router.ServeHTTP(recorder, request) - require.Equal(t, http.StatusBadRequest, recorder.Code) - var body struct { - Reason string `json:"reason"` - } - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) - require.Equal(t, "OPENAI_REASONING_POLICY_INVALID", body.Reason) - require.NotContains(t, recorder.Body.String(), "rejected-sensitive-marker") - require.Empty(t, stub.createdAccounts) - require.Zero(t, stub.updateAccountCalls) - require.Zero(t, stub.updateAccountExtraCalls) - require.Nil(t, stub.lastBulkUpdateAccountInput) - }) - } - } -} - -func TestOpenAIReasoningPolicyCodexPATRejectsMalformedValuesBeforeTokenExchange(t *testing.T) { - gin.SetMode(gin.TestMode) - handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) - router := gin.New() - router.POST("/pat", handler.CreateAccountFromCodexPAT) - for _, key := range []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/pat", bytes.NewBufferString(`{"access_token":"unused-test-token","extra":{"`+key+`":null}}`)) - request.Header.Set("Content-Type", "application/json") - router.ServeHTTP(recorder, request) - require.Equal(t, http.StatusBadRequest, recorder.Code) - require.Contains(t, recorder.Body.String(), "OPENAI_REASONING_POLICY_INVALID") - } -} - -func TestOpenAIReasoningPolicyImportMergeDistinguishesStoredValuesFromNewInput(t *testing.T) { - existing := map[string]any{ - service.OpenAIChatReasoningReplayEnabledExtraKey: "legacy-invalid", - service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: false, - "base_rpm": 20, - } - original := maps.Clone(existing) - for _, incoming := range []map[string]any{ - {"import_source": "codex_session"}, - {service.OpenAIChatReasoningReplayEnabledExtraKey: true}, - {service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, - } { - inputCopy := maps.Clone(incoming) - merged := mergeAccountUpdateExtra(existing, incoming) - require.NoError(t, service.ValidateOpenAIReasoningPolicyExtra(merged)) - require.Equal(t, 20, merged["base_rpm"]) - for _, key := range []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if value, provided := incoming[key]; provided { - require.Equal(t, value, merged[key]) - } else { - require.NotContains(t, merged, key, "the service must preserve the stored value, not revalidate it as submitted") - } - } - require.Equal(t, original, existing) - require.Equal(t, inputCopy, incoming) - } - malformed := mergeAccountUpdateExtra(existing, map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: "new-invalid"}) - require.Error(t, service.ValidateOpenAIReasoningPolicyExtra(malformed), "new malformed input is not silently discarded") -} - -type reasoningPolicyBulkAdminStub struct { - *stubAdminService - loadedIDs []int64 -} - -func (s *reasoningPolicyBulkAdminStub) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) { - s.loadedIDs = append([]int64(nil), ids...) - byID := make(map[int64]*service.Account, len(s.accounts)) - for index := range s.accounts { - byID[s.accounts[index].ID] = &s.accounts[index] - } - accounts := make([]*service.Account, 0, len(ids)) - for _, id := range ids { - if account := byID[id]; account != nil { - accounts = append(accounts, account) - } - } - return accounts, nil -} - -func submitReasoningPolicyBulkTestRequest(t *testing.T, stub *reasoningPolicyBulkAdminStub, req BulkUpdateAccountsRequest) (*httptest.ResponseRecorder, *accountJobSubmitRepository) { - t.Helper() - gin.SetMode(gin.TestMode) - handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - router := gin.New() - jobs := attachAccountJobSubmitter(router, handler) - router.POST("/bulk", handler.BulkUpdate) - body, err := json.Marshal(req) - require.NoError(t, err) - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/bulk", bytes.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - setAccountJobTestIdempotencyKey(request) - router.ServeHTTP(recorder, request) - return recorder, jobs -} - -func TestOpenAIReasoningPolicyBulkAdminPreflightsAllTargetsBeforeSubmittingJobs(t *testing.T) { - for _, filtered := range []bool{true, false} { - stub := &reasoningPolicyBulkAdminStub{stubAdminService: newStubAdminService()} - stub.accounts = make([]service.Account, 101) - ids := make([]int64, len(stub.accounts)) - for index := range stub.accounts { - ids[index] = int64(index + 1) - stub.accounts[index] = service.Account{ID: ids[index], Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey} - } - stub.accounts[100].Platform = service.PlatformGrok - req := BulkUpdateAccountsRequest{Extra: map[string]any{service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}} - if filtered { - req.Filters = &BulkUpdateAccountFilters{Search: "batch"} - } else { - req.AccountIDs = ids - } - recorder, jobs := submitReasoningPolicyBulkTestRequest(t, stub, req) - require.Equal(t, http.StatusBadRequest, recorder.Code) - require.Contains(t, recorder.Body.String(), "OPENAI_BULK_TARGET_INVALID") - require.Equal(t, ids, stub.loadedIDs) - require.Empty(t, jobs.created, "not even the first valid row may be enqueued") - require.Nil(t, stub.lastBulkUpdateAccountInput) - } -} - -func TestOpenAIReasoningPolicyBulkAdminFreezesFilteredSelection(t *testing.T) { - ids := make([]int64, 101) - for index := range ids { - ids[index] = int64(index + 1) - } - stub := newBulkJobScopeAdmin(ids...) - for _, account := range stub.accountsByID { - account.Type = service.AccountTypeSetupToken - } - stub.accountsByID[101] = &service.Account{ - ID: 101, Name: "OpenAI API key", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"base_url": "https://relay.example.test", "api_key": "test-key"}, - Status: service.StatusActive, - } - stub.matches = ids - router, handler, jobs := newBulkJobScopeRouter(stub) - req := BulkUpdateAccountsRequest{ - Filters: &BulkUpdateAccountFilters{Search: "batch"}, - Extra: map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: true}, - } - recorder := submitBulkJobScopeRequest(t, router, req, "reasoning-frozen-scope") - require.Equal(t, http.StatusAccepted, recorder.Code) - var submitted BulkUpdateAccountsRequest - params := requireSubmittedAccountJob(t, jobs.accountJobSubmitRepository, service.AccountJobKindBulkUpdate, &submitted) - require.Equal(t, req.Filters, submitted.Filters, "the original filter is retained only as the replay source") - require.Empty(t, submitted.AccountIDs, "resolved IDs must not change the original request hash") - require.Len(t, params.Items, len(ids)) - for index, seed := range params.Items { - require.NotNil(t, seed.TargetAccountID) - require.Equal(t, ids[index], *seed.TargetAccountID) - } - queriesAfterSubmission := stub.filterCalls - require.Empty(t, stub.bulkInputs, "submission remains a background job") - - // Every original account leaves the live filter, and a new invalid target - // enters it. Execution must still use only the already validated item IDs. - stub.accountsByID[102] = &service.Account{ID: 102, Platform: service.PlatformGrok} - stub.matches = []int64{102} - results := executeBulkJobScopeItems(t, handler, params) - require.Len(t, results, len(ids)) - for _, result := range results { - require.Equal(t, service.AccountJobItemStatusSucceeded, result.Status) - } - require.Equal(t, ids, stub.updatedIDs) - require.Len(t, stub.bulkInputs, len(ids)) - for index, input := range stub.bulkInputs { - require.Nil(t, input.Filters, "workers must not re-resolve the live filter") - require.Equal(t, []int64{ids[index]}, input.AccountIDs) - require.Equal(t, req.Extra, input.Extra) - } - require.Equal(t, queriesAfterSubmission, stub.filterCalls) - - for _, matches := range [][]int64{{102}, nil} { - stub.matches = matches - replayed := submitBulkJobScopeRequest(t, router, req, "reasoning-frozen-scope") - require.Equal(t, http.StatusAccepted, replayed.Code) - require.Equal(t, "true", replayed.Header().Get("Idempotency-Replayed")) - require.Len(t, jobs.created, 1) - require.Equal(t, params, jobs.created[0], "replay must preserve the original payload, expiry and frozen items") - require.Equal(t, queriesAfterSubmission, stub.filterCalls) - require.Len(t, stub.bulkInputs, len(ids), "replay cannot execute an item again") - } - changed := req - changed.Extra = map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: false} - conflict := submitBulkJobScopeRequest(t, router, changed, "reasoning-frozen-scope") - require.Equal(t, http.StatusConflict, conflict.Code) - require.Len(t, jobs.created, 1) - require.Equal(t, queriesAfterSubmission, stub.filterCalls) -} - -func TestOpenAIReasoningPolicyBulkAdminRejectsEmptyAndMissingTargets(t *testing.T) { - for _, req := range []BulkUpdateAccountsRequest{ - {Filters: &BulkUpdateAccountFilters{Search: "missing"}}, - {AccountIDs: []int64{1}}, - } { - stub := &reasoningPolicyBulkAdminStub{stubAdminService: newStubAdminService()} - stub.accounts = nil - req.Extra = map[string]any{service.OpenAIChatReasoningReplayEnabledExtraKey: false} - recorder, jobs := submitReasoningPolicyBulkTestRequest(t, stub, req) - require.Equal(t, http.StatusBadRequest, recorder.Code) - require.Empty(t, jobs.created) - } -} diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 6e42d0859a4d..66f1d0074383 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -266,44 +266,6 @@ func TestGroupHandlerEndpoints(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) } -func TestGroupHandlerRejectsLegacyProfitControlWrites(t *testing.T) { - router, _ := setupAdminRouter() - tests := []struct { - method string - path string - body map[string]any - }{ - { - method: http.MethodPost, - path: "/api/v1/admin/groups", - body: map[string]any{ - "name": "legacy-profit-create", "platform": "openai", "profit_control_enabled": false, - }, - }, - { - method: http.MethodPut, - path: "/api/v1/admin/groups/2", - body: map[string]any{"name": "legacy-profit-update", "profit_min_margin": 0}, - }, - { - method: http.MethodPut, - path: "/api/v1/admin/groups/2", - body: map[string]any{"name": "legacy-profit-null", "profit_safety_buffer": nil}, - }, - } - - for _, tt := range tests { - payload, err := json.Marshal(tt.body) - require.NoError(t, err) - rec := httptest.NewRecorder() - req := httptest.NewRequest(tt.method, tt.path, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusBadRequest, rec.Code) - require.Contains(t, rec.Body.String(), "利润控制不再支持") - } -} - func TestProxyHandlerEndpoints(t *testing.T) { router, _ := setupAdminRouter() diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fd0334a803ec..774a00f88273 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -206,9 +206,9 @@ type CreateGroupRequest struct { PeakStart string `json:"peak_start"` PeakEnd string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ProfitControlEnabled json.RawMessage `json:"profit_control_enabled"` - ProfitMinMargin json.RawMessage `json:"profit_min_margin"` - ProfitSafetyBuffer json.RawMessage `json:"profit_safety_buffer"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -281,9 +281,9 @@ type UpdateGroupRequest struct { PeakStart *string `json:"peak_start"` PeakEnd *string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ProfitControlEnabled json.RawMessage `json:"profit_control_enabled"` - ProfitMinMargin json.RawMessage `json:"profit_min_margin"` - ProfitSafetyBuffer json.RawMessage `json:"profit_safety_buffer"` + ProfitControlEnabled *bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -654,8 +654,10 @@ func (h *GroupHandler) Create(c *gin.Context) { return } - if len(req.ProfitControlEnabled) > 0 || len(req.ProfitMinMargin) > 0 || len(req.ProfitSafetyBuffer) > 0 { - response.BadRequest(c, "利润控制不再支持") + // platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台, + // 否则省略 platform 的请求会被误判成「平台不支持利润控制」。 + if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil { + response.BadRequest(c, err.Error()) return } @@ -683,6 +685,9 @@ func (h *GroupHandler) Create(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, @@ -797,10 +802,6 @@ func (h *GroupHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if len(req.ProfitControlEnabled) > 0 || len(req.ProfitMinMargin) > 0 || len(req.ProfitSafetyBuffer) > 0 { - response.BadRequest(c, "利润控制不再支持") - return - } if h.isSimpleMode() { sanitizeUpdateGroupRequestForSimpleMode(&req) } @@ -830,6 +831,9 @@ func (h *GroupHandler) Update(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index ca489b7e2d66..b96bf426a6da 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -374,10 +374,6 @@ func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) { response.ErrorFrom(c, err) return } - if err := service.ValidateOpenAIReasoningPolicyExtra(req.Extra); err != nil { - response.ErrorFrom(c, err) - return - } if req.Concurrency != nil && *req.Concurrency < 0 { response.BadRequest(c, "concurrency must be >= 0") return diff --git a/backend/internal/handler/admin/reasoning_recovery_handler.go b/backend/internal/handler/admin/reasoning_recovery_handler.go new file mode 100644 index 000000000000..cbc3a926bb14 --- /dev/null +++ b/backend/internal/handler/admin/reasoning_recovery_handler.go @@ -0,0 +1,50 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// ReasoningRecoveryHandler manages the global reasoning recovery switch. +type ReasoningRecoveryHandler struct { + service *service.ReasoningRecoveryService +} + +func NewReasoningRecoveryHandler(recovery *service.ReasoningRecoveryService) *ReasoningRecoveryHandler { + return &ReasoningRecoveryHandler{service: recovery} +} + +// Get returns the stored switch. +// GET /api/v1/admin/reasoning-recovery +func (h *ReasoningRecoveryHandler) Get(c *gin.Context) { + config, err := h.service.Get(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + c.Header("Cache-Control", "no-store") + response.Success(c, config) +} + +// Save stores the switch and returns what was saved. +// PUT /api/v1/admin/reasoning-recovery +func (h *ReasoningRecoveryHandler) Save(c *gin.Context) { + raw, err := c.GetRawData() + var request service.ReasoningRecoveryConfig + if err == nil { + request, err = service.DecodeReasoningRecoveryConfig(raw) + } + if err != nil { + response.BadRequest(c, "Invalid reasoning recovery settings: "+err.Error()) + return + } + config, err := h.service.Save(c.Request.Context(), request) + if err != nil { + response.ErrorFrom(c, err) + return + } + middleware.SetAuditExtra(c, map[string]any{"enabled": config.Enabled, "result": "reasoning_recovery_saved"}) + response.Success(c, config) +} diff --git a/backend/internal/handler/admin/reasoning_recovery_handler_test.go b/backend/internal/handler/admin/reasoning_recovery_handler_test.go new file mode 100644 index 000000000000..a5d517556699 --- /dev/null +++ b/backend/internal/handler/admin/reasoning_recovery_handler_test.go @@ -0,0 +1,81 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type reasoningRecoverySettingRepo struct { + service.SettingRepository + values map[string]string +} + +func (r *reasoningRecoverySettingRepo) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", service.ErrSettingNotFound + } + return value, nil +} + +func (r *reasoningRecoverySettingRepo) Set(_ context.Context, key, value string) error { + r.values[key] = value + return nil +} + +// The reasoning recovery endpoints read and save one switch in the standard +// envelope. Without a stored value the switch is on; a body that is not exactly +// an enabled boolean object is refused and stores nothing. +func TestReasoningRecoveryEndpointsReadAndSaveTheSwitch(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &reasoningRecoverySettingRepo{values: map[string]string{}} + recovery := service.NewReasoningRecoveryService(repo) + h := NewReasoningRecoveryHandler(recovery) + call := func(method, body string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(method, "/api/v1/admin/reasoning-recovery", strings.NewReader(body)) + if method == http.MethodGet { + h.Get(c) + } else { + h.Save(c) + } + return recorder + } + + read := call(http.MethodGet, "") + require.Equal(t, http.StatusOK, read.Code) + require.JSONEq(t, `{"code":0,"message":"success","data":{"enabled":true}}`, read.Body.String()) + require.Empty(t, repo.values) + + saved := call(http.MethodPut, `{"enabled":false}`) + require.Equal(t, http.StatusOK, saved.Code) + require.JSONEq(t, `{"code":0,"message":"success","data":{"enabled":false}}`, saved.Body.String()) + require.Equal(t, map[string]string{"reasoning_recovery_config": `{"enabled":false}`}, repo.values) + require.False(t, recovery.Enabled(), "a save applies to this instance at once") + require.JSONEq(t, `{"code":0,"message":"success","data":{"enabled":false}}`, call(http.MethodGet, "").Body.String()) + + for _, body := range []string{`{"enabled":"true"}`, `{"enabled":null}`, `{"enabled":true,"other":true}`, `{"other":true}`, `{}`, `null`, ``} { + rejected := call(http.MethodPut, body) + require.Equal(t, http.StatusBadRequest, rejected.Code, body) + require.Equal(t, map[string]string{"reasoning_recovery_config": `{"enabled":false}`}, repo.values) + require.False(t, recovery.Enabled()) + } + + require.Equal(t, http.StatusOK, call(http.MethodPut, `{"enabled":true}`).Code) + require.Equal(t, map[string]string{"reasoning_recovery_config": `{"enabled":true}`}, repo.values) + require.True(t, recovery.Enabled()) + + // A read answers with the stored value, here one another instance saved, + // and applies it to this instance. + repo.values["reasoning_recovery_config"] = `{"enabled":false}` + require.JSONEq(t, `{"code":0,"message":"success","data":{"enabled":false}}`, call(http.MethodGet, "").Body.String()) + require.False(t, recovery.Enabled()) +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index be34206f058e..77cf4db7dc2a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -513,14 +513,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { payload.DefaultPlatformQuotas = platformQuotas } - data := systemSettingsResponseData(payload, authSourceDefaults) - // Deprecated compatibility values are preserved on save but have no public - // write field or runtime effect; they are echoed read-only. - data["deprecated_settings"] = map[string]any{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled, - } - response.Success(c, data) + response.Success(c, systemSettingsResponseData(payload, authSourceDefaults)) } // openaiFastPolicySettingsToDTO converts service -> dto for OpenAI fast policy. diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index a3957afbcd8c..9218f5d625a9 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -619,12 +619,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICyberFailoverEnabled != after.OpenAICyberFailoverEnabled { changed = append(changed, "openai_cyber_failover_enabled") } - if before.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled != after.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled { - changed = append(changed, service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled) - } - if before.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled != after.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled { - changed = append(changed, service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled) - } if before.OpenAIRefusalRewriteEnabled != after.OpenAIRefusalRewriteEnabled { changed = append(changed, "openai_refusal_rewrite_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index 1f41a747ffb5..1389b593eb39 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -41,21 +41,6 @@ func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) { require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled]) } -func TestUpdateSettingsIgnoresDeprecatedAPIKeyCompatibilityWrites(t *testing.T) { - h, repo := newStepUpSwitchTestHandler(t, map[string]string{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "false", - }) - - rec := doUpdateSettings(t, h, map[string]any{ - "openai_apikey_alpha_search_responses_bridge_enabled": false, - "openai_apikey_prompt_cache_key_normalization_enabled": true, - }, nil) - require.Equal(t, http.StatusOK, rec.Code) - require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled]) - require.Equal(t, "false", repo.values[service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled]) -} - // A full payload keeps whole-document semantics: fields explicitly set to their // zero value are still cleared. func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) { diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index a46fddb605ce..6b50609a406d 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -2047,11 +2047,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { }(), OpenAIRefusalRecoveryEnabled: boolValueOrDefault(req.OpenAIRefusalRecoveryEnabled, previousSettings.OpenAIRefusalRecoveryEnabled), OpenAICyberFailoverEnabled: boolValueOrDefault(req.OpenAICyberFailoverEnabled, previousSettings.OpenAICyberFailoverEnabled), - // Deprecated compatibility values are deliberately preserved for a - // rollback window but are no longer public write fields or runtime gates. - OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: previousSettings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - OpenAIAPIKeyPromptCacheKeyNormalizationEnabled: previousSettings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled, - OpenAIRefusalRewriteEnabled: boolValueOrDefault(req.OpenAIRefusalRewriteEnabled, previousSettings.OpenAIRefusalRewriteEnabled), + OpenAIRefusalRewriteEnabled: boolValueOrDefault(req.OpenAIRefusalRewriteEnabled, previousSettings.OpenAIRefusalRewriteEnabled), OpenAIRefusalKeywords: func() []string { if req.OpenAIRefusalKeywords != nil { return append([]string(nil), (*req.OpenAIRefusalKeywords)...) diff --git a/backend/internal/handler/dto/group_mapper_profit_visibility_test.go b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go index 4e1080c98bd0..a3a9c5a83f2f 100644 --- a/backend/internal/handler/dto/group_mapper_profit_visibility_test.go +++ b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go @@ -60,12 +60,11 @@ func TestGroupFromServiceOmitsProfitControl(t *testing.T) { } } -// TestGroupFromServiceAdminReturnsStoredProfitControlFields shows administrators -// the stored legacy values of the retired profit control, read-only. -func TestGroupFromServiceAdminReturnsStoredProfitControlFields(t *testing.T) { +// TestGroupFromServiceAdminIncludesProfitControl 钉死管理端仍能读写利润控制配置。 +func TestGroupFromServiceAdminIncludesProfitControl(t *testing.T) { admin := GroupFromServiceAdmin(profitControlServiceGroup()) - if !admin.ProfitControlEnabled || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 { - t.Fatalf("管理员 DTO 必须原样返回库里的利润控制旧值: %+v", admin) + if admin.ProfitControlEnabled != true || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 { + t.Fatalf("管理员 DTO 未透传利润控制配置: %+v", admin) } fields := marshalToMap(t, admin) for _, f := range profitControlJSONFields { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 7e3d6f9f1d5c..2647ce3374d1 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -150,8 +150,6 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { if g == nil { return nil } - // Profit control is retired and has no runtime effect; the stored legacy - // values are returned read-only (writes are rejected). out := &AdminGroup{ Group: groupFromServiceBase(g), ForceOpenAIFast: g.ForceOpenAIFast, diff --git a/backend/internal/handler/failover_loop_profit_veto_test.go b/backend/internal/handler/failover_loop_profit_veto_test.go new file mode 100644 index 000000000000..c417c27e1042 --- /dev/null +++ b/backend/internal/handler/failover_loop_profit_veto_test.go @@ -0,0 +1,139 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// profitVetoLoopResult 记录一次模拟选号循环的终止方式与步数。 +type profitVetoLoopResult struct { + outcome string // "forwarded" | "exhausted" | "budget_exceeded" + forwardedID int64 + iterations int + backoffRetries int +} + +// runProfitVetoLoop 模拟 handler 的「选号 → 利润终检 → 重选」循环,只保留与 +// 活锁相关的状态机(FailoverState + 排除列表 + HandleSelectionExhausted), +// 不涉及真实调度器与上游转发。 +// +// pool 按顺序给出候选账号;vetoed 中的账号每次终检都被利润门否决——这对应 +// 「候选池快照与 per-account 快照短暂不一致」时门的确定性判定。 +// maxIterations 是测试自身的预算:循环超过它即视为活锁。 +func runProfitVetoLoop(t *testing.T, fs *FailoverState, pool []int64, vetoed map[int64]bool, maxIterations int) profitVetoLoopResult { + t.Helper() + res := profitVetoLoopResult{} + for res.iterations = 1; res.iterations <= maxIterations; res.iterations++ { + // 选号:返回第一个不在排除列表中的账号。 + var picked int64 + for _, id := range pool { + if _, excluded := fs.FailedAccountIDs[id]; !excluded { + picked = id + break + } + } + if picked == 0 { + // 选号耗尽,交给退避决策。 + switch fs.HandleSelectionExhausted(context.Background()) { + case FailoverContinue: + res.backoffRetries++ + continue + default: + res.outcome = "exhausted" + return res + } + } + if vetoed[picked] { + if fs.RecordProfitVeto(picked) == FailoverExhausted { + res.outcome = "exhausted" + return res + } + continue + } + res.outcome = "forwarded" + res.forwardedID = picked + return res + } + res.outcome = "budget_exceeded" + return res +} + +// TestProfitVetoAfter503DoesNotLivelock 钉死 #4925 引入的活锁回归: +// 一次真实 503 之后,若调度器持续返回会被利润门否决的账号, +// 「选号 → 否决 → 排除 → 选号耗尽 → 清空排除 → 睡 2s → 选号」会无限循环, +// 因为利润否决不推进 SwitchCount,退避条件永远成立。 +func TestProfitVetoAfter503DoesNotLivelock(t *testing.T) { + fs := NewFailoverState(10, false) + // 已经历一次真实 503(Antigravity 单账号分组 MODEL_CAPACITY_EXHAUSTED 是设计内路径)。 + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[1] = struct{}{} + + start := time.Now() + res := runProfitVetoLoop(t, fs, []int64{1}, map[int64]bool{1: true}, 50) + elapsed := time.Since(start) + + require.Equal(t, "exhausted", res.outcome, "整池被利润门否决时必须有限步终止") + require.LessOrEqual(t, res.backoffRetries, 1, "被否决的账号不得被退避分支复活并反复重选") + require.Less(t, elapsed, 10*time.Second, "不得每 2s 空转一轮") +} + +// TestProfitVetoKeepsBackoffUsefulForHealthyAccount 钉死修复没有削弱既有的 +// 503 退避语义:排除列表里仍有非利润否决的账号时,退避照常清空并重试, +// 只是被利润门否决的账号不再复活。 +func TestProfitVetoKeepsBackoffUsefulForHealthyAccount(t *testing.T) { + fs := NewFailoverState(10, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + // 账号 1 因真实 503 被排除;账号 2 会被利润门否决。 + fs.FailedAccountIDs[1] = struct{}{} + + res := runProfitVetoLoop(t, fs, []int64{2, 1}, map[int64]bool{2: true}, 50) + + require.Equal(t, "forwarded", res.outcome) + require.Equal(t, int64(1), res.forwardedID, "退避清空后应重新可选账号 1") + require.Equal(t, 1, res.backoffRetries, "应发生且只发生一次退避重试") + require.Contains(t, fs.FailedAccountIDs, int64(2), "利润否决的账号在退避清空后必须被放回排除集") +} + +// TestProfitVetoAttemptsCapped 钉死没有 503 参与时,大分组整池越线也会在 +// 常数步内终止,而不是把整池逐个选一遍。 +func TestProfitVetoAttemptsCapped(t *testing.T) { + fs := NewFailoverState(10, false) + pool := make([]int64, 0, 64) + vetoed := make(map[int64]bool, 64) + for id := int64(1); id <= 64; id++ { + pool = append(pool, id) + vetoed[id] = true + } + + res := runProfitVetoLoop(t, fs, pool, vetoed, 200) + + require.Equal(t, "exhausted", res.outcome) + require.Equal(t, maxProfitVetoAttempts, fs.ProfitVetoCount()) + require.Equal(t, maxProfitVetoAttempts, res.iterations, "达到上限即终止,不应继续遍历候选池") +} + +// TestRecordProfitVetoExcludesAccount 钉死 RecordProfitVeto 仍然把账号加入 +// 调度排除列表(选号入参用的就是 FailedAccountIDs)。 +func TestRecordProfitVetoExcludesAccount(t *testing.T) { + fs := NewFailoverState(10, false) + require.Equal(t, FailoverContinue, fs.RecordProfitVeto(42)) + require.Contains(t, fs.FailedAccountIDs, int64(42)) + require.Equal(t, 1, fs.ProfitVetoCount()) +} + +// TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto 钉死未启用利润控制的 +// 请求(无任何利润否决)走的仍是原有退避语义:清空排除列表并重试。 +func TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto(t *testing.T) { + fs := NewFailoverState(3, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[100] = struct{}{} + + require.Equal(t, FailoverContinue, fs.HandleSelectionExhausted(context.Background())) + require.Empty(t, fs.FailedAccountIDs, "无利润否决时排除列表应被完全清空") +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 4874db2b4288..74830149291d 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -45,6 +45,7 @@ type AdminHandlers struct { Compliance *admin.ComplianceHandler AuditLog *admin.AuditLogHandler SystemPrompt *admin.SystemPromptHandler + ReasoningRecovery *admin.ReasoningRecoveryHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index fa4e63efe1fa..d7c85f1a229d 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -104,6 +104,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { var sameAccountRetrySelection *service.AccountSelectionResult var oauth429FailoverState service.OpenAIOAuth429FailoverState + // The manifest is not billed, so the profit gate must not filter the + // accounts that can serve it. + c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context())) + for { retryingSameAccount := sameAccountRetrySelection != nil var selection *service.AccountSelectionResult diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 0fdd55aeea80..cfd20eca47cf 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -603,6 +604,61 @@ func TestCodexModelsHonorsAccountSwitchLimit(t *testing.T) { } } +func TestCodexModelsIgnoresGroupProfitControl(t *testing.T) { + gin.SetMode(gin.TestMode) + overThreshold := 0.8 + repo := codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "over-threshold", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + RateMultiplier: &overThreshold, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstBody: `{"models":[{"slug":"gpt-5.6-sol"}]}`} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ + ID: 46, + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: 1, + ProfitControlEnabled: true, + ProfitMinMargin: 0.5, + } + // The auth middleware puts the hydrated group into the request context, + // which is where the profit gate reads its configuration. + requestCtx := context.WithValue(context.Background(), ctxkey.Group, group) + gatedCtx, _ := gatewayService.WithOpenAIRequestPricingContext(requestCtx, &group.ID) + vetoed, _ := service.OpenAIProfitControlVeto(gatedCtx, &repo.accounts[0]) + require.True(t, vetoed, "the group's profit gate must reject the only account") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil).WithContext(requestCtx) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + + handler.CodexModels(c) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{1}, upstream.calls()) + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") +} + func newCodexModelsFailoverTestHandler(firstStatus int) (*OpenAIGatewayHandler, *codexModelsFailoverHTTPUpstream, int64) { return newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, 2, 3) } diff --git a/backend/internal/handler/openai_profit_slot_recheck_test.go b/backend/internal/handler/openai_profit_slot_recheck_test.go index 9bdd83e2483e..31e251c08a99 100644 --- a/backend/internal/handler/openai_profit_slot_recheck_test.go +++ b/backend/internal/handler/openai_profit_slot_recheck_test.go @@ -2,8 +2,9 @@ package handler -// 槽位准入与请求能力映射回归(handler 半程): -// - 历史利润设置不再否决账号;获取的槽位保留到调用方释放,且不写响应; +// 槽位终检与生图跳门回归(handler 半程): +// - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选, +// 不写响应、不绑定粘连; // - openAIResponsesRequiredCapability 的请求能力映射覆盖生图与原生远程压缩。 import ( @@ -95,7 +96,7 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { } } - t.Run("retired profit control preserves acquired slot without writing response", func(t *testing.T) { + t.Run("veto releases slot and requests reschedule without writing response", func(t *testing.T) { cache := &profitCountingConcurrencyCache{} h := newHandler(cache) w := httptest.NewRecorder() @@ -104,13 +105,10 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { streamStarted := false release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(1, 0.8)), false, &streamStarted, zap.NewNop()) - require.Equal(t, openAISlotAcquireOK, result, "已退役利润门不得因历史配置否决账号") - require.NotNil(t, release) - t.Cleanup(release) - require.Zero(t, w.Body.Len(), "正常槽位准入不得写出任何响应") - require.Zero(t, cache.accountReleases.Load(), "已获取的槽位应保留给调用方") - release() - require.Equal(t, int64(1), cache.accountReleases.Load(), "调用方结束使用后应释放一次槽位") + require.Equal(t, openAISlotAcquireProfitVetoed, result) + require.Nil(t, release) + require.Zero(t, w.Body.Len(), "利润终检否决不得写出任何响应") + require.Equal(t, int64(1), cache.accountReleases.Load(), "否决后必须立即释放已获取的槽位") }) t.Run("qualifying account acquires normally", func(t *testing.T) { diff --git a/backend/internal/handler/openai_profit_veto_budget_test.go b/backend/internal/handler/openai_profit_veto_budget_test.go new file mode 100644 index 000000000000..60e487112181 --- /dev/null +++ b/backend/internal/handler/openai_profit_veto_budget_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRecordOpenAIProfitVetoBounded 钉死 OpenAI 侧选号循环的利润否决预算: +// 账号被加入排除集,且第 maxProfitVetoAttempts 次否决返回 false 要求终止。 +// +// 上限是必需的:acquireResponsesAccountSlot 的 WaitPlan 分支先阻塞排队 +// (sticky 45s / fallback 30s)拿到槽位才做利润终检,无上限重选会把单次请求 +// 的延迟放大到 N × WaitPlan.Timeout。 +func TestRecordOpenAIProfitVetoBounded(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + + for i := int64(1); i < int64(maxProfitVetoAttempts); i++ { + require.True(t, recordOpenAIProfitVeto(failed, i, &count), "第 %d 次否决应允许继续重选", i) + require.Contains(t, failed, i, "否决的账号必须进入本请求排除集") + } + + require.False(t, + recordOpenAIProfitVeto(failed, int64(maxProfitVetoAttempts), &count), + "第 %d 次否决应耗尽预算并要求终止", maxProfitVetoAttempts) + require.Equal(t, maxProfitVetoAttempts, count) + require.Len(t, failed, maxProfitVetoAttempts) +} + +// TestOpenAIProfitVetoLoopTerminates 模拟「选号 → 抢槽 → 利润终检否决 → 重选」 +// 循环:整池越线时必须在常数步内终止,而不是把候选池逐个排队一遍。 +func TestOpenAIProfitVetoLoopTerminates(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + terminated := false + iterations := 0 + + // 候选池远大于否决上限:无上限时会对每个账号各排队一次。 + for accountID := int64(1); accountID <= 500; accountID++ { + iterations++ + if _, excluded := failed[accountID]; excluded { + continue + } + if !recordOpenAIProfitVeto(failed, accountID, &count) { + terminated = true + break + } + } + + require.True(t, terminated, "整池越线时循环必须由否决预算终止") + require.Equal(t, maxProfitVetoAttempts, iterations) +} + +// TestProfitVetoBudgetSharedWithFailoverState 钉死两条路径(FailoverState 与 +// OpenAI 独立计数器)使用同一上限语义,避免日后单边漂移。 +func TestProfitVetoBudgetSharedWithFailoverState(t *testing.T) { + fs := NewFailoverState(10, false) + failed := make(map[int64]struct{}) + count := 0 + + var fsStoppedAt, openAIStoppedAt int + for i := int64(1); i <= int64(maxProfitVetoAttempts)+5; i++ { + if fsStoppedAt == 0 && fs.RecordProfitVeto(i) == FailoverExhausted { + fsStoppedAt = fs.ProfitVetoCount() + } + if openAIStoppedAt == 0 && !recordOpenAIProfitVeto(failed, i, &count) { + openAIStoppedAt = count + } + } + + require.Equal(t, maxProfitVetoAttempts, fsStoppedAt) + require.Equal(t, fsStoppedAt, openAIStoppedAt, "两条路径的利润否决上限必须一致") +} diff --git a/backend/internal/handler/openai_replayable_endpoints_failover_test.go b/backend/internal/handler/openai_replayable_endpoints_failover_test.go index 9ba5b58f4b89..122721696095 100644 --- a/backend/internal/handler/openai_replayable_endpoints_failover_test.go +++ b/backend/internal/handler/openai_replayable_endpoints_failover_test.go @@ -4,7 +4,6 @@ package handler import ( "bytes" - "context" "errors" "io" "net/http" @@ -93,44 +92,6 @@ func (u *replayableEndpointFailoverUpstream) requestPaths() []string { return append([]string(nil), u.paths...) } -type replayableEndpointSettingRepo struct { - values map[string]string -} - -func (r replayableEndpointSettingRepo) Get(_ context.Context, key string) (*service.Setting, error) { - return nil, nil -} - -func (r replayableEndpointSettingRepo) GetValue(_ context.Context, key string) (string, error) { - return r.values[key], nil -} - -func (r replayableEndpointSettingRepo) Set(_ context.Context, key, value string) error { - return nil -} - -func (r replayableEndpointSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { - values := make(map[string]string, len(keys)) - for _, key := range keys { - if value, ok := r.values[key]; ok { - values[key] = value - } - } - return values, nil -} - -func (r replayableEndpointSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { - return nil -} - -func (r replayableEndpointSettingRepo) GetAll(_ context.Context) (map[string]string, error) { - return r.GetMultiple(context.Background(), []string{service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled}) -} - -func (r replayableEndpointSettingRepo) Delete(_ context.Context, key string) error { - return nil -} - func newReplayableEndpointFailoverHandler(t *testing.T, upstream service.HTTPUpstream) (*OpenAIGatewayHandler, int64) { t.Helper() groupID := int64(44001) @@ -165,14 +126,11 @@ func newReplayableEndpointFailoverHandler(t *testing.T, upstream service.HTTPUps }, } cfg := &config.Config{RunMode: config.RunModeSimple} - settingService := service.NewSettingService(replayableEndpointSettingRepo{values: map[string]string{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - }}, cfg) gatewayService := service.NewOpenAIGatewayService( openAIImagesFailoverAccountRepo{accounts: accounts}, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, upstream, - nil, nil, nil, nil, nil, nil, settingService, nil, + nil, nil, nil, nil, nil, nil, nil, nil, ) billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) t.Cleanup(billingService.Stop) @@ -217,7 +175,7 @@ func TestAlphaSearchRetriesPoolFailureOnExactSameAccount(t *testing.T) { require.Equal(t, []int64{1, 1}, upstream.calls()) } -func TestAlphaSearchLegacyBridgeSettingsDoNotSwitchOrdinaryAPIKeys(t *testing.T) { +func TestAlphaSearchOrdinaryAPIKeyToolErrorDoesNotSwitchAccounts(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &replayableEndpointFailoverUpstream{ firstStatus: http.StatusBadRequest, diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 62a5a98f8956..c650b0bb5da1 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -49,6 +49,7 @@ func ProvideAdminHandlers( complianceHandler *admin.ComplianceHandler, auditLogHandler *admin.AuditLogHandler, systemPromptHandler *admin.SystemPromptHandler, + reasoningRecoveryHandler *admin.ReasoningRecoveryHandler, _ *service.AccountJobRuntime, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, @@ -100,6 +101,7 @@ func ProvideAdminHandlers( Compliance: complianceHandler, AuditLog: auditLogHandler, SystemPrompt: systemPromptHandler, + ReasoningRecovery: reasoningRecoveryHandler, } } @@ -307,6 +309,7 @@ var ProviderSet = wire.NewSet( admin.NewComplianceHandler, admin.NewAuditLogHandler, admin.NewSystemPromptHandler, + admin.NewReasoningRecoveryHandler, ProvideAccountJobRuntime, // AdminHandlers and Handlers constructors diff --git a/backend/internal/nativeapi/codex_recovery.go b/backend/internal/nativeapi/codex_recovery.go index 6ae83974cd70..d11ee602cbf7 100644 --- a/backend/internal/nativeapi/codex_recovery.go +++ b/backend/internal/nativeapi/codex_recovery.go @@ -1,7 +1,8 @@ package nativeapi -// Recovery receives protocol shape and item indices. Neither ciphertext, -// prompt history, credentials nor complete upstream errors cross this boundary. +// 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"` @@ -34,24 +35,9 @@ type RecoverySelection struct { Reason string `json:"reason"` } -type RecoverySetting struct { - Configured bool `json:"configured"` - Valid bool `json:"valid"` - Value bool `json:"value"` -} - -type ReplayRules struct { - Version string `json:"version"` - Enabled bool `json:"enabled"` - MaxToolCalls int `json:"max_tool_calls"` - AllowOmittedReasoning bool `json:"allow_omitted_reasoning"` - ChatFields []string `json:"chat_fields"` - OutputKinds []string `json:"output_kinds"` -} - -// A structured request-state rejection must stay request-scoped even when an -// optional recovery plugin is absent. This validates protocol framing only; -// the plugin separately decides recovery eligibility and rewrite selection. +// 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 diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index b6fb52cf0e45..7fc48cbc2705 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -122,26 +122,6 @@ func convertChatMessagesToResponsesInput(msgs []ChatMessage) ([]ResponsesInputIt return out, nil } -// ChatMessageResponsesInput returns the exact ordinary conversion of one Chat -// message. A stateful bridge can use this projection to replace a whole -// assistant turn with verified original output items, never just insert hidden -// reasoning next to a duplicate textual projection. -func ChatMessageResponsesInput(message ChatMessage) ([]json.RawMessage, error) { - items, err := chatMessageToResponsesItems(message) - if err != nil { - return nil, err - } - out := make([]json.RawMessage, 0, len(items)) - for _, item := range items { - raw, err := json.Marshal(item) - if err != nil { - return nil, err - } - out = append(out, raw) - } - return out, nil -} - // chatMessageToResponsesItems converts a single ChatMessage into one or more // ResponsesInputItem values. func chatMessageToResponsesItems(m ChatMessage) ([]ResponsesInputItem, error) { diff --git a/backend/internal/repository/account_reasoning_options_purge_migration_integration_test.go b/backend/internal/repository/account_reasoning_options_purge_migration_integration_test.go new file mode 100644 index 000000000000..d7802fbf822a --- /dev/null +++ b/backend/internal/repository/account_reasoning_options_purge_migration_integration_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const accountReasoningOptionsPurgeMigration = "267_purge_account_reasoning_options.sql" + +// TestMain applied every migration, 267 included, to an empty database. The +// test stores accounts as the previous release could hold them, applies 267, +// checks which keys went and which rows were not written, and applies it again. +func TestMigration267PurgesAccountReasoningOptions(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + 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 + } + content, err := dbmigrations.FS.ReadFile(accountReasoningOptionsPurgeMigration) + require.NoError(t, err) + migration := string(content) + + insert := func(name, platform, accountType, extra string, deleted bool) int64 { + t.Helper() + var id int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra, deleted_at) +VALUES ($1, $2, $3, $4::jsonb, CASE WHEN $5 THEN NOW() END) +RETURNING id`, name, platform, accountType, extra, deleted).Scan(&id)) + return id + } + type account struct { + id int64 + want string + written bool + } + accounts := map[string]account{ + "both keys": { + id: insert("migration-267-both", "openai", "apikey", `{"openai_long_context_billing_enabled": false, "openai_chat_reasoning_replay_enabled": false, "openai_reasoning_signature_recovery_enabled": true, "openai_passthrough": true}`, false), + want: `{"openai_long_context_billing_enabled": false, "openai_passthrough": true}`, + written: true, + }, + "one key with a value that is not a boolean": { + id: insert("migration-267-one", "openai", "oauth", `{"openai_long_context_billing_enabled": true, "openai_reasoning_signature_recovery_enabled": "off", "codex_fingerprint_mode": "device"}`, false), + want: `{"openai_long_context_billing_enabled": true, "codex_fingerprint_mode": "device"}`, + written: true, + }, + "deleted account": { + id: insert("migration-267-deleted", "openai", "apikey", `{"openai_long_context_billing_enabled": false, "openai_chat_reasoning_replay_enabled": null}`, true), + want: `{"openai_long_context_billing_enabled": false}`, + written: true, + }, + "another platform": { + id: insert("migration-267-other-platform", "anthropic", "oauth", `{"openai_chat_reasoning_replay_enabled": true, "window_cost_limit": 5}`, false), + want: `{"window_cost_limit": 5}`, + written: true, + }, + "neither key": { + id: insert("migration-267-untouched", "openai", "apikey", `{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64", "note": "openai_reasoning_signature_recovery_enabled"}`, false), + want: `{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64", "note": "openai_reasoning_signature_recovery_enabled"}`, + }, + "the key only inside another object": { + id: insert("migration-267-nested", "openai", "apikey", `{"openai_long_context_billing_enabled": false, "model_rate_limits": {"openai_chat_reasoning_replay_enabled": true}}`, false), + want: `{"openai_long_context_billing_enabled": false, "model_rate_limits": {"openai_chat_reasoning_replay_enabled": true}}`, + }, + } + ctid := func(id int64) string { + t.Helper() + return text(`SELECT ctid::text FROM accounts WHERE id = $1`, id) + } + before := map[string]string{} + for name, row := range accounts { + before[name] = ctid(row.id) + } + outbox := count(`SELECT count(*) FROM scheduler_outbox`) + + _, err = tx.ExecContext(ctx, migration) + require.NoError(t, err) + after := map[string]string{} + for name, row := range accounts { + require.JSONEq(t, row.want, text(`SELECT extra::text FROM accounts WHERE id = $1`, row.id), name) + after[name] = ctid(row.id) + require.Equal(t, row.written, after[name] != before[name], "%s: only a row that held a key is written", name) + } + require.Equal(t, outbox, count(`SELECT count(*) FROM scheduler_outbox`), "the purge queues no scheduler event") + + _, err = tx.ExecContext(ctx, migration) + require.NoError(t, err) + for name, row := range accounts { + require.JSONEq(t, row.want, text(`SELECT extra::text FROM accounts WHERE id = $1`, row.id), name) + require.Equal(t, after[name], ctid(row.id), "%s: the second run writes no row", name) + } +} diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index 312aad7f456e..2f21aa553e42 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "strconv" "strings" "time" @@ -22,12 +23,27 @@ const openAIRuntimeBreakerHalfOpenRetention = 5 * time.Minute type gatewayCache struct { rdb *redis.Client + // reasoningStateRDB serves only the rejected-cipher memory; see + // newReasoningStateClient. + reasoningStateRDB *redis.Client } func NewGatewayCache(rdb *redis.Client) service.GatewayCache { - return &gatewayCache{rdb: rdb} + return &gatewayCache{rdb: rdb, reasoningStateRDB: newReasoningStateClient(rdb)} } +// Close releases the client of the rejected-cipher memory, the only resource +// the cache owns; application cleanup calls it through io.Closer. The shared +// client is closed by its owner. +func (c *gatewayCache) Close() error { + if c == nil || c.reasoningStateRDB == nil { + return nil + } + return c.reasoningStateRDB.Close() +} + +var _ io.Closer = (*gatewayCache)(nil) + // buildSessionKey 构建 session key,包含 groupID 实现分组隔离 // 格式: sticky_session:{groupID}:{sessionHash} func buildSessionKey(groupID int64, sessionHash string) string { diff --git a/backend/internal/repository/gateway_cache_reasoning_state.go b/backend/internal/repository/gateway_cache_reasoning_state.go index d5f9066d4389..5035a9c81cac 100644 --- a/backend/internal/repository/gateway_cache_reasoning_state.go +++ b/backend/internal/repository/gateway_cache_reasoning_state.go @@ -2,11 +2,7 @@ package repository import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" - "net" "strconv" "strings" "time" @@ -15,21 +11,18 @@ import ( "github.com/redis/go-redis/v9" ) -const ( - reasoningBatchPrefix = "openai_reasoning_state:v1:{openai_reasoning_batches}:" - rejectedReasoningPrefix = "openai_reasoning_state:v1:{openai_rejected_reasoning}:" -) +const rejectedReasoningPrefix = "openai_reasoning_state:v1:{openai_rejected_reasoning}:" var _ service.OpenAIReasoningStateStore = (*gatewayCache)(nil) -// All payloads have their own hard TTL. Index TTLs and lazy expiry cleanup are -// bookkeeping only: touching LRU never extends a payload's lifetime. The size, +// Every rejection has its own hard TTL. Index TTLs and lazy expiry cleanup are +// bookkeeping only: touching LRU never extends a rejection's lifetime. The size, // owner, expiry, and LRU indexes are updated atomically with every replacement -// and eviction, including records whose Redis payload TTL already elapsed. -var reasoningStateScript = redis.NewScript(` +// and eviction, including records whose Redis entry TTL already elapsed. +var rejectedReasoningScript = redis.NewScript(` local prefix, op, tenant = ARGV[1], ARGV[2], ARGV[3] local ttl, maxBytes, maxEntries = tonumber(ARGV[4]), tonumber(ARGV[5]), tonumber(ARGV[6]) -local maxTenantBytes, maxEntryBytes, kind = tonumber(ARGV[7]), tonumber(ARGV[8]), ARGV[9] +local maxTenantBytes, maxEntryBytes = tonumber(ARGV[7]), tonumber(ARGV[8]) local clock = redis.call('TIME') local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000) local tick = tonumber(clock[1]) * 1000000 + tonumber(clock[2]) @@ -63,48 +56,35 @@ end if op == 'get' then local result = {} - for index = 10, #ARGV do + for index = 9, #ARGV do local id = ARGV[index] - local value = redis.call('HMGET', prefix .. 'entry:' .. id, 'payload', 'version') + local payload = redis.call('HGET', prefix .. 'entry:' .. id, 'payload') local owner = redis.call('HGET', KEYS[4], id) - if value[1] and value[2] and owner == tenant then + if payload and owner == tenant then redis.call('ZADD', KEYS[1], tick, id) redis.call('ZADD', prefix .. 'tenant:' .. tenant, tick, id) table.insert(result, id) - table.insert(result, value[1]) - table.insert(result, value[2]) - elseif not value[1] then + table.insert(result, payload) + elseif not payload then remove(id) end end return result end -if op == 'delete' then - local id, expected = ARGV[10], ARGV[11] - local actual = redis.call('HGET', prefix .. 'entry:' .. id, 'version') - if not actual then remove(id); return 0 end - if actual ~= expected or redis.call('HGET', KEYS[4], id) ~= tenant then return 0 end - remove(id) - return 1 -end - if op ~= 'put' then return redis.error_reply('invalid reasoning state operation') end +local payload = string.format('%.0f:%.0f', now, now + ttl) local records = {} -for index = 10, #ARGV, 3 do - local id, payload, version = ARGV[index], ARGV[index + 1], ARGV[index + 2] - if kind == 'negative' then - payload = string.format('%.0f:%.0f', now, now + ttl) - end - local size = string.len(payload) - if kind == 'negative' then size = size + string.len(id) end +for index = 9, #ARGV do + local id = ARGV[index] + local size = string.len(payload) + string.len(id) if size > maxEntryBytes or size > maxBytes or size > maxTenantBytes then return 0 end - table.insert(records, {id, payload, version, size}) + table.insert(records, {id, size}) end for _, record in ipairs(records) do - local id, payload, version, size = record[1], record[2], record[3], record[4] + local id, size = record[1], record[2] remove(id) while (tonumber(redis.call('HGET', KEYS[5], tenant)) or 0) + size > maxTenantBytes do local victim = redis.call('ZRANGE', prefix .. 'tenant:' .. tenant, 0, 0)[1] @@ -117,7 +97,7 @@ for _, record in ipairs(records) do if not victim then return redis.error_reply('reasoning global index inconsistent') end remove(victim) end - redis.call('HSET', prefix .. 'entry:' .. id, 'payload', payload, 'version', version) + redis.call('HSET', prefix .. 'entry:' .. id, 'payload', payload) redis.call('PEXPIRE', prefix .. 'entry:' .. id, ttl) redis.call('HSET', KEYS[3], id, size) redis.call('HSET', KEYS[4], id, tenant) @@ -136,13 +116,6 @@ type reasoningStateLimits struct { bytes, entries, tenantBytes, entryBytes int } -var reasoningBatchLimits = reasoningStateLimits{ - bytes: service.OpenAIReasoningBatchTotalMaxBytes, - entries: service.OpenAIReasoningBatchTotalMaxEntries, - tenantBytes: service.OpenAIReasoningBatchTenantMaxBytes, - entryBytes: service.OpenAIReasoningBatchMaxBytes, -} - var rejectedReasoningLimits = reasoningStateLimits{ bytes: service.OpenAIRejectedReasoningMaxBytes, entries: service.OpenAIRejectedReasoningMaxEntries, @@ -150,17 +123,16 @@ var rejectedReasoningLimits = reasoningStateLimits{ entryBytes: 256, } -func reasoningStateKeys(prefix string) []string { - return []string{prefix + "lru", prefix + "expiry", prefix + "sizes", prefix + "owners", prefix + "totals"} +func rejectedReasoningKeys() []string { + return []string{ + rejectedReasoningPrefix + "lru", rejectedReasoningPrefix + "expiry", rejectedReasoningPrefix + "sizes", + rejectedReasoningPrefix + "owners", rejectedReasoningPrefix + "totals", + } } -func reasoningStateArguments(prefix, operation string, scope service.OpenAIReasoningCacheScope, limits reasoningStateLimits) []any { - kind := "positive" - if prefix == rejectedReasoningPrefix { - kind = "negative" - } - return []any{prefix, operation, scope.TenantHash, service.OpenAIReasoningStateTTL.Milliseconds(), - limits.bytes, limits.entries, limits.tenantBytes, limits.entryBytes, kind} +func rejectedReasoningArguments(operation string, scope service.OpenAIReasoningCacheScope, limits reasoningStateLimits) []any { + return []any{rejectedReasoningPrefix, operation, scope.TenantHash, service.OpenAIReasoningStateTTL.Milliseconds(), + limits.bytes, limits.entries, limits.tenantBytes, limits.entryBytes} } func validateReasoningStateKeys(scope service.OpenAIReasoningCacheScope, keys []string) error { @@ -176,102 +148,27 @@ func validateReasoningStateKeys(scope service.OpenAIReasoningCacheScope, keys [] return nil } -func reasoningPayloadDigest(payload []byte) string { - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:]) -} - -func encodeReasoningBatch(batch service.OpenAIReasoningBatch) ([]byte, error) { - if len(batch.Output) == 0 || len(batch.Output) > service.OpenAIReasoningBatchMaxItems || - !service.IsOpenAIReasoningCacheDigest(batch.InputPrefixHash) || - len(batch.Projection) == 0 || !json.Valid(batch.Projection) { - return nil, service.ErrOpenAIReasoningCacheInput - } - calls := 0 - for _, raw := range batch.Output { - var item struct { - Type string `json:"type"` - Status string `json:"status"` - } - if json.Unmarshal(raw, &item) != nil || (item.Status != "" && item.Status != "completed") { - return nil, service.ErrOpenAIReasoningCacheInput - } - switch item.Type { - case "reasoning", "message": - case "function_call": - calls++ - default: - // First version does not admit partial unknown/custom-tool batches. - return nil, service.ErrOpenAIReasoningCacheInput - } - } - if calls == 0 || calls > service.OpenAIReasoningBatchMaxCalls { - return nil, service.ErrOpenAIReasoningCacheInput - } - payload, err := json.Marshal(batch) - if err != nil || len(payload) > service.OpenAIReasoningBatchMaxBytes { - return nil, service.ErrOpenAIReasoningCacheInput - } - return payload, nil -} - -func (c *gatewayCache) GetOpenAIReasoningBatches(ctx context.Context, scope service.OpenAIReasoningCacheScope, keys []string) (map[string]service.OpenAIReasoningBatch, error) { - values, err := c.getReasoningState(ctx, reasoningBatchPrefix, scope, keys, reasoningBatchLimits) - if err != nil { +func (c *gatewayCache) GetOpenAIRejectedReasoning(ctx context.Context, scope service.OpenAIReasoningCacheScope, cipherHashes []string) (map[string]service.OpenAIRejectedReasoning, error) { + if err := validateReasoningStateKeys(scope, cipherHashes); err != nil { return nil, err } - result := make(map[string]service.OpenAIReasoningBatch, len(values)/3) - for index := 0; index < len(values); index += 3 { - key, keyOK := values[index].(string) - payload, payloadOK := values[index+1].(string) - version, versionOK := values[index+2].(string) - if !keyOK || !payloadOK || !versionOK || reasoningPayloadDigest([]byte(payload)) != version { - return nil, errors.New("invalid reasoning state cache record") - } - var batch service.OpenAIReasoningBatch - if json.Unmarshal([]byte(payload), &batch) != nil { - return nil, errors.New("invalid reasoning state cache record") - } - if _, err := encodeReasoningBatch(batch); err != nil { - return nil, errors.New("invalid reasoning state cache record") - } - batch.PayloadHash = version - result[strings.TrimPrefix(key, scope.ScopeHash+":")] = batch - } - return result, nil -} - -func (c *gatewayCache) PutOpenAIReasoningBatch(ctx context.Context, scope service.OpenAIReasoningCacheScope, key string, batch service.OpenAIReasoningBatch) (bool, error) { - if err := validateReasoningStateKeys(scope, []string{key}); err != nil { - return false, err - } - payload, err := encodeReasoningBatch(batch) - if err != nil { - return false, err + if len(cipherHashes) == 0 { + return nil, nil } - args := reasoningStateArguments(reasoningBatchPrefix, "put", scope, reasoningBatchLimits) - args = append(args, scope.ScopeHash+":"+key, payload, reasoningPayloadDigest(payload)) - result, err := c.runReasoningState(ctx, reasoningBatchPrefix, args) - return result == int64(1), err -} - -func (c *gatewayCache) DeleteOpenAIReasoningBatchIfMatch(ctx context.Context, scope service.OpenAIReasoningCacheScope, key, payloadHash string) (bool, error) { - if err := validateReasoningStateKeys(scope, []string{key, payloadHash}); err != nil { - return false, err + args := rejectedReasoningArguments("get", scope, rejectedReasoningLimits) + for _, hash := range cipherHashes { + args = append(args, scope.ScopeHash+":"+hash) } - args := reasoningStateArguments(reasoningBatchPrefix, "delete", scope, reasoningBatchLimits) - args = append(args, scope.ScopeHash+":"+key, payloadHash) - result, err := c.runReasoningState(ctx, reasoningBatchPrefix, args) - return result == int64(1), err -} - -func (c *gatewayCache) GetOpenAIRejectedReasoning(ctx context.Context, scope service.OpenAIReasoningCacheScope, cipherHashes []string) (map[string]service.OpenAIRejectedReasoning, error) { - values, err := c.getReasoningState(ctx, rejectedReasoningPrefix, scope, cipherHashes, rejectedReasoningLimits) + reply, err := c.runRejectedReasoning(ctx, args) if err != nil { return nil, err } - result := make(map[string]service.OpenAIRejectedReasoning, len(values)/3) - for index := 0; index < len(values); index += 3 { + values, ok := reply.([]any) + if !ok || len(values)%2 != 0 { + return nil, errors.New("invalid rejected reasoning cache result") + } + result := make(map[string]service.OpenAIRejectedReasoning, len(values)/2) + for index := 0; index < len(values); index += 2 { key, keyOK := values[index].(string) payload, payloadOK := values[index+1].(string) if !keyOK || !payloadOK { @@ -297,42 +194,44 @@ func (c *gatewayCache) PutOpenAIRejectedReasoning(ctx context.Context, scope ser if len(cipherHashes) == 0 { return nil } - args := reasoningStateArguments(rejectedReasoningPrefix, "put", scope, rejectedReasoningLimits) + args := rejectedReasoningArguments("put", scope, rejectedReasoningLimits) seen := make(map[string]bool, len(cipherHashes)) for _, hash := range cipherHashes { if !seen[hash] { - args = append(args, scope.ScopeHash+":"+hash, "", hash) + args = append(args, scope.ScopeHash+":"+hash) seen[hash] = true } } - _, err := c.runReasoningState(ctx, rejectedReasoningPrefix, args) + _, err := c.runRejectedReasoning(ctx, args) return err } -func (c *gatewayCache) getReasoningState(ctx context.Context, prefix string, scope service.OpenAIReasoningCacheScope, keys []string, limits reasoningStateLimits) ([]any, error) { - if err := validateReasoningStateKeys(scope, keys); err != nil { - return nil, err - } - if len(keys) == 0 { - return nil, nil - } - args := reasoningStateArguments(prefix, "get", scope, limits) - for _, key := range keys { - args = append(args, scope.ScopeHash+":"+key) - } - result, err := c.runReasoningState(ctx, prefix, args) - if err != nil { - return nil, err - } - values, ok := result.([]any) - if !ok || len(values)%3 != 0 { - return nil, errors.New("invalid reasoning state cache result") +// newReasoningStateClient builds the client the rejected-cipher memory runs on, +// once for the gateway cache, which owns it and closes it in Close. The +// shared client does not apply a context deadline to socket reads and writes +// (ContextTimeoutEnabled is off): on it every write and read gets a timeout of +// its own, counted from the moment it starts. This client applies the context +// deadline, so every write and read of an operation ends at the operation's +// deadline however long the connection took to acquire. It sends a command +// once and dials once, so an unreachable Redis fails at the refused dial. It +// opens connections on demand and keeps them for the next operation. The +// shared options are copied, never changed. +func newReasoningStateClient(shared *redis.Client) *redis.Client { + if shared == nil { + return nil } - return values, nil + opts := *shared.Options() + opts.ContextTimeoutEnabled = true + opts.MaxRetries = -1 + opts.DialerRetries = 1 + opts.MinIdleConns = 0 + // The shared client's notification processor holds that client's handlers. + opts.PushNotificationProcessor = nil + return redis.NewClient(&opts) } -func (c *gatewayCache) runReasoningState(ctx context.Context, prefix string, args []any) (any, error) { - if c == nil || c.rdb == nil { +func (c *gatewayCache) runRejectedReasoning(ctx context.Context, args []any) (any, error) { + if c == nil || c.reasoningStateRDB == nil { return nil, errors.New("reasoning state cache unavailable") } if err := ctx.Err(); err != nil { @@ -342,27 +241,5 @@ func (c *gatewayCache) runReasoningState(ctx context.Context, prefix string, arg // too; never inherit the ordinary multi-second Redis defaults for this cache. ioCtx, cancel := context.WithTimeout(ctx, service.OpenAIReasoningStateIOBudget) defer cancel() - deadline, _ := ioCtx.Deadline() - timeout := time.Until(deadline) - // WithTimeout alone inherits ContextTimeoutEnabled=false from the shared - // production client. Use one bounded, promptly closed connection instead of - // changing global Redis options or starting detached timeout goroutines. - opts := *c.rdb.Options() - opts.ContextTimeoutEnabled = true - opts.MaxRetries = -1 - opts.DialerRetries = 1 - opts.DialerRetryTimeout = time.Nanosecond - // MaxActiveConns is the actual connection bound. The logical size of two - // avoids go-redis launching its background redial loop after the first dial - // failure; this one-operation pool is closed immediately instead. - opts.PoolSize, opts.MaxActiveConns, opts.MaxIdleConns = 2, 1, 1 - opts.MinIdleConns, opts.MaxConcurrentDials = 0, 1 - opts.DialTimeout, opts.ReadTimeout, opts.WriteTimeout, opts.PoolTimeout = timeout, timeout, timeout, timeout - dial := opts.Dialer - opts.Dialer = func(_ context.Context, network, addr string) (net.Conn, error) { - return dial(ioCtx, network, addr) - } - client := redis.NewClient(&opts) - defer func() { _ = client.Close() }() - return reasoningStateScript.Run(ioCtx, client, reasoningStateKeys(prefix), args...).Result() + return rejectedReasoningScript.Run(ioCtx, c.reasoningStateRDB, rejectedReasoningKeys(), args...).Result() } diff --git a/backend/internal/repository/gateway_cache_reasoning_state_test.go b/backend/internal/repository/gateway_cache_reasoning_state_test.go index 32dd6550e4d5..a10db366a6f2 100644 --- a/backend/internal/repository/gateway_cache_reasoning_state_test.go +++ b/backend/internal/repository/gateway_cache_reasoning_state_test.go @@ -2,7 +2,6 @@ package repository import ( "context" - "encoding/json" "errors" "io" "net" @@ -18,227 +17,136 @@ import ( "github.com/stretchr/testify/require" ) +// One stored rejection is charged its payload (":", +// 13 digits each) plus its id (":"). +const reasoningStateTestEntryBytes = 13 + 1 + 13 + 64 + 1 + 64 + func newReasoningStateTestCache(t *testing.T) (*gatewayCache, *miniredis.Miniredis) { t.Helper() server := miniredis.RunT(t) - client := redis.NewClient(&redis.Options{Addr: server.Addr()}) - t.Cleanup(func() { _ = client.Close() }) - return &gatewayCache{rdb: client}, server -} - -func reasoningStateTestScope() service.OpenAIReasoningCacheScope { - return service.OpenAIReasoningCacheScope{ScopeHash: strings.Repeat("a", 64), TenantHash: strings.Repeat("b", 64)} + return newReasoningStateTestCacheOn(t, redis.NewClient(&redis.Options{Addr: server.Addr()})), server } -func reasoningStateTestBatch() service.OpenAIReasoningBatch { - return service.OpenAIReasoningBatch{ - Output: []json.RawMessage{ - json.RawMessage(`{"type":"reasoning","id":"rs_fixture","status":"completed","summary":[],"encrypted_content":"synthetic-cipher","future":{"kept":true}}`), - json.RawMessage(`{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"loading"}]}`), - json.RawMessage(`{"type":"function_call","call_id":"call_fixture","name":"load_orders","arguments":"{}","status":"completed"}`), - }, - Projection: json.RawMessage(`{"role":"assistant","content":"loading","tool_calls":[{"id":"call_fixture","type":"function","function":{"name":"load_orders","arguments":"{}"}}]}`), - InputPrefixHash: strings.Repeat("c", 64), - } +// The cache is built and closed as production does it: the rejection memory +// gets its own client derived from the shared one, and the cache closes it +// before the shared client is closed. +func newReasoningStateTestCacheOn(t *testing.T, client *redis.Client) *gatewayCache { + t.Helper() + cache, ok := NewGatewayCache(client).(*gatewayCache) + require.True(t, ok) + t.Cleanup(func() { + _ = cache.Close() + _ = client.Close() + }) + return cache } -func TestGatewayCacheReasoningStateRoundTripIsolationAndCAS(t *testing.T) { - cache, _ := newReasoningStateTestCache(t) - scope, key, ctx := reasoningStateTestScope(), strings.Repeat("d", 64), context.Background() - batch := reasoningStateTestBatch() - put, err := cache.PutOpenAIReasoningBatch(ctx, scope, key, batch) - require.NoError(t, err) - require.True(t, put) - values, err := cache.GetOpenAIReasoningBatches(ctx, scope, []string{key, strings.Repeat("e", 64)}) - require.NoError(t, err) - require.Len(t, values, 1) - stored := values[key] - require.Len(t, stored.Output, len(batch.Output)) - for index := range batch.Output { - require.JSONEq(t, string(batch.Output[index]), string(stored.Output[index])) - } - require.JSONEq(t, string(batch.Projection), string(stored.Projection)) - require.Equal(t, batch.InputPrefixHash, stored.InputPrefixHash) - require.True(t, service.IsOpenAIReasoningCacheDigest(stored.PayloadHash)) - - other := scope - other.ScopeHash = strings.Repeat("f", 64) - missing, err := cache.GetOpenAIReasoningBatches(ctx, other, []string{key}) - require.NoError(t, err) - require.Empty(t, missing) - other = scope - other.TenantHash = strings.Repeat("f", 64) - missing, err = cache.GetOpenAIReasoningBatches(ctx, other, []string{key}) - require.NoError(t, err) - require.Empty(t, missing) - deleted, err := cache.DeleteOpenAIReasoningBatchIfMatch(ctx, other, key, stored.PayloadHash) - require.NoError(t, err) - require.False(t, deleted) - - batch.Output[0] = json.RawMessage(`{"type":"reasoning","summary":[],"encrypted_content":"new-synthetic-cipher"}`) - put, err = cache.PutOpenAIReasoningBatch(ctx, scope, key, batch) - require.NoError(t, err) - require.True(t, put) - deleted, err = cache.DeleteOpenAIReasoningBatchIfMatch(ctx, scope, key, stored.PayloadHash) - require.NoError(t, err) - require.False(t, deleted) - values, err = cache.GetOpenAIReasoningBatches(ctx, scope, []string{key}) - require.NoError(t, err) - require.NotEqual(t, stored.PayloadHash, values[key].PayloadHash) - deleted, err = cache.DeleteOpenAIReasoningBatchIfMatch(ctx, scope, key, values[key].PayloadHash) - require.NoError(t, err) - require.True(t, deleted) - values, err = cache.GetOpenAIReasoningBatches(ctx, scope, []string{key}) - require.NoError(t, err) - require.Empty(t, values) +func reasoningStateTestScope() service.OpenAIReasoningCacheScope { + return service.OpenAIReasoningCacheScope{ScopeHash: strings.Repeat("a", 64), TenantHash: strings.Repeat("b", 64)} } -func TestGatewayCacheReasoningStateHardTTLAndNegativeSeparation(t *testing.T) { +func TestGatewayCacheReasoningStateHardTTLAndIsolation(t *testing.T) { cache, server := newReasoningStateTestCache(t) scope, key, ctx := reasoningStateTestScope(), strings.Repeat("d", 64), context.Background() - put, err := cache.PutOpenAIReasoningBatch(ctx, scope, key, reasoningStateTestBatch()) - require.NoError(t, err) - require.True(t, put) require.NoError(t, cache.PutOpenAIRejectedReasoning(ctx, scope, []string{key})) - positiveKey := reasoningBatchPrefix + "entry:" + scope.ScopeHash + ":" + key - negativeKey := rejectedReasoningPrefix + "entry:" + scope.ScopeHash + ":" + key - require.Equal(t, 24*time.Hour, server.TTL(positiveKey)) - require.Equal(t, 24*time.Hour, server.TTL(negativeKey)) + entryKey := rejectedReasoningPrefix + "entry:" + scope.ScopeHash + ":" + key + require.Equal(t, 24*time.Hour, server.TTL(entryKey)) server.FastForward(23 * time.Hour) - positive, err := cache.GetOpenAIReasoningBatches(ctx, scope, []string{key}) - require.NoError(t, err) - require.Len(t, positive, 1) - negative, err := cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key, strings.Repeat("e", 64)}) + rejected, err := cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key, strings.Repeat("e", 64)}) require.NoError(t, err) - require.Len(t, negative, 1) - require.Equal(t, 24*time.Hour, negative[key].ExpiresAt.Sub(negative[key].RejectedAt)) - require.Equal(t, time.Hour, server.TTL(positiveKey)) - require.Equal(t, time.Hour, server.TTL(negativeKey)) + require.Len(t, rejected, 1) + require.Equal(t, 24*time.Hour, rejected[key].ExpiresAt.Sub(rejected[key].RejectedAt)) + require.Equal(t, time.Hour, server.TTL(entryKey), "a lookup must not extend the rejection") other := scope other.ScopeHash = strings.Repeat("f", 64) none, err := cache.GetOpenAIRejectedReasoning(ctx, other, []string{key}) require.NoError(t, err) require.Empty(t, none) + other = scope + other.TenantHash = strings.Repeat("f", 64) + none, err = cache.GetOpenAIRejectedReasoning(ctx, other, []string{key}) + require.NoError(t, err) + require.Empty(t, none) - // Only another real rejection write renews the negative evidence. Positive - // state remains on its original hard deadline, in its separate namespace. + // Only another real rejection write renews the evidence. require.NoError(t, cache.PutOpenAIRejectedReasoning(ctx, scope, []string{key})) - require.Equal(t, 24*time.Hour, server.TTL(negativeKey)) - require.Equal(t, time.Hour, server.TTL(positiveKey)) + require.Equal(t, 24*time.Hour, server.TTL(entryKey)) server.FastForward(time.Hour + time.Millisecond) - positive, err = cache.GetOpenAIReasoningBatches(ctx, scope, []string{key}) - require.NoError(t, err) - require.Empty(t, positive) - negative, err = cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) + rejected, err = cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) require.NoError(t, err) - require.Len(t, negative, 1) + require.Len(t, rejected, 1) server.FastForward(23 * time.Hour) - negative, err = cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) + rejected, err = cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) require.NoError(t, err) - require.Empty(t, negative) + require.Empty(t, rejected) } func TestGatewayCacheReasoningStateBoundedAtomicLRU(t *testing.T) { cache, server := newReasoningStateTestCache(t) ctx, scope := context.Background(), reasoningStateTestScope() other := service.OpenAIReasoningCacheScope{ScopeHash: strings.Repeat("c", 64), TenantHash: strings.Repeat("d", 64)} - limits := reasoningStateLimits{bytes: 12, entries: 3, tenantBytes: 8, entryBytes: 8} + const unit = reasoningStateTestEntryBytes + limits := reasoningStateLimits{bytes: 3 * unit, entries: 3, tenantBytes: 2 * unit, entryBytes: unit} clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) put := func(owner service.OpenAIReasoningCacheScope, key string) { t.Helper() clock = clock.Add(time.Millisecond) server.SetTime(clock) - args := reasoningStateArguments(reasoningBatchPrefix, "put", owner, limits) - args = append(args, owner.ScopeHash+":"+key, "1234", key) - result, err := cache.runReasoningState(ctx, reasoningBatchPrefix, args) + args := append(rejectedReasoningArguments("put", owner, limits), owner.ScopeHash+":"+key) + result, err := cache.runRejectedReasoning(ctx, args) require.NoError(t, err) require.Equal(t, int64(1), result) } + entry := func(owner service.OpenAIReasoningCacheScope, key string) string { + return rejectedReasoningPrefix + "entry:" + owner.ScopeHash + ":" + key + } first, second, third, fourth, fifth := strings.Repeat("1", 64), strings.Repeat("2", 64), strings.Repeat("3", 64), strings.Repeat("4", 64), strings.Repeat("5", 64) put(scope, first) put(scope, second) put(other, third) clock = clock.Add(time.Millisecond) server.SetTime(clock) - _, err := cache.getReasoningState(ctx, reasoningBatchPrefix, scope, []string{first}, limits) + _, err := cache.runRejectedReasoning(ctx, append(rejectedReasoningArguments("get", scope, limits), scope.ScopeHash+":"+first)) require.NoError(t, err) - put(scope, fourth) // Per-key 8-byte bound evicts second, not the touched first. - require.True(t, server.Exists(reasoningBatchPrefix+"entry:"+scope.ScopeHash+":"+first)) - require.False(t, server.Exists(reasoningBatchPrefix+"entry:"+scope.ScopeHash+":"+second)) - put(other, fifth) // Global 12-byte bound evicts the oldest third. - require.False(t, server.Exists(reasoningBatchPrefix+"entry:"+other.ScopeHash+":"+third)) - require.Equal(t, "12", server.HGet(reasoningBatchPrefix+"totals", "_global")) - require.Equal(t, "8", server.HGet(reasoningBatchPrefix+"totals", scope.TenantHash)) - require.Equal(t, "4", server.HGet(reasoningBatchPrefix+"totals", other.TenantHash)) - count, err := cache.rdb.ZCard(ctx, reasoningBatchPrefix+"lru").Result() + put(scope, fourth) // The per-key bound of two entries evicts second, not the touched first. + require.True(t, server.Exists(entry(scope, first))) + require.False(t, server.Exists(entry(scope, second))) + put(other, fifth) // The global bound of three entries evicts the oldest third. + require.False(t, server.Exists(entry(other, third))) + require.Equal(t, strconv.Itoa(3*unit), server.HGet(rejectedReasoningPrefix+"totals", "_global")) + require.Equal(t, strconv.Itoa(2*unit), server.HGet(rejectedReasoningPrefix+"totals", scope.TenantHash)) + require.Equal(t, strconv.Itoa(unit), server.HGet(rejectedReasoningPrefix+"totals", other.TenantHash)) + count, err := cache.rdb.ZCard(ctx, rejectedReasoningPrefix+"lru").Result() require.NoError(t, err) require.Equal(t, int64(3), count) // Refuse an oversized incoming record without deleting an existing one. - args := reasoningStateArguments(reasoningBatchPrefix, "put", scope, limits) - args = append(args, scope.ScopeHash+":"+first, strings.Repeat("x", 9), first) - result, err := cache.runReasoningState(ctx, reasoningBatchPrefix, args) + stored := server.HGet(entry(scope, first), "payload") + oversized := limits + oversized.entryBytes = unit - 1 + result, err := cache.runRejectedReasoning(ctx, append(rejectedReasoningArguments("put", scope, oversized), scope.ScopeHash+":"+first)) require.NoError(t, err) require.Equal(t, int64(0), result) - require.Equal(t, "1234", server.HGet(reasoningBatchPrefix+"entry:"+scope.ScopeHash+":"+first, "payload")) + require.Equal(t, stored, server.HGet(entry(scope, first), "payload")) // Lower entry limit independently from bytes to exercise the count gate. - limits.entries, limits.bytes, limits.tenantBytes = 2, 100, 100 + limits.entries, limits.bytes, limits.tenantBytes = 2, 100*unit, 100*unit put(scope, strings.Repeat("6", 64)) - count, err = cache.rdb.ZCard(ctx, reasoningBatchPrefix+"lru").Result() + count, err = cache.rdb.ZCard(ctx, rejectedReasoningPrefix+"lru").Result() require.NoError(t, err) require.Equal(t, int64(2), count) } -func TestGatewayCacheReasoningStateBatchValidation(t *testing.T) { - cache, server := newReasoningStateTestCache(t) - scope, key := reasoningStateTestScope(), strings.Repeat("d", 64) - cases := map[string]func(*service.OpenAIReasoningBatch){ - "prefix missing": func(batch *service.OpenAIReasoningBatch) { batch.InputPrefixHash = "" }, - "unknown item": func(batch *service.OpenAIReasoningBatch) { - batch.Output = append(batch.Output, json.RawMessage(`{"type":"custom_tool_call"}`)) - }, - "incomplete": func(batch *service.OpenAIReasoningBatch) { - batch.Output[0] = json.RawMessage(`{"type":"reasoning","status":"incomplete"}`) - }, - "too many items": func(batch *service.OpenAIReasoningBatch) { - for len(batch.Output) <= service.OpenAIReasoningBatchMaxItems { - batch.Output = append(batch.Output, json.RawMessage(`{"type":"reasoning","summary":[]}`)) - } - }, - "too many calls": func(batch *service.OpenAIReasoningBatch) { - batch.Output = nil - for range service.OpenAIReasoningBatchMaxCalls + 1 { - batch.Output = append(batch.Output, json.RawMessage(`{"type":"function_call","status":"completed"}`)) - } - }, - "too many bytes": func(batch *service.OpenAIReasoningBatch) { - batch.Projection = json.RawMessage(strconv.Quote(strings.Repeat("x", service.OpenAIReasoningBatchMaxBytes))) - }, - } - for name, mutate := range cases { - t.Run(name, func(t *testing.T) { - batch := reasoningStateTestBatch() - mutate(&batch) - put, err := cache.PutOpenAIReasoningBatch(context.Background(), scope, key, batch) - require.ErrorIs(t, err, service.ErrOpenAIReasoningCacheInput) - require.False(t, put) - }) - } - require.Empty(t, server.Keys()) -} - -func TestGatewayCacheReasoningStateNegativeBoundAndDigestOnly(t *testing.T) { +func TestGatewayCacheReasoningStateBoundAndDigestOnly(t *testing.T) { cache, server := newReasoningStateTestCache(t) scope, ctx := reasoningStateTestScope(), context.Background() limits := reasoningStateLimits{bytes: 350, entries: 2, tenantBytes: 350, entryBytes: 256} - args := reasoningStateArguments(rejectedReasoningPrefix, "put", scope, limits) + args := rejectedReasoningArguments("put", scope, limits) for _, digit := range []string{"1", "2", "3"} { - key := strings.Repeat(digit, 64) - args = append(args, scope.ScopeHash+":"+key, "", key) + args = append(args, scope.ScopeHash+":"+strings.Repeat(digit, 64)) } - _, err := cache.runReasoningState(ctx, rejectedReasoningPrefix, args) + _, err := cache.runRejectedReasoning(ctx, args) require.NoError(t, err) count, err := cache.rdb.ZCard(ctx, rejectedReasoningPrefix+"lru").Result() require.NoError(t, err) @@ -248,6 +156,7 @@ func TestGatewayCacheReasoningStateNegativeBoundAndDigestOnly(t *testing.T) { require.LessOrEqual(t, total, 350) for _, key := range server.Keys() { require.True(t, strings.HasPrefix(key, rejectedReasoningPrefix)) + require.Positive(t, server.TTL(key), "every key of the rejection memory expires") if strings.Contains(key, ":entry:") { require.Regexp(t, `^\d+:\d+$`, server.HGet(key, "payload")) } @@ -257,46 +166,104 @@ func TestGatewayCacheReasoningStateNegativeBoundAndDigestOnly(t *testing.T) { func TestGatewayCacheReasoningStateConcurrentCapacity(t *testing.T) { cache, server := newReasoningStateTestCache(t) scope, ctx := reasoningStateTestScope(), context.Background() - limits := reasoningStateLimits{bytes: 16, entries: 4, tenantBytes: 8, entryBytes: 8} + const unit = reasoningStateTestEntryBytes + limits := reasoningStateLimits{bytes: 4 * unit, entries: 4, tenantBytes: 2 * unit, entryBytes: unit} results := make(chan error, 8) for index := range cap(results) { go func() { - key := reasoningPayloadDigest([]byte(strconv.Itoa(index))) - args := reasoningStateArguments(reasoningBatchPrefix, "put", scope, limits) - args = append(args, scope.ScopeHash+":"+key, "1234", key) - _, err := cache.runReasoningState(ctx, reasoningBatchPrefix, args) + key := strings.Repeat(strconv.Itoa(index), 64) + _, err := cache.runRejectedReasoning(ctx, append(rejectedReasoningArguments("put", scope, limits), scope.ScopeHash+":"+key)) results <- err }() } for range cap(results) { require.NoError(t, <-results) } - require.Equal(t, "8", server.HGet(reasoningBatchPrefix+"totals", "_global")) - require.Equal(t, "8", server.HGet(reasoningBatchPrefix+"totals", scope.TenantHash)) - count, err := cache.rdb.ZCard(ctx, reasoningBatchPrefix+"lru").Result() + require.Equal(t, strconv.Itoa(2*unit), server.HGet(rejectedReasoningPrefix+"totals", "_global")) + require.Equal(t, strconv.Itoa(2*unit), server.HGet(rejectedReasoningPrefix+"totals", scope.TenantHash)) + count, err := cache.rdb.ZCard(ctx, rejectedReasoningPrefix+"lru").Result() require.NoError(t, err) require.Equal(t, int64(2), count) } -func TestGatewayCacheReasoningStateBlockedTransportBudget(t *testing.T) { +// The rejection memory keeps its connections: operations that follow each other +// reuse one connection instead of dialing. +func TestGatewayCacheReasoningStateReusesItsConnection(t *testing.T) { + server := miniredis.RunT(t) var dials atomic.Int32 + cache := newReasoningStateTestCacheOn(t, redis.NewClient(&redis.Options{ + Addr: server.Addr(), + Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) { + dials.Add(1) + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + })) + scope, key, ctx := reasoningStateTestScope(), strings.Repeat("d", 64), context.Background() + for range 3 { + require.NoError(t, cache.PutOpenAIRejectedReasoning(ctx, scope, []string{key})) + rejected, err := cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) + require.NoError(t, err) + require.Len(t, rejected, 1) + } + require.Equal(t, int32(1), dials.Load()) +} + +// Closing the cache closes the rejection memory's client and nothing else: the +// shared client belongs to whoever built it. +func TestGatewayCacheReasoningStateCloseKeepsTheSharedClient(t *testing.T) { + cache, _ := newReasoningStateTestCache(t) + scope, key, ctx := reasoningStateTestScope(), strings.Repeat("d", 64), context.Background() + require.NoError(t, cache.PutOpenAIRejectedReasoning(ctx, scope, []string{key})) + require.NoError(t, cache.Close()) + _, err := cache.GetOpenAIRejectedReasoning(ctx, scope, []string{key}) + require.ErrorIs(t, err, redis.ErrClosed) + require.NoError(t, cache.rdb.Ping(ctx).Err(), "the shared client stays open") +} + +// reasoningStateDeadlineConn reports every deadline the client sets on its socket. +type reasoningStateDeadlineConn struct { + net.Conn + record func(time.Time) +} + +func (c reasoningStateDeadlineConn) SetDeadline(deadline time.Time) error { + c.record(deadline) + return c.Conn.SetDeadline(deadline) +} + +func (c reasoningStateDeadlineConn) SetReadDeadline(deadline time.Time) error { + c.record(deadline) + return c.Conn.SetReadDeadline(deadline) +} + +func (c reasoningStateDeadlineConn) SetWriteDeadline(deadline time.Time) error { + c.record(deadline) + return c.Conn.SetWriteDeadline(deadline) +} + +// A Redis that is slow to connect and then never answers costs the request no +// more than its I/O budget: no write or read gets a deadline later than the +// budget's own, although acquiring the connection already spent part of it. +// The bound belongs to the rejection memory's client: building it leaves the +// shared client's options as they were. +func TestGatewayCacheReasoningStateBlockedTransportBudget(t *testing.T) { + var socketDeadlines []time.Time client := redis.NewClient(&redis.Options{ Addr: "synthetic.invalid:6379", - Dialer: func(ctx context.Context, _, _ string) (net.Conn, error) { - dials.Add(1) + Dialer: func(context.Context, string, string) (net.Conn, error) { + time.Sleep(20 * time.Millisecond) local, peer := net.Pipe() go func() { defer func() { _ = peer.Close() }() _, _ = io.Copy(io.Discard, peer) // Accept writes, never send a reply. }() - return local, nil + return reasoningStateDeadlineConn{Conn: local, record: func(deadline time.Time) { + socketDeadlines = append(socketDeadlines, deadline) + }}, nil }, }) - t.Cleanup(func() { _ = client.Close() }) - // Compare with the initialized shared client, not a dependency version's - // timeout defaults: only the per-operation clone may tighten these bounds. sharedOptions := *client.Options() - cache := &gatewayCache{rdb: client} + cache := newReasoningStateTestCacheOn(t, client) budget := service.NewOpenAIReasoningCacheBudget() started := time.Now() require.NoError(t, budget.Do(context.Background(), func(ctx context.Context) error { @@ -307,43 +274,45 @@ func TestGatewayCacheReasoningStateBlockedTransportBudget(t *testing.T) { return ctx.Err() } })) + var budgetDeadline time.Time err := budget.Do(context.Background(), func(ctx context.Context) error { - _, err := cache.GetOpenAIReasoningBatches(ctx, reasoningStateTestScope(), []string{strings.Repeat("d", 64)}) + budgetDeadline, _ = ctx.Deadline() + _, err := cache.GetOpenAIRejectedReasoning(ctx, reasoningStateTestScope(), []string{strings.Repeat("d", 64)}) return err }) require.Error(t, err) require.LessOrEqual(t, time.Since(started), service.OpenAIReasoningStateIOBudget+50*time.Millisecond) - require.Equal(t, int32(1), dials.Load()) + require.NotEmpty(t, socketDeadlines, "the exchange started inside the budget") + for _, deadline := range socketDeadlines { + require.False(t, deadline.After(budgetDeadline), "a write or read may not outlast the budget") + } + require.ErrorIs(t, budget.Do(context.Background(), func(context.Context) error { return nil }), service.ErrOpenAIReasoningCacheBudget, + "a spent budget starts no further cache I/O for the request") options := client.Options() require.Equal(t, sharedOptions.ContextTimeoutEnabled, options.ContextTimeoutEnabled, "shared Redis options must remain unchanged") - require.Equal(t, sharedOptions.DialTimeout, options.DialTimeout) - require.Equal(t, sharedOptions.ReadTimeout, options.ReadTimeout) - require.Equal(t, sharedOptions.WriteTimeout, options.WriteTimeout) - require.Equal(t, sharedOptions.PoolTimeout, options.PoolTimeout) require.Equal(t, sharedOptions.MaxRetries, options.MaxRetries) require.Equal(t, sharedOptions.DialerRetries, options.DialerRetries) - require.Equal(t, sharedOptions.DialerRetryTimeout, options.DialerRetryTimeout) - require.Equal(t, sharedOptions.PoolSize, options.PoolSize) - require.Equal(t, sharedOptions.MaxActiveConns, options.MaxActiveConns) - require.Equal(t, sharedOptions.MaxIdleConns, options.MaxIdleConns) - require.Equal(t, sharedOptions.MinIdleConns, options.MinIdleConns) - require.Equal(t, sharedOptions.MaxConcurrentDials, options.MaxConcurrentDials) + require.Equal(t, sharedOptions.PushNotificationProcessor, options.PushNotificationProcessor) } -func TestGatewayCacheReasoningStateDialFailureDoesNotRetry(t *testing.T) { +// An unreachable Redis fails the operation at the first refused dial: nothing +// is retried and nothing waits for the budget to run out. A request that is +// already cancelled starts no Redis work. +func TestGatewayCacheReasoningStateRefusedDialFailsAtOnce(t *testing.T) { var dials atomic.Int32 - client := redis.NewClient(&redis.Options{ + cache := newReasoningStateTestCacheOn(t, redis.NewClient(&redis.Options{ Addr: "synthetic.invalid:6379", Dialer: func(context.Context, string, string) (net.Conn, error) { dials.Add(1) return nil, errors.New("synthetic cache unavailable") }, - }) - t.Cleanup(func() { _ = client.Close() }) - cache := &gatewayCache{rdb: client} + })) + started := time.Now() _, err := cache.GetOpenAIRejectedReasoning(context.Background(), reasoningStateTestScope(), []string{strings.Repeat("d", 64)}) require.Error(t, err) + require.Less(t, time.Since(started), service.OpenAIReasoningStateIOBudget) require.Equal(t, int32(1), dials.Load()) + ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = cache.GetOpenAIRejectedReasoning(ctx, reasoningStateTestScope(), []string{strings.Repeat("d", 64)}) diff --git a/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go b/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go new file mode 100644 index 000000000000..0256f561d0e6 --- /dev/null +++ b/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const openAIAPIKeyCompatSettingsPurgeMigration = "266_purge_retired_openai_apikey_compat_settings.sql" + +// TestMain applied every migration, 266 included, to an empty database. The +// test restores the two rows the previous release stored next to settings and +// an account option that must survive, applies 266, checks what was deleted +// and what stayed, and applies it again. +func TestMigration266PurgesRetiredOpenAIAPIKeyCompatSettings(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + 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 + } + content, err := dbmigrations.FS.ReadFile(openAIAPIKeyCompatSettingsPurgeMigration) + require.NoError(t, err) + migration := string(content) + + const retired = `key IN ('openai_apikey_alpha_search_responses_bridge_enabled', 'openai_apikey_prompt_cache_key_normalization_enabled')` + exec(`INSERT INTO settings (key, value) VALUES + ('openai_apikey_alpha_search_responses_bridge_enabled', 'true'), + ('openai_apikey_prompt_cache_key_normalization_enabled', 'false'), + ('openai_refusal_recovery_enabled', 'true'), + ('openai_cyber_failover_enabled', 'false'), + ('openai_apikey_alpha_search_responses_bridge_enabled_note', 'kept: not a retired key') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`) + require.Equal(t, 2, count(`SELECT count(*) FROM settings WHERE `+retired)) + + var accountID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-266-prompt-cache-key-mode', 'openai', 'apikey', + '{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64"}'::jsonb) +RETURNING id`).Scan(&accountID)) + + kept := map[string]string{ + "openai_refusal_recovery_enabled": "true", + "openai_cyber_failover_enabled": "false", + "openai_apikey_alpha_search_responses_bridge_enabled_note": "kept: not a retired key", + } + settingRow := func(key string) string { + t.Helper() + return text(`SELECT ctid::text || '/' || value FROM settings WHERE key = $1`, key) + } + untouched := map[string]string{} + for key := range kept { + untouched[key] = settingRow(key) + } + accountCTID := text(`SELECT ctid::text FROM accounts WHERE id = $1`, accountID) + others := count(`SELECT count(*) FROM settings WHERE NOT (` + retired + `)`) + + for run := 1; run <= 2; run++ { + exec(migration) + + require.Zero(t, count(`SELECT count(*) FROM settings WHERE `+retired), "run %d", run) + require.Equal(t, others, count(`SELECT count(*) FROM settings`), "run %d deletes no other setting", run) + for key, value := range kept { + require.Equal(t, value, text(`SELECT value FROM settings WHERE key = $1`, key), key) + require.Equal(t, untouched[key], settingRow(key), "run %d rewrites no kept setting", run) + } + require.Equal(t, accountCTID, text(`SELECT ctid::text FROM accounts WHERE id = $1`, accountID), "run %d writes no account", run) + require.JSONEq(t, `{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64"}`, + text(`SELECT extra::text FROM accounts WHERE id = $1`, accountID)) + } +} diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 2455922ba286..b3e24da66a5b 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -1066,8 +1066,6 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "openai_ws_force_http", "openai_responses_mode", "openai_responses_supported", - service.OpenAIChatReasoningReplayEnabledExtraKey, - service.OpenAIReasoningSignatureRecoveryEnabledExtraKey, // 透传开关必须进投影:候选过滤(ListSchedulableAccounts)读的是本投影, // 而 Account.IsModelSupported 靠 extra 上的这两个键短路 model_mapping 白名单。 // 裁掉它们,透传账号在选号阶段会退回按(常为过期的)白名单判定并被误判为 @@ -1120,16 +1118,6 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { } filtered := make(map[string]any) for _, key := range keys { - if key == service.OpenAIChatReasoningReplayEnabledExtraKey || key == service.OpenAIReasoningSignatureRecoveryEnabledExtraKey { - if value, exists := extra[key]; exists { - // These policies default to enabled only when absent. Preserve the - // getter's fail-closed meaning of every present malformed value, - // including nil, instead of dropping it into the enabled default. - enabled, _ := value.(bool) - filtered[key] = enabled - } - continue - } if value, ok := extra[key]; ok && value != nil { if key == service.UpstreamBillingProbeExtraKey { filteredProbe := filterSchedulerUpstreamBillingProbe(value) diff --git a/backend/internal/repository/scheduler_cache_reasoning_policy_test.go b/backend/internal/repository/scheduler_cache_reasoning_policy_test.go deleted file mode 100644 index 8a0c92b2a3a2..000000000000 --- a/backend/internal/repository/scheduler_cache_reasoning_policy_test.go +++ /dev/null @@ -1,76 +0,0 @@ -//go:build unit - -package repository - -import ( - "maps" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestSchedulerCacheReasoningPolicyProjectionPreservesMissingAndBooleanSemantics(t *testing.T) { - keys := []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} - for _, source := range []map[string]any{nil, {}, {"unrelated": true}, {"quota_limit": nil}} { - filtered := filterSchedulerExtra(source) - for _, key := range keys { - require.NotContains(t, filtered, key, "absence must keep the enabled policy default") - } - } - for _, key := range keys { - for _, value := range []any{true, false, nil, "true", "false", 0, 1, []bool{true}, map[string]any{}} { - source := map[string]any{key: value, "mixed_scheduling": true, "quota_limit": nil, "unrelated": true} - original := maps.Clone(source) - filtered := filterSchedulerExtra(source) - expected, _ := value.(bool) - require.Contains(t, filtered, key) - require.Equal(t, expected, filtered[key]) - require.Equal(t, true, filtered["mixed_scheduling"]) - require.NotContains(t, filtered, "quota_limit", "unrelated nil filtering must not change") - require.NotContains(t, filtered, "unrelated", "the existing allowlist stays restricted") - require.Equal(t, original, source, "projection must not rewrite persistent account values") - for _, other := range keys { - if other != key { - require.NotContains(t, filtered, other) - } - } - } - } -} - -func TestSchedulerCacheReasoningPolicyAccountRoundTripMatchesSource(t *testing.T) { - parentID := int64(10) - accounts := []service.Account{ - {Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}, - {Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, - {Platform: service.PlatformOpenAI, Type: service.AccountTypeSetupToken}, - {Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, ParentAccountID: &parentID, QuotaDimension: service.QuotaDimensionSpark}, - {Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, - } - for _, account := range accounts { - for _, extra := range []map[string]any{ - nil, - {service.OpenAIChatReasoningReplayEnabledExtraKey: false, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: true}, - {service.OpenAIChatReasoningReplayEnabledExtraKey: true, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, - {service.OpenAIChatReasoningReplayEnabledExtraKey: nil, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey: "true"}, - {service.OpenAIChatReasoningReplayEnabledExtraKey: 1}, - } { - account.Extra = extra - _, metadata, err := marshalSchedulerCacheAccount(account) - require.NoError(t, err) - restored, err := decodeCachedAccount(metadata) - require.NoError(t, err) - require.Equal(t, account.IsOpenAIChatReasoningReplayEnabled(), restored.IsOpenAIChatReasoningReplayEnabled()) - require.Equal(t, account.IsOpenAIReasoningSignatureRecoveryEnabled(), restored.IsOpenAIReasoningSignatureRecoveryEnabled()) - } - } -} - -func TestSchedulerCacheReasoningPolicyUpdatesUseExistingInvalidation(t *testing.T) { - for _, key := range []string{service.OpenAIChatReasoningReplayEnabledExtraKey, service.OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - for _, enabled := range []bool{true, false} { - require.True(t, shouldEnqueueSchedulerOutboxForExtraUpdates(map[string]any{key: enabled})) - } - } -} diff --git a/backend/internal/repository/system_prompt_binding_purge_migration_integration_test.go b/backend/internal/repository/system_prompt_binding_purge_migration_integration_test.go new file mode 100644 index 000000000000..5344e857ef32 --- /dev/null +++ b/backend/internal/repository/system_prompt_binding_purge_migration_integration_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const systemPromptBindingPurgeMigration = "268_purge_system_prompt_bindings_without_insertion_point.sql" + +// TestMain applied every migration, 268 included, to an empty database. The +// test stores bindings on accounts with and without an insertion point, +// applies 268, checks which bindings went and which rows were left alone, and +// applies it again. +func TestMigration268PurgesSystemPromptBindingsWithoutInsertionPoint(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + text := func(query string, args ...any) string { + t.Helper() + var value string + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + content, err := dbmigrations.FS.ReadFile(systemPromptBindingPurgeMigration) + require.NoError(t, err) + migration := string(content) + + const off = `"system_prompt": {"mode": "off"}` + const custom = `"system_prompt": {"mode": "custom", "prompt_id": "p-0123456789ab"}` + accounts := []struct { + name, platform, extra string + deleted bool + purged string // the extra 268 leaves; empty when it must not write the row + id int64 + ctid string + }{ + {name: "typesafe", platform: "typesafe", extra: `{` + off + `, "upstream_request_id_header": "x-request-id"}`, purged: `{"upstream_request_id_header": "x-request-id"}`}, + {name: "typesafe-deleted", platform: "typesafe", extra: `{` + custom + `}`, deleted: true, purged: `{}`}, + {name: "legacy-platform", platform: "kiro", extra: `{` + custom + `, "quota_limit": 5}`, purged: `{"quota_limit": 5}`}, + {name: "typesafe-unbound", platform: "typesafe", extra: `{"upstream_request_id_header": "x-request-id"}`}, + {name: "typesafe-array-extra", platform: "typesafe", extra: `["system_prompt"]`}, + {name: "openai", platform: "openai", extra: `{` + custom + `, "openai_long_context_billing_enabled": false}`}, + {name: "anthropic", platform: "anthropic", extra: `{` + off + `}`}, + {name: "opencode-deleted", platform: "opencode_go", extra: `{` + off + `}`, deleted: true}, + } + for i := range accounts { + account := &accounts[i] + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra, deleted_at) +VALUES ($1, $2, 'apikey', $3::jsonb, CASE WHEN $4 THEN NOW() END) +RETURNING id, ctid::text`, + "migration-268-"+account.name, account.platform, account.extra, account.deleted).Scan(&account.id, &account.ctid)) + } + + for run := 1; run <= 2; run++ { + _, err := tx.ExecContext(ctx, migration) + require.NoError(t, err, "run %d", run) + + for _, account := range accounts { + extra := text(`SELECT extra::text FROM accounts WHERE id = $1`, account.id) + if account.purged != "" { + require.JSONEq(t, account.purged, extra, "run %d %s", run, account.name) + continue + } + require.JSONEq(t, account.extra, extra, "run %d %s", run, account.name) + require.Equal(t, account.ctid, text(`SELECT ctid::text FROM accounts WHERE id = $1`, account.id), + "run %d writes no %s row", run, account.name) + } + } +} diff --git a/backend/internal/repository/system_prompt_bindings.go b/backend/internal/repository/system_prompt_bindings.go index 099b49c77d13..649c9724d0ea 100644 --- a/backend/internal/repository/system_prompt_bindings.go +++ b/backend/internal/repository/system_prompt_bindings.go @@ -6,14 +6,16 @@ import ( "errors" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" ) type systemPromptBindingStats struct { db *sql.DB } -// NewSystemPromptBindingStats counts accounts by their extra.system_prompt -// binding for the admin page. Request paths never call it. +// NewSystemPromptBindingStats counts the accounts that can take a prompt by +// their extra.system_prompt binding for the admin page. Request paths never +// call it. func NewSystemPromptBindingStats(db *sql.DB) service.SystemPromptBindingStats { return &systemPromptBindingStats{db: db} } @@ -28,7 +30,8 @@ func (r *systemPromptBindingStats) CountSystemPromptBindings(ctx context.Context COUNT(*) FROM accounts WHERE deleted_at IS NULL - GROUP BY 1, 2`) + AND platform = ANY($1) + GROUP BY 1, 2`, pq.Array(service.SystemPromptPlatforms)) if err != nil { return nil, err } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 5205a95fe6a6..bb8ec546a842 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1032,7 +1032,6 @@ func TestAPIContracts(t *testing.T) { "wechat_connect_redirect_url": "", "wechat_connect_frontend_redirect_url": "/auth/wechat/callback", "wechat_connect_scopes": "snsapi_login", - "deprecated_settings": {"openai_apikey_alpha_search_responses_bridge_enabled": false, "openai_apikey_prompt_cache_key_normalization_enabled": false}, "allow_user_view_error_requests": false } }`, @@ -1396,7 +1395,6 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_dingtalk_grant_on_signup": false, "auth_source_default_dingtalk_grant_on_first_bind": false, "force_email_on_third_party_signup": false, - "deprecated_settings": {"openai_apikey_alpha_search_responses_bridge_enabled": false, "openai_apikey_prompt_cache_key_normalization_enabled": false}, "allow_user_view_error_requests": false } }`, diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 1b6cc0654d2b..35d399eb5a2c 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -146,6 +146,7 @@ var auditActionOverrides = map[string]string{ "POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete", "PUT /api/v1/admin/system-prompts": "admin.system_prompts.update", "PUT /api/v1/admin/system-prompts/bindings": "admin.system_prompts.bindings.update", + "PUT /api/v1/admin/reasoning-recovery": "admin.reasoning_recovery.update", } // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 783e98fe0e4c..725725eac5e0 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -153,6 +153,10 @@ func TestSystemPromptAuditRoutesHaveStableActionsAndKeepPromptBodies(t *testing. require.Contains(t, auditVerbatimBodyRoutes, "PUT /api/v1/admin/system-prompts") } +func TestReasoningRecoveryAuditRouteHasStableAction(t *testing.T) { + require.Equal(t, "admin.reasoning_recovery.update", auditActionOverrides["PUT /api/v1/admin/reasoning-recovery"]) +} + // Administrators review what a system prompt save changed, so the audit row // keeps the submitted body verbatim. Extra fields remain the allowlisted flags. func TestSystemPromptConfigAuditStoresPromptBodiesVerbatim(t *testing.T) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 9c549806d077..5d10b68e0a9c 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -129,6 +129,9 @@ func RegisterAdminRoutes( // 独立业务 System Prompt 管理 registerSystemPromptRoutes(admin, h) + // 推理恢复全局开关 + registerReasoningRecoveryRoutes(admin, h) + // 邀请返利(专属用户管理) registerAffiliateRoutes(admin, h) @@ -158,6 +161,14 @@ func registerSystemPromptRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +func registerReasoningRecoveryRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + recovery := admin.Group("/reasoning-recovery") + { + recovery.GET("", h.Admin.ReasoningRecovery.Get) + recovery.PUT("", h.Admin.ReasoningRecovery.Save) + } +} + func registerPromptAuditRoutes(admin *gin.RouterGroup, h *handler.Handlers) { promptAudit := admin.Group("/prompt-audit") { diff --git a/backend/internal/server/routes/reasoning_recovery_routes_test.go b/backend/internal/server/routes/reasoning_recovery_routes_test.go new file mode 100644 index 000000000000..1cfa3a46b9fc --- /dev/null +++ b/backend/internal/server/routes/reasoning_recovery_routes_test.go @@ -0,0 +1,65 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// Both reasoning recovery routes sit behind admin authentication, and neither +// asks for a TOTP step-up: an admin request reaches the handler, which here has +// no service and answers 503. +func TestReasoningRecoveryRoutesRequireAdminAuthenticationWithoutStepUp(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + handlers := &handler.Handlers{Admin: &handler.AdminHandlers{ + ReasoningRecovery: adminhandler.NewReasoningRecoveryHandler(nil), + }} + adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) { + switch c.GetHeader("Authorization") { + case "": + servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required") + case "Bearer admin-token": + c.Next() + default: + servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required") + } + }) + auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }) + stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { + servermiddleware.AbortWithError(c, http.StatusPreconditionRequired, "STEP_UP_REQUIRED", "TOTP step-up required") + }) + RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil) + + for _, route := range []struct{ method, body string }{ + {http.MethodGet, ""}, + {http.MethodPut, `{"enabled":false}`}, + } { + for _, testCase := range []struct { + name string + auth string + wantStatus int + }{ + {name: "unauthenticated", wantStatus: http.StatusUnauthorized}, + {name: "non-admin", auth: "Bearer user-token", wantStatus: http.StatusForbidden}, + {name: "admin", auth: "Bearer admin-token", wantStatus: http.StatusServiceUnavailable}, + } { + t.Run(route.method+" "+testCase.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(route.method, "/api/v1/admin/reasoning-recovery", strings.NewReader(route.body)) + if testCase.auth != "" { + request.Header.Set("Authorization", testCase.auth) + } + router.ServeHTTP(recorder, request) + require.Equal(t, testCase.wantStatus, recorder.Code) + }) + } + } +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 69c335d8e2e6..e11f9d808e69 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -2,7 +2,6 @@ package service import ( - "context" "encoding/json" "errors" "hash/fnv" @@ -117,11 +116,6 @@ type OpenAIEndpointCapability string const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled" -const ( - OpenAIChatReasoningReplayEnabledExtraKey = "openai_chat_reasoning_replay_enabled" - OpenAIReasoningSignatureRecoveryEnabledExtraKey = "openai_reasoning_signature_recovery_enabled" -) - const ( OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions" OpenAIEndpointCapabilityMessages OpenAIEndpointCapability = "messages" @@ -2169,25 +2163,10 @@ func (a *Account) IsOveragesEnabled() bool { return false } -// IsOpenAIChatReasoningReplayEnabled reports the account policy for replaying -// completed tool reasoning on Chat-to-Responses routes. Endpoint eligibility is -// checked by the caller; enabling this policy does not change the selected route. -func (a *Account) IsOpenAIChatReasoningReplayEnabled() bool { - return a.isOpenAIReasoningPolicyEnabled(OpenAIChatReasoningReplayEnabledExtraKey) -} - -// IsOpenAIReasoningSignatureRecoveryEnabled reports the account policy for the -// bounded HTTP reasoning-signature recovery, including the passthrough exception. -func (a *Account) IsOpenAIReasoningSignatureRecoveryEnabled() bool { - return a.isOpenAIReasoningPolicyEnabled(OpenAIReasoningSignatureRecoveryEnabledExtraKey) -} - -func (a *Account) isOpenAIReasoningPolicyEnabled(key string) bool { - enabled, err := openAIReasoningPolicyEnabled(context.Background(), a, key) - return err == nil && enabled -} - -func (a *Account) supportsOpenAIReasoningPolicies() bool { +// supportsOpenAIReasoningRecovery reports whether the bounded HTTP +// reasoning-signature recovery applies to this kind of account, including +// passthrough accounts. Whether it runs is the global switch. +func (a *Account) supportsOpenAIReasoningRecovery() bool { if a == nil || !a.IsOpenAI() { return false } diff --git a/backend/internal/service/account_reasoning_policy_test.go b/backend/internal/service/account_reasoning_policy_test.go deleted file mode 100644 index cf06be10fa21..000000000000 --- a/backend/internal/service/account_reasoning_policy_test.go +++ /dev/null @@ -1,227 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "maps" - "net/http" - "testing" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - "github.com/stretchr/testify/require" -) - -func TestOpenAIReasoningPolicyAccountDefaultsAndIsolation(t *testing.T) { - policies := []struct { - key string - get func(*Account) bool - }{ - {OpenAIChatReasoningReplayEnabledExtraKey, (*Account).IsOpenAIChatReasoningReplayEnabled}, - {OpenAIReasoningSignatureRecoveryEnabledExtraKey, (*Account).IsOpenAIReasoningSignatureRecoveryEnabled}, - } - for _, policy := range policies { - t.Run(policy.key, func(t *testing.T) { - require.False(t, policy.get(nil)) - for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth, AccountTypeSetupToken} { - t.Run(accountType, func(t *testing.T) { - account := &Account{Platform: PlatformOpenAI, Type: accountType} - require.True(t, policy.get(account), "missing extra uses the enabled policy default") - account.Extra = map[string]any{} - require.True(t, policy.get(account)) - for _, value := range []bool{true, false} { - account.Extra[policy.key] = value - require.Equal(t, value, policy.get(account)) - } - for _, malformed := range []any{nil, "true", "false", 1, []bool{true}, map[string]any{}} { - account.Extra[policy.key] = malformed - require.False(t, policy.get(account), "stored malformed policy must fail closed") - } - }) - } - for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformGemini, PlatformAntigravity} { - require.False(t, policy.get(&Account{Platform: platform, Type: AccountTypeAPIKey, Extra: map[string]any{policy.key: true}})) - } - for _, accountType := range []string{"", AccountTypeUpstream, AccountTypeBedrock} { - require.False(t, policy.get(&Account{Platform: PlatformOpenAI, Type: accountType, Extra: map[string]any{policy.key: true}})) - } - t.Run("actual_platform_scope", func(t *testing.T) { - // The Codex recovery capability is declared for the actual OpenAI - // platform; OpenAI-compatible wire protocols do not widen that scope. - require.True(t, policy.get(&Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai"}, Extra: map[string]any{policy.key: true}}), "a legacy Laxa URL does not relabel an ordinary OpenAI account") - }) - require.True(t, policy.get(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Extra: map[string]any{"openai_passthrough": true}})) - }) - } -} - -func TestOpenAIReasoningPolicyCreateDefaultsAndExplicitValues(t *testing.T) { - for _, extra := range []map[string]any{ - nil, - {}, - {OpenAIChatReasoningReplayEnabledExtraKey: false, OpenAIReasoningSignatureRecoveryEnabledExtraKey: true}, - {OpenAIChatReasoningReplayEnabledExtraKey: true, OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, - } { - repo := &longContextBillingRepoStub{} - svc := &adminServiceImpl{accountRepo: repo} - original := maps.Clone(extra) - account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ - Name: "reasoning-policy", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - Credentials: map[string]any{}, Extra: extra, SkipDefaultGroupBind: true, - }) - require.NoError(t, err) - require.Equal(t, original, extra, "creation must not populate defaults into the caller's map") - for _, key := range []string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - value, provided := original[key] - if provided { - require.Equal(t, value, account.Extra[key]) - } else { - require.NotContains(t, account.Extra, key, "read-time defaults need no persistence migration") - } - } - } -} - -func TestOpenAIReasoningPolicyUpdatePreservesOmittedPolicies(t *testing.T) { - for _, stored := range []any{true, false, "legacy-invalid", nil} { - for _, incoming := range []map[string]any{nil, {}, {"base_rpm": 20}} { - account := &Account{ - ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - Extra: map[string]any{OpenAIChatReasoningReplayEnabledExtraKey: stored, OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, - } - repo := &longContextBillingRepoStub{account: account} - svc := &adminServiceImpl{accountRepo: repo} - original := maps.Clone(incoming) - updated, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Name: "renamed", Extra: incoming}) - require.NoError(t, err) - require.Contains(t, updated.Extra, OpenAIChatReasoningReplayEnabledExtraKey) - require.Equal(t, stored, updated.Extra[OpenAIChatReasoningReplayEnabledExtraKey]) - require.Equal(t, false, updated.Extra[OpenAIReasoningSignatureRecoveryEnabledExtraKey]) - require.Equal(t, original, incoming) - } - } -} - -func TestOpenAIReasoningPolicyUpdateCanOverrideLegacyMalformedValuesIndependently(t *testing.T) { - for _, enabled := range []bool{true, false} { - repo := &longContextBillingRepoStub{account: &Account{ - ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, - Extra: map[string]any{OpenAIChatReasoningReplayEnabledExtraKey: "legacy-invalid", OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, - }} - svc := &adminServiceImpl{accountRepo: repo} - updated, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Extra: map[string]any{OpenAIChatReasoningReplayEnabledExtraKey: enabled}}) - require.NoError(t, err) - require.Equal(t, enabled, updated.IsOpenAIChatReasoningReplayEnabled()) - require.False(t, updated.IsOpenAIReasoningSignatureRecoveryEnabled()) - } -} - -func TestOpenAIReasoningPolicyRejectsMalformedNewAdminInputBeforeWrite(t *testing.T) { - for _, key := range []string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - for _, invalid := range []any{nil, "rejected-sensitive-marker", 1, []bool{false}, map[string]any{}} { - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic} { - for _, operation := range []string{"create", "update", "merge", "bulk"} { - t.Run(key+"/"+platform+"/"+operation, func(t *testing.T) { - repo := &longContextBillingRepoStub{account: &Account{ID: 1, Name: "unchanged", Platform: platform, Type: AccountTypeAPIKey}} - svc := &adminServiceImpl{accountRepo: repo} - extra := map[string]any{key: invalid} - var err error - switch operation { - case "create": - _, err = svc.CreateAccount(context.Background(), &CreateAccountInput{Platform: platform, Type: AccountTypeAPIKey, Extra: extra, SkipDefaultGroupBind: true}) - case "update": - _, err = svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{Name: "changed", Extra: extra}) - case "merge": - err = svc.UpdateAccountExtra(context.Background(), 1, extra) - case "bulk": - _, err = svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{1}, Extra: extra}) - } - require.Error(t, err) - require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) - requireApplicationErrorReason(t, err, "OPENAI_REASONING_POLICY_INVALID") - require.NotContains(t, err.Error(), "rejected-sensitive-marker") - require.Nil(t, repo.createdAccount) - require.Zero(t, repo.updateExtraCalls) - require.Zero(t, repo.bulkUpdateCalls) - require.Equal(t, "unchanged", repo.account.Name) - }) - } - } - } - } -} - -func TestOpenAIReasoningPolicyBulkOnlySubmitsExplicitKeys(t *testing.T) { - for _, extra := range []map[string]any{ - {"base_rpm": 20}, - {OpenAIChatReasoningReplayEnabledExtraKey: false}, - {OpenAIReasoningSignatureRecoveryEnabledExtraKey: true}, - } { - repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ - {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - }} - svc := &adminServiceImpl{accountRepo: repo} - original := maps.Clone(extra) - result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{1, 2}, Extra: extra}) - require.NoError(t, err) - require.Equal(t, 2, result.Success) - require.Equal(t, original, repo.lastBulkUpdate.Extra) - // The repository applies this exact patch with JSONB key merge. No default - // for an omitted policy may enter the patch and override per-row choices. - for _, key := range []string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if _, supplied := original[key]; !supplied { - require.NotContains(t, repo.lastBulkUpdate.Extra, key) - } - } - } -} - -func TestOpenAIReasoningPolicyBulkRejectsInvalidCompleteTargetSet(t *testing.T) { - for _, invalid := range []*Account{ - nil, - {ID: 101, Platform: PlatformGrok, Type: AccountTypeOAuth}, - {ID: 101, Platform: PlatformOpenAI, Type: AccountTypeUpstream}, - {ID: 101, Platform: PlatformOpenAI, Type: AccountTypeBedrock}, - {ID: 101, Platform: PlatformOpenAI}, - } { - ids := make([]int64, 101) - accounts := make([]*Account, 101) - for index := range accounts { - ids[index] = int64(index + 1) - accounts[index] = &Account{ID: ids[index], Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - } - accounts[100] = invalid - for _, key := range []string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: accounts} - svc := &adminServiceImpl{accountRepo: repo} - result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: ids, Extra: map[string]any{key: false}}) - require.Nil(t, result) - requireApplicationErrorReason(t, err, "OPENAI_BULK_TARGET_INVALID") - require.True(t, repo.getByIDsCalled) - require.Equal(t, ids, repo.getByIDsIDs, "the full set must be loaded, not the first UI page") - require.Zero(t, repo.bulkUpdateCalls, "no target may be written when any target is invalid") - } - } -} - -func TestOpenAIReasoningPolicyBulkAcceptsEligibleTargetsIndependentlyOfCurrentPolicy(t *testing.T) { - parentID := int64(1) - for _, account := range []*Account{ - {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, - {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, - {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark}, - } { - account.Extra = map[string]any{OpenAIChatReasoningReplayEnabledExtraKey: false, OpenAIReasoningSignatureRecoveryEnabledExtraKey: "legacy-invalid"} - repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{account}} - svc := &adminServiceImpl{accountRepo: repo} - result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ - AccountIDs: []int64{account.ID}, - Extra: map[string]any{OpenAIChatReasoningReplayEnabledExtraKey: true, OpenAIReasoningSignatureRecoveryEnabledExtraKey: true}, - }) - require.NoError(t, err) - require.Equal(t, 1, result.Success) - require.Equal(t, 1, repo.bulkUpdateCalls) - } -} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index bdb643418634..c5deb1c40953 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -387,37 +387,6 @@ func ValidateOpenAILongContextBillingExtra(platform string, extra map[string]any return nil } -// ValidateOpenAIReasoningPolicyExtra validates only newly supplied policy values. -// Defaults are read-time behavior; no default value is persisted or coerced here. -func ValidateOpenAIReasoningPolicyExtra(extra map[string]any) error { - for _, key := range [...]string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if raw, exists := extra[key]; exists { - if _, valid := raw.(bool); !valid { - return infraerrors.BadRequest("OPENAI_REASONING_POLICY_INVALID", key+" must be a boolean") - } - } - } - return nil -} - -func preserveOmittedOpenAIReasoningPolicyExtra(current, incoming map[string]any) map[string]any { - normalized := maps.Clone(incoming) - for _, key := range [...]string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if _, provided := incoming[key]; provided { - continue - } - if value, exists := current[key]; exists { - if normalized == nil { - normalized = make(map[string]any, 2) - } - // Preserve even legacy malformed values: omission must not silently - // turn a fail-closed policy into the enabled default. - normalized[key] = value - } - } - return normalized -} - func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]any) (map[string]any, error) { if platform != PlatformOpenAI { return extra, nil @@ -484,6 +453,11 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an Status: StatusActive, Schedulable: true, } + // A binding given with a new account (data import, duplicate) is stored + // only where a request can use it. + if !AccountTakesSystemPrompt(account) { + delete(account.Extra, AccountExtraSystemPromptKey) + } if err := ValidateModelContextOverrides(account, input.ModelContextOverrides); err != nil { return nil, err } @@ -542,9 +516,6 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { NormalizeAccountCredentialBaseURLs(input.Credentials) - if err := ValidateOpenAIReasoningPolicyExtra(input.Extra); err != nil { - return nil, err - } if err := ValidateOpenAIPromptCacheKeyModeExtra(input.Extra); err != nil { return nil, err } @@ -656,9 +627,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } var normalizedExtra map[string]any if input.Extra != nil { - if err := ValidateOpenAIReasoningPolicyExtra(input.Extra); err != nil { - return nil, err - } if err := ValidateOpenAIPromptCacheKeyModeExtra(input.Extra); err != nil { return nil, err } @@ -673,7 +641,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := ValidateUpstreamRequestIDHeaderExtra(normalizedExtra); err != nil { return nil, err } - normalizedExtra = preserveOmittedOpenAIReasoningPolicyExtra(account.Extra, normalizedExtra) } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) @@ -1025,9 +992,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U // UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 // (如 model_rate_limits / passive_usage_* 等)。 func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { - if err := ValidateOpenAIReasoningPolicyExtra(updates); err != nil { - return err - } updates = sanitizedCodexFingerprintExtraUpdates(updates) updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) @@ -1040,6 +1004,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd delete(updates, UpstreamModelMetadataExtraKey) delete(updates, OpenCodeGoUsageAutoRefreshExtraKey) delete(updates, OpenCodeGoUsageSnapshotExtraKey) + // System prompt bindings are written only by the binding endpoint. + delete(updates, AccountExtraSystemPromptKey) if _, exists := updates[openAILongContextBillingEnabledKey]; exists { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { @@ -1058,9 +1024,6 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd // BulkUpdateAccounts updates multiple accounts in one request. // It merges credentials/extra keys instead of overwriting the whole object. func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { - if err := ValidateOpenAIReasoningPolicyExtra(input.Extra); err != nil { - return nil, err - } if err := ValidateOpenAIPromptCacheKeyModeExtra(input.Extra); err != nil { return nil, err } @@ -1077,6 +1040,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp delete(input.Extra, UpstreamModelMetadataExtraKey) delete(input.Extra, OpenCodeGoUsageAutoRefreshExtraKey) delete(input.Extra, OpenCodeGoUsageSnapshotExtraKey) + // System prompt bindings are written only by the binding endpoint. + delete(input.Extra, AccountExtraSystemPromptKey) if len(input.AccountIDs) == 0 && input.Filters != nil { accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index c616d6737691..859d19d6f280 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -103,6 +103,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { PeakStart: source.PeakStart, PeakEnd: source.PeakEnd, PeakRateMultiplier: source.PeakRateMultiplier, + ProfitControlEnabled: source.ProfitControlEnabled, + ProfitMinMargin: source.ProfitMinMargin, + ProfitSafetyBuffer: source.ProfitSafetyBuffer, IsExclusive: source.IsExclusive, Status: duplicateGroupInactiveStatus, DuplicateOperationID: operationID, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0dd09332361f..d52e1468713b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -304,7 +304,7 @@ type CreateGroupInput struct { MaxReasoningEffortOverLimit string // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []ReasoningEffortMapping - // 分组利润控制(margin/buffer 为小数,nil 按 0 处理) + // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) ProfitControlEnabled bool ProfitMinMargin *float64 ProfitSafetyBuffer *float64 diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 29428ee36633..d87e130205fc 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -225,7 +225,7 @@ func TestAdminServiceSimpleModeNormalizesAllUnsupportedCreateFieldsDirectly(t *t AudioRealtimePricePerMin: &one, ClaudeCodeOnly: true, FallbackGroupID: &fallbackID, ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: true, AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, RequireOAuthOnly: true, - RPMLimit: 99, MaxReasoningEffort: "high", + RPMLimit: 99, MaxReasoningEffort: "high", ProfitControlEnabled: true, ProfitMinMargin: &one, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"claude-*"}}, CopyAccountsFromGroupIDs: []int64{2}, } @@ -264,7 +264,8 @@ func TestAdminServiceSimpleModeNormalizesAllUnsupportedUpdateFieldsDirectly(t *t AudioRealtimePricePerMin: &one, ClaudeCodeOnly: &truth, FallbackGroupID: &fallbackID, ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: &truth, AllowMessagesDispatch: &truth, AllowLive: &truth, ForceOpenAIFast: &truth, RequireOAuthOnly: &truth, - RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), + RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), ProfitControlEnabled: &truth, + ProfitMinMargin: &one, ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"claude-*"}}, CopyAccountsFromGroupIDs: []int64{2}, } diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 2592ced75649..e6f40e1f8d03 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 27 // v27: migration 260 moved retired-platform groups to openai; reload pre-migration snapshots +const apiKeyAuthSnapshotVersion = 28 // v28: group profit control fields carry the stored values again; reload v27 snapshots written with constant zeros type apiKeyAuthCacheConfig struct { l1Size int @@ -432,9 +432,9 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) PeakStart: apiKey.Group.PeakStart, PeakEnd: apiKey.Group.PeakEnd, PeakRateMultiplier: apiKey.Group.PeakRateMultiplier, - ProfitControlEnabled: false, - ProfitMinMargin: 0, - ProfitSafetyBuffer: 0, + ProfitControlEnabled: apiKey.Group.ProfitControlEnabled, + ProfitMinMargin: apiKey.Group.ProfitMinMargin, + ProfitSafetyBuffer: apiKey.Group.ProfitSafetyBuffer, } } return snapshot @@ -534,9 +534,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho PeakStart: snapshot.Group.PeakStart, PeakEnd: snapshot.Group.PeakEnd, PeakRateMultiplier: snapshot.Group.PeakRateMultiplier, - ProfitControlEnabled: false, - ProfitMinMargin: 0, - ProfitSafetyBuffer: 0, + ProfitControlEnabled: snapshot.Group.ProfitControlEnabled, + ProfitMinMargin: snapshot.Group.ProfitMinMargin, + ProfitSafetyBuffer: snapshot.Group.ProfitSafetyBuffer, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index 92dc02f8f43b..6ce7ecfce623 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -1,13 +1,16 @@ package service -// Historical profit-control fields remain in the cache schema for backward -// compatibility, but new snapshots materialize them as dormant zero values. +// 投影漏列回归(service 半程):认证快照 build → L2 JSON 序列化 +// → 反序列化 → 还原 apiKey.Group → 请求 ctx → 利润门解析,全链路保真。 +// repository 半程(真实 GetByKeyForAuth 投影)见 +// internal/repository/api_key_repo_profit_projection_integration_test.go。 import ( "context" "encoding/json" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/stretchr/testify/require" ) @@ -41,7 +44,9 @@ func profitAuthTestAPIKey() *APIKey { } } -func TestAPIKeyAuthSnapshotDormantProfitControlRoundtrip(t *testing.T) { +// 快照构建 → L2 JSON 往返 → 还原 → 装门:利润字段必须全程保真,阈值与 +// 计费同源(0.06 × (1−0.25) = 0.045)。 +func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { svc := &APIKeyService{} apiKey := profitAuthTestAPIKey() @@ -61,10 +66,17 @@ func TestAPIKeyAuthSnapshotDormantProfitControlRoundtrip(t *testing.T) { require.True(t, used) require.NotNil(t, materialized.Group) require.True(t, materialized.Group.Hydrated) - require.False(t, materialized.Group.ProfitControlEnabled) - require.Zero(t, materialized.Group.ProfitMinMargin) - require.Zero(t, materialized.Group.ProfitSafetyBuffer) + require.True(t, materialized.Group.ProfitControlEnabled) + require.InDelta(t, 0.2, materialized.Group.ProfitMinMargin, 1e-12) + require.InDelta(t, 0.05, materialized.Group.ProfitSafetyBuffer, 1e-12) require.InDelta(t, 0.06, materialized.Group.RateMultiplier, 1e-12) + + // 中间件语义:materialized.Group 进请求 ctx → 门必须按快照配置装上。 + ctx := context.WithValue(context.Background(), ctxkey.Group, materialized.Group) + gwSvc := &OpenAIGatewayService{} + gate := gwSvc.resolveOpenAIProfitControlGate(ctx, materialized.GroupID) + require.NotNil(t, gate, "还原后的认证分组必须能装门(投影漏列时本断言最先失败)") + require.InDelta(t, 0.06*(1-0.25), gate.threshold, 1e-12) } // 旧版本快照(v16 及更早,无利润字段保真保证)必须被淘汰回源,不得复用。 diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 4b25872c1af2..4ff97cfd4dec 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -228,33 +228,31 @@ const ( SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组) // 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。 // 默认 false:非白名单域名直接拒绝(白名单严格模式)。 - SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 - SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) - SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 - SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 - SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 - SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) - SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) - SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) - SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) - SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 - SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 - SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) - SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) - SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling - SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) - SettingKeyOpenAIRefusalRecoveryEnabled = "openai_refusal_recovery_enabled" - SettingKeyOpenAICyberFailoverEnabled = "openai_cyber_failover_enabled" - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled = "openai_apikey_alpha_search_responses_bridge_enabled" - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled = "openai_apikey_prompt_cache_key_normalization_enabled" - SettingKeyOpenAIRefusalRewriteEnabled = "openai_refusal_rewrite_enabled" - SettingKeyOpenAIRefusalKeywords = "openai_refusal_keywords" - SettingKeyOpenAIRefusalReplacement = "openai_refusal_replacement" - SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 - SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox - SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) - SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) + SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 + SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 + SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) + SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 + SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 + SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) + SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling + SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) + SettingKeyOpenAIRefusalRecoveryEnabled = "openai_refusal_recovery_enabled" + SettingKeyOpenAICyberFailoverEnabled = "openai_cyber_failover_enabled" + SettingKeyOpenAIRefusalRewriteEnabled = "openai_refusal_rewrite_enabled" + SettingKeyOpenAIRefusalKeywords = "openai_refusal_keywords" + SettingKeyOpenAIRefusalReplacement = "openai_refusal_replacement" + SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 + SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox + SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) + SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPPort = "smtp_port" // SMTP端口 diff --git a/backend/internal/service/gateway_profit_control.go b/backend/internal/service/gateway_profit_control.go index 862e007e90f7..fe8ba1475253 100644 --- a/backend/internal/service/gateway_profit_control.go +++ b/backend/internal/service/gateway_profit_control.go @@ -11,9 +11,6 @@ import ( // token requests. This keeps media, metadata, and models-list paths outside // the profit-control surface by construction. func (s *GatewayService) withGatewayProfitControlGate(ctx context.Context, groupID *int64) context.Context { - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx) - } if _, ok := gatewayTokenRequestPricingAtFromContext(ctx); !ok || groupID == nil || *groupID <= 0 { return ctx } diff --git a/backend/internal/service/gateway_profit_control_v2_test.go b/backend/internal/service/gateway_profit_control_v2_test.go index 53cca6f9e212..c05f4501e167 100644 --- a/backend/internal/service/gateway_profit_control_v2_test.go +++ b/backend/internal/service/gateway_profit_control_v2_test.go @@ -49,7 +49,7 @@ func gatewayProfitTestAccount(id int64, platform string, rate float64, groupID i } } -func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { +func TestGatewayProfitControlInstallsForFivePlatformsOnlyOnTokenRequests(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformAnthropic, @@ -62,20 +62,11 @@ func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { groupID := group.ID svc := &GatewayService{} - base := gatewayProfitTestContext(group) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(base) - require.True(t, ok) - tokenCtx := svc.withGatewayProfitControlGate(base, &groupID) + tokenCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &groupID) gate, _ := tokenCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - actual, ok := gatewayTokenRequestPricingAtFromContext(tokenCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - require.False(t, pricingAt.IsZero()) - require.True(t, group.ProfitControlEnabled) - require.Equal(t, 0.5, group.RateMultiplier) - expensive := gatewayProfitTestAccount(102, platform, 5, groupID) - require.True(t, svc.isGatewayAccountProfitEligible(tokenCtx, &expensive)) + require.NotNil(t, gate) + require.Equal(t, platform, gate.platform) + require.InDelta(t, 0.5, gate.threshold, 1e-12) metadataCtx := context.WithValue(context.Background(), ctxkey.Group, group) metadataCtx = svc.withGatewayProfitControlGate(metadataCtx, &groupID) @@ -85,7 +76,7 @@ func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { } } -func TestGatewayDormantProfitControlPreservesCompositeBilling(t *testing.T) { +func TestGatewayProfitControlCompositeBillingUsesScheduledMemberConfig(t *testing.T) { billingGroup := &Group{ ID: 201, Platform: PlatformComposite, @@ -111,13 +102,11 @@ func TestGatewayDormantProfitControlPreservesCompositeBilling(t *testing.T) { } ctx = svc.withGatewayProfitControlGate(ctx, &memberGroup.ID) gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - actual, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - require.Equal(t, 0.4, billingGroup.RateMultiplier) - require.Equal(t, 99.0, memberGroup.RateMultiplier) - require.Equal(t, 0.25, memberGroup.ProfitMinMargin) + require.NotNil(t, gate) + require.Equal(t, memberGroup.ID, gate.groupID) + require.Equal(t, PlatformAnthropic, gate.platform) + require.Equal(t, pricingAt, gate.pricingAt) + require.InDelta(t, 0.4*(1-0.25), gate.threshold, 1e-12, "D 必须取 composite 计费父分组,margin 取被调度成员分组") } func TestGatewayProfitControlGroupLoadFailureClearsForeignGate(t *testing.T) { @@ -168,7 +157,7 @@ func (profitControlFailingGroupRepo) GetByID(context.Context, int64) (*Group, er panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") } -func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testing.T) { +func TestGatewayProfitControlLegacyMixedAndRoutedSelection(t *testing.T) { t.Run("legacy single-platform selection", func(t *testing.T) { group := gatewayProfitTestGroup(111, PlatformGrok) cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) @@ -187,16 +176,16 @@ func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testin gatewayProfitTestContext(group), &group.ID, "", "", nil, ) require.NoError(t, err) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selected.ID) + require.Equal(t, cheap.ID, selected.ID) - selected, err = svc.SelectAccountForModelWithExclusions( + _, err = svc.SelectAccountForModelWithExclusions( gatewayProfitTestContext(group), &group.ID, "", "", map[int64]struct{}{cheap.ID: {}}, ) - require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID, "explicitly excluded cheap account must not return") + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) }) - t.Run("mixed routing retains its model-routed account", func(t *testing.T) { + t.Run("mixed routing filters the routed account", func(t *testing.T) { group := gatewayProfitTestGroup(112, PlatformAnthropic) group.ModelRoutingEnabled = true group.ModelRouting = map[string][]int64{"claude-test": {2, 1}} @@ -218,16 +207,11 @@ func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testin gatewayProfitTestContext(group), &group.ID, "", "claude-test", nil, ) require.NoError(t, err) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selected.ID) - selected, err = svc.SelectAccountForModelWithExclusions( - gatewayProfitTestContext(group), &group.ID, "", "claude-test", map[int64]struct{}{cheap.ID: {}}, - ) - require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID) + require.Equal(t, cheap.ID, selected.ID) }) } -func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testing.T) { +func TestGatewayProfitControlLoadAwareSelectionAndFailover(t *testing.T) { group := gatewayProfitTestGroup(121, PlatformGrok) cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID) @@ -249,8 +233,7 @@ func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testin ) require.NoError(t, err) require.NotNil(t, result) - require.Contains(t, []int64{cheap.ID, expensive.ID}, result.Account.ID) - require.False(t, result.ProfitGateActive()) + require.Equal(t, cheap.ID, result.Account.ID) if result.ReleaseFunc != nil { result.ReleaseFunc() } @@ -264,16 +247,12 @@ func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testin "", 0, ) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, expensive.ID, result.Account.ID) - require.False(t, result.ProfitGateActive()) - if result.ReleaseFunc != nil { - result.ReleaseFunc() - } + require.Nil(t, result) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) } -func TestGatewayDormantProfitControlKeepsStickyBindingAcrossRateChanges(t *testing.T) { +func TestGatewayProfitControlStickyVetoKeepsBindingUntilRateRecovers(t *testing.T) { group := gatewayProfitTestGroup(131, PlatformAnthropic) expensive := gatewayProfitTestAccount(1, PlatformAnthropic, 0.8, group.ID) cheap := gatewayProfitTestAccount(2, PlatformAnthropic, 0.2, group.ID) @@ -293,14 +272,14 @@ func TestGatewayDormantProfitControlKeepsStickyBindingAcrossRateChanges(t *testi selected, err := svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil) require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID) + require.Equal(t, cheap.ID, selected.ID) require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "候选过滤不得覆盖旧粘性绑定") require.NoError(t, svc.BindStickySessionAfterProfitAdmission( svc.withGatewayProfitControlGate(ctx, &group.ID), &group.ID, "sticky-profit", - selected.ID, + cheap.ID, )) require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "终检通过的 fallback 账号也不得覆盖旧绑定") require.Zero(t, cache.deletedSessions["sticky-profit"]) @@ -409,9 +388,9 @@ func TestGatewayProfitControlTerminalRefreshFailureFallsBackToSelectedObject(t * require.Empty(t, reason) } -// Runtime selection carries no retired gate to the handler, while its pricing -// context and selected account remain unchanged. -func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testing.T) { +// 选号结果携带门:门安装在调度栈局部 ctx 上,handler 必须经 +// ContextWithSelectionProfitGate 重放后终检与准入后绑定才可见(评审修复回归)。 +func TestGatewayProfitControlSelectionCarriesGateToHandlerContext(t *testing.T) { group := gatewayProfitTestGroup(1, PlatformAnthropic) svc := &GatewayService{} expensive := gatewayProfitTestAccount(161, PlatformAnthropic, 0.9, group.ID) @@ -419,22 +398,17 @@ func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testi gateCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &group.ID) selection, err := svc.newSelectionResult(gateCtx, &expensive, true, nil, nil) require.NoError(t, err) - require.False(t, selection.ProfitGateActive()) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(gateCtx) - require.True(t, ok) + require.True(t, selection.ProfitGateActive(), "选号结果必须携带调度栈内生效的门") // 修复前的缺陷形态:handler 原始 ctx 不含门,终检退化为空操作。 _, vetoed, _ := svc.GatewayProfitControlVetoLatest(context.Background(), &expensive) require.False(t, vetoed, "对照组:不重放门时终检确实看不到门") - handlerCtx := ContextWithSelectionProfitGate(gateCtx, selection) + handlerCtx := ContextWithSelectionProfitGate(context.Background(), selection) latest, vetoed, reason := svc.GatewayProfitControlVetoLatest(handlerCtx, &expensive) - require.False(t, vetoed) - require.Empty(t, reason) - require.Same(t, &expensive, latest) - actual, ok := gatewayTokenRequestPricingAtFromContext(handlerCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) + require.True(t, vetoed, "重放门后终检必须真实生效") + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + require.NotNil(t, latest) // 无门选号不携带门,重放为无操作。 plain, err := svc.newSelectionResult(context.Background(), &expensive, true, nil, nil) @@ -443,23 +417,18 @@ func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testi require.Equal(t, context.Background(), ContextWithSelectionProfitGate(context.Background(), plain)) } -// Image intent is independent of retired profit admission and must not erase -// the existing token-pricing context. -func TestGatewayDormantProfitControlImageIntentKeepsTokenPricing(t *testing.T) { +// 生图意图不关门(H1/H2 回归锚点):/v1/responses 混合请求即使带生图声明, +// token 定价上下文照常装配,共享门照常安装并否决越线账号。 +func TestGatewayProfitControlImageIntentDoesNotDisableGate(t *testing.T) { group := gatewayProfitTestGroup(2, PlatformAnthropic) svc := &GatewayService{} expensive := gatewayProfitTestAccount(162, PlatformAnthropic, 0.9, group.ID) ctx := gatewayProfitTestContext(group) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) ctx = WithOpenAIImageGenerationIntent(ctx) gateCtx := svc.withGatewayProfitControlGate(ctx, &group.ID) - require.True(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive)) - require.False(t, gatewayProfitControlGateActive(gateCtx)) - actual, ok := gatewayTokenRequestPricingAtFromContext(gateCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) + require.False(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive), + "请求体里的生图声明(含被动 image_gen namespace)不得关闭利润门") } // 无门时准入后绑定回退官方 eager 语义;门下读失败保守不写(评审 M-Bind 回归)。 diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 4798308e8113..5d497bd118f7 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -2424,6 +2424,8 @@ type selectionFailureStats struct { PlatformFiltered int ModelUnsupported int ModelRateLimited int + ProfitThreshold int + ProfitInvalidRate int SamplePlatformIDs []int64 SampleMappingIDs []int64 SampleRateLimitIDs []string @@ -2447,7 +2449,7 @@ func (s *GatewayService) logDetailedSelectionFailure( stats := s.collectSelectionFailureStats(ctx, accounts, requestedModel, platform, excludedIDs, allowMixedScheduling) logger.LegacyPrintf( "service.gateway", - "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", + "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", derefGroupID(groupID), requestedModel, platform, @@ -2459,6 +2461,8 @@ func (s *GatewayService) logDetailedSelectionFailure( stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, stats.SamplePlatformIDs, stats.SampleMappingIDs, stats.SampleRateLimitIDs, @@ -2496,6 +2500,10 @@ func (s *GatewayService) collectSelectionFailureStats( stats.ModelRateLimited++ remaining := acc.GetRateLimitRemainingTimeWithContext(ctx, requestedModel).Truncate(time.Second) stats.SampleRateLimitIDs = appendSelectionFailureRateSample(stats.SampleRateLimitIDs, acc.ID, remaining) + case openAIProfitFilterReasonThreshold: + stats.ProfitThreshold++ + case openAIProfitFilterReasonInvalidAccountRate: + stats.ProfitInvalidRate++ default: stats.Eligible++ } @@ -2580,7 +2588,7 @@ func appendSelectionFailureRateSample(samples []string, accountID int64, remaini func summarizeSelectionFailureStats(stats selectionFailureStats) string { return fmt.Sprintf( - "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d", + "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d", stats.Total, stats.Eligible, stats.Excluded, @@ -2588,6 +2596,8 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string { stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, ) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9c54a5f86d41..68f39ca5d5c0 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -617,8 +617,9 @@ type AccountSelectionResult struct { runtimeBreakerProbeEnded atomic.Bool // stickySessionHit 标记账号来自会话粘性绑定命中,供非高级调度路径回填决策标签。 stickySessionHit bool - // profitGate carries the gate applied during selection so post-acquire - // eligibility checks use the same pricing decision. + // profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的 + // 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在 + // 调度栈之外做抢槽后终检与准入后粘性绑定。 profitGate *openAIProfitControlGate } @@ -629,7 +630,7 @@ func (r *AccountSelectionResult) HasOpenAIRuntimeBreakerProbe() bool { (strings.TrimSpace(r.runtimeBreakerProbeOwner) != "" && len(r.runtimeBreakerProbeModels) > 0)) } -// ProfitGateActive reports whether this selection was made under profit control. +// ProfitGateActive 报告本次选号是否处于利润门之下。 func (r *AccountSelectionResult) ProfitGateActive() bool { return r != nil && r.profitGate != nil } @@ -1023,6 +1024,10 @@ func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL) } +// bindGatewayStickySessionDuringSelection preserves the normal eager sticky +// behavior unless a profit gate is installed. Profit-controlled requests bind +// only after the terminal post-slot check, otherwise a rejected candidate could +// overwrite a healthy pre-existing sticky binding. func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { if gatewayProfitControlGateActive(ctx) { return nil @@ -1030,8 +1035,12 @@ func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Con return s.BindStickySession(ctx, groupID, sessionHash, accountID) } -// BindStickySessionAfterProfitAdmission preserves an existing different -// binding while a profit gate is active and binds only the admitted account. +// BindStickySessionAfterProfitAdmission records a terminally admitted +// account. Without a profit gate it preserves the pre-existing eager binding +// behavior at the handler bind points. With a gate it never replaces a +// different binding that already exists: a temporarily ineligible sticky +// account remains bound and automatically becomes eligible again if its +// account rate recovers. func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { if sessionHash == "" || accountID <= 0 || s.cache == nil { return nil @@ -1041,6 +1050,7 @@ func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Conte } existingAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash) if err != nil && !errors.Is(err, ErrStickySessionNotFound) { + // 读失败时无法判断既有绑定,保守跳过而不是冒着覆盖健康绑定的风险写入。 slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err) return nil } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 6d587f16c58b..bb6804a82307 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -400,7 +400,15 @@ func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (t return } +// validProfitControlRatio 判定 margin/buffer 是否为可落库的合法小数:[0,1) 且非 NaN/Inf。 +func validProfitControlRatio(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1 +} + // NormalizeGroupPlatform 把创建分组时省略的 platform 归一化为默认平台。 +// handler 的入参预校验必须与 CreateGroup 落库时用同一个归一化结果,否则 +// 「省略 platform + 启用利润控制」会被 handler 以「平台不支持」400 掉, +// 而该分组本会被建成受支持的 anthropic 分组。 func NormalizeGroupPlatform(platform string) string { if platform == "" { return PlatformAnthropic @@ -408,11 +416,6 @@ func NormalizeGroupPlatform(platform string) string { return platform } -// validProfitControlRatio accepts finite ratios in [0, 1). -func validProfitControlRatio(v float64) bool { - return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1 -} - // ValidateProfitControlConfig 是分组利润控制配置的唯一校验来源,handler 与 service 层共用。 // enabled=true 时仅允许五个可计费平台分组;margin/buffer 各自 ∈ [0,1),且 margin+buffer < 1 // (相加 >=1 时阈值 <=0,所有可核价账号都会被排除,视为配置错误而不是静默全黑)。 diff --git a/backend/internal/service/group_profit_platform_test.go b/backend/internal/service/group_profit_platform_test.go new file mode 100644 index 000000000000..28a5f2290a7e --- /dev/null +++ b/backend/internal/service/group_profit_platform_test.go @@ -0,0 +1,20 @@ +package service + +import "testing" + +// TestNormalizeGroupPlatformDefaultsToAnthropic 钉死创建分组时省略 platform 的 +// 归一化结果:handler 的利润控制预校验与 CreateGroup 落库共用这个函数, +// 两边一旦漂移,「省略 platform + profit_control_enabled=true」的创建请求 +// 会被 handler 以「平台不支持」400 掉,而该分组本会建成受支持的 anthropic。 +func TestNormalizeGroupPlatformDefaultsToAnthropic(t *testing.T) { + if got := NormalizeGroupPlatform(""); got != PlatformAnthropic { + t.Fatalf("空 platform 应归一化为 %s,got %s", PlatformAnthropic, got) + } + if got := NormalizeGroupPlatform(PlatformOpenAI); got != PlatformOpenAI { + t.Fatalf("显式 platform 不应被改写,got %s", got) + } + // 归一化后的默认平台必须真的支持利润控制,否则预校验仍会误拒。 + if err := ValidateProfitControlConfig(NormalizeGroupPlatform(""), true, 0.3, 0.05); err != nil { + t.Fatalf("省略 platform 且启用利润控制不应被预校验拒绝: %v", err) + } +} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index a96cb833f4ce..0f6337babc58 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1890,6 +1890,8 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if !accountSupportsOpenAICapabilities(ctx, account, requestedModel, req.RequiredCapability, req.RequiredImageCapability) { return false, "capability_mismatch" } + // 分组利润控制:不合格账号在候选过滤与抢槽后终检阶段即被排除, + // 排序/评分/粘性/熔断只在合格账号之间工作;named reason 进入 filter stats。 if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed { return false, reason } diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index cb5ed55e9da0..b677fe665d86 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -193,7 +193,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Contains(t, gjson.GetBytes(wireBody, "input.0.content.0.text").String(), `"search_query"`) } -func TestForwardAlphaSearchOrdinaryAPIKeyIgnoresLegacyBridgeSettings(t *testing.T) { +func TestForwardAlphaSearchOrdinaryAPIKeyUsesDirectAlphaSearchEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"id":"search-session","model":"gpt-5.6-luna","commands":{"search_query":[{"q":"news"}]}}`) recorder := httptest.NewRecorder() @@ -206,12 +206,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyIgnoresLegacyBridgeSettings(t *testing. Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 47, Platform: PlatformOpenAI, @@ -243,12 +238,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyToolErrorUsesOfficialFailurePath(t *tes Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(upstreamBody)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 49, Platform: PlatformOpenAI, @@ -279,12 +269,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyGenericBadRequestDoesNotFanOut(t *testi Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(upstreamBody)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 50, Platform: PlatformOpenAI, @@ -317,12 +302,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyTrustsOfficialAlphaSearchSuccess(t *tes Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(textOnlySSE)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 51, Platform: PlatformOpenAI, diff --git a/backend/internal/service/openai_bulk_account_settings.go b/backend/internal/service/openai_bulk_account_settings.go index b235febc95b7..60ebf9a9c50e 100644 --- a/backend/internal/service/openai_bulk_account_settings.go +++ b/backend/internal/service/openai_bulk_account_settings.go @@ -12,13 +12,12 @@ type bulkOpenAISettings struct { longContextBilling bool endpointCapabilities bool responsesMode bool - reasoningPolicies bool capabilitiesIncludeChat bool forcedResponsesMode bool } func (s bulkOpenAISettings) any() bool { - return s.longContextBilling || s.endpointCapabilities || s.responsesMode || s.reasoningPolicies + return s.longContextBilling || s.endpointCapabilities || s.responsesMode } func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISettings, error) { @@ -26,7 +25,6 @@ func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISett if input == nil { return settings, nil } - settings.reasoningPolicies = HasOpenAIReasoningPolicyUpdates(input.Extra) if _, exists := input.Extra[openAILongContextBillingEnabledKey]; exists { settings.longContextBilling = true @@ -168,11 +166,6 @@ func validateBulkOpenAISettingsTargets( if !ok || account == nil { return 0, invalidBulkOpenAITarget(accountID, "account does not exist") } - if settings.reasoningPolicies { - if err := validateOpenAIReasoningPolicyTarget(accountID, account); err != nil { - return 0, err - } - } if settings.longContextBilling { if account.Platform != PlatformOpenAI || !supportsOpenAILongContextBilling(account.Type) { @@ -214,44 +207,6 @@ func supportsOpenAILongContextBilling(accountType string) bool { } } -// HasOpenAIReasoningPolicyUpdates distinguishes explicit policy changes from -// unrelated extra patches; the values are validated at the admin input boundary. -func HasOpenAIReasoningPolicyUpdates(extra map[string]any) bool { - for _, key := range [...]string{OpenAIChatReasoningReplayEnabledExtraKey, OpenAIReasoningSignatureRecoveryEnabledExtraKey} { - if _, provided := extra[key]; provided { - return true - } - } - return false -} - -// ValidateOpenAIReasoningPolicyTargets allows callers to validate and freeze a -// complete bulk target set before an asynchronous job splits it into items. -func ValidateOpenAIReasoningPolicyTargets(ids []int64, accounts []*Account) error { - byID := make(map[int64]*Account, len(accounts)) - for _, account := range accounts { - if account != nil { - byID[account.ID] = account - } - } - for _, id := range ids { - if err := validateOpenAIReasoningPolicyTarget(id, byID[id]); err != nil { - return err - } - } - return nil -} - -func validateOpenAIReasoningPolicyTarget(id int64, account *Account) error { - if account == nil { - return invalidBulkOpenAITarget(id, "account does not exist") - } - if !account.supportsOpenAIReasoningPolicies() { - return invalidBulkOpenAITarget(id, "reasoning policies require an OpenAI-wire OAuth, setup-token, or API-key account") - } - return nil -} - func invalidBulkOpenAITarget(accountID int64, message string) error { return infraerrors.BadRequest( "OPENAI_BULK_TARGET_INVALID", diff --git a/backend/internal/service/openai_chat_reasoning_replay.go b/backend/internal/service/openai_chat_reasoning_replay.go deleted file mode 100644 index f811a0570f03..000000000000 --- a/backend/internal/service/openai_chat_reasoning_replay.go +++ /dev/null @@ -1,718 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "slices" - "strings" - - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -const openAIChatReasoningReplayContextKey = "openai_chat_reasoning_replay" - -// This is a cache of completed, reversible tool batches, not a conversation -// archive. Prefixes, ownership and source identity are retained only as hashes. -type openAIChatReasoningReplay struct { - ctx context.Context - c *gin.Context - store OpenAIReasoningStateStore - budget *OpenAIReasoningCacheBudget - scope OpenAIReasoningCacheScope - rules extensionv1.ReplayRules - projectedPrefix string - actualPrefix string - replayed []openAIChatReasoningReplayHit - recorder openAIChatReasoningReplayRecorder -} - -type openAIChatReasoningReplayHit struct { - key string - payloadHash string - ciphers map[string]struct{} -} - -type openAIChatReasoningProjection struct { - Content string `json:"content"` - Reasoning string `json:"reasoning_content"` - Calls []openAIChatReasoningProjectionCall `json:"tool_calls"` -} - -type openAIChatReasoningProjectionCall struct { - ID string `json:"id"` - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// normalizeOpenAIChatReasoningProjection is deliberately narrower than the -// general compatibility converter. If a client changes a semantic field that -// Chat cannot round-trip, a cache miss is safer than replacing that field. -func normalizeOpenAIChatReasoningProjection(message apicompat.ChatMessage, rules extensionv1.ReplayRules) (json.RawMessage, bool) { - if !rules.Enabled || message.Role != "assistant" || message.Name != "" || message.ToolCallID != "" || message.FunctionCall != nil || len(message.ToolCalls) == 0 || len(message.ToolCalls) > rules.MaxToolCalls { - return nil, false - } - projection := openAIChatReasoningProjection{Reasoning: message.ReasoningContent} - if message.Reasoning != "" { - if projection.Reasoning != "" && projection.Reasoning != message.Reasoning { - return nil, false - } - projection.Reasoning = message.Reasoning - } - if len(message.Content) > 0 && string(bytes.TrimSpace(message.Content)) != "null" { - if err := json.Unmarshal(message.Content, &projection.Content); err != nil { - var parts []map[string]json.RawMessage - if err := json.Unmarshal(message.Content, &parts); err != nil { - return nil, false - } - for _, part := range parts { - var kind, text string - if len(part) != 2 || json.Unmarshal(part["type"], &kind) != nil || kind != "text" || json.Unmarshal(part["text"], &text) != nil { - return nil, false - } - projection.Content += text - } - } - } - seen := make(map[string]struct{}, len(message.ToolCalls)) - for _, call := range message.ToolCalls { - if call.Type != "function" || call.ID == "" || call.Function.Name == "" || !json.Valid([]byte(call.Function.Arguments)) { - return nil, false - } - if _, exists := seen[call.ID]; exists { - return nil, false - } - seen[call.ID] = struct{}{} - projection.Calls = append(projection.Calls, openAIChatReasoningProjectionCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments}) - } - raw, err := json.Marshal(projection) - return raw, err == nil -} - -func openAIChatReasoningRawMessageSupported(raw json.RawMessage, rules extensionv1.ReplayRules) bool { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) != nil { - return false - } - for name, value := range fields { - if !slices.Contains(rules.ChatFields, name) || (name == "refusal" && string(bytes.TrimSpace(value)) != "null") { - return false - } - } - var calls []map[string]json.RawMessage - if json.Unmarshal(fields["tool_calls"], &calls) != nil { - return false - } - for _, call := range calls { - if len(call) != 3 { - return false - } - for key := range call { - if key != "id" && key != "type" && key != "function" { - return false - } - } - var function map[string]json.RawMessage - if json.Unmarshal(call["function"], &function) != nil || len(function) != 2 || function["name"] == nil || function["arguments"] == nil { - return false - } - } - return true -} - -func openAIChatReasoningHash(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -func openAIChatReasoningCanonical(raw json.RawMessage) ([]byte, error) { - // Duplicate members have no portable wire meaning. Disable replay for an - // ambiguous history instead of collapsing distinct inputs onto one key. - return canonicalReasoningCacheJSON(raw) -} - -// The prefix is the exact Responses history and instruction/tool contract, not -// the gateway's prompt-cache key (which is only a coarse scheduling hint). -// Tool choice, streaming and per-turn output limits intentionally do not bind a -// previous completed batch: callers normally release a forced tool on round 2. -func openAIChatReasoningPrefixHash(body []byte, input []json.RawMessage) (string, error) { - var envelope map[string]json.RawMessage - if err := json.Unmarshal(body, &envelope); err != nil { - return "", err - } - stable := make(map[string]json.RawMessage) - for _, name := range []string{"instructions", "tools", "parallel_tool_calls", "text", "previous_response_id", "conversation"} { - if value, ok := envelope[name]; ok { - stable[name] = value - } - } - inputJSON, err := json.Marshal(input) - if err != nil { - return "", err - } - stable["input"] = inputJSON - encoded, err := json.Marshal(stable) - if err != nil { - return "", err - } - canonical, err := openAIChatReasoningCanonical(encoded) - if err != nil { - return "", err - } - return openAIChatReasoningHash(canonical), nil -} - -func openAIChatReasoningBatchKey(prefix string, projection json.RawMessage, rules extensionv1.ReplayRules) string { - var visible openAIChatReasoningProjection - if json.Unmarshal(projection, &visible) != nil { - return "" - } - // reasoning_content is a nonstandard Chat extension. A normal SDK may - // omit it on replay; completing precisely that lost state is this cache's - // purpose. Visible assistant content and tool identity are always required. - if rules.AllowOmittedReasoning { - visible.Reasoning = "" - } - policy, _ := json.Marshal(rules) - canonical, _ := json.Marshal(visible) - return openAIChatReasoningHash(append([]byte("chat-replay-v1:"+openAIChatReasoningHash(policy)+":"+prefix+":"), canonical...)) -} - -func openAIChatReasoningProjectionMatches(stored, incoming json.RawMessage, rules extensionv1.ReplayRules) bool { - var original, client openAIChatReasoningProjection - if json.Unmarshal(stored, &original) != nil || json.Unmarshal(incoming, &client) != nil { - return false - } - if (!rules.AllowOmittedReasoning || client.Reasoning != "") && client.Reasoning != original.Reasoning { - return false - } - original.Reasoning, client.Reasoning = "", "" - left, _ := json.Marshal(original) - right, _ := json.Marshal(client) - return bytes.Equal(left, right) -} - -func openAIChatReasoningInput(body []byte) ([]json.RawMessage, bool) { - input := gjson.GetBytes(body, "input") - if !input.IsArray() { - return nil, false - } - var items []json.RawMessage - if json.Unmarshal([]byte(input.Raw), &items) != nil { - return nil, false - } - return items, true -} - -func openAIChatReasoningFindProjection(input, projection []json.RawMessage) (int, bool) { - if len(projection) == 0 || len(projection) > len(input) { - return 0, false - } - wanted := make([][]byte, len(projection)) - for i := range projection { - var err error - wanted[i], err = openAIChatReasoningCanonical(projection[i]) - if err != nil { - return 0, false - } - } - match := -1 - for start := 0; start+len(projection) <= len(input); start++ { - equal := true - for i := range projection { - actual, err := openAIChatReasoningCanonical(input[start+i]) - if err != nil || !bytes.Equal(actual, wanted[i]) { - equal = false - break - } - } - if equal { - if match >= 0 { // Reused tool identity is not an unambiguous batch. - return 0, false - } - match = start - } - } - return match, match >= 0 -} - -func openAIChatReasoningToolResultsComplete(messages []apicompat.ChatMessage, index int) bool { - pending := make(map[string]struct{}, len(messages[index].ToolCalls)) - for _, call := range messages[index].ToolCalls { - pending[call.ID] = struct{}{} - } - for i := index + 1; i < len(messages) && messages[i].Role == "tool"; i++ { - if _, ok := pending[messages[i].ToolCallID]; !ok { - return false - } - delete(pending, messages[i].ToolCallID) - } - return len(pending) == 0 -} - -type openAIChatReasoningReplayCandidate struct { - key string - start int - length int - projection json.RawMessage - canonical []json.RawMessage -} - -func (s *OpenAIGatewayService) prepareOpenAIChatReasoningReplay(ctx context.Context, c *gin.Context, account *Account, request *http.Request, chatBody, wireBody []byte, enabled bool) (*openAIChatReasoningReplay, []byte) { - if c != nil { - c.Set(openAIChatReasoningReplayContextKey, (*openAIChatReasoningReplay)(nil)) - c.Set("openai_chat_reasoning_replay_hits", 0) - c.Set("openai_chat_reasoning_replay_stored", false) - } - if !enabled || account == nil || !account.IsOpenAIChatReasoningReplayEnabled() || ctx.Err() != nil { - return nil, wireBody - } - rules, policyErr := readOpenAIReplayRules(ctx) - if policyErr != nil || !rules.Enabled { - return nil, wireBody - } - store := s.openAIReasoningStateStore() - if store == nil { - return nil, wireBody - } - scope, err := buildOpenAIReasoningScope(c, account, request, wireBody) - if err != nil { - return nil, wireBody - } - input, ok := openAIChatReasoningInput(wireBody) - if !ok { - return nil, wireBody - } - replay := &openAIChatReasoningReplay{ctx: ctx, c: c, store: store, budget: openAIReasoningCacheBudgetForRequest(c), scope: scope, rules: rules} - var requestMessages struct { - Messages []json.RawMessage `json:"messages"` - } - var chatRequest apicompat.ChatCompletionsRequest - if json.Unmarshal(chatBody, &requestMessages) != nil || json.Unmarshal(chatBody, &chatRequest) != nil || len(requestMessages.Messages) != len(chatRequest.Messages) { - return nil, wireBody - } - var candidates []openAIChatReasoningReplayCandidate - var keys []string - for index, message := range chatRequest.Messages { - if len(candidates) == OpenAIReasoningStateMaxLookupEntries { - break - } - projection, supported := normalizeOpenAIChatReasoningProjection(message, rules) - if !supported || !openAIChatReasoningRawMessageSupported(requestMessages.Messages[index], rules) || !openAIChatReasoningToolResultsComplete(chatRequest.Messages, index) { - continue - } - items, err := apicompat.ChatMessageResponsesInput(message) - if err != nil { - continue - } - start, unique := openAIChatReasoningFindProjection(input, items) - if !unique || (len(candidates) > 0 && start < candidates[len(candidates)-1].start+candidates[len(candidates)-1].length) { - continue - } - withoutReasoning := message - withoutReasoning.ReasoningContent, withoutReasoning.Reasoning = "", "" - canonical, err := apicompat.ChatMessageResponsesInput(withoutReasoning) - if err != nil { - continue - } - candidates = append(candidates, openAIChatReasoningReplayCandidate{start: start, length: len(items), projection: projection, canonical: canonical}) - } - // Normalize optional reasoning only in the hash projection; the real input - // is never modified on a miss. This keeps keys stable across ordinary Chat - // SDKs that omit reasoning_content and extended clients that return it. - projected := make([]json.RawMessage, 0, len(input)) - previousEnd := 0 - for i := range candidates { - candidate := &candidates[i] - projected = append(projected, input[previousEnd:candidate.start]...) - prefix, hashErr := openAIChatReasoningPrefixHash(wireBody, projected) - if hashErr != nil { - return nil, wireBody - } - candidate.key = openAIChatReasoningBatchKey(prefix, candidate.projection, rules) - keys = append(keys, candidate.key) - projected = append(projected, candidate.canonical...) - previousEnd = candidate.start + candidate.length - } - projected = append(projected, input[previousEnd:]...) - replay.projectedPrefix, err = openAIChatReasoningPrefixHash(wireBody, projected) - if err != nil { - return nil, wireBody - } - if len(keys) > 0 { - var batches map[string]OpenAIReasoningBatch - err = replay.budget.Do(ctx, func(ioCtx context.Context) error { - var readErr error - batches, readErr = store.GetOpenAIReasoningBatches(ioCtx, scope, keys) - return readErr - }) - if err == nil { - // Every lookup is batched. Replacements remain ordered so a missing - // earlier batch cannot grant authority to a later opaque prefix. - offset := 0 - for _, candidate := range candidates { - batch, found := batches[candidate.key] - if !found || !openAIChatReasoningProjectionMatches(batch.Projection, candidate.projection, rules) { - continue - } - actualStart := candidate.start + offset - prefix, hashErr := openAIChatReasoningPrefixHash(wireBody, input[:actualStart]) - projection, valid := projectOpenAIChatReasoningRawBatch(batch.Output, rules) - if hashErr != nil || prefix != batch.InputPrefixHash || !valid || !bytes.Equal(projection, batch.Projection) { - continue - } - next := make([]json.RawMessage, 0, len(input)-candidate.length+len(batch.Output)) - next = append(next, input[:actualStart]...) - next = append(next, batch.Output...) - next = append(next, input[actualStart+candidate.length:]...) - input = next - offset += len(batch.Output) - candidate.length - replay.replayed = append(replay.replayed, openAIChatReasoningReplayHit{key: candidate.key, payloadHash: batch.PayloadHash, ciphers: openAIChatReasoningCipherHashes(batch.Output)}) - } - } - } - if len(replay.replayed) > 0 { - encoded, marshalErr := json.Marshal(input) - if marshalErr != nil { - return nil, wireBody - } - rewritten, rewriteErr := sjson.SetRawBytes(wireBody, "input", encoded) - if rewriteErr != nil { - return nil, wireBody - } - wireBody = rewritten - } - if c != nil { - c.Set(openAIChatReasoningReplayContextKey, replay) - c.Set("openai_chat_reasoning_replay_hits", len(replay.replayed)) - } - return replay, wireBody -} - -// SetSentBody runs after both the replay and negative-cache preparation so the -// next batch binds to what actually reached the transport, including a visible -// lossy recovery on this request. It does not retain any history plaintext. -func (r *openAIChatReasoningReplay) SetSentBody(body []byte) { - if r == nil { - return - } - r.recorder = openAIChatReasoningReplayRecorder{rules: r.rules} - r.actualPrefix = "" - if input, ok := openAIChatReasoningInput(body); ok { - r.actualPrefix, _ = openAIChatReasoningPrefixHash(body, input) - } -} - -func (r *openAIChatReasoningReplay) InvalidateRejected(cipherHashes []string) { - if r == nil || len(cipherHashes) == 0 { - return - } - for _, hit := range r.replayed { - matches := false - for _, hash := range cipherHashes { - if _, found := hit.ciphers[hash]; found { - matches = true - break - } - } - if matches { - _ = r.budget.Do(r.ctx, func(ioCtx context.Context) error { - _, err := r.store.DeleteOpenAIReasoningBatchIfMatch(ioCtx, r.scope, hit.key, hit.payloadHash) - return err - }) - } - } -} - -func (r *openAIChatReasoningReplay) Commit() { - if r == nil || r.actualPrefix == "" || r.ctx.Err() != nil || (r.c != nil && r.c.Request != nil && r.c.Request.Context().Err() != nil) { - return - } - batch, ok := r.recorder.Batch() - if !ok { - return - } - if current, err := readOpenAIReplayRules(r.ctx); err != nil || !current.Enabled { - return - } - batch.InputPrefixHash = r.actualPrefix - key := openAIChatReasoningBatchKey(r.projectedPrefix, batch.Projection, r.rules) - stored := false - err := r.budget.Do(r.ctx, func(ioCtx context.Context) error { - var writeErr error - stored, writeErr = r.store.PutOpenAIReasoningBatch(ioCtx, r.scope, key, batch) - return writeErr - }) - if r.c != nil { - r.c.Set("openai_chat_reasoning_replay_stored", err == nil && stored) - } -} - -func openAIChatReasoningReplayFromContext(c *gin.Context) *openAIChatReasoningReplay { - if c == nil { - return nil - } - value, _ := c.Get(openAIChatReasoningReplayContextKey) - replay, _ := value.(*openAIChatReasoningReplay) - return replay -} - -func openAIChatReasoningRecoveryFromContext(c *gin.Context) *openAIReasoningRecoveryState { - if c == nil { - return nil - } - value, _ := c.Get(openAIReasoningRecoveryContextKey) - recovery, _ := value.(*openAIReasoningRecoveryState) - return recovery -} - -func observeOpenAIChatReasoningReplayPayload(c *gin.Context, payload []byte) { - if replay := openAIChatReasoningReplayFromContext(c); replay != nil { - replay.recorder.ObservePayload(payload) - } -} - -func openAIChatReasoningCipherHashes(output []json.RawMessage) map[string]struct{} { - hashes := make(map[string]struct{}) - for _, item := range output { - if gjson.GetBytes(item, "type").String() == "reasoning" { - cipher := gjson.GetBytes(item, "encrypted_content") - if cipher.Type == gjson.String && cipher.String() != "" { - hashes[openAIChatReasoningHash([]byte(cipher.String()))] = struct{}{} - } - } - } - return hashes -} - -// Only known function batches are reversible. Unknown fields on known items -// are retained verbatim, but unknown item/content kinds are never truncated. -func projectOpenAIChatReasoningRawBatch(output []json.RawMessage, rules extensionv1.ReplayRules) (json.RawMessage, bool) { - if len(output) == 0 || len(output) > 64 { - return nil, false - } - message := apicompat.ChatMessage{Role: "assistant"} - var text strings.Builder - var reasoning strings.Builder - totalBytes := 0 - hasCipher := false - for _, raw := range output { - totalBytes += len(raw) - if totalBytes > 256*1024 || !gjson.ValidBytes(raw) || !gjson.ParseBytes(raw).IsObject() { - return nil, false - } - if _, err := canonicalReasoningCacheJSON(raw); err != nil { - return nil, false - } - if status := gjson.GetBytes(raw, "status"); status.Exists() && status.String() != "completed" { - return nil, false - } - if !slices.Contains(rules.OutputKinds, gjson.GetBytes(raw, "type").String()) { - return nil, false - } - switch gjson.GetBytes(raw, "type").String() { - case "reasoning": - summary := gjson.GetBytes(raw, "summary") - if !summary.IsArray() { - return nil, false - } - for _, part := range summary.Array() { - if part.Get("type").String() != "summary_text" || part.Get("text").Type != gjson.String { - return nil, false - } - _, _ = reasoning.WriteString(part.Get("text").String()) - } - cipher := gjson.GetBytes(raw, "encrypted_content") - if cipher.Exists() && cipher.Type != gjson.Null && cipher.Type != gjson.String { - return nil, false - } - hasCipher = hasCipher || cipher.String() != "" - case "message": - if gjson.GetBytes(raw, "role").String() != "assistant" || !gjson.GetBytes(raw, "content").IsArray() { - return nil, false - } - for _, part := range gjson.GetBytes(raw, "content").Array() { - if part.Get("type").String() != "output_text" || part.Get("text").Type != gjson.String { - return nil, false - } - _, _ = text.WriteString(part.Get("text").String()) - } - case "function_call": - if namespace := gjson.GetBytes(raw, "namespace"); namespace.Exists() && namespace.Type != gjson.Null && namespace.String() != "" { - return nil, false - } - args := gjson.GetBytes(raw, "arguments") - if args.Type != gjson.String || gjson.GetBytes(raw, "call_id").Type != gjson.String || gjson.GetBytes(raw, "name").Type != gjson.String { - return nil, false - } - message.ToolCalls = append(message.ToolCalls, apicompat.ChatToolCall{ID: gjson.GetBytes(raw, "call_id").String(), Type: "function", Function: apicompat.ChatFunctionCall{Name: gjson.GetBytes(raw, "name").String(), Arguments: args.String()}}) - default: - return nil, false - } - } - if !hasCipher { - return nil, false - } - message.Content, _ = json.Marshal(text.String()) - message.ReasoningContent = reasoning.String() - return normalizeOpenAIChatReasoningProjection(message, rules) -} - -type openAIChatReasoningReplayRecorder struct { - rules extensionv1.ReplayRules - output []json.RawMessage - projection openAIChatReasoningProjection - completed bool - invalid bool - finished bool - bytes int -} - -func (r *openAIChatReasoningReplayRecorder) ObservePayload(payload []byte) { - if r == nil || r.invalid || r.completed { - return - } - kind := gjson.GetBytes(payload, "type").String() - if kind == "response.failed" || kind == "response.incomplete" || kind == "error" { - r.invalid = true - return - } - if kind != "response.completed" && kind != "response.done" { - return - } - response := gjson.GetBytes(payload, "response") - if response.Get("status").String() != "completed" || (response.Get("error").Exists() && response.Get("error").Type != gjson.Null) || (response.Get("incomplete_details").Exists() && response.Get("incomplete_details").Type != gjson.Null) || !response.Get("output").IsArray() { - r.invalid = true - return - } - // No reconstruction from partial done items: this path requires a complete - // authoritative terminal output array and a matching downstream projection. - outputRaw := []byte(response.Get("output").Raw) - if len(outputRaw) > 256*1024 || json.Unmarshal(outputRaw, &r.output) != nil { - r.invalid = true - return - } - if _, valid := projectOpenAIChatReasoningRawBatch(r.output, r.rules); !valid { - r.invalid = true - return - } - r.completed = true -} - -func (r *openAIChatReasoningReplayRecorder) ObserveMessage(message apicompat.ChatMessage) { - if r == nil || r.invalid { - return - } - projection, ok := normalizeOpenAIChatReasoningProjection(message, r.rules) - if !ok || json.Unmarshal(projection, &r.projection) != nil { - r.invalid = true - return - } - r.finished = true -} - -func (r *openAIChatReasoningReplayRecorder) ObserveChunks(chunks []apicompat.ChatCompletionsChunk) { - if r == nil || r.invalid { - return - } - for _, chunk := range chunks { - for _, choice := range chunk.Choices { - if choice.Index != 0 || (choice.Delta.Role != "" && choice.Delta.Role != "assistant") || choice.Delta.Reasoning != nil { - r.invalid = true - return - } - if choice.Delta.Content != nil { - r.projection.Content += *choice.Delta.Content - r.bytes += len(*choice.Delta.Content) - } - if choice.Delta.ReasoningContent != nil { - r.projection.Reasoning += *choice.Delta.ReasoningContent - r.bytes += len(*choice.Delta.ReasoningContent) - } - for _, call := range choice.Delta.ToolCalls { - if call.Index == nil || *call.Index < 0 || *call.Index >= 32 || *call.Index > len(r.projection.Calls) { - r.invalid = true - return - } - if *call.Index == len(r.projection.Calls) { - r.projection.Calls = append(r.projection.Calls, openAIChatReasoningProjectionCall{}) - } - known := &r.projection.Calls[*call.Index] - if (call.Type != "" && call.Type != "function") || - (call.ID != "" && known.ID != "" && call.ID != known.ID) || - (call.Function.Name != "" && known.Name != "" && call.Function.Name != known.Name) { - r.invalid = true - return - } - if call.ID != "" { - known.ID = call.ID - } - if call.Function.Name != "" { - known.Name = call.Function.Name - } - r.projection.Calls[*call.Index].Arguments += call.Function.Arguments - r.bytes += len(call.ID) + len(call.Function.Name) + len(call.Function.Arguments) - if r.bytes > OpenAIReasoningBatchMaxBytes { - r.Stop() - return - } - } - if choice.FinishReason != nil { - if *choice.FinishReason != "tool_calls" || r.finished { - r.invalid = true - return - } - r.finished = true - } - } - } - if r.bytes > OpenAIReasoningBatchMaxBytes { - r.Stop() - } -} - -func (r *openAIChatReasoningReplayRecorder) Batch() (OpenAIReasoningBatch, bool) { - if r == nil || r.invalid || !r.completed || !r.finished { - return OpenAIReasoningBatch{}, false - } - wanted, ok := projectOpenAIChatReasoningRawBatch(r.output, r.rules) - actual, err := json.Marshal(r.projection) - if !ok || err != nil || !bytes.Equal(wanted, actual) { - return OpenAIReasoningBatch{}, false - } - return OpenAIReasoningBatch{Output: r.output, Projection: actual}, true -} - -func (r *openAIChatReasoningReplayRecorder) Stop() { - if r != nil { - r.invalid = true - r.output = nil - r.projection = openAIChatReasoningProjection{} - } -} - -// Semantic output, unlike a role chunk or SSE comment, commits this generation -// and disallows hidden re-execution. This is intentionally independent of usage. -func openAIChatChunksHaveSemanticOutput(chunks []apicompat.ChatCompletionsChunk) bool { - for _, chunk := range chunks { - for _, choice := range chunk.Choices { - delta := choice.Delta - if len(delta.ToolCalls) > 0 || (delta.Content != nil && *delta.Content != "") || (delta.ReasoningContent != nil && *delta.ReasoningContent != "") || (delta.Reasoning != nil && *delta.Reasoning != "") { - return true - } - } - } - return false -} - -func openAIChatReasoningReplayError(err error) error { - return fmt.Errorf("prepare chat reasoning replay request: %w", err) -} diff --git a/backend/internal/service/openai_chat_reasoning_replay_test.go b/backend/internal/service/openai_chat_reasoning_replay_test.go deleted file mode 100644 index 2e4f06f2ca12..000000000000 --- a/backend/internal/service/openai_chat_reasoning_replay_test.go +++ /dev/null @@ -1,494 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "slices" - "strings" - "testing" - "time" - - codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" -) - -const openAIChatReplayTestOutput = `[{"type":"reasoning","id":"rs_original","status":"completed","summary":[{"type":"summary_text","text":"checking carefully "}],"encrypted_content":"opaque-original","extension":{"precision":9007199254740993}},{"type":"message","id":"msg_original","status":"completed","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Checking orders."}],"future":{"keep":true}},{"type":"function_call","id":"fc_original","status":"completed","call_id":"call_orders","name":"load_orders","arguments":"{}","phase":"analysis","future":"retained"}]` - -func openAIChatReplayTestMessage() apicompat.ChatMessage { - return apicompat.ChatMessage{Role: "assistant", Content: json.RawMessage(`"Checking orders."`), ReasoningContent: "checking carefully ", ToolCalls: []apicompat.ChatToolCall{{ID: "call_orders", Type: "function", Function: apicompat.ChatFunctionCall{Name: "load_orders", Arguments: "{}"}}}} -} - -func openAIChatReplayTestPayload(output string) []byte { - return []byte(`{"type":"response.completed","response":{"id":"resp_original","model":"gpt-5.6-sol","status":"completed","output":` + output + `,"usage":{"input_tokens":4,"output_tokens":6}}}`) -} - -func openAIChatReplayTestSSE(output string, streamDeltas bool) string { - var frames []string - if streamDeltas { - frames = append(frames, - `{"type":"response.created","response":{"id":"resp_original","model":"gpt-5.6-sol","status":"in_progress"}}`, - `{"type":"response.reasoning_summary_text.delta","output_index":0,"delta":"checking carefully "}`, - `{"type":"response.output_text.delta","output_index":1,"delta":"Checking orders."}`, - `{"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","id":"fc_original","call_id":"call_orders","name":"load_orders","arguments":"","status":"in_progress"}}`, - `{"type":"response.function_call_arguments.delta","output_index":2,"delta":"{}"}`, - ) - } - frames = append(frames, string(openAIChatReplayTestPayload(output))) - return "data: " + strings.Join(frames, "\n\ndata: ") + "\n\ndata: [DONE]\n\n" -} - -func openAIChatReplayTestResponse(payload string, status int) *http.Response { - return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(payload))} -} - -func TestOpenAIChatReasoningReplayRecorderRequiresMatchingCompletedProjection(t *testing.T) { - t.Parallel() - recorder := openAIChatReasoningReplayRecorder{rules: openAIChatReplayTestRules()} - recorder.ObserveMessage(openAIChatReplayTestMessage()) - _, ok := recorder.Batch() - require.False(t, ok, "downstream projection is not proof of completion") - recorder.ObservePayload(openAIChatReplayTestPayload(openAIChatReplayTestOutput)) - batch, ok := recorder.Batch() - require.True(t, ok) - require.Equal(t, "commentary", gjson.GetBytes(batch.Output[1], "phase").String()) - require.Equal(t, "9007199254740993", gjson.GetBytes(batch.Output[0], "extension.precision").Raw) - require.JSONEq(t, openAIChatReplayTestOutput, string(mustJSONChatReplay(t, batch.Output))) - - for _, kind := range []string{"response.failed", "response.incomplete", "error"} { - r := openAIChatReasoningReplayRecorder{rules: openAIChatReplayTestRules()} - r.ObservePayload([]byte(`{"type":"` + kind + `"}`)) - r.ObservePayload(openAIChatReplayTestPayload(openAIChatReplayTestOutput)) - r.ObserveMessage(openAIChatReplayTestMessage()) - _, ok := r.Batch() - require.False(t, ok, kind) - } - wrong := openAIChatReplayTestMessage() - wrong.Content = json.RawMessage(`"changed visible text"`) - recorder.ObserveMessage(wrong) - _, ok = recorder.Batch() - require.False(t, ok, "rewritten text cannot claim the old raw batch") -} - -func TestOpenAIChatReasoningReplayRecorderStreamingProjection(t *testing.T) { - t.Parallel() - for _, mutate := range []string{"none", "different_reasoning", "missing_delta_suffix", "conflicting_arguments", "no_deltas"} { - t.Run(mutate, func(t *testing.T) { - recorder := openAIChatReasoningReplayRecorder{rules: openAIChatReplayTestRules()} - state := apicompat.NewResponsesEventToChatState() - payload := openAIChatReplayTestSSE(openAIChatReplayTestOutput, mutate != "no_deltas") - if mutate == "different_reasoning" { - payload = strings.Replace(payload, `"delta":"checking carefully "`, `"delta":"different reasoning"`, 1) - } - if mutate == "missing_delta_suffix" { - payload = strings.Replace(payload, `"delta":"{}"`, `"delta":"{"`, 1) - } - if mutate == "conflicting_arguments" { - payload = strings.Replace(payload, `"delta":"{}"`, `"delta":"[]"`, 1) - } - for _, frame := range strings.Split(payload, "\n\n") { - raw := []byte(strings.TrimPrefix(frame, "data: ")) - var event apicompat.ResponsesStreamEvent - if json.Unmarshal(raw, &event) != nil { - continue - } - recorder.ObservePayload(raw) - recorder.ObserveChunks(apicompat.ResponsesEventToChatChunks(&event, state)) - } - _, ok := recorder.Batch() - require.Equal(t, mutate == "none" || mutate == "missing_delta_suffix", ok) - }) - } -} - -func TestOpenAIChatReasoningReplayRejectsNonReversibleBatches(t *testing.T) { - t.Parallel() - cases := map[string]string{ - "custom": strings.Replace(openAIChatReplayTestOutput, `"type":"function_call"`, `"type":"custom_tool_call"`, 1), - "builtin": strings.Replace(openAIChatReplayTestOutput, `"type":"function_call"`, `"type":"web_search_call"`, 1), - "unknown": strings.Replace(openAIChatReplayTestOutput, `"type":"message"`, `"type":"future_item"`, 1), - "refusal": strings.Replace(openAIChatReplayTestOutput, `"type":"output_text"`, `"type":"refusal"`, 1), - "partial": strings.Replace(openAIChatReplayTestOutput, `"status":"completed"`, `"status":"in_progress"`, 1), - "args_empty": strings.Replace(openAIChatReplayTestOutput, `"arguments":"{}"`, `"arguments":""`, 1), - "call_id_numeric": strings.Replace(openAIChatReplayTestOutput, `"call_id":"call_orders"`, `"call_id":123`, 1), - "namespace": strings.Replace(openAIChatReplayTestOutput, `"name":"load_orders"`, `"namespace":"orders","name":"load_orders"`, 1), - "summary_missing": strings.Replace(openAIChatReplayTestOutput, `"summary":[{"type":"summary_text","text":"checking carefully "}],`, "", 1), - "duplicate_call": strings.TrimSuffix(openAIChatReplayTestOutput, "]") + `,{"type":"function_call","call_id":"call_orders","name":"load_orders","arguments":"{}"}]`, - } - for name, output := range cases { - t.Run(name, func(t *testing.T) { - var raw []json.RawMessage - require.NoError(t, json.Unmarshal([]byte(output), &raw)) - _, ok := projectOpenAIChatReasoningRawBatch(raw, openAIChatReplayTestRules()) - require.False(t, ok) - }) - } -} - -func TestOpenAIChatReasoningReplayOptionalReasoningButRequiredVisibleContent(t *testing.T) { - t.Parallel() - stored, ok := normalizeOpenAIChatReasoningProjection(openAIChatReplayTestMessage(), openAIChatReplayTestRules()) - require.True(t, ok) - for _, variant := range []string{"normal", "omitted", "alias", "different_reasoning", "different_content", "different_arguments"} { - message := openAIChatReplayTestMessage() - switch variant { - case "omitted": - message.ReasoningContent = "" - case "alias": - message.Reasoning, message.ReasoningContent = message.ReasoningContent, "" - case "different_reasoning": - message.ReasoningContent = " changed " - case "different_content": - message.Content = json.RawMessage(`"changed"`) - case "different_arguments": - message.ToolCalls[0].Function.Arguments = `{"changed":true}` - } - incoming, ok := normalizeOpenAIChatReasoningProjection(message, openAIChatReplayTestRules()) - require.True(t, ok) - want := variant == "normal" || variant == "omitted" || variant == "alias" - require.Equal(t, want, openAIChatReasoningProjectionMatches(stored, incoming, openAIChatReplayTestRules()), variant) - if want { - require.Equal(t, openAIChatReasoningBatchKey("prefix", stored, openAIChatReplayTestRules()), openAIChatReasoningBatchKey("prefix", incoming, openAIChatReplayTestRules())) - } - } -} - -type openAIChatReplayTestCache struct { - GatewayCache - batches map[string]OpenAIReasoningBatch - rejected map[string]OpenAIRejectedReasoning - readErr error - deletes int -} - -func newOpenAIChatReplayTestCache() *openAIChatReplayTestCache { - return &openAIChatReplayTestCache{batches: map[string]OpenAIReasoningBatch{}, rejected: map[string]OpenAIRejectedReasoning{}} -} - -func (s *openAIChatReplayTestCache) GetOpenAIReasoningBatches(_ context.Context, scope OpenAIReasoningCacheScope, keys []string) (map[string]OpenAIReasoningBatch, error) { - result := map[string]OpenAIReasoningBatch{} - for _, key := range keys { - if value, ok := s.batches[scope.ScopeHash+":"+key]; ok { - result[key] = value - } - } - return result, s.readErr -} - -func (s *openAIChatReplayTestCache) PutOpenAIReasoningBatch(_ context.Context, scope OpenAIReasoningCacheScope, key string, batch OpenAIReasoningBatch) (bool, error) { - encoded, err := json.Marshal(batch) - if err != nil { - return false, err - } - batch.PayloadHash = openAIChatReasoningHash(encoded) - s.batches[scope.ScopeHash+":"+key] = batch - return true, nil -} - -func (s *openAIChatReplayTestCache) DeleteOpenAIReasoningBatchIfMatch(_ context.Context, scope OpenAIReasoningCacheScope, key, payloadHash string) (bool, error) { - key = scope.ScopeHash + ":" + key - if batch, ok := s.batches[key]; ok && batch.PayloadHash == payloadHash { - delete(s.batches, key) - s.deletes++ - return true, nil - } - return false, nil -} - -func (s *openAIChatReplayTestCache) GetOpenAIRejectedReasoning(_ context.Context, scope OpenAIReasoningCacheScope, hashes []string) (map[string]OpenAIRejectedReasoning, error) { - result := map[string]OpenAIRejectedReasoning{} - for _, hash := range hashes { - if value, ok := s.rejected[scope.ScopeHash+":"+hash]; ok { - result[hash] = value - } - } - return result, s.readErr -} - -func (s *openAIChatReplayTestCache) PutOpenAIRejectedReasoning(_ context.Context, scope OpenAIReasoningCacheScope, hashes []string) error { - for _, hash := range hashes { - s.rejected[scope.ScopeHash+":"+hash] = OpenAIRejectedReasoning{RejectedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour)} - } - return nil -} - -func mustJSONChatReplay(t *testing.T, value any) []byte { - t.Helper() - data, err := json.Marshal(value) - require.NoError(t, err) - return data -} - -func openAIChatReplayTestBody(t *testing.T, followup bool, stream bool, includeReasoning bool) []byte { - t.Helper() - messages := []apicompat.ChatMessage{{Role: "user", Content: json.RawMessage(`"Use the orders tool."`)}} - if followup { - message := openAIChatReplayTestMessage() - if !includeReasoning { - message.ReasoningContent = "" - } - messages = append(messages, message, apicompat.ChatMessage{Role: "tool", ToolCallID: "call_orders", Content: json.RawMessage(`"order data"`)}) - } - body := map[string]any{ - "model": "gpt-5.6-sol", "messages": messages, "stream": stream, - "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "load_orders", "parameters": map[string]any{"type": "object"}}}}, - "reasoning": json.RawMessage(`{"effort":"xhigh","mode":"auto","context":"auto","future":{"precise":9007199254740993}}`), - } - return mustJSONChatReplay(t, body) -} - -func openAIChatReplayTestContext(t *testing.T, body []byte) (*gin.Context, *httptest.ResponseRecorder) { - t.Helper() - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) - group := int64(4) - c.Set("api_key", &APIKey{ID: 71, UserID: 9, GroupID: &group}) - return c, recorder -} - -func TestOpenAIChatReasoningReplayForwardAPIKeyAndOAuth(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, oauth := range []bool{false, true} { - for _, stream := range []bool{false, true} { - for _, includeReasoning := range []bool{false, true} { - t.Run(fmt.Sprintf("oauth_%t_stream_%t_optional_reasoning_%t", oauth, stream, includeReasoning), func(t *testing.T) { - account := newOpenAIRejectedFieldTestAccount() - if oauth { - account = newOpenAIOAuthNamespaceTestAccount() - } - upstream := &httpUpstreamRecorder{responses: []*http.Response{ - openAIChatReplayTestResponse(openAIChatReplayTestSSE(openAIChatReplayTestOutput, true), http.StatusOK), - openAIChatReplayTestResponse(string(openAIChatReplayTestPayload(`[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}]`)), http.StatusOK), - }} - upstream.responses[1] = openAIChatReplayTestResponse("data: "+string(openAIChatReplayTestPayload(`[]`))+"\n\n", http.StatusOK) - cache := newOpenAIChatReplayTestCache() - svc := newOpenAIRejectedFieldTestService(upstream) - svc.cache = cache - first := openAIChatReplayTestBody(t, false, stream, true) - c, _ := openAIChatReplayTestContext(t, first) - _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, first, "", "") - require.NoError(t, err) - require.True(t, c.GetBool("openai_chat_reasoning_replay_stored")) - require.Len(t, cache.batches, 1) - second := openAIChatReplayTestBody(t, true, stream, includeReasoning) - c, _ = openAIChatReplayTestContext(t, second) - _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, second, "", "") - require.NoError(t, err) - require.Equal(t, 1, c.GetInt("openai_chat_reasoning_replay_hits")) - require.Len(t, upstream.bodies, 2) - wire := upstream.bodies[1] - require.Equal(t, "9007199254740993", gjson.GetBytes(wire, "reasoning.future.precise").Raw) - require.NotContains(t, string(wire), "") - input := gjson.GetBytes(wire, "input").Array() - require.Len(t, input, 5) - require.JSONEq(t, openAIChatReplayTestOutput, "["+input[1].Raw+","+input[2].Raw+","+input[3].Raw+"]") - require.Equal(t, input[3].Get("call_id").String(), input[4].Get("call_id").String()) - }) - } - } - } -} - -func TestOpenAIChatReasoningReplayMissDoesNotChangeInput(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, variant := range []string{"missing", "cache_error", "disabled", "unknown_tenant", "changed_account", "changed_reasoning", "changed_prefix", "changed_actual_prefix"} { - t.Run(variant, func(t *testing.T) { - cache := newOpenAIChatReplayTestCache() - account := newOpenAIRejectedFieldTestAccount() - svc := &OpenAIGatewayService{cache: cache} - chat := openAIChatReplayTestBody(t, true, false, true) - var req apicompat.ChatCompletionsRequest - require.NoError(t, json.Unmarshal(chat, &req)) - converted, err := apicompat.ChatCompletionsToResponses(&req) - require.NoError(t, err) - wire := mustJSONChatReplay(t, converted) - request, err := http.NewRequest(http.MethodPost, "https://compat.example/v1/responses", bytes.NewReader(wire)) - require.NoError(t, err) - request.Header.Set("Authorization", "Bearer test") - c, _ := openAIChatReplayTestContext(t, chat) - scope, err := buildOpenAIReasoningScope(c, account, request, wire) - require.NoError(t, err) - input, _ := openAIChatReasoningInput(wire) - prefix, err := openAIChatReasoningPrefixHash(wire, input[:1]) - require.NoError(t, err) - projection, _ := normalizeOpenAIChatReasoningProjection(openAIChatReplayTestMessage(), openAIChatReplayTestRules()) - var output []json.RawMessage - require.NoError(t, json.Unmarshal([]byte(openAIChatReplayTestOutput), &output)) - batch := OpenAIReasoningBatch{Output: output, Projection: projection, InputPrefixHash: prefix} - if variant == "changed_actual_prefix" { - batch.InputPrefixHash = strings.Repeat("0", 64) - } - _, err = cache.PutOpenAIReasoningBatch(context.Background(), scope, openAIChatReasoningBatchKey(prefix, projection, openAIChatReplayTestRules()), batch) - require.NoError(t, err) - switch variant { - case "missing": - cache.batches = map[string]OpenAIReasoningBatch{} - case "cache_error": - cache.readErr = errors.New("cache unavailable") - case "disabled": - account.Extra["openai_chat_reasoning_replay_enabled"] = false - case "unknown_tenant": - c.Set("api_key", &APIKey{ID: 71}) - case "changed_account": - account.ID++ - case "changed_reasoning": - wire, err = sjson.SetBytes(wire, "reasoning.future.precise", json.Number("9007199254740994")) - case "changed_prefix": - wire, err = sjson.SetBytes(wire, "input.0.content.0.text", "changed") - } - require.NoError(t, err) - _, got := svc.prepareOpenAIChatReasoningReplay(context.Background(), c, account, request, chat, wire, true) - require.Equal(t, wire, got) - require.Equal(t, 0, c.GetInt("openai_chat_reasoning_replay_hits")) - }) - } -} - -func TestOpenAIChatReasoningReplayRecoversOnceAndInvalidatesCAS(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, failure := range []string{"http", "sse_before_output", "sse_after_output", "recovery_fails"} { - t.Run(failure, func(t *testing.T) { - cache := newOpenAIChatReplayTestCache() - account := newOpenAIRejectedFieldTestAccount() - account.Extra["same_account_retry_count"] = 0 - rejected := `{"error":{"code":"invalid_encrypted_content","param":"input[1].encrypted_content"}}` - rejection := openAIChatReplayTestResponse(rejected, http.StatusBadRequest) - if strings.HasPrefix(failure, "sse_") { - payload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"first\"}}\n\n" - if failure == "sse_after_output" { - payload += "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"already thinking\"}\n\n" - } - payload += `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"invalid_encrypted_content","param":"input[1].encrypted_content"}}}` + "\n\n" - rejection = openAIChatReplayTestResponse(payload, http.StatusOK) - } - final := openAIChatReplayTestResponse("data: "+string(openAIChatReplayTestPayload(`[]`))+"\n\n", http.StatusOK) - if failure == "recovery_fails" { - final = openAIChatReplayTestResponse(`{"error":{"code":"server_error"}}`, http.StatusInternalServerError) - } - upstream := &httpUpstreamRecorder{responses: []*http.Response{openAIChatReplayTestResponse(openAIChatReplayTestSSE(openAIChatReplayTestOutput, true), http.StatusOK), rejection, final}} - svc := newOpenAIRejectedFieldTestService(upstream) - svc.cache = cache - first := openAIChatReplayTestBody(t, false, true, true) - c, _ := openAIChatReplayTestContext(t, first) - _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, first, "", "") - require.NoError(t, err) - require.Len(t, cache.batches, 1) - second := openAIChatReplayTestBody(t, true, true, false) - c, _ = openAIChatReplayTestContext(t, second) - _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, second, "", "") - if failure == "sse_after_output" { - require.Error(t, err) - require.Len(t, upstream.requests, 2) - require.Zero(t, cache.deletes) - return - } - if failure == "recovery_fails" { - require.Error(t, err) - var failover *UpstreamFailoverError - require.False(t, errors.As(err, &failover), "one recovery cannot reenter the account pool") - } else { - require.NoError(t, err) - } - require.Len(t, upstream.requests, 3) - require.Equal(t, 1, cache.deletes) - require.Len(t, cache.rejected, 1) - expected, err := sjson.DeleteBytes(upstream.bodies[1], "input.1.encrypted_content") - require.NoError(t, err) - require.JSONEq(t, string(expected), string(upstream.bodies[2])) - require.Equal(t, upstream.requests[1].Header, upstream.requests[2].Header) - require.Equal(t, upstream.requests[1].URL, upstream.requests[2].URL) - require.Nil(t, upstream.requests[2].GetBody) - }) - } -} - -func TestOpenAIChatReasoningReplayHooksAreNoopOutsideChat(t *testing.T) { - gin.SetMode(gin.TestMode) - c, _ := openAIChatReplayTestContext(t, nil) - require.NotPanics(t, func() { - observeOpenAIChatReasoningReplayPayload(nil, openAIChatReplayTestPayload(openAIChatReplayTestOutput)) - observeOpenAIChatReasoningReplayPayload(c, openAIChatReplayTestPayload(openAIChatReplayTestOutput)) - }) - _, found := c.Get(openAIChatReasoningReplayContextKey) - require.False(t, found) -} - -func TestOpenAIChatReasoningReplayRejectsDuplicateJSONMembers(t *testing.T) { - _, err := openAIChatReasoningCanonical(json.RawMessage(`{"input":[{"role":"user","content":"a","content":"b"}]}`)) - require.Error(t, err) - var output []json.RawMessage - ambiguous := strings.Replace(openAIChatReplayTestOutput, `"precision":9007199254740993`, `"precision":9007199254740993,"precision":9007199254740994`, 1) - require.NoError(t, json.Unmarshal([]byte(ambiguous), &output)) - _, ok := projectOpenAIChatReasoningRawBatch(output, openAIChatReplayTestRules()) - require.False(t, ok) -} - -func TestOpenAIChatReasoningReplayTerminalFunctionSnapshotRoundTrip(t *testing.T) { - output := `[{"type":"reasoning","id":"rs_snapshot","status":"completed","summary":[],"encrypted_content":"opaque-snapshot"},{"type":"function_call","id":"fc_snapshot","call_id":"call_orders","name":"load_orders","arguments":"{}","status":"completed"}]` - upstream := &httpUpstreamRecorder{responses: []*http.Response{ - openAIChatReplayTestResponse(openAIChatReplayTestSSE(output, false), 200), - openAIChatReplayTestResponse("data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: "+string(openAIChatReplayTestPayload(`[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]`))+"\n\n", 200), - }} - svc := newOpenAIRejectedFieldTestService(upstream) - cache := newOpenAIChatReplayTestCache() - svc.cache = cache - account := newOpenAIRejectedFieldTestAccount() - first := openAIChatReplayTestBody(t, false, true, false) - c, writer := openAIChatReplayTestContext(t, first) - _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, first, "", "") - require.NoError(t, err) - require.Contains(t, writer.Body.String(), `"arguments":"{}"`) - require.Len(t, cache.batches, 1) - var request map[string]any - require.NoError(t, decodeOpenAIJSONUseNumber(first, &request)) - messages, ok := request["messages"].([]any) - require.True(t, ok) - request["messages"] = append(messages, - map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"id": "call_orders", "type": "function", "function": map[string]any{"name": "load_orders", "arguments": "{}"}}}}, - map[string]any{"role": "tool", "tool_call_id": "call_orders", "content": "orders"}) - second := mustJSONChatReplay(t, request) - c, _ = openAIChatReplayTestContext(t, second) - _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, second, "", "") - require.NoError(t, err) - require.Equal(t, 1, c.GetInt("openai_chat_reasoning_replay_hits")) - require.Contains(t, string(upstream.bodies[1]), "opaque-snapshot") -} - -func openAIChatReplayTestRules() extensionv1.ReplayRules { - rules := codexrecovery.ReplayPolicy() - slices.Sort(rules.ChatFields) - slices.Sort(rules.OutputKinds) - return rules -} - -func TestOpenAIChatReasoningReplayRulesChangeIdentityAndOptionalMatching(t *testing.T) { - rules := openAIChatReplayTestRules() - message := openAIChatReplayTestMessage() - stored, ok := normalizeOpenAIChatReasoningProjection(message, rules) - require.True(t, ok) - message.ReasoningContent, message.Reasoning = "", "" - incoming, ok := normalizeOpenAIChatReasoningProjection(message, rules) - require.True(t, ok) - require.True(t, openAIChatReasoningProjectionMatches(stored, incoming, rules)) - strict := rules - strict.AllowOmittedReasoning = false - require.False(t, openAIChatReasoningProjectionMatches(stored, incoming, strict)) - require.NotEqual(t, openAIChatReasoningBatchKey("prefix", stored, rules), openAIChatReasoningBatchKey("prefix", stored, strict)) - updated := rules - updated.Version = "next-policy" - require.NotEqual(t, openAIChatReasoningBatchKey("prefix", stored, rules), openAIChatReasoningBatchKey("prefix", stored, updated)) - strict = rules - strict.ChatFields = []string{"role", "content", "tool_calls"} - require.False(t, openAIChatReasoningRawMessageSupported([]byte(`{"role":"assistant","content":"","refusal":null,"tool_calls":[]}`), strict)) -} diff --git a/backend/internal/service/openai_compat_host_observation_test.go b/backend/internal/service/openai_compat_host_observation_test.go index e9c6c851f47c..7600176e283c 100644 --- a/backend/internal/service/openai_compat_host_observation_test.go +++ b/backend/internal/service/openai_compat_host_observation_test.go @@ -178,32 +178,27 @@ func TestCompatHostObservations_ShadowSuccessNotifiesParent(t *testing.T) { } func TestCompatHostObservations_RecoveryLogsOnlyFinalSuccess(t *testing.T) { - cache := newOpenAIChatReplayTestCache() - completed, err := sjson.DeleteBytes(openAIChatReplayTestPayload(`[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]`), "response.usage") + store := &reasoningRecoveryMemoryStore{GatewayCache: &stubGatewayCache{}} + completed, err := sjson.Delete(reasoningRecoveryChatCompleted, "response.usage") require.NoError(t, err) failed := "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"invalid_encrypted_content\",\"param\":\"input[1].encrypted_content\"}}}\n\n" upstream := &httpUpstreamRecorder{responses: []*http.Response{ - openAIChatReplayTestResponse(openAIChatReplayTestSSE(openAIChatReplayTestOutput, false), http.StatusOK), - openAIChatReplayTestResponse(failed, http.StatusOK), - openAIChatReplayTestResponse("data: "+string(completed)+"\n\n", http.StatusOK), + reasoningRecoverySSEResponse(failed, http.StatusOK), + reasoningRecoverySSEResponse("data: "+completed+"\n\n", http.StatusOK), }} svc := newOpenAIRejectedFieldTestService(upstream) - svc.cache = cache + svc.cache = store account := newOpenAIRejectedFieldTestAccount() - first := openAIChatReplayTestBody(t, false, false, false) - c, _ := openAIChatReplayTestContext(t, first) - _, err = svc.ForwardAsChatCompletions(c.Request.Context(), c, account, first, "", "") - require.NoError(t, err) - second := openAIChatReplayTestBody(t, true, false, false) - c, _ = openAIChatReplayTestContext(t, second) + body := reasoningRecoveryChatBody(t, false) + c, _ := reasoningRecoveryChatContext(t, body) core, logs := observer.New(zap.WarnLevel) c.Request = c.Request.WithContext(logger.IntoContext(c.Request.Context(), zap.New(core))) before := observeCompatMissingUsage(t) - result, err := svc.ForwardAsChatCompletions(c.Request.Context(), c, account, second, "", "") + result, err := svc.ForwardAsChatCompletions(c.Request.Context(), c, account, body, "", "") require.NoError(t, err) require.NotNil(t, result) - require.Len(t, upstream.requests, 3, "only the existing priming, rejected replay and bounded recovery may send") - require.Len(t, cache.rejected, 1) + require.Len(t, upstream.requests, 2, "only the rejected attempt and its bounded recovery may send") + require.Len(t, store.values, 1) require.Equal(t, uint64(1), openAIMissingUsageSampler.total.Load()-before) require.Len(t, logs.FilterMessage("openai_usage.success_missing_usage").All(), 1) } @@ -236,7 +231,7 @@ func TestCompatHostObservations_IncompleteDoesNotCountAsCompleted(t *testing.T) before := observeCompatMissingUsage(t) c, body, logs := compatObservationContext(t, protocol, stream) payload := "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_incomplete\",\"model\":\"gpt-5.4\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"partial fixture\"}]}]}}\n\n" - upstream := &httpUpstreamRecorder{resp: openAIChatReplayTestResponse(payload, http.StatusOK)} + upstream := &httpUpstreamRecorder{resp: reasoningRecoverySSEResponse(payload, http.StatusOK)} svc := newOpenAIRejectedFieldTestService(upstream) result, err := forwardCompatObservation(svc, protocol, c, newOpenAIRejectedFieldTestAccount(), body) require.NoError(t, err, "the existing bounded-output response contract must remain unchanged") diff --git a/backend/internal/service/openai_continuation_diagnostic.go b/backend/internal/service/openai_continuation_diagnostic.go index 9f5fb16d2b7e..99d5c8a03f60 100644 --- a/backend/internal/service/openai_continuation_diagnostic.go +++ b/backend/internal/service/openai_continuation_diagnostic.go @@ -451,7 +451,7 @@ func sanitizeOpenAIContinuationDiagnosticValues(in *OpenAIContinuationDiagnostic switch recovery.NotAttemptedReason { case "disabled", "semantic_output_committed", "request_cancelled", "protected_status", "not_signature_rejection", "invalid_request_snapshot", "invalid_tool_history", "no_reasoning_ciphertext", "target_not_reasoning_ciphertext", - "unsupported_error_param", "server_held_context", "ambiguous_encrypted_carriers", "rewrite_failed", "recovery_not_dispatched", "source_changed", "endpoint_changed", "policy_unavailable": + "unsupported_error_param", "server_held_context", "ambiguous_encrypted_carriers", "rewrite_failed", "recovery_not_dispatched", "source_changed", "endpoint_changed": default: recovery.NotAttemptedReason = "" } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 4e86a0b4ffa7..461a97acd139 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "strings" "sync/atomic" @@ -74,9 +73,8 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( compatPromptCacheTenantIsolated bool, ) (*OpenAIForwardResult, error) { // A scheduler may reuse gin.Context for another account or protocol. - // Never leave an earlier Chat recorder attached to a fallback route. + // Never leave an earlier recovery state attached to a fallback route. if c != nil { - c.Set(openAIChatReasoningReplayContextKey, (*openAIChatReasoningReplay)(nil)) c.Set(openAIReasoningRecoveryContextKey, (*openAIReasoningRecoveryState)(nil)) } rememberOpenCodeInboundBody(c, body) @@ -333,7 +331,6 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( codexResult := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ SkipDefaultInstructions: !isResponsesShape, OmitPromotedSystemMessagesFromInput: !isResponsesShape && !isJSONObjectFormat, - PreserveToolCallIDs: !isResponsesShape, }) if !isResponsesShape { ensureCodexOAuthInstructionsField(reqBody) @@ -395,59 +392,34 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return nil, fmt.Errorf("get access token: %w", err) } - // 6. Build upstream request - upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) - upstreamReq, err := s.buildUpstreamRequestPrepared(upstreamCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false, false) - releaseUpstreamCtx() - if err != nil { - return nil, fmt.Errorf("build upstream request: %w", err) - } - - // 7. Send request + // 6. Build and send the upstream request. A reasoning recovery repeats this + // once from the clean body, without the rejected ciphertext. proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - - // The builder can make final endpoint adaptations. Both the - // positive cache and recovery state must observe that exact wire request. - if upstreamReq.GetBody != nil { - reader, readErr := upstreamReq.GetBody() - if readErr != nil { - return nil, openAIChatReasoningReplayError(readErr) - } - responsesBody, readErr = io.ReadAll(reader) - _ = reader.Close() - if readErr != nil { - return nil, openAIChatReasoningReplayError(readErr) - } - } - replay, replayBody := s.prepareOpenAIChatReasoningReplay(ctx, c, account, upstreamReq, body, responsesBody, !isResponsesShape) - if upstreamReq.Body != nil { - _ = upstreamReq.Body.Close() - } - responsesBody = replayBody recovery := s.newOpenAIReasoningRecoveryState(ctx, c, account, token) defer recovery.Close() - recovery.SetRejectedCallback(replay.InvalidateRejected) + var upstreamReq *http.Request var resp *http.Response var result *OpenAIForwardResult var handleErr error var wireBody []byte - // Keep the builder's transport context as the uncanceled base. Each stream - // attempt must cancel before closing its body without canceling the single - // same-source reasoning recovery; PrepareRequest reapplies client cancellation - // and its deadline to that recovery attempt. - upstreamRequestCtx := upstreamReq.Context() for { - upstreamReq, err = s.buildUpstreamRequest(upstreamRequestCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false) + upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + upstreamReq, err = s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, upstreamPromptCacheKey, false) + releaseUpstreamCtx() if err != nil { - return nil, err + return nil, fmt.Errorf("build upstream request: %w", err) } + // A stream attempt cancels its own context before closing its body. That + // context is derived per attempt, so it never cancels the single + // same-source reasoning recovery; PrepareRequest reapplies client + // cancellation and its deadline to that recovery attempt. cancelUpstream := func() {} if clientStream { var attemptCtx context.Context - attemptCtx, cancelUpstream = context.WithCancel(upstreamRequestCtx) + attemptCtx, cancelUpstream = context.WithCancel(upstreamReq.Context()) upstreamReq = upstreamReq.WithContext(attemptCtx) } upstreamReq, responsesBody, wireBody, err = prepareReasoningRecoveryRequest(recovery, upstreamReq, responsesBody, proxyURL) @@ -467,7 +439,6 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( upstreamReq.Header.Set("session_id", generateSessionUUID(sessionKey)) } } - replay.SetSentBody(responsesBody) resp, err = s.doOpenAICodexUpstream(upstreamReq, account, proxyURL) if err != nil { cancelUpstream() @@ -552,7 +523,6 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // Propagate ServiceTier and ReasoningEffort to result for billing if handleErr == nil && result != nil { - replay.Commit() if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { result.ServiceTier = tier } @@ -682,7 +652,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", clientMsg) return nil, fmt.Errorf("openai cyber_policy: %s", msg) } - if recovery := openAIChatReasoningRecoveryFromContext(c); recovery != nil && recovery.RecoveryAttempt() { + if recovery := openAIReasoningRecoveryStateFromContext(c); recovery != nil && recovery.RecoveryAttempt() { return nil, recovery.StopError(errors.New("upstream rejected reasoning recovery")) } message := openAICompatFailedResponseMessage(finalResponse) @@ -725,15 +695,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( // writeContentType 仅在头不存在时才设置,无法覆盖。这里显式 Set 强制改回 JSON, // 否则下游"看头判流式"的中间层(如 new-api)会把本应聚合的 JSON 当成 SSE 处理。 c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - previousErrors := len(c.Errors) c.JSON(http.StatusOK, chatResp) - if replay := openAIChatReasoningReplayFromContext(c); replay != nil { - if len(c.Errors) != previousErrors || len(chatResp.Choices) != 1 || chatResp.Choices[0].FinishReason != "tool_calls" { - replay.recorder.Stop() - } else { - replay.recorder.ObserveMessage(chatResp.Choices[0].Message) - } - } result := &OpenAIForwardResult{ RequestID: requestID, @@ -805,6 +767,20 @@ func (s *OpenAIGatewayService) newOpenAICompatBufferedReadFailoverError( return failoverErr } +// Semantic output, unlike a role chunk or SSE comment, commits this generation +// and disallows hidden re-execution. This is intentionally independent of usage. +func openAIChatChunksHaveSemanticOutput(chunks []apicompat.ChatCompletionsChunk) bool { + for _, chunk := range chunks { + for _, choice := range chunk.Choices { + delta := choice.Delta + if len(delta.ToolCalls) > 0 || (delta.Content != nil && *delta.Content != "") || (delta.ReasoningContent != nil && *delta.ReasoningContent != "") || (delta.Reasoning != nil && *delta.Reasoning != "") { + return true + } + } + } + return false +} + // handleChatStreamingResponse reads Responses SSE events from upstream, // converts each to Chat Completions SSE chunks, and writes them to the client. func (s *OpenAIGatewayService) handleChatStreamingResponse( @@ -889,7 +865,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( processDataLine := func(payload string) bool { rawPayloadBytes := []byte(payload) observeOpenAIReasoningAttemptUsage(c, rawPayloadBytes) - observeOpenAIChatReasoningReplayPayload(c, rawPayloadBytes) rawEventType := strings.TrimSpace(gjson.GetBytes(rawPayloadBytes, "type").String()) if isOpenAICompatResponsesTerminalEvent(rawEventType) { pendingReasoningRejection = nil @@ -908,7 +883,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( return true } if rawEventType == "response.failed" || rawEventType == "error" { - recovery := openAIChatReasoningRecoveryFromContext(c) + recovery := openAIReasoningRecoveryStateFromContext(c) if recovery == nil || !recovery.RecoveryAttempt() { if failoverErr, ok := s.openAIBudgetExceededHTTPResponseTerminalFailover( c.Request.Context(), c, account, resp.StatusCode, resp.Header, rawPayloadBytes, @@ -987,7 +962,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } return true } - if recovery := openAIChatReasoningRecoveryFromContext(c); recovery != nil && recovery.RecoveryAttempt() { + if recovery := openAIReasoningRecoveryStateFromContext(c); recovery != nil && recovery.RecoveryAttempt() { streamNonFailoverErr = recovery.StopError(errors.New("upstream rejected reasoning recovery")) return true } @@ -1050,9 +1025,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } return true } - if replay := openAIChatReasoningReplayFromContext(c); replay != nil { - replay.recorder.ObserveChunks(chunks) - } if !clientDisconnected { for _, chunk := range chunks { semanticChunk := openAIChatChunksHaveSemanticOutput([]apicompat.ChatCompletionsChunk{chunk}) @@ -1112,13 +1084,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } finalizeStream := func() (*OpenAIForwardResult, error) { - defer func() { - if clientDisconnected || streamFailoverErr != nil || streamNonFailoverErr != nil { - if replay := openAIChatReasoningReplayFromContext(c); replay != nil { - replay.recorder.Stop() - } - } - }() if streamFailoverErr != nil { if c == nil || c.Writer == nil || !c.Writer.Written() { return nil, streamFailoverErr diff --git a/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go b/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go index 5135b4cdab38..87c3e40b4658 100644 --- a/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go @@ -161,10 +161,10 @@ func TestForwardAsChatCompletions_StreamCancellationIsPerReasoningAttempt(t *tes ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() body := []byte(strings.Replace(reasoningRecoveryFixture, `"store":false`, `"stream":true,"store":false`, 1)) - c, recorder := openAIChatReplayTestContext(t, body) + c, recorder := reasoningRecoveryChatContext(t, body) c.Request = c.Request.WithContext(ctx) failed := newContextBoundBlockingReadCloser([]byte(`data: {"type":"response.failed","response":{"id":"resp_rejected","status":"failed","error":{"code":"invalid_encrypted_content","param":"input[1].encrypted_content"}}}` + "\n\n")) - completed := newContextBoundBlockingReadCloser([]byte("data: " + string(openAIChatReplayTestPayload(`[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]`)) + "\n\n")) + completed := newContextBoundBlockingReadCloser([]byte("data: " + reasoningRecoveryChatCompleted + "\n\n")) t.Cleanup(failed.forceUnblock) t.Cleanup(completed.forceUnblock) upstream := &contextBoundChatRecoveryUpstream{httpUpstreamRecorder: httpUpstreamRecorder{responses: []*http.Response{ diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 9cd556e00a25..ac42dac6f4c0 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1389,12 +1389,6 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { } func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { - return s.buildUpstreamRequestPrepared(ctx, c, account, body, token, isStream, promptCacheKey, isCodexCLI, true) -} - -// Replay preparation needs the real endpoint and auth identity, but retains a -// clean request. Only the final build applies the system prompt. -func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool, includePrompt bool) (*http.Request, error) { // Determine target URL based on account type var targetURL string switch account.Type { @@ -1430,13 +1424,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, return nil, fmt.Errorf("normalize compatible Responses reasoning summary: %w", err) } - // Callers that already applied the system prompt still get the final-wire - // prompt_cache_key option; finalizeResponsesForSend covers both otherwise. - if includePrompt { - body, err = s.finalizeResponsesForSend(c, account, body) - } else { - body, err = applyOpenAIAPIKeyPromptCacheKeyMode(c, account, body) - } + body, err = s.finalizeResponsesForSend(c, account, body) if err != nil { return nil, err } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 2f83dacd0a56..b1e378729b0c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -851,7 +851,6 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( if frame, ok := parser.Finish(); ok { payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType) observeOpenAIReasoningAttemptUsage(c, []byte(payload)) - observeOpenAIChatReasoningReplayPayload(c, []byte(payload)) var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err == nil { s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) @@ -895,7 +894,6 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( } payload := openAICompatPayloadWithEventType(frame.Data, frame.EventType) observeOpenAIReasoningAttemptUsage(c, []byte(payload)) - observeOpenAIChatReasoningReplayPayload(c, []byte(payload)) var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 28f00a7fad2b..9785e3cef1aa 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1920,9 +1920,9 @@ func openAIHTTPAuthoritativeTerminalEvent(payload []byte, eventType string) bool } // An explicit signature rejection is request-scoped even when recovery is -// disabled, unavailable, or already consumed. It must not fall into generic -// account-pool retries. Once semantic bytes were committed the parser retains -// the genuine failure event and returns a plain error instead of replaying. +// disabled or already consumed. It must not fall into generic account-pool +// retries. Once semantic bytes were committed the parser retains the genuine +// failure event and returns a plain error instead of replaying. func openAIHTTPReasoningRejectionBeforeOutput(c *gin.Context, payload []byte, semanticCommitted bool) error { if bodyHasSSEFraming(payload) { forEachOpenAISSEFrame(string(payload), func(_ string, data []byte) { diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 9f87735105c8..429d9a0e4954 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -909,8 +909,9 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusionsStickyHit(ctx return nil, false, err } - // 4. 设置粘性会话绑定 - // Set sticky session binding + // 4. 设置粘性会话绑定(利润门下推迟到 handler 终检通过后再绑定, + // 终检否决的账号不得成为新的粘性目标;无门保持既有 eager 绑定与 TTL) + // Set sticky session binding (deferred until terminal admission under a profit gate) if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } @@ -1114,6 +1115,8 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool // SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan. func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + // 分组利润控制:legacy 公共入口同样装门,保证不经 + // selectAccountWithScheduler 的调用方也无法绕过利润准入。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountWithLoadAwareness(ctx, groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, "", true) } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 925997769223..198991238290 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -486,6 +486,7 @@ type OpenAIGatewayService struct { settingService *SettingService userPlatformQuotaRepo UserPlatformQuotaRepository systemPrompts *SystemPromptService + reasoningRecovery *ReasoningRecoveryService usageCache *UsageCache usageCommitObserver UsageCommitObserver liveAttestation liveattestation.Provider diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 85e99d16bec6..083ce4c1e1be 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -673,6 +673,7 @@ func TestOpenAIGatewayService_Forward_HTTPWrappedInvalidEncryptedContentStopsWit cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + svc.SetReasoningRecoveryService(newReasoningRecoverySwitchForTest(false)) account := &Account{ ID: 12, Name: "openai-apikey", @@ -683,7 +684,7 @@ func TestOpenAIGatewayService_Forward_HTTPWrappedInvalidEncryptedContentStopsWit "api_key": "sk-test", "base_url": "https://example.com", }, - Extra: map[string]any{"use_responses_api": true, OpenAIReasoningSignatureRecoveryEnabledExtraKey: false}, + Extra: map[string]any{"use_responses_api": true}, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 2a747771093c..31d1d5a34888 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -33,7 +33,10 @@ type OpenAIRecordUsageInput struct { RequestPayloadHash string APIKeyService APIKeyQuotaUpdater QuotaPlatform string // user×platform quota platform resolved by the handler before async billing. - PricingAt time.Time + // PricingAt 是请求级定价时刻(请求开始捕获,与利润门的 D 同源):高峰因子 + // 按该时刻计算,保证同一请求从准入到扣费不中途变价。零值回退记录时刻 + //(既有行为),供未装配的路径(图片/异步/cyber 等)沿用。 + PricingAt time.Time // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。 CyberBlocked bool // NativeCompactionV2 is an orthogonal semantic flag captured by the @@ -43,13 +46,6 @@ type OpenAIRecordUsageInput struct { ChannelUsageFields } -func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { - if input != nil && !input.PricingAt.IsZero() { - return input.PricingAt - } - return timezone.Now() -} - // CyberPolicyUsageInput 是 cyber 拒绝、未走正常 RecordUsage 的请求记录用量的入参。 // 用量按上游真实 token 计费,与 WS cyber 及正常请求口径一致(InputTokens/OutputTokens // 取自上游 response.failed 报告的 usage,即 mark.UpstreamInTok/OutTok)。 @@ -127,6 +123,15 @@ func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Contex return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) } +// openAIUsagePricingAt 返回本次用量记录使用的定价时刻:优先请求级 PricingAt +// (与利润门 D 同源同刻),未装配时回退记录时刻(既有行为)。 +func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { + if input != nil && !input.PricingAt.IsZero() { + return input.PricingAt + } + return timezone.Now() +} + func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { return false @@ -203,8 +208,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, - // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。 + // 高峰因子按请求级 PricingAt 现算(与利润门 D 同源同刻,跨峰谷请求不中途 + // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的 + // Resolve,以免污染 user:group 倍率缓存。 baseMultiplier := multiplier pricingAt := openAIUsagePricingAt(input) multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt) diff --git a/backend/internal/service/openai_profit_control.go b/backend/internal/service/openai_profit_control.go index 74e87d1b420c..a3418e6fcf35 100644 --- a/backend/internal/service/openai_profit_control.go +++ b/backend/internal/service/openai_profit_control.go @@ -72,10 +72,6 @@ import ( ) const ( - // Downstream retired profit admission. Retain historical schema and pure - // numerical helpers, but no configuration or stored row may enable a gate. - profitControlRuntimeEnabled = false - // profitControlRateEpsilon 吸收 decimal(10,4) 落库与浮点乘法的边界误差: // U 与阈值的相对差在该量级内视为相等(即 U == 阈值 判定为合格)。 profitControlRateEpsilon = 1e-9 @@ -91,13 +87,6 @@ const ( type openAIProfitControlGateCtxKey struct{} -func withoutRuntimeProfitControlGate(ctx context.Context) context.Context { - if gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); gate != nil { - return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, (*openAIProfitControlGate)(nil)) - } - return ctx -} - // openAIProfitControlSuppressCtxKey 标记本请求显式跳过利润门(独立图片/视频 // 端点、Grok 媒体、count_tokens、live 等利润门范围外流量)。所有装门点看到该 // 标记后一律不装门,防止 service 层防御性装门把边界外流量重新拉回利润过滤。 @@ -163,9 +152,6 @@ func WithOpenAIProfitControlSuppressed(ctx context.Context) context.Context { func (s *OpenAIGatewayService) WithOpenAITurnPricingContext(ctx context.Context, groupID *int64) (context.Context, time.Time) { pricingAt := timezone.Now() ctx = context.WithValue(ctx, openAIPricingAtCtxKey{}, pricingAt) - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx), pricingAt - } if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { return ctx, pricingAt } @@ -206,9 +192,6 @@ func OpenAIPricingAtFromContext(ctx context.Context) time.Time { // (门不存在,全部否决点自动放行,既有行为零变化)。ctx 已有同分组门时直接 // 复用:同一请求的全部 failover 重入共享同一阈值。 func (s *OpenAIGatewayService) withOpenAIProfitControlGate(ctx context.Context, groupID *int64) context.Context { - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx) - } if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { return ctx } @@ -232,9 +215,6 @@ func (s *OpenAIGatewayService) withOpenAIProfitControlGate(ctx context.Context, } func (s *OpenAIGatewayService) resolveOpenAIProfitControlGate(ctx context.Context, groupID *int64) *openAIProfitControlGate { - if !profitControlRuntimeEnabled { - return nil - } if s == nil || groupID == nil || *groupID <= 0 { return nil } diff --git a/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go index 53cde7f71eff..19e6b027e273 100644 --- a/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go +++ b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go @@ -28,26 +28,22 @@ func legacyProfitDiagnosticAccount(id int64) *Account { return a } -func TestSelectAccountWithScheduler_DormantProfitKeepsNonProfitDiagnostics(t *testing.T) { +func TestSelectAccountWithScheduler_LegacyProfitDiagnostics(t *testing.T) { groupID := int64(5313) ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) - t.Run("legacy margin does not create a threshold rejection", func(t *testing.T) { + t.Run("threshold reports deterministic pool count", func(t *testing.T) { account := legacyProfitDiagnosticAccount(53131) profitControlTestAccountWithRate(account, 0.9) svc := legacyProfitDiagnosticService([]Account{*account}) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, account.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "pool=1, filtered: profit_threshold=1") }) - t.Run("missing account rate does not activate retired validation", func(t *testing.T) { + t.Run("missing account rate reports invalid rate", func(t *testing.T) { account := upstreamCostTestOAuthAccount(53132) account.Status = StatusActive account.Schedulable = true @@ -55,13 +51,9 @@ func TestSelectAccountWithScheduler_DormantProfitKeepsNonProfitDiagnostics(t *te svc := legacyProfitDiagnosticService([]Account{*account}) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, account.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "profit_invalid_account_rate=1") }) t.Run("model support gate does not report profit reasons", func(t *testing.T) { diff --git a/backend/internal/service/openai_profit_control_paths_test.go b/backend/internal/service/openai_profit_control_paths_test.go index 2b5703a00326..239a7d26f2a1 100644 --- a/backend/internal/service/openai_profit_control_paths_test.go +++ b/backend/internal/service/openai_profit_control_paths_test.go @@ -1,9 +1,11 @@ package service -// Runtime path cases follow the downstream retirement contract: legacy profit -// fields cannot filter accounts, but explicit exclusions, model capability, -// sticky identity and billing rates remain effective. Private manually-built -// gate cases preserve the independent numerical/admission helper coverage. +// 利润控制请求路径矩阵测试:证明所有文本调度路径都经过利润准入过滤, +// 且任何 fallback 都不能把已排除账号重新放回候选。 +// 覆盖:高级调度器候选池(openai_profit_control_test.go)、legacy 引擎、 +// previous_response WSv2 粘连(跳过复用但保留绑定 + 倍率恢复重粘连)、 +// failover 排除不回收、抢槽后终检、倍率恢复重新准入、 +// 用户覆盖倍率 D、composite 计费分组与调度分组分离。 import ( "context" @@ -26,9 +28,8 @@ func profitControlWSAccount(id int64, rate float64, now time.Time) Account { return *account } -// Dormant admission never rejects the previous-response binding based on a -// saved legacy margin; both old and subsequently changed rates remain usable. -func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { +// previous_response_id 粘连:利润不合格 → 跳过复用但不删绑定;倍率恢复 → 重新粘连。 +func TestProfitControl_PreviousResponseStickyVetoKeepsBinding(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(23, 0.5, 0)) groupID := int64(23) now := time.Now() @@ -47,14 +48,9 @@ func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_profit", "gpt-5.1", nil, false) require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection, "上游倍率 0.8 超过阈值 0.5 的账号不应继续命中 previous_response_id 粘连") - // Historical profit data must not invalidate the existing response binding. + // 利润不合格与 quota auto-pause 同为暂时状态:绑定必须保留。 boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_profit") require.NoError(t, getErr) require.Equal(t, expensive.ID, boundAccountID) @@ -72,7 +68,7 @@ func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { } // legacy 引擎(高级调度器关闭):候选过滤、全排除错误语义与既有语义一致。 -func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) { +func TestProfitControl_LegacyEngineFiltersCandidates(t *testing.T) { now := time.Now() cheap := upstreamCostTestAccount(41, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) expensive := upstreamCostTestAccount(42, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) @@ -91,30 +87,25 @@ func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) } groupID := int64(7) - t.Run("legacy path admits either otherwise eligible account", func(t *testing.T) { - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, expected := range []int64{cheap.ID, expensive.ID} { - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}} - delete(excluded, expected) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + t.Run("legacy path only admits profitable accounts", func(t *testing.T) { + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Equal(t, expected, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) + require.Equal(t, cheap.ID, selection.Account.ID) if selection.ReleaseFunc != nil { selection.ReleaseFunc() } } }) - t.Run("explicit exclusions still return the standard error", func(t *testing.T) { + t.Run("legacy path all excluded returns standard error", func(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", map[int64]struct{}{cheap.ID: {}, expensive.ID: {}}, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.Nil(t, selection) require.Error(t, err) require.True(t, errors.Is(err, ErrNoAvailableAccounts)) - require.NotContains(t, err.Error(), openAIProfitFilterReasonThreshold) }) t.Run("legacy path keeps official behavior when gate disabled", func(t *testing.T) { @@ -129,9 +120,8 @@ func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) }) } -// Explicit failures stay excluded; historical profit settings must not also -// exclude the remaining otherwise eligible account. -func TestProfitControl_DormantFailoverKeepsExplicitExclusions(t *testing.T) { +// failover:可盈利账号因失败被排除后,剩余不合格账号不得被"放回"候选。 +func TestProfitControl_FailoverDoesNotReadmitExcluded(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -156,21 +146,15 @@ func TestProfitControl_DormantFailoverKeepsExplicitExclusions(t *testing.T) { concurrencyService: NewConcurrencyService(cache), } groupID := int64(7) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) - // Simulate a real failure exclusion; the remaining expensive account is - // still eligible because profit admission is retired. + // 模拟 failover:上一轮失败的 cheap 已进入 excludedIDs,仅剩 expensive 不合格。 excluded := map[int64]struct{}{cheap.ID: {}} selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.Contains(t, excluded, cheap.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection, "failover 后不得回收利润不合格账号") + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=1") } // 抢槽后终检:候选构建后才变得不合格的账号(状态竞态)在取得槽位前被拦截。 @@ -201,9 +185,8 @@ func TestProfitControl_PostSlotRecheckVetoes(t *testing.T) { require.Equal(t, cache.totalAcquires(), cache.releaseCount(expensive.ID), "被拦截账号不得泄漏并发槽位") } -// Rate changes remain observable account data but do not toggle retired -// admission; the only healthy account is selectable on both sides. -func TestProfitControl_DormantRateChangesKeepAccountEligible(t *testing.T) { +// 倍率恢复:探测刷新回落到阈值内后,此前被排除的账号重新参与调度。 +func TestProfitControl_RateRecoveryReadmitsAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -226,13 +209,8 @@ func TestProfitControl_DormantRateChangesKeepAccountEligible(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.Error(t, err) // 同步/手工写回:账号倍率回落到 0.3(阈值 0.5 内)后自动恢复参与。 recovered := upstreamCostTestAccount(71, UpstreamBillingProbeStatusOK, 0.3, time.Now().Add(-time.Minute), 30*time.Minute) @@ -260,8 +238,8 @@ func (r profitControlUserRateRepo) GetByUserAndGroup(context.Context, int64, int return r.rate, nil } -// Retirement does not alter the ordinary user-specific billing rate resolver. -func TestProfitControl_DormantGatePreservesUserOverrideRate(t *testing.T) { +// D 必须取请求用户的真实倍率:有用户覆盖时用覆盖值,绝不退回分组默认。 +func TestProfitControl_GateUsesUserOverrideRate(t *testing.T) { override := 0.5 svc := &OpenAIGatewayService{ userGroupRateResolver: newUserGroupRateResolver( @@ -274,15 +252,13 @@ func TestProfitControl_DormantGatePreservesUserOverrideRate(t *testing.T) { ctx := context.WithValue(profitControlTestCtx(group), ctxkey.UserID, int64(42)) gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) - require.Nil(t, gate) - require.InDelta(t, 0.5, svc.ResolveUserGroupRateMultiplier(ctx, 42, groupID, group.RateMultiplier), 1e-12, - "ordinary billing still resolves the saved user override") + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "阈值必须基于用户覆盖倍率 0.5,而不是分组默认 2.0") // 无用户身份(内部调用)时按分组默认倍率计算。 gate = svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.Equal(t, 2.0, group.RateMultiplier) - require.Equal(t, 0.5, override) + require.NotNil(t, gate) + require.InDelta(t, 2.0, gate.threshold, 1e-12) } type profitControlGroupRepo struct { @@ -301,8 +277,8 @@ func (r profitControlGroupRepo) GetByID(context.Context, int64) (*Group, error) panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") } -// Cross-group retirement preserves both billing and scheduled-group data. -func TestProfitControl_DormantCompositePreservesBothGroupRates(t *testing.T) { +// composite 路由:门配置取被调度成员分组,D 取请求真实计费分组(ctx 认证分组)。 +func TestProfitControl_CompositeUsesBillingGroupRate(t *testing.T) { memberGroupID := int64(7) memberGroup := profitControlTestGroup(memberGroupID, 0.5, 0) memberGroup.RateMultiplier = 99 // 若 D 误取成员分组倍率,阈值会是 49.5 @@ -320,12 +296,8 @@ func TestProfitControl_DormantCompositePreservesBothGroupRates(t *testing.T) { ctx := profitControlTestCtx(billingGroup) gate := svc.resolveOpenAIProfitControlGate(ctx, &memberGroupID) - require.Nil(t, gate) - priced, pricingAt := svc.WithOpenAIRequestPricingContext(ctx, &memberGroupID) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(priced)) - require.Equal(t, 1.0, billingGroup.RateMultiplier) - require.Equal(t, 99.0, memberGroup.RateMultiplier) - require.True(t, memberGroup.ProfitControlEnabled, "retirement must not rewrite persisted legacy configuration") + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "D 必须来自计费分组(composite 父分组)倍率 1.0") } // legacy 引擎与 DB recheck 共用的资格判定直接覆盖利润门。 @@ -350,9 +322,9 @@ func TestProfitControl_EligibilityFunctionVetoes(t *testing.T) { require.True(t, isOpenAICompatibleAccountEligibleForRequest(context.Background(), expensive, PlatformOpenAI, "", false, "")) } -// Legacy engine selection keeps ordinary eager binding even if persisted rows -// still carry retired profit configuration. -func TestProfitControl_DormantLegacyEngineKeepsEagerStickyBinding(t *testing.T) { +// legacy 引擎粘性写回(评审 M-Legacy 回归):门下选号阶段不得直写粘性—— +// 终检否决的账号不能成为新绑定;无门保持官方 eager 绑定与原 TTL 语义。 +func TestProfitControl_LegacyEngineDefersStickyBindingUnderGate(t *testing.T) { now := time.Now() cheap := upstreamCostTestAccount(45, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) expensive := upstreamCostTestAccount(46, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) @@ -376,16 +348,15 @@ func TestProfitControl_DormantLegacyEngineKeepsEagerStickyBinding(t *testing.T) }, cache } - t.Run("legacy enabled values keep eager binding without a runtime gate", func(t *testing.T) { + t.Run("gated selection defers binding to terminal admission", func(t *testing.T) { svc, cache := newSvc(map[string]int64{}) ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", sessionHash, "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selection.Account.ID) - require.Contains(t, cache.sessionBindings, "openai:"+sessionHash) - require.Equal(t, selection.Account.ID, cache.sessionBindings["openai:"+sessionHash]) - require.False(t, selection.ProfitGateActive()) + require.Equal(t, cheap.ID, selection.Account.ID) + require.Empty(t, cache.sessionBindings, "门下 legacy 选号阶段不得直写粘性绑定") + require.True(t, selection.ProfitGateActive(), "legacy 选号结果同样携带门") if selection.ReleaseFunc != nil { selection.ReleaseFunc() } diff --git a/backend/internal/service/openai_profit_control_pricing_test.go b/backend/internal/service/openai_profit_control_pricing_test.go index 726284954443..379789f20020 100644 --- a/backend/internal/service/openai_profit_control_pricing_test.go +++ b/backend/internal/service/openai_profit_control_pricing_test.go @@ -1,8 +1,8 @@ package service -// Downstream runtime boundaries keep profit admission dormant while preserving -// pricingAt, endpoint eligibility and sticky behavior. Manually constructed -// private-gate tests below still cover the unchanged numerical/usage helpers. +// 请求级定价与利润门回归:请求级 pricingAt 定价上下文、门复用(failover 阈值稳定)、 +// Responses 文本能力利润门、U 使用账号倍率且与探测新鲜度解耦、 +// 用量记录定价时刻取值。 import ( "context" @@ -14,25 +14,23 @@ import ( "github.com/stretchr/testify/require" ) -// Downstream retirement keeps request pricing but never enables admission, -// including historical enabled rows and explicitly suppressed requests. -func TestProfitControl_DormantRequestPricingContext(t *testing.T) { +// WithOpenAIRequestPricingContext:装门 + 固定 pricingAt;显式抑制标记 +// (媒体/count_tokens/live 等门范围外路径)跳门且防御性装门无法把门加回来。 +func TestProfitControl_RequestPricingContext(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(61) now := time.Now() expensive := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) profitControlTestAccountWithRate(expensive, 0.8) - t.Run("keeps pricing instant without admission", func(t *testing.T) { + t.Run("installs gate and pricing instant", func(t *testing.T) { base := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID) require.False(t, pricingAt.IsZero()) require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) vetoed, reason := OpenAIProfitControlVeto(ctx, expensive) - require.False(t, vetoed) - require.Empty(t, reason) - require.False(t, gatewayProfitControlGateActive(ctx)) - require.Equal(t, 0.8, *expensive.RateMultiplier) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) }) t.Run("suppress marker skips gate everywhere", func(t *testing.T) { @@ -48,40 +46,37 @@ func TestProfitControl_DormantRequestPricingContext(t *testing.T) { }) } -// Failover keeps the request price instant; neither a legacy setting change -// nor a carried private gate can re-enable admission at a runtime boundary. -func TestProfitControl_DormantFailoverKeepsPricingContext(t *testing.T) { +// failover 重入复用同一门:请求中途分组配置变化不得改变本请求阈值。 +func TestProfitControl_GateReuseKeepsThresholdAcrossFailover(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(62) group := profitControlTestGroup(groupID, 0.5, 0) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID) - gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) + ctx := svc.withOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.InDelta(t, 0.5, gate.threshold, 1e-12) // 模拟请求进行中管理员改配置(ctx 分组为同一指针,与 auth 快照语义一致)。 group.ProfitMinMargin = 0.9 reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID) - reGate, _ := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, reGate) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(reCtx)) - require.Equal(t, 0.9, group.ProfitMinMargin, "retirement does not rewrite legacy rows") + reGate, ok := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.Same(t, gate, reGate, "failover 重入必须复用同一门,阈值不得中途变化") // 换分组(composite/模型路由成员调度)重新解析;成员分组无门时必须清除 // 父分组门,阈值不得跨组泄漏。 otherID := int64(63) - carried := context.WithValue(reCtx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{groupID: groupID, threshold: 0}) - otherCtx := svc.withOpenAIProfitControlGate(carried, &otherID) + otherCtx := svc.withOpenAIProfitControlGate(reCtx, &otherID) otherGate, _ := otherCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) require.Nil(t, otherGate, "成员分组未启用利润控制时父分组门必须清除") - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(otherCtx)) now := time.Now() expensive := upstreamCostTestAccount(8, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute) vetoed, _ := openAIProfitControlVetoReason(otherCtx, expensive) require.False(t, vetoed) } -// Billing still uses pricingAt and its peak factor without a runtime gate. -func TestProfitControl_DormantAdmissionPreservesPeakPricingAt(t *testing.T) { +// D 固定在 pricingAt:高峰因子按请求开始时刻计算,与"当前时刻"无关。 +func TestProfitControl_PricingAtFixesDownstreamPeakFactor(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(64) group := profitControlTestGroup(groupID, 0, 0) @@ -98,11 +93,9 @@ func TestProfitControl_DormantAdmissionPreservesPeakPricingAt(t *testing.T) { ctx := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, pricingAt) gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) - require.Nil(t, gate) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - require.Equal(t, pricingAt, openAIUsagePricingAt(&OpenAIRecordUsageInput{PricingAt: pricingAt})) - require.Equal(t, 3.0, group.PeakMultiplierAt(pricingAt), "billing peak factor must not change with admission retirement") - require.Equal(t, 1.0, group.PeakMultiplierAt(outsideWindow)) + require.NotNil(t, gate) + require.InDelta(t, 3.0, gate.threshold, 1e-9, "阈值必须用 pricingAt 时刻的高峰因子(1.0×3.0×(1-0))") + require.Equal(t, pricingAt, gate.pricingAt) } // U 只取账号倍率:探测快照内容和新鲜度不再直接参与利润判断。 @@ -116,9 +109,9 @@ func TestProfitControl_UsesAccountRateInsteadOfProbeSnapshot(t *testing.T) { require.Equal(t, openAIProfitFilterReasonThreshold, reason) } -// Endpoint capability selection remains active; retired admission cannot turn -// otherwise valid Chat/Responses candidates into no-available errors. -func TestProfitControl_DormantAdmissionKeepsResponsesSelection(t *testing.T) { +// Responses 是端点能力,不代表媒体请求;原生远程压缩同样要求该能力, +// 因此唯一文本调度入口必须照常安装利润门。 +func TestProfitControl_ResponsesCapabilityUsesTextGateAtScheduler(t *testing.T) { now := time.Now() expensive := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) expensive.Status = StatusActive @@ -131,23 +124,19 @@ func TestProfitControl_DormantAdmissionKeepsResponsesSelection(t *testing.T) { concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), } groupID := int64(77) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, capability := range []OpenAIEndpointCapability{OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityResponses} { - selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, capability, false, false, true) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } - } + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + _, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true) + require.ErrorIs(t, err, ErrNoAvailableAccounts, "文本能力必须过利润门") + + selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, false, false, true) + require.ErrorIs(t, err, ErrNoAvailableAccounts, "Responses 文本能力不得绕过利润门") + require.Nil(t, selection) } -// Runtime retirement leaves missing/manual rates untouched; the private-gate -// numerical classifier has separate unchanged compatibility tests. -func TestProfitControl_DormantAdmissionDoesNotClassifyAccountRates(t *testing.T) { +// 账号倍率缺失一律视为非法保守拒绝;手工或同步维护了倍率的任意账号类型都按 +// 同一阈值判断(OAuth 与 API Key 无差别)。 +func TestProfitControl_AccountRateSemantics(t *testing.T) { now := time.Now() missing := upstreamCostTestOAuthAccount(2) manualOAuth := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(3), 0.3) @@ -157,16 +146,19 @@ func TestProfitControl_DormantAdmissionDoesNotClassifyAccountRates(t *testing.T) group.RateMultiplier = 1 base := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, now) gate := (&OpenAIGatewayService{}).resolveOpenAIProfitControlGate(base, &group.ID) - require.Nil(t, gate) - for _, account := range []*Account{missing, manualOAuth, expensive} { - vetoed, reason := openAIProfitControlVetoReason(base, account) - require.False(t, vetoed) - require.Empty(t, reason) - } - require.Nil(t, missing.RateMultiplier) - require.Equal(t, 0.3, *manualOAuth.RateMultiplier) - require.Equal(t, 0.8, *expensive.RateMultiplier) - require.Equal(t, now, OpenAIPricingAtFromContext(base)) + require.NotNil(t, gate) + gateCtx := context.WithValue(base, openAIProfitControlGateCtxKey{}, gate) + + vetoed, reason := openAIProfitControlVetoReason(gateCtx, missing) + require.True(t, vetoed, "缺失账号倍率必须保守拒绝") + require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason) + + vetoed, _ = openAIProfitControlVetoReason(gateCtx, manualOAuth) + require.False(t, vetoed, "手工维护的 OAuth 倍率应正常准入") + + vetoed, reason = openAIProfitControlVetoReason(gateCtx, expensive) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) } // 用量记录定价时刻:优先请求级 PricingAt,未装配回退记录时刻。 @@ -205,43 +197,41 @@ func TestOpenAIProfitControlStickyBindingOccursOnlyAfterTerminalAdmission(t *tes require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无既有绑定时应在终检通过后建立粘性") } -// Turn boundaries refresh pricingAt, clear carried gates, and never revive -// admission from historical configuration. -func TestProfitControl_DormantTurnPricingContext(t *testing.T) { +// WithOpenAITurnPricingContext:长连接 turn 边界重新冻结 pricingAt 并按当前 +// 配置重装门(区别于请求级同门复用);已装门时以门所属调度分组为准。 +func TestProfitControl_TurnPricingContext(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(63) expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.8, time.Now().Add(-time.Minute), 30*time.Minute) profitControlTestAccountWithRate(expensive, 0.8) - t.Run("refreshes instant without reviving changed legacy config", func(t *testing.T) { + t.Run("refreshes instant and re-resolves gate config", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.5, 0) base := profitControlTestCtx(group) connCtx, connAt := svc.WithOpenAIRequestPricingContext(base, &groupID) vetoed, _ := OpenAIProfitControlVeto(connCtx, expensive) - require.False(t, vetoed) + require.True(t, vetoed) - // A saved legacy margin change must not reactivate a retired feature. + // 连接中途运营者放宽 margin:turn 级重装必须生效(请求级复用不生效)。 group.ProfitMinMargin = 0.1 turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &groupID) require.False(t, turnAt.Before(connAt)) require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx)) vetoed, _ = OpenAIProfitControlVeto(turnCtx, expensive) - require.False(t, vetoed) - require.False(t, gatewayProfitControlGateActive(turnCtx)) - require.Equal(t, 0.1, group.ProfitMinMargin) + require.False(t, vetoed, "turn 级重装应采用最新分组配置") }) - t.Run("clears a carried scheduled-group gate", func(t *testing.T) { + t.Run("keeps scheduled group of the existing gate", func(t *testing.T) { scheduledGroupID := int64(64) scheduled := profitControlTestGroup(scheduledGroupID, 0.5, 0) connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(scheduled), &scheduledGroupID) - connCtx = context.WithValue(connCtx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{groupID: scheduledGroupID, threshold: 0}) // 入口分组与调度分组不同(composite 成员分组场景):turn 重装取门的分组。 entryGroupID := int64(65) - turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID) - gate, _ := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx)) + turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID) + gate, ok := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.NotNil(t, gate) + require.Equal(t, scheduledGroupID, gate.groupID) }) t.Run("suppress marker only refreshes instant", func(t *testing.T) { diff --git a/backend/internal/service/openai_profit_control_test.go b/backend/internal/service/openai_profit_control_test.go index 12c7bfd28c65..c0fb51e64d2f 100644 --- a/backend/internal/service/openai_profit_control_test.go +++ b/backend/internal/service/openai_profit_control_test.go @@ -37,7 +37,7 @@ func profitControlTestAccountWithRate(account *Account, rate float64) *Account { return account } -func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { +func TestResolveOpenAIProfitControlGate(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(7) @@ -61,14 +61,14 @@ func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) }) - t.Run("grok legacy fields cannot enable a gate", func(t *testing.T) { + t.Run("grok group routed through openai handler installs gate", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.3, 0.05) group.Platform = PlatformGrok group.RateMultiplier = 0.5 gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.True(t, group.ProfitControlEnabled) - require.Equal(t, 0.5, group.RateMultiplier) + require.NotNil(t, gate) + require.Equal(t, PlatformGrok, gate.platform) + require.InDelta(t, 0.5*(1-0.35), gate.threshold, 1e-12) }) t.Run("ctx group id mismatch without snapshot yields no gate", func(t *testing.T) { @@ -76,33 +76,29 @@ func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) }) - t.Run("legacy margin and buffer do not change ordinary billing rate", func(t *testing.T) { + t.Run("threshold composes margin and buffer from downstream rate", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.3, 0.05) group.RateMultiplier = 2.0 gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID) - require.False(t, pricingAt.IsZero()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - require.Equal(t, 2.0, group.RateMultiplier) - require.Equal(t, 0.3, group.ProfitMinMargin) - require.Equal(t, 0.05, group.ProfitSafetyBuffer) + require.NotNil(t, gate) + require.InDelta(t, 2.0*(1-0.35), gate.threshold, 1e-12) + require.Equal(t, PlatformOpenAI, gate.platform) + require.False(t, gate.pricingAt.IsZero()) + require.Equal(t, groupID, gate.groupID) }) - t.Run("retirement preserves peak factor without installing admission", func(t *testing.T) { + t.Run("threshold applies peak factor exactly like billing", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.5, 0) group.SubscriptionType = SubscriptionTypeSubscription group.PeakRateEnabled = true group.PeakStart = "00:00" group.PeakEnd = "23:59" group.PeakRateMultiplier = 3.0 - fixed := time.Date(2026, 1, 15, 12, 0, 0, 0, timezone.Location()) - expected := group.PeakMultiplierAt(fixed) - require.Equal(t, 3.0, expected) gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.Equal(t, expected, group.PeakMultiplierAt(fixed)) - require.Equal(t, 1.0, group.RateMultiplier) + require.NotNil(t, gate) + expected := group.RateMultiplier * group.PeakMultiplierAt(timezone.Now()) * 0.5 + require.InDelta(t, expected, gate.threshold, 1e-9) + require.Equal(t, PlatformOpenAI, gate.platform) }) } @@ -184,7 +180,7 @@ func TestOpenAIProfitControlVetoReason(t *testing.T) { }) } -func TestProfitControlSchedulerDormantKeepsEligibleCandidates(t *testing.T) { +func TestProfitControlSchedulerFiltersCandidates(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -213,40 +209,37 @@ func TestProfitControlSchedulerDormantKeepsEligibleCandidates(t *testing.T) { } groupID := int64(7) - t.Run("legacy profit fields cannot exclude an otherwise eligible account", func(t *testing.T) { - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, expected := range []int64{cheap.ID, expensive.ID, oauth.ID} { - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}, oauth.ID: {}} - delete(excluded, expected) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + t.Run("unprofitable and invalid-rate accounts never win", func(t *testing.T) { + // margin 0.5 → 阈值 0.5:expensive(0.8) 超阈值、oauth 倍率缺失,仅 cheap 可选。 + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Equal(t, expected, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) + require.Equal(t, cheap.ID, selection.Account.ID) if selection.ReleaseFunc != nil { selection.ReleaseFunc() } } }) - t.Run("explicit exclusions still exhaust the pool without profit reasons", func(t *testing.T) { + t.Run("all excluded surfaces standard no-available error with profit reasons", func(t *testing.T) { + // margin+buffer 0.8 → 阈值 0.2:cheap/expensive 超阈值,oauth 倍率非法。 ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}, oauth.ID: {}} - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.Nil(t, selection) require.Error(t, err) require.True(t, errors.Is(err, ErrNoAvailableAccounts)) - require.NotContains(t, err.Error(), openAIProfitFilterReasonThreshold) - require.NotContains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=2") + require.Contains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate+"=1") }) t.Run("manually rated oauth account is admitted", func(t *testing.T) { - // Explicit request exclusions, not retired profitability, select OAuth. + // 阈值 0.2 排除两个 API Key;OAuth 手工倍率 0.1 可参与调度。 profitControlTestAccountWithRate(oauth, 0.1) svc.accountRepo = schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive, *oauth}} ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", map[int64]struct{}{cheap.ID: {}, expensive.ID: {}}, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) require.Equal(t, oauth.ID, selection.Account.ID) diff --git a/backend/internal/service/openai_reasoning_recovery.go b/backend/internal/service/openai_reasoning_recovery.go index ae5f77af1f76..901b10d88a86 100644 --- a/backend/internal/service/openai_reasoning_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery.go @@ -14,7 +14,7 @@ import ( "strings" "time" - extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" + 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" @@ -40,24 +40,22 @@ const ( // HTTP Responses request. It never re-enters a protocol converter or scheduler. // original is immutable; wire is the exact payload of the latest sent attempt. type openAIReasoningRecoveryState struct { - ctx context.Context - c *gin.Context - account *Account - enabled bool - policyUnavailable bool - token string - original []byte - wire []byte - scope OpenAIReasoningCacheScope - store OpenAIReasoningStateStore - budget *OpenAIReasoningCacheBudget + ctx context.Context + c *gin.Context + account *Account + enabled bool + token string + original []byte + wire []byte + scope OpenAIReasoningCacheScope + store OpenAIReasoningStateStore + budget *OpenAIReasoningCacheBudget identity string attemptUsage map[string]int64 retryBody []byte retryUsed bool stopRecorded bool - onRejected func([]string) retryCleanups []func() diagnosticIncoming []byte @@ -81,12 +79,11 @@ func (s *OpenAIGatewayService) newOpenAIReasoningRecoveryState(ctx context.Conte if ctx == nil { ctx = context.Background() } - enabled, policyErr := openAIReasoningPolicyEnabled(ctx, account, OpenAIReasoningSignatureRecoveryEnabledExtraKey) + // The global switch is read here, once for this forwarding attempt. r := &openAIReasoningRecoveryState{ ctx: ctx, c: c, account: account, token: token, - enabled: enabled && policyErr == nil && !isOpenAICompatMessagesBridgeContext(c), - policyUnavailable: policyErr != nil, - store: s.openAIReasoningStateStore(), budget: openAIReasoningCacheBudgetForRequest(c), + enabled: s.reasoningRecovery.Enabled() && account.supportsOpenAIReasoningRecovery() && !isOpenAICompatMessagesBridgeContext(c), + store: s.openAIReasoningStateStore(), budget: openAIReasoningCacheBudgetForRequest(c), } r.redactPayload = func(payload []byte) []byte { return s.redactAgentIdentitySensitiveBody(ctx, account, payload) } if c != nil { @@ -95,6 +92,14 @@ func (s *OpenAIGatewayService) newOpenAIReasoningRecoveryState(ctx context.Conte return r } +// SetReasoningRecoveryService installs the global switch. Without one, recovery +// is enabled. +func (s *OpenAIGatewayService) SetReasoningRecoveryService(recovery *ReasoningRecoveryService) { + if s != nil { + s.reasoningRecovery = recovery + } +} + func (r *openAIReasoningRecoveryState) Close() { if r == nil { return @@ -105,12 +110,6 @@ func (r *openAIReasoningRecoveryState) Close() { r.retryCleanups = nil } -func (r *openAIReasoningRecoveryState) SetRejectedCallback(fn func([]string)) { - if r != nil { - r.onRejected = fn - } -} - func (r *openAIReasoningRecoveryState) RecoveryAttempt() bool { return r != nil && r.retryUsed } func openAIReasoningRecoveryStateFromContext(c *gin.Context) *openAIReasoningRecoveryState { @@ -181,7 +180,7 @@ func markOpenAIReasoningFailureTerminalForwarded(c *gin.Context) { } } -// buildOpenAIReasoningScope is shared by positive replay and rejection memory. +// buildOpenAIReasoningScope identifies the source whose rejections are remembered. // Credentials and route identity enter only a digest; neither is stored or logged. func buildOpenAIReasoningScope(c *gin.Context, account *Account, req *http.Request, wireBody []byte) (OpenAIReasoningCacheScope, error) { if c == nil || account == nil || req == nil || req.URL == nil { @@ -235,19 +234,9 @@ func openAIReasoningDigest(b []byte) string { } // PrepareRequest is called at the actual HTTP send boundary, after all normal -// transformations and any positive replay. Recovery compares the entire final -// body and source, so a later builder cannot silently remap or reinject state. +// transformations. Recovery compares the entire final body and source, so a +// later builder cannot silently remap or reinject state. func (r *openAIReasoningRecoveryState) PrepareRequest(req *http.Request, body []byte, proxyURL string) (*http.Request, []byte, error) { - if r != nil && r.enabled { - enabled, err := openAIReasoningPolicyEnabled(r.ctx, r.account, OpenAIReasoningSignatureRecoveryEnabledExtraKey) - if !enabled || err != nil { - r.enabled, r.policyUnavailable = false, err != nil - if r.retryUsed { - r.diagnosticStopReason = "policy_unavailable" - return nil, nil, r.StopError(errors.New("recovery policy unavailable")) - } - } - } if r == nil || !r.enabled { return req, body, nil } @@ -392,14 +381,8 @@ func openAIReasoningCipherItems(body []byte) []openAIReasoningCipherItem { type openAIReasoningRejection struct{ code, param string } func parseOpenAIReasoningRejection(payload []byte) (openAIReasoningRejection, bool) { - envelope := openAIRecoveryEnvelope(payload) - if !extensionv1.ValidReasoningRejectionEnvelope(envelope) { - return openAIReasoningRejection{}, false - } - if *envelope.Code != "thinking_signature_invalid" && *envelope.Code != "invalid_encrypted_content" { - return openAIReasoningRejection{}, false - } - return openAIReasoningRejection{code: *envelope.Code, param: envelope.Param}, true + result := codexrecovery.Rejection(openAIRecoveryEnvelope(payload)) + return openAIReasoningRejection{code: result.Code, param: result.Param}, result.Recognized } func openAIReasoningToolHistoryAllowsRecovery(body []byte) bool { @@ -412,9 +395,9 @@ func openAIReasoningToolHistoryAllowsRecovery(body []byte) bool { return validateOpenAIResponsesToolOutputs(input, hasServerContext) == nil } -func openAIReasoningRejectedIndicesContext(ctx context.Context, body []byte, rejection openAIReasoningRejection) ([]int, []string) { - selection, err := selectOpenAIRecoveryIndices(ctx, body, rejection.param) - if err != nil || len(selection.Indices) == 0 { +func openAIReasoningRejectedIndices(body []byte, rejection openAIReasoningRejection) ([]int, []string) { + selection := selectOpenAIRecoveryIndices(body, rejection.param) + if len(selection.Indices) == 0 { return nil, nil } byIndex := map[int]string{} @@ -522,15 +505,11 @@ func openAIReasoningRecoverySignal(c *gin.Context, payload []byte, semanticCommi if r == nil || !r.enabled || r.retryUsed || r.ctx.Err() != nil { return nil } - rejection, ok, policyErr := readOpenAIRecoveryRejection(r.ctx, payload) - if policyErr != nil { - r.policyUnavailable = true - return nil - } + rejection, ok := parseOpenAIReasoningRejection(payload) if !ok { return nil } - indices, _ := openAIReasoningRejectedIndicesContext(r.ctx, r.wire, rejection) + indices, _ := openAIReasoningRejectedIndices(r.wire, rejection) if len(indices) == 0 { return nil } @@ -561,20 +540,11 @@ func (r *openAIReasoningRecoveryState) TryRecover(status int, headers http.Heade if r == nil || !r.enabled || r.retryUsed || semanticCommitted || r.ctx.Err() != nil || openAIReasoningRecoveryProtectedStatus(status) { return nil, false } - enabled, policyErr := openAIReasoningPolicyEnabled(r.ctx, r.account, OpenAIReasoningSignatureRecoveryEnabledExtraKey) - if !enabled || policyErr != nil { - r.enabled, r.policyUnavailable = false, policyErr != nil - return nil, false - } - rejection, ok, parseErr := readOpenAIRecoveryRejection(r.ctx, payload) - if parseErr != nil { - r.policyUnavailable = true - return nil, false - } + rejection, ok := parseOpenAIReasoningRejection(payload) if !ok { return nil, false } - indices, hashes := openAIReasoningRejectedIndicesContext(r.ctx, r.wire, rejection) + indices, hashes := openAIReasoningRejectedIndices(r.wire, rejection) if len(indices) == 0 { return nil, false } @@ -585,9 +555,6 @@ func (r *openAIReasoningRecoveryState) TryRecover(status int, headers http.Heade r.retryUsed = true r.retryBody = bytes.Clone(stripped) r.diagnosticState = "retry_prepared" - if r.onRejected != nil { - r.onRejected(append([]string(nil), hashes...)) - } if r.store != nil && r.scope.ScopeHash != "" { _ = r.budget.Do(r.ctx, func(ioCtx context.Context) error { return r.store.PutOpenAIRejectedReasoning(ioCtx, r.scope, hashes) @@ -755,8 +722,6 @@ func (r *openAIReasoningRecoveryState) continuationDiagnosticRecovery(payload [] func (r *openAIReasoningRecoveryState) recoveryNotAttemptedReason(payload []byte) string { switch { - case r.policyUnavailable: - return "policy_unavailable" case !r.enabled: return "disabled" case r.semanticCommitted: @@ -766,17 +731,11 @@ func (r *openAIReasoningRecoveryState) recoveryNotAttemptedReason(payload []byte case openAIReasoningRecoveryProtectedStatus(r.responseStatus): return "protected_status" } - rejection, ok, policyErr := readOpenAIRecoveryRejection(r.ctx, payload) - if policyErr != nil { - return "policy_unavailable" - } + rejection, ok := parseOpenAIReasoningRejection(payload) if !ok { return "not_signature_rejection" } - selection, err := selectOpenAIRecoveryIndices(r.ctx, r.wire, rejection.param) - if err != nil { - return "policy_unavailable" - } + selection := selectOpenAIRecoveryIndices(r.wire, rejection.param) if len(selection.Indices) > 0 { if _, err := stripOpenAIReasoningCipherIndices(r.wire, selection.Indices); err != nil { return "rewrite_failed" diff --git a/backend/internal/service/openai_reasoning_recovery_integration_test.go b/backend/internal/service/openai_reasoning_recovery_integration_test.go index 3bd9fdd964a6..4aed45686e10 100644 --- a/backend/internal/service/openai_reasoning_recovery_integration_test.go +++ b/backend/internal/service/openai_reasoning_recovery_integration_test.go @@ -3,18 +3,23 @@ package service import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" "net/http" + "net/http/httptest" "strings" "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func assertReasoningRecoveryUsageEvidence(t *testing.T, c *gin.Context, input int64, output *int64) { @@ -57,16 +62,44 @@ func TestOpenAIReasoningRecoveryLegacyDoneAndEarlierUsage(t *testing.T) { } } -func TestOpenAIChatReasoningReplayWaitsForAuthoritativeRejection(t *testing.T) { +// A Chat message has no field for reasoning ciphertext. On the Chat Completions +// endpoint only a Responses-shaped body carries it to the Responses upstream. +const reasoningRecoveryChatFixture = `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"},"store":false,"input":[{"role":"user","content":"constraints"},{"type":"reasoning","summary":[],"encrypted_content":"opaque-old"},{"role":"user","content":"continue"}]}` + +const reasoningRecoveryChatCompleted = `{"type":"response.completed","response":{"id":"resp_final","model":"gpt-5.6-sol","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":4,"output_tokens":6}}}` + +func reasoningRecoveryChatBody(t *testing.T, stream bool) []byte { + t.Helper() + body, err := sjson.SetBytes([]byte(reasoningRecoveryChatFixture), "stream", stream) + require.NoError(t, err) + return body +} + +func reasoningRecoveryChatContext(t *testing.T, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + group := int64(4) + c.Set("api_key", &APIKey{ID: 71, UserID: 9, GroupID: &group}) + return c, recorder +} + +func reasoningRecoverySSEResponse(payload string, status int) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(payload))} +} + +func TestOpenAIReasoningRecoveryChatPathWaitsForAuthoritativeRejection(t *testing.T) { for _, stream := range []bool{false, true} { for _, terminal := range []string{"response.failed", "response.done", "response.completed", "eof"} { t.Run(fmt.Sprintf("stream_%t/%s", stream, terminal), func(t *testing.T) { - cache := newOpenAIChatReplayTestCache() + store := &reasoningRecoveryMemoryStore{GatewayCache: &stubGatewayCache{}} account := newOpenAIRejectedFieldTestAccount() progress := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_rejected\"}}\n\n" + "data: {\"type\":\"response.in_progress\",\"response\":{\"usage\":{\"input_tokens\":3}}}\n\n" bare := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_encrypted_content\",\"param\":\"input[1].encrypted_content\"}}\n\n" - completed := "data: " + string(openAIChatReplayTestPayload(`[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]`)) + "\n\n" + completed := "data: " + reasoningRecoveryChatCompleted + "\n\n" firstFailure := progress + bare switch terminal { case "response.completed": @@ -75,29 +108,24 @@ func TestOpenAIChatReasoningReplayWaitsForAuthoritativeRejection(t *testing.T) { firstFailure += fmt.Sprintf("data: {\"type\":%q,\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"invalid_encrypted_content\",\"param\":\"input[1].encrypted_content\"}}}\n\n", terminal) } upstream := &httpUpstreamRecorder{responses: []*http.Response{ - openAIChatReplayTestResponse(openAIChatReplayTestSSE(openAIChatReplayTestOutput, stream), http.StatusOK), - openAIChatReplayTestResponse(firstFailure, http.StatusOK), - openAIChatReplayTestResponse(completed, http.StatusOK), + reasoningRecoverySSEResponse(firstFailure, http.StatusOK), + reasoningRecoverySSEResponse(completed, http.StatusOK), }} svc := newOpenAIRejectedFieldTestService(upstream) - svc.cache = cache - first := openAIChatReplayTestBody(t, false, stream, false) - c, _ := openAIChatReplayTestContext(t, first) - _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, first, "", "") - require.NoError(t, err) - second := openAIChatReplayTestBody(t, true, stream, false) - c, writer := openAIChatReplayTestContext(t, second) - result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, second, "", "") + svc.cache = store + body := reasoningRecoveryChatBody(t, stream) + c, writer := reasoningRecoveryChatContext(t, body) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") require.NoError(t, err) require.Equal(t, 4, result.Usage.InputTokens) require.Equal(t, 6, result.Usage.OutputTokens) if terminal == "response.completed" { - require.Len(t, upstream.requests, 2) - require.Empty(t, cache.rejected) + require.Len(t, upstream.requests, 1) + require.Empty(t, store.values) } else { require.NotContains(t, writer.Body.String(), "resp_rejected") - require.Len(t, upstream.requests, 3) - require.Len(t, cache.rejected, 1) + require.Len(t, upstream.requests, 2) + require.Len(t, store.values, 1) assertReasoningRecoveryUsageEvidence(t, c, 3, nil) } }) @@ -105,6 +133,57 @@ func TestOpenAIChatReasoningReplayWaitsForAuthoritativeRejection(t *testing.T) { } } +func TestOpenAIReasoningRecoveryChatPathRecoversOnce(t *testing.T) { + for _, failure := range []string{"http", "sse_before_output", "sse_after_output", "recovery_fails"} { + t.Run(failure, func(t *testing.T) { + store := &reasoningRecoveryMemoryStore{GatewayCache: &stubGatewayCache{}} + account := newOpenAIRejectedFieldTestAccount() + account.Extra["same_account_retry_count"] = 0 + rejection := reasoningRecoverySSEResponse(`{"error":{"code":"invalid_encrypted_content","param":"input[1].encrypted_content"}}`, http.StatusBadRequest) + if strings.HasPrefix(failure, "sse_") { + payload := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"first\"}}\n\n" + if failure == "sse_after_output" { + payload += "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"already thinking\"}\n\n" + } + payload += `data: {"type":"response.failed","response":{"status":"failed","error":{"code":"invalid_encrypted_content","param":"input[1].encrypted_content"}}}` + "\n\n" + rejection = reasoningRecoverySSEResponse(payload, http.StatusOK) + } + final := reasoningRecoverySSEResponse("data: "+reasoningRecoveryChatCompleted+"\n\n", http.StatusOK) + if failure == "recovery_fails" { + final = reasoningRecoverySSEResponse(`{"error":{"code":"server_error"}}`, http.StatusInternalServerError) + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{rejection, final}} + svc := newOpenAIRejectedFieldTestService(upstream) + svc.cache = store + body := reasoningRecoveryChatBody(t, true) + c, _ := reasoningRecoveryChatContext(t, body) + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + if failure == "sse_after_output" { + require.Error(t, err) + require.Len(t, upstream.requests, 1) + require.Empty(t, store.values) + return + } + if failure == "recovery_fails" { + require.Error(t, err) + var failover *UpstreamFailoverError + require.False(t, errors.As(err, &failover), "one recovery cannot reenter the account pool") + } else { + require.NoError(t, err) + } + require.Len(t, upstream.requests, 2) + require.Len(t, store.values, 1) + require.Equal(t, "opaque-old", gjson.GetBytes(upstream.bodies[0], "input.1.encrypted_content").String()) + expected, err := sjson.DeleteBytes(upstream.bodies[0], "input.1.encrypted_content") + require.NoError(t, err) + require.JSONEq(t, string(expected), string(upstream.bodies[1])) + require.Equal(t, upstream.requests[0].Header, upstream.requests[1].Header) + require.Equal(t, upstream.requests[0].URL, upstream.requests[1].URL) + require.Nil(t, upstream.requests[1].GetBody) + }) + } +} + type reasoningPartialWriter struct{ gin.ResponseWriter } func (w reasoningPartialWriter) Write(p []byte) (int, error) { @@ -112,14 +191,14 @@ func (w reasoningPartialWriter) Write(p []byte) (int, error) { return n, io.ErrClosedPipe } -func TestOpenAIChatReasoningReplayPartialWriteCannotRecover(t *testing.T) { +func TestOpenAIReasoningRecoveryChatPartialWriteCannotRecover(t *testing.T) { state, _, writer := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) state.c.Writer = reasoningPartialWriter{state.c.Writer} body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_partial\",\"status\":\"in_progress\"}}\n\n" + "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"visible\"}\n\n" + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"invalid_encrypted_content\",\"param\":\"input[1].encrypted_content\"}}}\n\n" svc := newOpenAIRejectedFieldTestService(&httpUpstreamRecorder{}) - _, err := svc.handleChatStreamingResponse(openAIChatReplayTestResponse(body, 200), state.c, state.account, "gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", time.Now(), 32) + _, err := svc.handleChatStreamingResponse(reasoningRecoverySSEResponse(body, 200), state.c, state.account, "gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", time.Now(), 32) require.Error(t, err) require.NotEmpty(t, writer.Body.String()) var signal *openAIReasoningRecoverySignalError @@ -127,3 +206,56 @@ func TestOpenAIChatReasoningReplayPartialWriteCannotRecover(t *testing.T) { _, recovered := state.TryRecoverError(err) require.False(t, recovered) } + +// A tool round on the Chat Completions endpoint is forwarded as the plain +// conversion of the client's messages: the upstream reasoning of the first turn +// is neither kept nor put back, and no reasoning state is read or written. +func TestOpenAIReasoningRecoveryChatToolRoundKeepsNoReasoningState(t *testing.T) { + const firstOutput = `[{"type":"reasoning","id":"rs_original","status":"completed","summary":[{"type":"summary_text","text":"checking carefully "}],"encrypted_content":"opaque-original"},{"type":"function_call","id":"fc_original","status":"completed","call_id":"call_orders","name":"load_orders","arguments":"{}"}]` + messages := []apicompat.ChatMessage{{Role: "user", Content: json.RawMessage(`"Use the orders tool."`)}} + chatBody := func(stream bool) []byte { + body, err := json.Marshal(map[string]any{ + "model": "gpt-5.6-sol", "messages": messages, "stream": stream, + "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "load_orders", "parameters": map[string]any{"type": "object"}}}}, + }) + require.NoError(t, err) + return body + } + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream_%t", stream), func(t *testing.T) { + messages = messages[:1] + store := &reasoningRecoveryMemoryStore{GatewayCache: &stubGatewayCache{}} + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + reasoningRecoverySSEResponse("data: "+strings.Replace(reasoningRecoveryChatCompleted, `[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]`, firstOutput, 1)+"\n\n", http.StatusOK), + reasoningRecoverySSEResponse("data: "+reasoningRecoveryChatCompleted+"\n\n", http.StatusOK), + }} + svc := newOpenAIRejectedFieldTestService(upstream) + svc.cache = store + account := newOpenAIRejectedFieldTestAccount() + first := chatBody(stream) + c, writer := reasoningRecoveryChatContext(t, first) + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, first, "", "") + require.NoError(t, err) + require.Contains(t, writer.Body.String(), "call_orders") + + messages = append(messages, + apicompat.ChatMessage{Role: "assistant", ReasoningContent: "checking carefully ", ToolCalls: []apicompat.ChatToolCall{{ID: "call_orders", Type: "function", Function: apicompat.ChatFunctionCall{Name: "load_orders", Arguments: "{}"}}}}, + apicompat.ChatMessage{Role: "tool", ToolCallID: "call_orders", Content: json.RawMessage(`"order data"`)}) + second := chatBody(stream) + c, _ = reasoningRecoveryChatContext(t, second) + _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, second, "", "") + require.NoError(t, err) + + require.Len(t, upstream.bodies, 2) + var request apicompat.ChatCompletionsRequest + require.NoError(t, json.Unmarshal(second, &request)) + converted, err := apicompat.ChatCompletionsToResponses(&request) + require.NoError(t, err) + require.JSONEq(t, string(converted.Input), gjson.GetBytes(upstream.bodies[1], "input").Raw) + require.NotContains(t, string(upstream.bodies[1]), "opaque-original") + require.NotContains(t, string(upstream.bodies[1]), "rs_original") + require.Zero(t, store.gets) + require.Zero(t, store.puts) + }) + } +} diff --git a/backend/internal/service/plugin_codex_recovery.go b/backend/internal/service/openai_reasoning_recovery_policy.go similarity index 68% rename from backend/internal/service/plugin_codex_recovery.go rename to backend/internal/service/openai_reasoning_recovery_policy.go index 857790ed38bb..d40cd4d400ec 100644 --- a/backend/internal/service/plugin_codex_recovery.go +++ b/backend/internal/service/openai_reasoning_recovery_policy.go @@ -1,31 +1,15 @@ package service import ( - "context" "encoding/json" - "slices" codexrecovery "github.com/Wei-Shaw/sub2api/internal/codexruntime/recovery" extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi" "github.com/tidwall/gjson" ) -func readOpenAIReplayRules(ctx context.Context) (extensionv1.ReplayRules, error) { - rules := codexrecovery.ReplayPolicy() - slices.Sort(rules.ChatFields) - slices.Sort(rules.OutputKinds) - return rules, nil -} - -func openAIReasoningPolicyEnabled(ctx context.Context, account *Account, key string) (bool, error) { - if account == nil || !account.supportsOpenAIReasoningPolicies() { - return false, nil - } - raw, configured := account.Extra[key] - value, valid := raw.(bool) - return codexrecovery.Enabled(extensionv1.RecoverySetting{Configured: configured, Valid: valid, Value: value}), nil -} - +// 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 if _, err := canonicalReasoningCacheJSON(payload); err != nil { @@ -58,12 +42,9 @@ func openAIRecoveryEnvelope(payload []byte) extensionv1.RecoveryEnvelope { return envelope } -func readOpenAIRecoveryRejection(ctx context.Context, payload []byte) (openAIReasoningRejection, bool, error) { - result := codexrecovery.Rejection(openAIRecoveryEnvelope(payload)) - return openAIReasoningRejection{result.Code, result.Param}, result.Recognized, nil -} - -func selectOpenAIRecoveryIndices(ctx context.Context, body []byte, param string) (extensionv1.RecoverySelection, error) { +// selectOpenAIRecoveryIndices asks the recovery policy which reasoning items of +// the sent body may lose their ciphertext for the rejection's param. +func selectOpenAIRecoveryIndices(body []byte, param string) extensionv1.RecoverySelection { query := extensionv1.RecoverySelectionQuery{Param: param} if _, err := canonicalReasoningCacheJSON(body); err == nil { query.BodyValid = true @@ -78,5 +59,5 @@ func selectOpenAIRecoveryIndices(ctx context.Context, body []byte, param string) query.EncryptedFields = countOpenAIEncryptedFields(parsed) } } - return codexrecovery.Select(query), nil + return codexrecovery.Select(query) } diff --git a/backend/internal/service/openai_reasoning_recovery_policy_test.go b/backend/internal/service/openai_reasoning_recovery_policy_test.go new file mode 100644 index 000000000000..7e878b0a388a --- /dev/null +++ b/backend/internal/service/openai_reasoning_recovery_policy_test.go @@ -0,0 +1,16 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIReasoningRecoveryRejectionExcludesProtectedStatuses(t *testing.T) { + _, ok := parseOpenAIReasoningRejection([]byte(`{"error":{"code":"thinking_signature_invalid"}}`)) + require.True(t, ok) + _, ok = parseOpenAIReasoningRejection([]byte(`{"error":{"status":429,"code":"thinking_signature_invalid"}}`)) + require.False(t, ok, "quota/protected failures cannot become recovery markers") +} diff --git a/backend/internal/service/openai_reasoning_recovery_terminal_test.go b/backend/internal/service/openai_reasoning_recovery_terminal_test.go index ce558282937a..b8ffa76db22b 100644 --- a/backend/internal/service/openai_reasoning_recovery_terminal_test.go +++ b/backend/internal/service/openai_reasoning_recovery_terminal_test.go @@ -19,7 +19,7 @@ import ( ) type reasoningRecoverySeededNegativeCache struct { - *openAIChatReplayTestCache + GatewayCache entry OpenAIRejectedReasoning gets int puts int @@ -101,10 +101,9 @@ func TestOpenAIReasoningRecoveryTerminalsPreserveClassificationAndDiagnostic(t * var negative *reasoningRecoverySeededNegativeCache if behavior == "cache_skip_then_validation" { negative = &reasoningRecoverySeededNegativeCache{ - openAIChatReplayTestCache: newOpenAIChatReplayTestCache(), - entry: OpenAIRejectedReasoning{RejectedAt: time.Now().Add(-time.Minute), ExpiresAt: time.Now().Add(time.Hour)}, + GatewayCache: &stubGatewayCache{}, + entry: OpenAIRejectedReasoning{RejectedAt: time.Now().Add(-time.Minute), ExpiresAt: time.Now().Add(time.Hour)}, } - negative.GatewayCache = &stubGatewayCache{} svc.cache = negative } result, err := svc.Forward(context.Background(), c, account, []byte(input)) diff --git a/backend/internal/service/openai_reasoning_recovery_test.go b/backend/internal/service/openai_reasoning_recovery_test.go index 714ca6552bca..e7233134fe5d 100644 --- a/backend/internal/service/openai_reasoning_recovery_test.go +++ b/backend/internal/service/openai_reasoning_recovery_test.go @@ -86,19 +86,12 @@ func TestOpenAIReasoningRecoveryStrictErrorsAndScope(t *testing.T) { func TestOpenAIReasoningRecoveryPreservesEveryNonCipherField(t *testing.T) { state, _, rec := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) before := bytes.Clone(state.wire) - callbackCalls := 0 - state.SetRejectedCallback(func(hashes []string) { - callbackCalls++ - require.Equal(t, []string{openAIReasoningDigest([]byte("opaque-old"))}, hashes) - }) payload := []byte(`{"type":"response.failed","response":{"error":{"code":"invalid_encrypted_content","param":"input.1.encrypted_content"},"usage":{"input_tokens":3,"output_tokens":2}}}`) signal := openAIReasoningRecoverySignal(state.c, payload, false) require.Error(t, signal) require.False(t, state.RecoveryAttempt(), "SSE signal must be pure before authoritative terminal") - require.Zero(t, callbackCalls) after, retry := state.TryRecoverError(signal) require.True(t, retry) - require.Equal(t, 1, callbackCalls) require.Equal(t, before, state.wire) require.False(t, gjson.GetBytes(after, "input.1.encrypted_content").Exists()) for _, path := range []string{"model", "reasoning", "store", "input.0", "input.1.id", "input.1.phase", "input.1.summary", "input.1.unknown", "input.2", "input.3"} { @@ -194,14 +187,20 @@ func TestOpenAIReasoningRecoveryRetryPreservesContextAndIdentity(t *testing.T) { } } +// reasoningRecoveryMemoryStore is the rejection memory of one test. Embedding a +// GatewayCache lets a forwarding test install it as the service cache. type reasoningRecoveryMemoryStore struct { - OpenAIReasoningStateStore + GatewayCache values map[string]OpenAIRejectedReasoning + gets int puts int fail bool } +var _ OpenAIReasoningStateStore = (*reasoningRecoveryMemoryStore)(nil) + func (m *reasoningRecoveryMemoryStore) GetOpenAIRejectedReasoning(_ context.Context, scope OpenAIReasoningCacheScope, hashes []string) (map[string]OpenAIRejectedReasoning, error) { + m.gets++ if m.fail { return nil, errors.New("cache unavailable") } diff --git a/backend/internal/service/openai_reasoning_state_cache.go b/backend/internal/service/openai_reasoning_state_cache.go index 6de3c549d029..f7a4454f00a0 100644 --- a/backend/internal/service/openai_reasoning_state_cache.go +++ b/backend/internal/service/openai_reasoning_state_cache.go @@ -18,12 +18,6 @@ import ( const ( OpenAIReasoningStateTTL = 24 * time.Hour OpenAIReasoningStateIOBudget = 100 * time.Millisecond - OpenAIReasoningBatchMaxBytes = 256 << 10 - OpenAIReasoningBatchMaxItems = 64 - OpenAIReasoningBatchMaxCalls = 32 - OpenAIReasoningBatchTotalMaxBytes = 64 << 20 - OpenAIReasoningBatchTotalMaxEntries = 8192 - OpenAIReasoningBatchTenantMaxBytes = 8 << 20 OpenAIRejectedReasoningMaxBytes = 8 << 20 OpenAIRejectedReasoningMaxEntries = 32768 OpenAIReasoningStateMaxLookupEntries = 64 @@ -171,17 +165,6 @@ func IsOpenAIReasoningCacheDigest(value string) bool { return true } -// OpenAIReasoningBatch is one complete, reversible Chat assistant projection. -// Output retains the original item sequence, including unknown subfields. -// InputPrefixHash proves the actual upstream prefix after earlier replay. No -// full input history is stored. PayloadHash is the CAS version returned by Get. -type OpenAIReasoningBatch struct { - Output []json.RawMessage `json:"output"` - Projection json.RawMessage `json:"projection"` - InputPrefixHash string `json:"input_prefix_hash"` - PayloadHash string `json:"-"` -} - type OpenAIRejectedReasoning struct { RejectedAt time.Time ExpiresAt time.Time @@ -191,9 +174,6 @@ type OpenAIRejectedReasoning struct { // but unrelated cache adapters and their mocks need not grow new methods. Cache // errors mean unavailable, never evidence that any history should be erased. type OpenAIReasoningStateStore interface { - GetOpenAIReasoningBatches(ctx context.Context, scope OpenAIReasoningCacheScope, keys []string) (map[string]OpenAIReasoningBatch, error) - PutOpenAIReasoningBatch(ctx context.Context, scope OpenAIReasoningCacheScope, key string, batch OpenAIReasoningBatch) (bool, error) - DeleteOpenAIReasoningBatchIfMatch(ctx context.Context, scope OpenAIReasoningCacheScope, key, payloadHash string) (bool, error) GetOpenAIRejectedReasoning(ctx context.Context, scope OpenAIReasoningCacheScope, cipherHashes []string) (map[string]OpenAIRejectedReasoning, error) // Put is only for a real structured rejection. Get never refreshes this TTL. PutOpenAIRejectedReasoning(ctx context.Context, scope OpenAIReasoningCacheScope, cipherHashes []string) error @@ -208,7 +188,7 @@ func (s *OpenAIGatewayService) openAIReasoningStateStore() OpenAIReasoningStateS } // OpenAIReasoningCacheBudget charges only I/O time, not model generation time. -// Replay, rejection lookup, invalidation, and write-back share this one budget. +// The rejection lookup and the rejection write share this one budget. // Callbacks must respect the derived context and must not start detached work. type OpenAIReasoningCacheBudget struct { mu sync.Mutex diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 2f5a453582ed..e2a176661e4f 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -534,6 +534,8 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( excludedIDs map[int64]struct{}, requireCompact bool, ) (*AccountSelectionResult, error) { + // 分组利润控制:公共入口装门,保证不经 selectAccountWithScheduler + // 的调用方也无法绕过利润准入(scheduler 内部路径已在唯一调度入口装门)。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact) } @@ -809,6 +811,9 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { return policyMiss() } + // 分组利润控制:与 quota auto-pause 同语义——利润不合格是暂时 + // 状态(上游倍率/高峰随时间变化),只跳过本次复用、落回普通调度,不删除 + // 绑定(倍率恢复后可继续按 previous_response_id 粘连)。 if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { return policyMiss() } diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go index 932cfb651915..462c42565655 100644 --- a/backend/internal/service/openai_ws_protocol_forward_test.go +++ b/backend/internal/service/openai_ws_protocol_forward_test.go @@ -245,10 +245,10 @@ func TestOpenAIGatewayService_Forward_HTTPIngressInvalidEncryptedContentIsTermin "base_url": wsFallbackServer.URL, }, Extra: map[string]any{ - "responses_websockets_v2_enabled": true, - OpenAIReasoningSignatureRecoveryEnabledExtraKey: false, + "responses_websockets_v2_enabled": true, }, } + svc.SetReasoningRecoveryService(newReasoningRecoverySwitchForTest(false)) body := []byte(`{"model":"gpt-5.1","stream":false,"input":[{"type":"reasoning","encrypted_content":"gAAA","summary":[{"type":"summary_text","text":"keep me"}]},{"type":"input_text","text":"hello"}]}`) result, err := svc.Forward(context.Background(), c, account, body) diff --git a/backend/internal/service/plugin_codex_recovery_test.go b/backend/internal/service/plugin_codex_recovery_test.go deleted file mode 100644 index abd9f44c2cde..000000000000 --- a/backend/internal/service/plugin_codex_recovery_test.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "net/http" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRecoveryDisableAfterFirstAttemptDoesNotSpendAnotherRequest(t *testing.T) { - state, _, _ := newReasoningRecoveryTestState(t, context.Background(), reasoningRecoveryFixture) - state.account.Extra = map[string]any{OpenAIReasoningSignatureRecoveryEnabledExtraKey: false} - _, retry := state.TryRecover(http.StatusBadRequest, nil, []byte(`{"error":{"code":"invalid_encrypted_content"}}`), false) - require.False(t, retry) - require.False(t, state.retryUsed) - require.Equal(t, "disabled", state.recoveryNotAttemptedReason(nil)) - require.Equal(t, reasoningRecoveryFixture, string(state.wire)) -} - -func TestSignatureRejectionRemainsRequestScopedWhenPluginIsAbsent(t *testing.T) { - _, ok := parseOpenAIReasoningRejection([]byte(`{"error":{"code":"thinking_signature_invalid"}}`)) - require.True(t, ok) - _, ok = parseOpenAIReasoningRejection([]byte(`{"error":{"status":429,"code":"thinking_signature_invalid"}}`)) - require.False(t, ok, "quota/protected failures cannot become recovery markers") -} diff --git a/backend/internal/service/profit_control_dormant_regression_test.go b/backend/internal/service/profit_control_dormant_regression_test.go deleted file mode 100644 index 25e73dfcd481..000000000000 --- a/backend/internal/service/profit_control_dormant_regression_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package service - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - "github.com/stretchr/testify/require" -) - -type dormantProfitGroupRepository struct { - GroupRepository - group *Group - reads []int64 -} - -func (r *dormantProfitGroupRepository) GetByIDLite(_ context.Context, id int64) (*Group, error) { - r.reads = append(r.reads, id) - if r.group == nil || r.group.ID != id { - return nil, fmt.Errorf("unexpected synthetic group lookup %d", id) - } - copy := *r.group - return ©, nil -} - -func TestDormantProfitControlCrossGroupSnapshotCannotEnableAdmission(t *testing.T) { - for _, engine := range []string{"openai", "gateway"} { - t.Run(engine, func(t *testing.T) { - // Auth-cache zeroing cannot sanitize a different scheduled group. - // Keep a legacy-enabled row as the real snapshot service's repository - // result, matching the current Ent projection's retained fields. - legacy := &Group{ID: 902, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, - RateMultiplier: 0.2, ProfitControlEnabled: true, ProfitMinMargin: 0.3, ProfitSafetyBuffer: 0.05} - if engine == "gateway" { - legacy.Platform = PlatformAnthropic - } - preserved := *legacy - repository := &dormantProfitGroupRepository{group: legacy} - snapshot := NewSchedulerSnapshotService(nil, nil, nil, repository, nil) - projected, err := snapshot.GetGroupByIDLite(context.Background(), legacy.ID) - require.NoError(t, err) - require.True(t, projected.ProfitControlEnabled, "fixture must not silently zero the legacy projection") - require.Equal(t, []int64{legacy.ID}, repository.reads) - billing := &Group{ID: 901, Platform: PlatformComposite, Status: StatusActive, Hydrated: true, - RateMultiplier: 0.7, SubscriptionType: SubscriptionTypeStandard} - base := context.WithValue(context.Background(), ctxkey.Group, billing) - var ctx context.Context - var pricingAt time.Time - if engine == "openai" { - ctx, pricingAt = (&OpenAIGatewayService{schedulerSnapshot: snapshot}).WithOpenAIRequestPricingContext(base, &legacy.ID) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - } else { - ctx, pricingAt = WithGatewayTokenRequestPricing(base) - ctx = (&GatewayService{schedulerSnapshot: snapshot}).withGatewayProfitControlGate(ctx, &legacy.ID) - actual, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - } - require.False(t, pricingAt.IsZero(), "retirement cannot remove the billing pricing instant") - gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate, "legacy cross-group fields cannot reactivate the retired gate; lookups=%v", repository.reads) - require.Equal(t, []int64{legacy.ID}, repository.reads, "runtime retirement must not depend on reading or rewriting a legacy row") - rate := 5.0 - vetoed, reason := OpenAIProfitControlVeto(ctx, &Account{ID: 903, RateMultiplier: &rate}) - require.False(t, vetoed) - require.Empty(t, reason) - require.Equal(t, preserved, *legacy, "stored configuration remains untouched") - require.Equal(t, 0.7, billing.RateMultiplier, "ordinary billing multipliers remain untouched") - }) - } -} - -func TestDormantProfitControlClearsGateAtBoundariesButKeepsPrivateMath(t *testing.T) { - now := time.Now().Add(-time.Minute) - gate := &openAIProfitControlGate{groupID: 12, platform: PlatformOpenAI, threshold: 0, pricingAt: now} - base := context.WithValue(context.Background(), openAIPricingAtCtxKey{}, now) - base = context.WithValue(base, openAIProfitControlGateCtxKey{}, gate) - rate := 5.0 - account := &Account{ID: 13, RateMultiplier: &rate} - - vetoed, reason := openAIProfitControlVetoReason(base, account) - require.True(t, vetoed, "private numerical predicates remain available to upstream compatibility fixtures") - require.Equal(t, openAIProfitFilterReasonThreshold, reason) - latest, vetoed, reason := profitControlVetoLatest(base, account, nil) - require.Same(t, account, latest) - require.True(t, vetoed) - require.Equal(t, openAIProfitFilterReasonThreshold, reason) - - request, requestAt := (&OpenAIGatewayService{}).WithOpenAIRequestPricingContext(base, &gate.groupID) - carried, _ := request.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) - require.Equal(t, requestAt, OpenAIPricingAtFromContext(request)) - require.False(t, gatewayProfitControlGateActive(request)) - vetoed, reason = openAIProfitControlVetoReason(request, account) - require.False(t, vetoed) - require.Empty(t, reason) - selection := &AccountSelectionResult{Account: account} - require.Same(t, selection, attachSelectionProfitGate(request, selection)) - require.Nil(t, selection.profitGate) - require.Same(t, request, ContextWithSelectionProfitGate(request, selection)) - turn, turnAt := (&OpenAIGatewayService{}).WithOpenAITurnPricingContext(base, &gate.groupID) - carried, _ = turn.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) - require.Equal(t, turnAt, OpenAIPricingAtFromContext(turn)) - require.False(t, turnAt.Before(now)) - general := (&GatewayService{}).withGatewayProfitControlGate(base, &gate.groupID) - carried, _ = general.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) -} diff --git a/backend/internal/service/profit_preview.go b/backend/internal/service/profit_preview.go new file mode 100644 index 000000000000..d63b9317cecf --- /dev/null +++ b/backend/internal/service/profit_preview.go @@ -0,0 +1,211 @@ +package service + +// 分组利润控制离线预演:使用生产只读导出的分组配置、账号倍率、探测状态、 +// 用户覆盖倍率与主力模型清单,复用线上 U/D/epsilon 语义推演准入。 +// 探测状态仅用于解释账号倍率来源是否健康,不影响准入结论。 + +import ( + "math" + "sort" + "time" +) + +const ( + ProfitPreviewRateSourceManual = "manual" + ProfitPreviewRateSourceUpstreamProbe = "upstream_probe_sync" + + ProfitPreviewWarningProbeMissing = "probe_snapshot_missing" + ProfitPreviewWarningProbeStale = "probe_snapshot_stale" + ProfitPreviewWarningProbeFailed = "probe_failed" + ProfitPreviewWarningProbeUnsupported = "probe_unsupported" + ProfitPreviewWarningManualRateOne = "manual_rate_1_suspected_unmaintained" +) + +// ProfitPreviewGroupInput 是一个分组的预演输入。 +type ProfitPreviewGroupInput struct { + Group *Group + // Accounts 为该分组绑定且状态可调度的账号,允许 Anthropic/Gemini 分组中 + // 参与 mixed scheduling 的 Antigravity 账号。 + Accounts []*Account + // UserOverrides 为该分组所有用户覆盖倍率(user_id → 倍率)。 + UserOverrides map[int64]float64 + // Models 为该分组近期主力模型清单。 + Models []string + // AssumeEnabled 在分组当前关闭时仍按其保存配置启用利润门,供部署前执行 + // “先关组、预演、逐组重开”。 + AssumeEnabled bool +} + +// 预演账号分类与线上否决原因一一对应。 +const ( + ProfitPreviewClassAdmitted = "admitted" + ProfitPreviewClassRejectedThreshold = "rejected_threshold" + ProfitPreviewClassRejectedInvalidRate = "rejected_invalid_account_rate" +) + +// ProfitPreviewAccountVerdict 是单账号的预演结论。 +type ProfitPreviewAccountVerdict struct { + AccountID int64 `json:"account_id"` + Name string `json:"name"` + Platform string `json:"platform"` + Class string `json:"class"` + AccountRate *float64 `json:"account_rate,omitempty"` + RateSource string `json:"rate_source"` + Warnings []string `json:"warnings,omitempty"` + // RejectedUnderMinD 表示账号在最低有效 D(用户覆盖最坏口径)下会被排除。 + RejectedUnderMinD bool `json:"rejected_under_min_d,omitempty"` + // SupportedModels 为该账号支持的主力模型子集。 + SupportedModels []string `json:"supported_models,omitempty"` +} + +// ProfitPreviewGroupReport 是单分组的预演报告。 +type ProfitPreviewGroupReport struct { + GroupID int64 `json:"group_id"` + GroupName string `json:"group_name"` + Platform string `json:"platform"` + EffectiveGate bool `json:"effective_gate"` + AssumedEnabled bool `json:"assumed_enabled,omitempty"` + // DefaultD / MinEffectiveD:分组默认 D 与最低有效 D(含 evalAt 高峰因子; + // MinEffectiveD = min(分组默认, 全部非空用户覆盖) × 高峰因子)。 + DefaultD float64 `json:"default_d"` + MinEffectiveD float64 `json:"min_effective_d"` + // ThresholdDefault / ThresholdMinD:两档 D 对应的准入阈值。 + ThresholdDefault float64 `json:"threshold_default"` + ThresholdMinD float64 `json:"threshold_min_d"` + // RemainingByModel 仅表示利润门准入账号数,不模拟健康、冷却、限流或槽位。 + RemainingByModel map[string]int `json:"profit_admitted_by_model"` + RemainingByModelMinD map[string]int `json:"profit_admitted_by_model_min_d"` + Verdicts []ProfitPreviewAccountVerdict `json:"verdicts"` +} + +// PreviewProfitAdmission 对五大平台分组推演利润门准入结果。未启用的分组仅在 +// AssumeEnabled=true 时按保存配置执行门;Composite 分组始终不直接安装利润门。 +func PreviewProfitAdmission(inputs []ProfitPreviewGroupInput, evalAt time.Time) []ProfitPreviewGroupReport { + reports := make([]ProfitPreviewGroupReport, 0, len(inputs)) + for _, in := range inputs { + if in.Group == nil { + continue + } + group := in.Group + effectiveGate := (group.ProfitControlEnabled || in.AssumeEnabled) && profitControlPlatformSupported(group.Platform) + report := ProfitPreviewGroupReport{ + GroupID: group.ID, + GroupName: group.Name, + Platform: group.Platform, + EffectiveGate: effectiveGate, + AssumedEnabled: in.AssumeEnabled && !group.ProfitControlEnabled && effectiveGate, + RemainingByModel: make(map[string]int, len(in.Models)), + RemainingByModelMinD: make(map[string]int, len(in.Models)), + } + for _, model := range in.Models { + report.RemainingByModel[model] = 0 + report.RemainingByModelMinD[model] = 0 + } + + peak := group.PeakMultiplierAt(evalAt) + defaultD := group.RateMultiplier * peak + minRate := group.RateMultiplier + for _, override := range in.UserOverrides { + if math.IsNaN(override) || math.IsInf(override, 0) || override < 0 { + continue + } + if override < minRate { + minRate = override + } + } + minD := minRate * peak + deduction := group.ProfitMinMargin + group.ProfitSafetyBuffer + thresholdDefault := clampProfitControlThreshold(defaultD * (1 - deduction)) + thresholdMinD := clampProfitControlThreshold(minD * (1 - deduction)) + report.DefaultD = defaultD + report.MinEffectiveD = minD + report.ThresholdDefault = thresholdDefault + report.ThresholdMinD = thresholdMinD + + for _, account := range in.Accounts { + if account == nil { + continue + } + verdict := previewAccountProfitAdmission(account, effectiveGate, thresholdDefault, thresholdMinD, evalAt) + admittedDefault := verdict.Class == ProfitPreviewClassAdmitted + admittedMinD := admittedDefault && !verdict.RejectedUnderMinD + for _, model := range in.Models { + if !account.IsModelSupported(model) { + continue + } + verdict.SupportedModels = append(verdict.SupportedModels, model) + if admittedDefault { + report.RemainingByModel[model]++ + } + if admittedMinD { + report.RemainingByModelMinD[model]++ + } + } + sort.Strings(verdict.SupportedModels) + report.Verdicts = append(report.Verdicts, verdict) + } + sort.Slice(report.Verdicts, func(i, j int) bool { return report.Verdicts[i].AccountID < report.Verdicts[j].AccountID }) + reports = append(reports, report) + } + return reports +} + +func previewAccountProfitAdmission( + account *Account, + effectiveGate bool, + thresholdDefault float64, + thresholdMinD float64, + evalAt time.Time, +) ProfitPreviewAccountVerdict { + verdict := ProfitPreviewAccountVerdict{ + AccountID: account.ID, + Name: account.Name, + Platform: account.Platform, + RateSource: ProfitPreviewRateSourceManual, + } + if enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool); enabled { + verdict.RateSource = ProfitPreviewRateSourceUpstreamProbe + verdict.Warnings = append(verdict.Warnings, profitPreviewProbeWarnings(account, evalAt)...) + } else if account.RateMultiplier != nil && *account.RateMultiplier == 1 { + verdict.Warnings = append(verdict.Warnings, ProfitPreviewWarningManualRateOne) + } + + validRate := account.RateMultiplier != nil && + !math.IsNaN(*account.RateMultiplier) && + !math.IsInf(*account.RateMultiplier, 0) && + *account.RateMultiplier >= 0 + if validRate { + rate := *account.RateMultiplier + verdict.AccountRate = &rate + } + switch { + case !effectiveGate: + verdict.Class = ProfitPreviewClassAdmitted + case !validRate: + verdict.Class = ProfitPreviewClassRejectedInvalidRate + case profitControlOverThreshold(*account.RateMultiplier, thresholdDefault): + verdict.Class = ProfitPreviewClassRejectedThreshold + default: + verdict.Class = ProfitPreviewClassAdmitted + verdict.RejectedUnderMinD = profitControlOverThreshold(*account.RateMultiplier, thresholdMinD) + } + return verdict +} + +func profitPreviewProbeWarnings(account *Account, evalAt time.Time) []string { + snapshot := decodeUpstreamBillingProbeSnapshot(account.Extra) + if snapshot == nil { + return []string{ProfitPreviewWarningProbeMissing} + } + switch snapshot.Status { + case UpstreamBillingProbeStatusFailed: + return []string{ProfitPreviewWarningProbeFailed} + case UpstreamBillingProbeStatusUnsupported: + return []string{ProfitPreviewWarningProbeUnsupported} + case UpstreamBillingProbeStatusOK: + if snapshot.FreshUntil == nil || !evalAt.Before(*snapshot.FreshUntil) { + return []string{ProfitPreviewWarningProbeStale} + } + } + return nil +} diff --git a/backend/internal/service/profit_preview_test.go b/backend/internal/service/profit_preview_test.go new file mode 100644 index 000000000000..1030ef41aa09 --- /dev/null +++ b/backend/internal/service/profit_preview_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPreviewProfitAdmissionUsesAccountRatesAndPreinitializesModels(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(50, 0.2, 0) + group.Name = "VIP-preview" + + cheap := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1.0, now.Add(-3*time.Hour), 30*time.Minute), + 0.5, + ) + cheap.Name = "cheap" + cheap.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true + cheap.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-sol": "gpt-sol"}} + + boundary := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), + 0.8, + ) + boundary.Name = "boundary" + boundary.Credentials = map[string]any{"model_mapping": map[string]any{ + "gpt-sol": "gpt-sol", + "gpt-luna": "gpt-luna", + }} + + expensive := profitControlTestAccountWithRate( + upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 1.0, + ) + expensive.Name = "expensive" + + invalid := upstreamCostTestOAuthAccount(4) + invalid.Name = "invalid" + + reports := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheap, boundary, expensive, invalid}, + UserOverrides: map[int64]float64{40: 0.5}, + Models: []string{"gpt-sol", "gpt-luna", "gpt-no-account"}, + }}, now) + require.Len(t, reports, 1) + report := reports[0] + + require.True(t, report.EffectiveGate) + require.InDelta(t, 1.0, report.DefaultD, 1e-12) + require.InDelta(t, 0.8, report.ThresholdDefault, 1e-12) + require.InDelta(t, 0.5, report.MinEffectiveD, 1e-12) + require.InDelta(t, 0.4, report.ThresholdMinD, 1e-12) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range report.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheap.ID].Class) + require.Equal(t, ProfitPreviewRateSourceUpstreamProbe, byID[cheap.ID].RateSource) + require.Contains(t, byID[cheap.ID].Warnings, ProfitPreviewWarningProbeStale) + require.True(t, byID[cheap.ID].RejectedUnderMinD) + + require.Equal(t, ProfitPreviewClassAdmitted, byID[boundary.ID].Class, "U == 阈值按 epsilon 语义准入") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensive.ID].Class) + require.Contains(t, byID[expensive.ID].Warnings, ProfitPreviewWarningManualRateOne) + require.Equal(t, ProfitPreviewClassRejectedInvalidRate, byID[invalid.ID].Class) + + require.Equal(t, 2, report.RemainingByModel["gpt-sol"]) + require.Equal(t, 1, report.RemainingByModel["gpt-luna"]) + require.Equal(t, 0, report.RemainingByModel["gpt-no-account"]) + _, present := report.RemainingByModel["gpt-no-account"] + require.True(t, present, "全部账号均不支持时也必须显式保留 0,供 CLI 发出警告") +} + +func TestPreviewProfitAdmissionAssumeEnabled(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(51, 0, 0) + group.Platform = PlatformOpenAI + group.RateMultiplier = 0.5 + group.ProfitControlEnabled = false + + cheapAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute), + 0.2, + ) + expensiveAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 0.9, + ) + + withoutAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + }}, now)[0] + require.False(t, withoutAssume.EffectiveGate) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[0].Class) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[1].Class) + + withAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + AssumeEnabled: true, + }}, now)[0] + require.True(t, withAssume.EffectiveGate) + require.True(t, withAssume.AssumedEnabled) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range withAssume.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheapAccount.ID].Class, + "账号倍率 0.2 <= 阈值 0.5,探测快照的高倍率不参与准入判断") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensiveAccount.ID].Class, + "账号倍率 0.9 > 阈值 0.5,探测快照的低倍率不能替代账号倍率") +} + +func TestPreviewProfitAdmissionSupportsFivePlatforms(t *testing.T) { + for i, platform := range []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGemini, + PlatformGrok, + PlatformAntigravity, + } { + group := profitControlTestGroup(int64(100+i), 0, 0) + group.Platform = platform + rate := 0.2 + account := &Account{ + ID: int64(200 + i), + Platform: platform, + RateMultiplier: &rate, + } + report := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{account}, + Models: []string{"model"}, + }}, time.Now())[0] + require.True(t, report.EffectiveGate, platform) + require.Equal(t, ProfitPreviewClassAdmitted, report.Verdicts[0].Class, platform) + } +} diff --git a/backend/internal/service/reasoning_fidelity_support_test.go b/backend/internal/service/reasoning_fidelity_support_test.go index ee273ff7d9da..1d44c109fce8 100644 --- a/backend/internal/service/reasoning_fidelity_support_test.go +++ b/backend/internal/service/reasoning_fidelity_support_test.go @@ -26,6 +26,11 @@ func ReasoningFidelityGatewayForTest(cfg *config.Config, upstream HTTPUpstream, frozen.values[key] = value } svc.settingService = NewSettingService(frozen, cfg) + recovery, err := ProvideReasoningRecoveryService(frozen) + if err != nil { + return nil, errors.New("invalid_frozen_reasoning_recovery") + } + svc.SetReasoningRecoveryService(recovery) if prompts != nil { normalized, err := normalizeSystemPromptConfig(*prompts) if err != nil { diff --git a/backend/internal/service/reasoning_recovery_setting.go b/backend/internal/service/reasoning_recovery_setting.go new file mode 100644 index 000000000000..8250e12dee37 --- /dev/null +++ b/backend/internal/service/reasoning_recovery_setting.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// Reasoning recovery has one switch for every OpenAI account. It is one JSON +// setting; the forwarding path reads an in-memory value, so deciding whether a +// rejected reasoning ciphertext may be recovered never queries the database. + +// SettingKeyReasoningRecoveryConfig stores ReasoningRecoveryConfig as JSON. +// Without a stored value recovery is enabled. +const SettingKeyReasoningRecoveryConfig = "reasoning_recovery_config" + +const ( + reasoningRecoveryRefreshInterval = time.Minute + reasoningRecoveryRetryInterval = 5 * time.Second + reasoningRecoveryStoreTimeout = 5 * time.Second + reasoningRecoveryWarnInterval = time.Minute +) + +var ErrReasoningRecoveryUnavailable = infraerrors.ServiceUnavailable("REASONING_RECOVERY_UNAVAILABLE", "reasoning recovery configuration is unavailable") + +// ReasoningRecoveryConfig is the stored switch and its admin view. +type ReasoningRecoveryConfig struct { + Enabled bool `json:"enabled"` +} + +// DecodeReasoningRecoveryConfig accepts exactly {"enabled": }. +func DecodeReasoningRecoveryConfig(raw []byte) (ReasoningRecoveryConfig, error) { + var fields struct { + Enabled *bool `json:"enabled"` + } + if err := DecodeSwitchSettings(raw, &fields, "enabled"); err != nil { + return ReasoningRecoveryConfig{}, err + } + if fields.Enabled == nil { + return ReasoningRecoveryConfig{}, errors.New("enabled is required") + } + return ReasoningRecoveryConfig{Enabled: *fields.Enabled}, nil +} + +// ReasoningRecoveryService owns the switch and its in-memory value. +type ReasoningRecoveryService struct { + settings SettingRepository + + // storeMu orders stored reads and writes, so a refresh that began before a + // save cannot publish the older value after it. + storeMu sync.Mutex + // The zero value is the default: enabled. + disabled atomic.Bool + nextRefresh atomic.Int64 + refreshing atomic.Bool + warnedAt atomic.Int64 +} + +func NewReasoningRecoveryService(settings SettingRepository) *ReasoningRecoveryService { + return &ReasoningRecoveryService{settings: settings} +} + +// ProvideReasoningRecoveryService loads the switch once at startup. A failed +// read stops startup: a stored "off" must not silently become the enabled +// default. +func ProvideReasoningRecoveryService(settings SettingRepository) (*ReasoningRecoveryService, error) { + svc := NewReasoningRecoveryService(settings) + ctx, cancel := context.WithTimeout(context.Background(), reasoningRecoveryStoreTimeout) + defer cancel() + if _, err := svc.Get(ctx); err != nil { + return nil, err + } + return svc, nil +} + +// Enabled reports the switch for one forwarding decision. It reads memory only +// and never waits: when the value is stale a background refresh picks up what +// another instance saved. Without a service the default applies. +func (s *ReasoningRecoveryService) Enabled() bool { + if s == nil { + return true + } + if s.settings != nil && time.Now().UnixNano() >= s.nextRefresh.Load() && s.refreshing.CompareAndSwap(false, true) { + go func() { + defer s.refreshing.Store(false) + ctx, cancel := context.WithTimeout(context.Background(), reasoningRecoveryStoreTimeout) + defer cancel() + if _, err := s.Get(ctx); err != nil { + s.warn("reasoning_recovery.refresh_failed", "error", err) + } + }() + } + return !s.disabled.Load() +} + +// Get returns the stored switch and makes it this instance's value. A failed +// read keeps the value this instance already applies. +func (s *ReasoningRecoveryService) Get(ctx context.Context) (ReasoningRecoveryConfig, error) { + if s == nil || s.settings == nil { + return ReasoningRecoveryConfig{}, ErrReasoningRecoveryUnavailable + } + s.storeMu.Lock() + defer s.storeMu.Unlock() + config, err := s.read(ctx) + if err != nil { + s.nextRefresh.Store(time.Now().Add(reasoningRecoveryRetryInterval).UnixNano()) + return ReasoningRecoveryConfig{}, err + } + s.publish(config) + return config, nil +} + +// Save stores the switch and applies it to this instance at once. +func (s *ReasoningRecoveryService) Save(ctx context.Context, config ReasoningRecoveryConfig) (ReasoningRecoveryConfig, error) { + if s == nil || s.settings == nil { + return ReasoningRecoveryConfig{}, ErrReasoningRecoveryUnavailable + } + raw, err := json.Marshal(config) + if err != nil { + return ReasoningRecoveryConfig{}, err + } + s.storeMu.Lock() + defer s.storeMu.Unlock() + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), reasoningRecoveryStoreTimeout) + defer cancel() + if err := s.settings.Set(dbCtx, SettingKeyReasoningRecoveryConfig, string(raw)); err != nil { + return ReasoningRecoveryConfig{}, err + } + s.publish(config) + return config, nil +} + +// read returns the stored switch; a missing row is the enabled default. +func (s *ReasoningRecoveryService) read(ctx context.Context) (ReasoningRecoveryConfig, error) { + dbCtx, cancel := context.WithTimeout(ctx, reasoningRecoveryStoreTimeout) + defer cancel() + raw, err := s.settings.GetValue(dbCtx, SettingKeyReasoningRecoveryConfig) + if errors.Is(err, ErrSettingNotFound) { + return ReasoningRecoveryConfig{Enabled: true}, nil + } + if err != nil { + return ReasoningRecoveryConfig{}, fmt.Errorf("read reasoning recovery configuration: %w", err) + } + config, err := DecodeReasoningRecoveryConfig([]byte(raw)) + if err != nil { + return ReasoningRecoveryConfig{}, fmt.Errorf("stored reasoning recovery configuration is invalid: %w", err) + } + return config, nil +} + +func (s *ReasoningRecoveryService) publish(config ReasoningRecoveryConfig) { + s.disabled.Store(!config.Enabled) + s.nextRefresh.Store(time.Now().Add(reasoningRecoveryRefreshInterval).UnixNano()) +} + +func (s *ReasoningRecoveryService) warn(msg string, args ...any) { + now := time.Now().UnixNano() + last := s.warnedAt.Load() + if now-last < int64(reasoningRecoveryWarnInterval) || !s.warnedAt.CompareAndSwap(last, now) { + return + } + slog.Warn(msg, args...) +} diff --git a/backend/internal/service/reasoning_recovery_setting_test.go b/backend/internal/service/reasoning_recovery_setting_test.go new file mode 100644 index 000000000000..d6877fbf00f7 --- /dev/null +++ b/backend/internal/service/reasoning_recovery_setting_test.go @@ -0,0 +1,187 @@ +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// newReasoningRecoverySwitchForTest returns a switch with a fixed value and no +// store behind it. +func newReasoningRecoverySwitchForTest(enabled bool) *ReasoningRecoveryService { + recovery := NewReasoningRecoveryService(nil) + recovery.publish(ReasoningRecoveryConfig{Enabled: enabled}) + return recovery +} + +type reasoningRecoverySettingStore struct { + SettingRepository + mu sync.Mutex + values map[string]string + reads int + readErr error + // A non-nil gate holds the answer of every read until it is closed. The + // value is read before that, as a query that is still in flight has. + gate chan struct{} +} + +func (s *reasoningRecoverySettingStore) GetValue(_ context.Context, key string) (string, error) { + s.mu.Lock() + s.reads++ + value, ok := s.values[key] + err, gate := s.readErr, s.gate + s.mu.Unlock() + if gate != nil { + <-gate + } + if err != nil { + return "", err + } + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func (s *reasoningRecoverySettingStore) Set(_ context.Context, key, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.values[key] = value + return nil +} + +func (s *reasoningRecoverySettingStore) put(value string) { + s.mu.Lock() + defer s.mu.Unlock() + s.values[SettingKeyReasoningRecoveryConfig] = value +} + +func (s *reasoningRecoverySettingStore) readCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.reads +} + +func (s *reasoningRecoverySettingStore) hold() chan struct{} { + gate := make(chan struct{}) + s.mu.Lock() + defer s.mu.Unlock() + s.gate = gate + return gate +} + +func TestReasoningRecoveryServiceDefaultsToEnabledWithoutRow(t *testing.T) { + store := &reasoningRecoverySettingStore{values: map[string]string{}} + recovery, err := ProvideReasoningRecoveryService(store) + require.NoError(t, err) + require.True(t, recovery.Enabled()) + config, err := recovery.Get(context.Background()) + require.NoError(t, err) + require.Equal(t, ReasoningRecoveryConfig{Enabled: true}, config) + require.Empty(t, store.values, "reading the default stores nothing") + require.True(t, (*ReasoningRecoveryService)(nil).Enabled(), "a gateway without the service applies the default") +} + +func TestReasoningRecoveryServiceSaveAppliesToTheNextRequestWithoutDatabaseRead(t *testing.T) { + gin.SetMode(gin.TestMode) + store := &reasoningRecoverySettingStore{values: map[string]string{}} + recovery, err := ProvideReasoningRecoveryService(store) + require.NoError(t, err) + svc := &OpenAIGatewayService{} + svc.SetReasoningRecoveryService(recovery) + account := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + requestEnabled := func() bool { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + return svc.newOpenAIReasoningRecoveryState(context.Background(), c, account, "source-token").enabled + } + require.True(t, requestEnabled()) + + saved, err := recovery.Save(context.Background(), ReasoningRecoveryConfig{Enabled: false}) + require.NoError(t, err) + require.Equal(t, ReasoningRecoveryConfig{Enabled: false}, saved) + require.Equal(t, map[string]string{SettingKeyReasoningRecoveryConfig: `{"enabled":false}`}, store.values) + reads := store.readCount() + require.False(t, requestEnabled(), "the very next request applies the saved switch") + require.False(t, recovery.Enabled()) + require.Equal(t, reads, store.readCount(), "the forwarding path reads memory only") + + stored, err := recovery.Get(context.Background()) + require.NoError(t, err) + require.Equal(t, ReasoningRecoveryConfig{Enabled: false}, stored) + + _, err = recovery.Save(context.Background(), ReasoningRecoveryConfig{Enabled: true}) + require.NoError(t, err) + require.Equal(t, map[string]string{SettingKeyReasoningRecoveryConfig: `{"enabled":true}`}, store.values) + require.True(t, requestEnabled()) +} + +func TestReasoningRecoveryServiceKeepsItsValueWhenTheStoredOneIsUnreadable(t *testing.T) { + store := &reasoningRecoverySettingStore{values: map[string]string{SettingKeyReasoningRecoveryConfig: `{"enabled":false}`}} + recovery, err := ProvideReasoningRecoveryService(store) + require.NoError(t, err) + require.False(t, recovery.Enabled()) + for _, raw := range []string{`true`, `{"enabled":"false"}`, `{"enabled":null}`, `{"enabled":false,"other":true}`, `{}`, ``} { + store.put(raw) + _, err := recovery.Get(context.Background()) + require.Error(t, err, raw) + require.False(t, recovery.Enabled(), "a stored off never becomes the enabled default") + } + store.put(`{"enabled":false}`) + store.readErr = errors.New("database unavailable") + _, err = recovery.Get(context.Background()) + require.Error(t, err) + require.False(t, recovery.Enabled()) + _, err = ProvideReasoningRecoveryService(store) + require.Error(t, err, "startup does not guess the switch") +} + +func TestReasoningRecoveryServiceConvergesOnAnotherInstancesSave(t *testing.T) { + store := &reasoningRecoverySettingStore{values: map[string]string{}} + recovery, err := ProvideReasoningRecoveryService(store) + require.NoError(t, err) + store.put(`{"enabled":false}`) + require.True(t, recovery.Enabled(), "a fresh value is served from memory") + + gate := store.hold() + recovery.nextRefresh.Store(0) + require.True(t, recovery.Enabled(), "a stale read returns at once and refreshes in the background") + close(gate) + require.Eventually(t, func() bool { return !recovery.Enabled() }, time.Second, time.Millisecond) +} + +// A refresh that read the stored value before a save must not publish it after +// the save: the saving instance keeps what it saved. +func TestReasoningRecoveryServiceSaveIsNotReplacedByAnEarlierRead(t *testing.T) { + store := &reasoningRecoverySettingStore{values: map[string]string{}} + recovery, err := ProvideReasoningRecoveryService(store) + require.NoError(t, err) + reads := store.readCount() + gate := store.hold() + recovery.nextRefresh.Store(0) + require.True(t, recovery.Enabled()) + require.Eventually(t, func() bool { return store.readCount() > reads }, time.Second, time.Millisecond, "the refresh has read the stored default") + + saved := make(chan error, 1) + go func() { + _, err := recovery.Save(context.Background(), ReasoningRecoveryConfig{Enabled: false}) + saved <- err + }() + select { + case err := <-saved: + t.Fatalf("the save did not wait for the read that began before it: %v", err) + case <-time.After(30 * time.Millisecond): + } + close(gate) + require.NoError(t, <-saved) + require.False(t, recovery.Enabled()) + require.Eventually(t, func() bool { return !recovery.refreshing.Load() }, time.Second, time.Millisecond) + require.False(t, recovery.Enabled(), "the earlier read cannot replace the saved value") +} diff --git a/backend/internal/service/reasoning_replay_diagnostic_control_test.go b/backend/internal/service/reasoning_replay_diagnostic_control_test.go deleted file mode 100644 index 2db1e7522ae3..000000000000 --- a/backend/internal/service/reasoning_replay_diagnostic_control_test.go +++ /dev/null @@ -1,275 +0,0 @@ -//go:build reasoning_fidelity && reasoning_replay_diagnostic - -package service_test - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - "github.com/Wei-Shaw/sub2api/internal/service" -) - -type replayDiagnosticOfflineUpstream struct { - calls int - status int - response string - last *http.Request - body []byte -} - -func (s *replayDiagnosticOfflineUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { - s.calls++ - s.last = req - s.body, _ = io.ReadAll(req.Body) - status := s.status - if status == 0 { - status = 200 - } - response := s.response - if response == "" { - response = `{"id":"resp_private","status":"completed","model":"gpt-5.6-sol","output":[{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"{"count":25,"first":"ABCDFE","last":"CBEADF"}"}]}],"usage":{"input_tokens":1,"output_tokens":2}}` - } - return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(response))}, nil -} -func (s *replayDiagnosticOfflineUpstream) DoWithTLS(req *http.Request, p string, id int64, n int, _ *tlsfingerprint.Profile) (*http.Response, error) { - return s.Do(req, p, id, n) -} -func replayDiagnosticOfflineBudget(t *testing.T, caseID int, grants ...map[string]any) (*replayDiagnosticUpstream, *replayDiagnosticOfflineUpstream, *bytes.Buffer) { - t.Helper() - var input, output bytes.Buffer - for _, grant := range grants { - if err := json.NewEncoder(&input).Encode(grant); err != nil { - t.Fatal(err) - } - } - source := fidelityOfflineBootstrap().Source - source.UserID = 920000015522 - h := &replayDiagnosticHarness{boot: replayDiagnosticBootstrap{SchemaVersion: 1, RunID: replayDiagnosticRunID, ConfigMode: "production_env", SourceSHA: strings.Repeat("a", 40), Source: source}, input: bufio.NewReader(&input), output: json.NewEncoder(&output), ctx: context.Background()} - h.accountHash = fidelityHashJSON(&h.boot.Source.Account) - fake := &replayDiagnosticOfflineUpstream{} - fixture := "native_single" - if caseID == 6 { - fixture = "invalid_cipher" - } - u := &replayDiagnosticUpstream{h: h, inner: fake, fixture: replayDiagnosticCase{ID: caseID, Model: "gpt-5.6-sol", Effort: "xhigh", Fixture: fixture}, used: make(map[int]bool), endpoint: "https://upstream.invalid/v1/responses", authorizationHash: fidelityHash([]byte("Bearer offline-fake"))} - h.upstream = u - return u, fake, &output -} -func replayDiagnosticGrant(caseID, slot, attempt int) map[string]any { - return map[string]any{"type": "send_granted", "case_id": caseID, "slot": slot, "attempt": attempt, "fingerprint": strings.Repeat("a", 64), "elapsed_ms": 0} -} -func replayDiagnosticReadClose(t *testing.T, resp *http.Response, err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } - if _, err = io.Copy(io.Discard, resp.Body); err != nil { - t.Fatal(err) - } - if err = resp.Body.Close(); err != nil { - t.Fatal(err) - } -} -func TestReasoningReplayDiagnosticControl(t *testing.T) { - t.Run("authoritative_terminal_supersedes_preliminary_rejection", func(t *testing.T) { - bare := "data: {\"type\":\"error\",\"error\":{\"code\":\"invalid_encrypted_content\"}}\n\n" - for _, contentType := range []string{"text/event-stream", ""} { - if got := replayDiagnosticErrorCode([]byte(bare+"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"), contentType); got != "" { - t.Fatal("completed response incorrectly authorizes recovery") - } - if got := replayDiagnosticErrorCode([]byte(bare+"data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"server_error\"}}}\n\n"), contentType); got != "" { - t.Fatal("unrelated final rejection inherited an earlier code") - } - if got := replayDiagnosticErrorCode([]byte("event: response.done\ndata: {\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"thinking_signature_invalid\"}}}\n\n"), contentType); got != "thinking_signature_invalid" { - t.Fatal("event-only failed terminal lost its exact code") - } - } - }) - t.Run("one_send_and_private_attempt_evidence", func(t *testing.T) { - u, fake, out := replayDiagnosticOfflineBudget(t, 1, replayDiagnosticGrant(1, 1, 1)) - resp, err := u.Do(fidelityOfflineRequest(t), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - if fake.calls != 1 || u.attempts != 1 || fake.last.GetBody != nil || !service.HTTPUpstreamRedirectsDisabled(fake.last.Context()) { - t.Fatal("outbound boundary not guarded") - } - if _, err = u.Do(fidelityOfflineRequest(t), "", 15522, 1); err == nil || fake.calls != 1 || u.blocked != 1 { - t.Fatal("ordinary internal retry escaped") - } - if strings.Count(out.String(), `"type":"before_send"`) != 1 || strings.Count(out.String(), `"type":"attempt_result"`) != 1 { - t.Fatal("attempt evidence protocol incomplete") - } - for _, private := range []string{"offline-fake", "upstream.invalid", "resp_private", "ABCDFE"} { - if strings.Contains(out.String(), private) { - t.Fatal("raw source or answer leaked") - } - } - }) - t.Run("exact_rejection_allows_one_extra_and_not_third", func(t *testing.T) { - u, fake, out := replayDiagnosticOfflineBudget(t, 6, replayDiagnosticGrant(6, 6, 1), replayDiagnosticGrant(6, 7, 2)) - fake.status = 400 - fake.response = `{"error":{"code":"invalid_encrypted_content","message":"never expose this upstream body"}}` - resp, err := u.Do(fidelityOfflineRequest(t), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - fake.status = 200 - fake.response = "" - resp, err = u.Do(fidelityOfflineRequest(t), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - if fake.calls != 2 || u.attempts != 2 || len(u.records) != 2 || u.records[0].result.ErrorCode != "invalid_encrypted_content" { - t.Fatal("second recovery attempt not distinct") - } - if _, err = u.Do(fidelityOfflineRequest(t), "", 15522, 1); err == nil || fake.calls != 2 { - t.Fatal("third call escaped") - } - if strings.Contains(out.String(), "never expose") { - t.Fatal("private upstream error leaked") - } - }) - t.Run("keywords_generic_error_and_failed_code_cannot_recover", func(t *testing.T) { - for _, response := range []string{`{"error":{"message":"invalid_encrypted_content"}}`, `{"error":{"code":"upstream_error"}}`, `{"error":{"code":400}}`} { - u, fake, _ := replayDiagnosticOfflineBudget(t, 6, replayDiagnosticGrant(6, 6, 1), replayDiagnosticGrant(6, 7, 2)) - fake.status = 400 - fake.response = response - resp, err := u.Do(fidelityOfflineRequest(t), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - if _, err = u.Do(fidelityOfflineRequest(t), "", 15522, 1); err == nil || fake.calls != 1 { - t.Fatal("unproven rejection retried") - } - } - }) - t.Run("source_auth_budget_and_cancel_fail_before_grant", func(t *testing.T) { - for _, kind := range []string{"source", "budget", "cancel", "auth"} { - u, fake, out := replayDiagnosticOfflineBudget(t, 1, replayDiagnosticGrant(1, 1, 1)) - req := fidelityOfflineRequest(t) - switch kind { - case "source": - u.h.boot.Source.Account.Credentials["api_key"] = "changed" - case "budget": - u.attempts = 16 - case "cancel": - ctx, cancel := context.WithCancel(context.Background()) - cancel() - u.activeContext = ctx - case "auth": - req.Header.Set("Authorization", "Bearer different") - } - if _, err := u.Do(req, "", 15522, 1); err == nil || fake.calls != 0 || out.Len() != 0 { - t.Fatalf("unapproved %s send escaped", kind) - } - } - }) - t.Run("auth_quota_stops_all_subsequent_calls", func(t *testing.T) { - for _, status := range []int{401, 403, 402, 429} { - u, fake, _ := replayDiagnosticOfflineBudget(t, 6, replayDiagnosticGrant(6, 6, 1), replayDiagnosticGrant(6, 7, 2)) - fake.status = status - fake.response = `{"error":{"code":"invalid_encrypted_content"}}` - resp, err := u.Do(fidelityOfflineRequest(t), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - if _, err = u.Do(fidelityOfflineRequest(t), "", 15522, 1); err == nil || fake.calls != 1 || u.h.stopped == "" { - t.Fatal("auth or quota did not stop recovery") - } - } - }) - t.Run("recoveries_share_the_original_deadline", func(t *testing.T) { - u, fake, _ := replayDiagnosticOfflineBudget(t, 6, replayDiagnosticGrant(6, 6, 1), replayDiagnosticGrant(6, 7, 2)) - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - u.activeContext = ctx - fake.status = 400 - fake.response = `{"error":{"code":"thinking_signature_invalid"}}` - resp, err := u.Do(fidelityOfflineRequest(t).WithContext(context.WithoutCancel(ctx)), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - deadline1, ok := fake.last.Context().Deadline() - if !ok { - t.Fatal("detached Forward lost study deadline") - } - fake.status = 200 - fake.response = "" - resp, err = u.Do(fidelityOfflineRequest(t).WithContext(context.WithoutCancel(ctx)), "", 15522, 1) - replayDiagnosticReadClose(t, resp, err) - deadline2, _ := fake.last.Context().Deadline() - if !deadline1.Equal(deadline2) { - t.Fatal("recovery extended the request deadline") - } - }) - t.Run("bad_grant_is_zero_network_calls", func(t *testing.T) { - for _, bad := range []map[string]any{{"type": "send_denied"}, replayDiagnosticGrant(6, 7, 1), replayDiagnosticGrant(1, 1, 2)} { - u, fake, _ := replayDiagnosticOfflineBudget(t, 1, bad) - if _, err := u.Do(fidelityOfflineRequest(t), "", 15522, 1); err == nil || fake.calls != 0 || u.h.stopped == "" { - t.Fatal("invalid permission sent") - } - } - }) - t.Run("controlled_cipher_changes_no_other_raw_state", func(t *testing.T) { - body := fidelityRequestBody("gpt-5.6-sol", "xhigh", fidelitySinglePrompt, false) - items := []json.RawMessage{json.RawMessage(`{"type":"reasoning","id":"rs_private","encrypted_content":"opaque-long-private-cipher","summary":[{"type":"summary_text","text":"visible"}],"extension":{"keep":true}}`), json.RawMessage(`{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"previous answer"}],"extension":true}`)} - e := replayDiagnosticExchange{request: body, result: replayDiagnosticResult{Status: "completed"}, raw: fidelityResponse{Status: "completed", ReasoningComplete: true, Output: items}} - bad, cipher, ok := replayDiagnosticInvalidBody(e) - if !ok || cipher == "opaque-long-private-cipher" { - t.Fatal("controlled corrupt fixture unavailable") - } - var obj map[string]json.RawMessage - var input []json.RawMessage - _ = json.Unmarshal(bad, &obj) - _ = json.Unmarshal(obj["input"], &input) - if len(input) != 4 || !fidelityJSONEqual(input[2], items[1]) { - t.Fatal("non-reasoning state changed") - } - var before, after map[string]json.RawMessage - _ = json.Unmarshal(items[0], &before) - _ = json.Unmarshal(input[1], &after) - delete(before, "encrypted_content") - delete(after, "encrypted_content") - if fidelityHashJSON(before) != fidelityHashJSON(after) { - t.Fatal("cipher corruption altered another reasoning field") - } - e.raw.Output = append(e.raw.Output, items[0]) - if _, _, ok = replayDiagnosticInvalidBody(e); ok { - t.Fatal("duplicate reasoning identity accepted") - } - e.raw.Output[2] = json.RawMessage(strings.Replace(string(items[0]), "rs_private", "rs_private_second", 1)) - multiple, _, ok := replayDiagnosticInvalidBody(e) - if !ok { - t.Fatal("distinct completed reasoning history rejected") - } - _ = json.Unmarshal(multiple, &obj) - _ = json.Unmarshal(obj["input"], &input) - if !fidelityJSONEqual(input[3], e.raw.Output[2]) { - t.Fatal("fixture altered more than the first reasoning cipher") - } - }) - t.Run("negative_fixture_preserves_bad_old_and_new_reasoning_for_real_store", func(t *testing.T) { - invalid := []byte(`{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"},"input":[{"type":"reasoning","encrypted_content":"old-bad","summary":[]}]}`) - recovered := replayDiagnosticExchange{result: replayDiagnosticResult{Status: "completed", RecoveryObserved: true}, raw: fidelityResponse{Status: "completed", Output: []json.RawMessage{json.RawMessage(`{"type":"reasoning","encrypted_content":"new-good","summary":[]}`), json.RawMessage(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}`)}}} - next, ciphers, ok := replayDiagnosticNegativeBody(invalid, recovered) - if !ok || len(ciphers) != 1 { - t.Fatal("negative followup unavailable") - } - var obj map[string]json.RawMessage - _ = json.Unmarshal(next, &obj) - if !replayDiagnosticHasCipher(obj["input"], "old-bad") || !replayDiagnosticHasCipher(obj["input"], "new-good") { - t.Fatal("harness pre-removed history instead of testing service") - } - recovered.result.RecoveryObserved = false - if _, _, ok = replayDiagnosticNegativeBody(invalid, recovered); ok { - t.Fatal("unproven recovery used for negative cache claim") - } - }) - t.Run("bootstrap_exact_identity_and_nonzero_synthetic_tenant", func(t *testing.T) { - u, _, _ := replayDiagnosticOfflineBudget(t, 1) - if err := replayDiagnosticValidateBootstrap(u.h.boot); err != nil { - t.Fatal(err) - } - u.h.boot.Source.Account.ID = 15523 - if replayDiagnosticValidateBootstrap(u.h.boot) == nil { - t.Fatal("different account accepted") - } - }) -} diff --git a/backend/internal/service/reasoning_replay_diagnostic_live_test.go b/backend/internal/service/reasoning_replay_diagnostic_live_test.go deleted file mode 100644 index fc89b69db33b..000000000000 --- a/backend/internal/service/reasoning_replay_diagnostic_live_test.go +++ /dev/null @@ -1,759 +0,0 @@ -//go:build reasoning_fidelity && reasoning_replay_diagnostic - -package service_test - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "io" - "log" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - "github.com/Wei-Shaw/sub2api/internal/repository" - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -const replayDiagnosticRunID = "reasoning-replay-recovery-20260907" -const replayDiagnosticMaxAttempts = 16 - -type replayDiagnosticBootstrap struct { - SchemaVersion int `json:"schema_version"` - RunID string `json:"run_id"` - ConfigMode string `json:"config_mode"` - SourceSHA string `json:"source_sha"` - Source fidelitySource `json:"source"` -} -type replayDiagnosticCase struct { - ID int - Model, Effort, Fixture string - Chat, Stream bool -} -type replayDiagnosticAttempt struct { - Type string `json:"type"` - CaseID int `json:"case_id"` - Slot int `json:"slot"` - Model string `json:"model"` - Effort string `json:"effort"` - Fixture string `json:"fixture"` - Status string `json:"status"` - ErrorClass string `json:"error_class,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - Attempts int `json:"attempts"` - DurationMS int64 `json:"duration_ms"` - HTTPStatus int `json:"http_status"` - Usage *fidelityUsage `json:"usage"` - SentBodySHA string `json:"sent_body_sha256,omitempty"` - SentInputSHA string `json:"sent_input_sha256,omitempty"` - SentModelSHA string `json:"sent_model_sha256,omitempty"` - SentReasoningSHA string `json:"sent_reasoning_sha256,omitempty"` - ResponseIDSHA string `json:"response_id_sha256,omitempty"` - ResponseOutputSHA string `json:"response_output_sha256,omitempty"` - EncryptedInputSent int `json:"encrypted_input_sent"` -} -type replayDiagnosticResult struct { - Type string `json:"type"` - CaseID int `json:"case_id"` - Model string `json:"model"` - Effort string `json:"effort"` - Fixture string `json:"fixture"` - Status string `json:"status"` - Attempted bool `json:"attempted"` - AttemptsUsed int `json:"attempts_used"` - Attempts int `json:"attempts"` - BlockedRetries int `json:"blocked_retries"` - DurationMS int64 `json:"duration_ms"` - Correct *bool `json:"correct"` - FixtureSHA string `json:"fixture_sha256"` - AnswerSHA string `json:"answer_sha256,omitempty"` - ReasoningConfigPreserved bool `json:"reasoning_config_preserved"` - ResponseModelMatchesSent bool `json:"response_model_matches_sent"` - EncryptedReasoningComplete bool `json:"encrypted_reasoning_complete"` - ReplayHits int `json:"replay_hits"` - ReplayStored int `json:"replay_stored"` - ReplayFieldsPreserved *bool `json:"replay_fields_preserved"` - RecoveryObserved bool `json:"recovery_observed"` - NegativeCacheObserved bool `json:"negative_cache_observed"` - OldCipherRemoved *bool `json:"old_cipher_removed"` - NewCipherPreserved *bool `json:"new_cipher_preserved"` -} -type replayDiagnosticExchange struct { - result replayDiagnosticResult - raw fidelityResponse - chat replayDiagnosticChat - request, sent []byte - attempts []replayDiagnosticRecord -} -type replayDiagnosticRecord struct { - result replayDiagnosticAttempt - body []byte - response fidelityResponse -} -type replayDiagnosticHarness struct { - boot replayDiagnosticBootstrap - input *bufio.Reader - output *json.Encoder - gateway *service.OpenAIGatewayService - upstream *replayDiagnosticUpstream - ctx context.Context - accountHash, stopped string - firstSend time.Time - results int -} - -// A compiled binary is inert unless invoked by the reviewed broker. Neither -// ordinary go test nor preflight is authorized to issue a POST. -func TestReasoningReplayDiagnosticLive(t *testing.T) { - if os.Getenv("SUB2API_REASONING_REPLAY_LIVE") != "1" { - t.Skip("explicit controlled runner opt-in required") - } - out := os.Stdout - null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) - if err != nil { - t.Fatal("diagnostic_output_initialization_failed") - } - os.Stdout, os.Stderr = null, null - gin.SetMode(gin.ReleaseMode) - gin.DefaultWriter, gin.DefaultErrorWriter = io.Discard, io.Discard - _ = logger.Init(logger.InitOptions{Level: "fatal", Output: logger.OutputOptions{ToStdout: true}}) - logger.SetSink(nil) - log.SetOutput(io.Discard) - slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - encoder := json.NewEncoder(out) - defer func() { - if recover() != nil { - _ = encoder.Encode(map[string]any{"type": "summary", "status": "internal_panic", "attempts_unknown": true}) - t.Fail() - } - }() - if err = replayDiagnosticRun(bufio.NewReaderSize(os.Stdin, 64<<10), encoder); err != nil { - _ = encoder.Encode(map[string]any{"type": "summary", "status": "harness_failed", "error_class": err.Error()}) - t.Fail() - } -} -func replayDiagnosticValidateBootstrap(boot replayDiagnosticBootstrap) error { - if boot.SchemaVersion != 1 || boot.RunID != replayDiagnosticRunID || boot.ConfigMode != "production_env" || - len(boot.SourceSHA) != 40 || strings.Trim(boot.SourceSHA, "0123456789abcdef") != "" || - boot.Source.Account.ID != 15522 || boot.Source.UserID != 920000015522 { - return errors.New("invalid_bootstrap_contract") - } - legacy := fidelityBootstrap{SchemaVersion: 1, Phase: "after", RunID: boot.RunID, ConfigMode: boot.ConfigMode, Source: boot.Source} - return fidelityValidateBootstrap(legacy) -} -func replayDiagnosticRun(input *bufio.Reader, output *json.Encoder) error { - var boot replayDiagnosticBootstrap - if err := fidelityReadJSON(input, &boot); err != nil { - return errors.New("invalid_bootstrap") - } - if err := replayDiagnosticValidateBootstrap(boot); err != nil { - return err - } - if expected := os.Getenv("REASONING_REPLAY_SOURCE_SHA"); expected != "" && expected != boot.SourceSHA { - return errors.New("invalid_bootstrap_contract") - } - cfg, err := config.LoadForBootstrap() - if err != nil { - return errors.New("production_config_load_failed") - } - h := &replayDiagnosticHarness{boot: boot, input: input, output: output, ctx: context.Background()} - h.accountHash = fidelityHashJSON(&h.boot.Source.Account) - u := &replayDiagnosticUpstream{h: h, inner: repository.NewHTTPUpstream(cfg), used: make(map[int]bool)} - h.upstream = u - h.gateway, err = service.ReasoningFidelityGatewayForTest(cfg, u, boot.Source.SystemPrompts, boot.Source.Settings) - if err != nil { - return errors.New("unsupported_source_policy") - } - service.ReasoningReplayDiagnosticAttach(h.gateway) - probe := fidelityRequestBody("gpt-5.6-sol", "xhigh", fidelitySinglePrompt, false) - probe, err = fidelityPrepareIngressBody(probe, boot.Source) - if err != nil { - return errors.New("source_group_policy_rejected") - } - c, _ := h.newContext(h.ctx, probe, replayDiagnosticCase{Model: "gpt-5.6-sol", Effort: "xhigh"}) - req, err := service.ReasoningFidelityDirectRequestForTest(c.Request.Context(), h.gateway, c, &h.boot.Source.Account, probe) - if err != nil { - return errors.New("source_endpoint_validation_failed") - } - if req.URL == nil || req.URL.Scheme != "https" || req.URL.User != nil || req.URL.RawQuery != "" { - return errors.New("source_endpoint_not_https_responses") - } - if !strings.HasSuffix(req.URL.Path, "/responses") { - return errors.New("source_endpoint_not_responses") - } - u.endpoint = req.URL.String() - u.authorizationHash = fidelityHash([]byte(req.Header.Get("Authorization"))) - _ = req.Body.Close() - if h.boot.Source.Account.Proxy != nil { - u.proxy = h.boot.Source.Account.Proxy.URL() - } - _ = output.Encode(map[string]any{"type": "ready", "source_fingerprint": boot.Source.Fingerprint, "endpoint_sha256": fidelityHash([]byte(u.endpoint)), "attempts": 0}) - if os.Getenv("SUB2API_REASONING_REPLAY_VALIDATE_ONLY") == "1" { - return output.Encode(map[string]any{"type": "summary", "status": "validated", "attempts": 0}) - } - h.run() - status := "completed" - if h.stopped != "" { - status = h.stopped - } - return output.Encode(map[string]any{"type": "summary", "status": status, "attempts": u.attempts, "results_count": h.results, "blocked_retries": u.blocked}) -} -func (h *replayDiagnosticHarness) newContext(ctx context.Context, body []byte, fixture replayDiagnosticCase) (*gin.Context, *httptest.ResponseRecorder) { - record := httptest.NewRecorder() - c, _ := gin.CreateTestContext(record) - path := "/v1/responses" - if fixture.Chat { - path = "/v1/chat/completions" - } - c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)).WithContext(ctx) - c.Request.Header.Set("Content-Type", "application/json") - c.Request.Header.Set("Accept", "text/event-stream") - c.Request.Header.Set("User-Agent", "sub2api-reasoning-replay-diagnostic/1") - c.Request.Header.Set("X-Client-Request-Id", fidelityNewID()) - service.ReasoningFidelityContextForTest(ctx, c, &h.boot.Source.Group, h.boot.Source.FastPolicy, h.boot.Source.UserID, fixture.Effort) - service.ReasoningReplayDiagnosticTenant(c, &h.boot.Source.Group) - return c, record -} -func (h *replayDiagnosticHarness) run() { - for index, model := range []string{"gpt-5.6-sol", "gpt-6-astra"} { - base := index * 8 - effort := []string{"xhigh", "max"}[index] - fixture := replayDiagnosticCase{ID: base + 1, Model: model, Effort: effort, Fixture: "native_single", Stream: true} - single := h.runCase(fixture, fidelityRequestBody(model, effort, fidelitySinglePrompt, false), nil, "", nil) - for mode, stream := range []bool{true, false} { - label := "chat_stream_" - if !stream { - label = "chat_nonstream_" - } - firstCase := replayDiagnosticCase{ID: base + 2 + mode*2, Model: model, Effort: effort, Fixture: label + "first", Chat: true, Stream: stream} - firstBody := replayDiagnosticChatBody(model, effort, stream) - first := h.runCase(firstCase, firstBody, nil, "", nil) - finalCase := firstCase - finalCase.ID++ - finalCase.Fixture = label + "final" - next, ok := replayDiagnosticChatContinuation(firstBody, first.chat) - if !ok { - h.skip(finalCase, "not_covered") - continue - } - h.runCase(finalCase, next, first.raw.Output, "", nil) - } - recoveryCase := replayDiagnosticCase{ID: base + 6, Model: model, Effort: effort, Fixture: "invalid_cipher", Stream: true} - invalid, oldCipher, ok := replayDiagnosticInvalidBody(single) - if !ok { - h.skip(recoveryCase, "not_covered") - h.skip(replayDiagnosticCase{ID: base + 8, Model: model, Effort: effort, Fixture: "negative_cache", Stream: true}, "not_covered") - continue - } - recovered := h.runCase(recoveryCase, invalid, nil, oldCipher, nil) - negativeCase := recoveryCase - negativeCase.ID = base + 8 - negativeCase.Fixture = "negative_cache" - next, newCiphers, ok := replayDiagnosticNegativeBody(invalid, recovered) - if !ok { - h.skip(negativeCase, "not_covered") - continue - } - h.runCase(negativeCase, next, nil, oldCipher, newCiphers) - } -} -func (h *replayDiagnosticHarness) skip(f replayDiagnosticCase, status string) replayDiagnosticExchange { - r := replayDiagnosticResult{Type: "case_result", CaseID: f.ID, Model: f.Model, Effort: f.Effort, Fixture: f.Fixture, Status: status, Attempts: h.upstream.attempts, FixtureSHA: replayDiagnosticFixtureHash(f.Fixture)} - h.results++ - _ = h.output.Encode(r) - return replayDiagnosticExchange{result: r} -} -func replayDiagnosticFixtureHash(fixture string) string { - if strings.HasPrefix(fixture, "chat_") { - return fidelityHash([]byte(fidelityOrdersPrompt + "\n" + fidelityOrdersResult)) - } - return fidelityHash([]byte(fidelitySinglePrompt)) -} -func (h *replayDiagnosticHarness) runCase(f replayDiagnosticCase, body []byte, expected []json.RawMessage, oldCipher string, newCiphers []string) replayDiagnosticExchange { - if h.stopped != "" { - return h.skip(f, "batch_stopped") - } - if !h.firstSend.IsZero() && time.Since(h.firstSend) >= fidelityMaxDuration { - h.stopped = "batch_time_limit" - return h.skip(f, "batch_stopped") - } - original := append([]byte(nil), body...) - if !f.Chat { - var err error - body, err = fidelityPrepareIngressBody(body, h.boot.Source) - if err != nil { - return h.skip(f, "unsupported_source_policy") - } - } - ctx, cancel := context.WithTimeout(h.ctx, fidelityRequestTimeout) - defer cancel() - c, writer := h.newContext(ctx, body, f) - u := h.upstream - u.fixture = f - u.activeContext = c.Request.Context() - u.records = nil - u.caseSent = 0 - u.lastError = "" - attemptsBefore, blockedBefore := u.attempts, u.blocked - started := time.Now() - var err error - if f.Chat { - _, err = h.gateway.ForwardAsChatCompletions(c.Request.Context(), c, &h.boot.Source.Account, body, "", h.boot.Source.ChannelModels[f.Model]) - } else { - _, err = h.gateway.Forward(c.Request.Context(), c, &h.boot.Source.Account, body) - } - e := replayDiagnosticExchange{request: original, attempts: append([]replayDiagnosticRecord(nil), u.records...)} - text := "" - if f.Chat { - e.chat = replayDiagnosticParseChat(writer.Body.Bytes(), writer.Header().Get("Content-Type"), writer.Code) - text = e.chat.Text - } else { - e.raw = fidelityParseResponse(writer.Body.Bytes(), writer.Header().Get("Content-Type"), writer.Code) - text = e.raw.Text - } - if len(u.records) > 0 { - e.sent = append([]byte(nil), u.records[len(u.records)-1].body...) - if f.Chat { - e.raw = u.records[len(u.records)-1].response - } - } - status := e.raw.Status - if f.Chat { - status = e.chat.Status - } - if status == "" { - status = "no_response" - } - if err != nil && status == "completed" { - status = "request_failed" - } - if u.blocked > blockedBefore { - status = "retry_blocked" - } - r := replayDiagnosticResult{Type: "case_result", CaseID: f.ID, Model: f.Model, Effort: f.Effort, Fixture: f.Fixture, Status: status, Attempted: u.attempts > attemptsBefore, AttemptsUsed: u.attempts - attemptsBefore, Attempts: u.attempts, BlockedRetries: u.blocked - blockedBefore, DurationMS: time.Since(started).Milliseconds(), FixtureSHA: replayDiagnosticFixtureHash(f.Fixture), EncryptedReasoningComplete: e.raw.ReasoningComplete} - if text != "" { - r.AnswerSHA = fidelityHash([]byte(text)) - } - if status == "completed" && !strings.HasSuffix(f.Fixture, "_first") { - kind := "single" - if f.Chat { - kind = "tool_final" - } - correct := fidelityScore(kind, text) - r.Correct = &correct - } - var sent map[string]json.RawMessage - _ = json.Unmarshal(e.sent, &sent) - var reasoning struct { - Effort string `json:"effort"` - } - _ = json.Unmarshal(sent["reasoning"], &reasoning) - r.ReasoningConfigPreserved = reasoning.Effort == f.Effort - var sentModel string - _ = json.Unmarshal(sent["model"], &sentModel) - r.ResponseModelMatchesSent = e.raw.Model != "" && e.raw.Model == sentModel - r.ReplayHits, r.ReplayStored = service.ReasoningReplayDiagnosticObserve(c) - if expected != nil { - preserved := replayDiagnosticContainsItems(sent["input"], expected) - r.ReplayFieldsPreserved = &preserved - } - r.RecoveryObserved = f.Fixture == "invalid_cipher" && len(u.records) == 2 && replayDiagnosticSignatureCode(u.records[0].result.ErrorCode) - r.NegativeCacheObserved = f.Fixture == "negative_cache" && len(u.records) == 1 && writer.Header().Get("X-Sub2API-Reasoning-Recovery") == "rejected_history_skipped" - if oldCipher != "" && len(e.sent) > 0 { - removed := !replayDiagnosticHasCipher(sent["input"], oldCipher) - r.OldCipherRemoved = &removed - } - if len(newCiphers) > 0 { - preserved := true - for _, cipher := range newCiphers { - preserved = preserved && replayDiagnosticHasCipher(sent["input"], cipher) - } - r.NewCipherPreserved = &preserved - } - e.result = r - h.results++ - _ = h.output.Encode(r) - return e -} - -func replayDiagnosticContainsItems(input json.RawMessage, items []json.RawMessage) bool { - var list []json.RawMessage - if json.Unmarshal(input, &list) != nil || len(items) == 0 { - return false - } - for start := 0; start+len(items) <= len(list); start++ { - match := true - for i, item := range items { - match = match && fidelityJSONEqual(item, list[start+i]) - } - if match { - return true - } - } - return false -} -func replayDiagnosticHasCipher(input json.RawMessage, cipher string) bool { - var items []map[string]json.RawMessage - _ = json.Unmarshal(input, &items) - for _, item := range items { - var actual string - _ = json.Unmarshal(item["encrypted_content"], &actual) - if actual == cipher { - return true - } - } - return false -} -func replayDiagnosticInvalidBody(e replayDiagnosticExchange) ([]byte, string, bool) { - if e.result.Status != "completed" || !e.raw.ReasoningComplete || e.raw.HasTool || e.raw.HasRefusal { - return nil, "", false - } - var request map[string]json.RawMessage - _ = json.Unmarshal(e.request, &request) - for _, key := range []string{"previous_response_id", "conversation", "context_management"} { - if _, ok := request[key]; ok { - return nil, "", false - } - } - var input []json.RawMessage - if json.Unmarshal(request["input"], &input) != nil { - return nil, "", false - } - items := make([]json.RawMessage, len(e.raw.Output)) - count := 0 - bad := "" - seenIDs := make(map[string]bool) - for i, raw := range e.raw.Output { - var item map[string]json.RawMessage - if json.Unmarshal(raw, &item) != nil { - return nil, "", false - } - var kind string - _ = json.Unmarshal(item["type"], &kind) - var id string - _ = json.Unmarshal(item["id"], &id) - if id != "" { - if seenIDs[id] { - return nil, "", false - } - seenIDs[id] = true - } - if kind == "compaction" { - return nil, "", false - } - if kind == "reasoning" { - var cipher string - _ = json.Unmarshal(item["encrypted_content"], &cipher) - if len(cipher) < 16 { - return nil, "", false - } - if bad == "" { - // Alter exactly one ciphertext field; other completed reasoning - // items remain part of the genuine inline history being tested. - changed := []byte(cipher) - at := len(changed) / 2 - if changed[at] == 'A' { - changed[at] = 'B' - } else { - changed[at] = 'A' - } - bad = string(changed) - item["encrypted_content"], _ = json.Marshal(bad) - } - count++ - } - items[i], _ = json.Marshal(item) - } - if count == 0 { - return nil, "", false - } - input = append(input, items...) - msg, _ := json.Marshal(map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Check the same arrangement task again and return only the requested JSON."}}}) - input = append(input, msg) - request["input"], _ = json.Marshal(input) - body, err := json.Marshal(request) - return body, bad, err == nil -} -func replayDiagnosticNegativeBody(invalid []byte, recovered replayDiagnosticExchange) ([]byte, []string, bool) { - if !recovered.result.RecoveryObserved || recovered.result.Status != "completed" || recovered.raw.HasTool || recovered.raw.HasRefusal { - return nil, nil, false - } - var body map[string]json.RawMessage - var input []json.RawMessage - if json.Unmarshal(invalid, &body) != nil || json.Unmarshal(body["input"], &input) != nil { - return nil, nil, false - } - ciphers := []string{} - for _, raw := range recovered.raw.Output { - var item map[string]json.RawMessage - _ = json.Unmarshal(raw, &item) - var kind, cipher string - _ = json.Unmarshal(item["type"], &kind) - _ = json.Unmarshal(item["encrypted_content"], &cipher) - if kind == "compaction" { - return nil, nil, false - } - if kind == "reasoning" && cipher != "" { - ciphers = append(ciphers, cipher) - } - } - if len(ciphers) == 0 { - return nil, nil, false - } - input = append(input, recovered.raw.Output...) - msg, _ := json.Marshal(map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Verify the arrangement total and lexicographic endpoints once more. Return only the same JSON fields."}}}) - input = append(input, msg) - body["input"], _ = json.Marshal(input) - raw, err := json.Marshal(body) - return raw, ciphers, err == nil -} - -type replayDiagnosticUpstream struct { - h *replayDiagnosticHarness - inner service.HTTPUpstream - fixture replayDiagnosticCase - activeContext context.Context - endpoint, authorizationHash, proxy, lastError string - attempts, blocked, caseSent int - used map[int]bool - records []replayDiagnosticRecord -} - -func (u *replayDiagnosticUpstream) Do(req *http.Request, proxy string, id int64, concurrency int) (*http.Response, error) { - return u.send(req, proxy, id, concurrency, nil) -} -func (u *replayDiagnosticUpstream) DoWithTLS(req *http.Request, proxy string, id int64, concurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { - return u.send(req, proxy, id, concurrency, profile) -} -func replayDiagnosticSignatureCode(code string) bool { - return code == "thinking_signature_invalid" || code == "invalid_encrypted_content" -} -func (u *replayDiagnosticUpstream) send(req *http.Request, proxy string, id int64, concurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { - fail := func(code string) (*http.Response, error) { u.lastError = code; return nil, errors.New(code) } - slot := u.fixture.ID + u.caseSent - max := 1 - if u.fixture.ID == 6 || u.fixture.ID == 14 { - max = 2 - } - if u.caseSent >= max || u.used[slot] || u.caseSent == 1 && (len(u.records) != 1 || !replayDiagnosticSignatureCode(u.records[0].result.ErrorCode)) { - u.blocked++ - return fail("slot_retry_blocked") - } - if u.attempts >= replayDiagnosticMaxAttempts { - return fail("attempt_budget_exhausted") - } - if u.h.stopped != "" { - return fail("source_changed") - } - if req == nil || req.URL == nil || req.Method != http.MethodPost || req.URL.String() != u.endpoint || - id != 15522 || concurrency != u.h.boot.Source.Account.Concurrency || proxy != u.proxy || - fidelityHash([]byte(req.Header.Get("Authorization"))) != u.authorizationHash || - fidelityHashJSON(&u.h.boot.Source.Account) != u.h.accountHash { - u.h.stopped = "source_verification_stopped" - return fail("source_changed") - } - ctx := u.activeContext - if ctx == nil { - ctx = u.h.ctx - } - if req.Context().Err() != nil || ctx.Err() != nil { - return fail("request_timeout") - } - if !u.h.firstSend.IsZero() && time.Since(u.h.firstSend) >= fidelityMaxDuration { - u.h.stopped = "batch_time_limit" - return fail("attempt_budget_exhausted") - } - body, err := io.ReadAll(io.LimitReader(req.Body, fidelityMaxBody+1)) - if err != nil || len(body) > fidelityMaxBody { - return fail("request_body_unavailable") - } - _ = req.Body.Close() - if err = u.h.output.Encode(map[string]any{"type": "before_send", "case_id": u.fixture.ID, "slot": slot, "expected_fingerprint": u.h.boot.Source.Fingerprint}); err != nil { - u.h.stopped = "source_verification_stopped" - return fail("broker_unavailable") - } - var grant struct { - fidelityGrant - CaseID int `json:"case_id"` - } - ch := make(chan error, 1) - go func() { ch <- fidelityReadJSON(u.h.input, &grant) }() - select { - case err = <-ch: - case <-ctx.Done(): - u.h.stopped = "source_verification_stopped" - return fail("broker_timeout") - } - if err != nil { - u.h.stopped = "source_verification_stopped" - return fail("broker_unavailable") - } - if grant.Type != "send_granted" || grant.CaseID != u.fixture.ID || grant.Slot != slot || - grant.Fingerprint != u.h.boot.Source.Fingerprint || grant.Attempt != u.attempts+1 || grant.Attempt > 16 || - grant.ElapsedMS < 0 || grant.ElapsedMS >= fidelityMaxDuration.Milliseconds() { - u.h.stopped = "source_verification_stopped" - return fail("invalid_broker_grant") - } - if u.h.firstSend.IsZero() { - u.h.firstSend = time.Now() - } - u.used[slot] = true - u.attempts = grant.Attempt - u.caseSent++ - started := time.Now() - sendCtx := req.Context() - cancelDeadline := func() {} - if deadline, ok := ctx.Deadline(); ok { - sendCtx, cancelDeadline = context.WithDeadline(sendCtx, deadline) - } - sendCtx, cancelSend := context.WithCancel(sendCtx) - stop := context.AfterFunc(ctx, cancelSend) - cleanup := func() { stop(); cancelSend(); cancelDeadline() } - req = req.Clone(service.WithHTTPUpstreamRedirectsDisabled(sendCtx)) - req.Body = io.NopCloser(bytes.NewReader(body)) - req.GetBody = nil - var resp *http.Response - if profile == nil { - resp, err = u.inner.Do(req, proxy, id, concurrency) - } else { - resp, err = u.inner.DoWithTLS(req, proxy, id, concurrency, profile) - } - if err != nil { - cleanup() - u.record(slot, body, nil, "", 0, started, "transport_error") - return fail("transport_error") - } - if resp == nil || resp.Body == nil { - cleanup() - u.record(slot, body, nil, "", 0, started, "empty_upstream_response") - return fail("empty_upstream_response") - } - code, contentType := resp.StatusCode, resp.Header.Get("Content-Type") - resp.Body = &replayDiagnosticCapture{ReadCloser: resp.Body, cleanup: cleanup, finish: func(data []byte) { u.record(slot, body, data, contentType, code, started, "") }} - return resp, nil -} -func (u *replayDiagnosticUpstream) record(slot int, body, data []byte, contentType string, status int, started time.Time, class string) { - parsed := fidelityParseResponse(data, contentType, status) - r := replayDiagnosticAttempt{Type: "attempt_result", CaseID: u.fixture.ID, Slot: slot, Model: u.fixture.Model, Effort: u.fixture.Effort, Fixture: u.fixture.Fixture, Status: parsed.Status, ErrorClass: parsed.ErrorClass, ErrorCode: replayDiagnosticErrorCode(data, contentType), Attempts: u.attempts, DurationMS: time.Since(started).Milliseconds(), HTTPStatus: status, Usage: parsed.Usage, SentBodySHA: fidelityHash(body)} - if class != "" { - r.Status = "request_failed" - r.ErrorClass = class - } - var request map[string]json.RawMessage - _ = json.Unmarshal(body, &request) - r.SentInputSHA = fidelityHash(request["input"]) - r.SentModelSHA = fidelityHash(request["model"]) - r.SentReasoningSHA = fidelityHash(request["reasoning"]) - r.EncryptedInputSent, _ = fidelityInputCounts(request["input"]) - if parsed.ID != "" { - r.ResponseIDSHA = fidelityHash([]byte(parsed.ID)) - } - if len(parsed.Output) > 0 { - r.ResponseOutputSHA = fidelityHashJSON(parsed.Output) - } - u.records = append(u.records, replayDiagnosticRecord{result: r, body: append([]byte(nil), body...), response: parsed}) - if status == 401 || status == 403 || parsed.ErrorClass == "auth" { - u.h.stopped = "authentication_stopped" - } - if status == 402 || status == 429 || parsed.ErrorClass == "quota" || parsed.ErrorClass == "rate_limit" { - u.h.stopped = "quota_or_rate_limit_stopped" - } - _ = u.h.output.Encode(r) -} -func replayDiagnosticErrorCode(body []byte, contentType string) string { - physical := bytes.ReplaceAll(bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")), []byte("\r"), []byte("\n")) - framed := bytes.HasPrefix(physical, []byte("data:")) || bytes.HasPrefix(physical, []byte("event:")) || bytes.Contains(physical, []byte("\ndata:")) || bytes.Contains(physical, []byte("\nevent:")) - if !strings.Contains(contentType, "text/event-stream") && !framed { - return service.ReasoningReplayDiagnosticRejectionCode(body) - } - scanner := bufio.NewScanner(bytes.NewReader(body)) - scanner.Buffer(make([]byte, 4096), fidelityMaxBody) - scanner.Split(fidelitySSELine) - var data []byte - eventName := "" - found := "" - apply := func() { - if len(data) == 0 { - return - } - payload := bytes.TrimSuffix(data, []byte{'\n'}) - object, err := fidelityJSONObject(payload) - if err != nil { - found = "" - } else { - kind := eventName - if raw := object["type"]; len(raw) > 0 { - _ = json.Unmarshal(raw, &kind) - } - switch kind { - case "response.completed", "response.incomplete", "response.cancelled", "response.canceled": - found = "" - case "response.failed", "response.done", "error": - found = service.ReasoningReplayDiagnosticRejectionCode(payload) - } - } - data = nil - eventName = "" - } - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - apply() - continue - } - if bytes.HasPrefix(line, []byte("data:")) { - value := line[5:] - if len(value) > 0 && value[0] == ' ' { - value = value[1:] - } - data = append(data, value...) - data = append(data, '\n') - } else if bytes.HasPrefix(line, []byte("event:")) { - eventName = strings.TrimSpace(string(line[6:])) - } - } - apply() - if scanner.Err() != nil { - return "" - } - return found -} - -type replayDiagnosticCapture struct { - io.ReadCloser - mu sync.Mutex - raw []byte - once sync.Once - cleanup func() - finish func([]byte) -} - -func (c *replayDiagnosticCapture) Read(p []byte) (int, error) { - n, err := c.ReadCloser.Read(p) - c.mu.Lock() - defer c.mu.Unlock() - if n > 0 { - if len(c.raw)+n > fidelityMaxBody { - return 0, errors.New("response_body_limit") - } - c.raw = append(c.raw, p[:n]...) - } - return n, err -} -func (c *replayDiagnosticCapture) Close() error { - err := c.ReadCloser.Close() - c.once.Do(func() { c.cleanup(); c.mu.Lock(); raw := append([]byte(nil), c.raw...); c.mu.Unlock(); c.finish(raw) }) - return err -} diff --git a/backend/internal/service/reasoning_replay_diagnostic_parse_test.go b/backend/internal/service/reasoning_replay_diagnostic_parse_test.go deleted file mode 100644 index 0b5bb41a6389..000000000000 --- a/backend/internal/service/reasoning_replay_diagnostic_parse_test.go +++ /dev/null @@ -1,706 +0,0 @@ -//go:build reasoning_fidelity && reasoning_replay_diagnostic - -package service_test - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "strings" - "testing" -) - -// The complete client-visible assistant projection remains in memory only. -// Text is populated only for a completed final answer, never from reasoning. -type replayDiagnosticChat struct { - Status string - Text string - Model string - ID string - Message json.RawMessage - HasTool bool -} - -func replayDiagnosticChatBody(model, effort string, stream bool) []byte { - body := map[string]any{ - "model": model, "reasoning_effort": effort, "stream": stream, - "n": 1, "store": false, "parallel_tool_calls": false, - "messages": []any{map[string]any{"role": "user", "content": fidelityOrdersPrompt}}, - "tools": []any{map[string]any{ - "type": "function", "function": map[string]any{ - "name": "load_orders", "description": "Read the fixed order catalog.", "strict": true, - "parameters": map[string]any{"type": "object", "properties": map[string]any{}, - "required": []string{}, "additionalProperties": false}, - }, - }}, - "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": "load_orders"}}, - } - if stream { - body["stream_options"] = map[string]any{"include_usage": true} - } - raw, _ := json.Marshal(body) - return raw -} - -// Preserve the original request and full observed assistant message. No -// constraints are repeated, no call ID is fabricated, and the only locally -// executable tool is the one empty-object load_orders call specified above. -func replayDiagnosticChatContinuation(firstBody []byte, parsed replayDiagnosticChat) ([]byte, bool) { - if parsed.Status != "completed" || !parsed.HasTool { - return nil, false - } - _, calls, ok := replayDiagnosticInspectChatMessage(parsed.Message) - if !ok || len(calls) != 1 { - return nil, false - } - call, _ := fidelityJSONObject(calls[0]) - function, _ := fidelityJSONObject(call["function"]) - var id, name, arguments string - if json.Unmarshal(call["id"], &id) != nil || json.Unmarshal(function["name"], &name) != nil || - json.Unmarshal(function["arguments"], &arguments) != nil || name != "load_orders" { - return nil, false - } - args, err := fidelityJSONObject([]byte(arguments)) - if err != nil || len(args) != 0 { - return nil, false - } - body, err := fidelityJSONObject(firstBody) - if err != nil { - return nil, false - } - var messages []json.RawMessage - if json.Unmarshal(body["messages"], &messages) != nil || len(messages) == 0 { - return nil, false - } - for _, message := range messages { - if _, err := fidelityJSONObject(message); err != nil { - return nil, false - } - } - toolResult, _ := json.Marshal(map[string]any{"role": "tool", "tool_call_id": id, "content": fidelityOrdersResult}) - messages = append(messages, bytes.Clone(parsed.Message), toolResult) - body["messages"], err = json.Marshal(messages) - if err != nil { - return nil, false - } - body["tool_choice"] = json.RawMessage(`"none"`) - raw, err := json.Marshal(body) - return raw, err == nil -} - -func replayDiagnosticParseChat(body []byte, contentType string, status int) replayDiagnosticChat { - result := replayDiagnosticChat{Status: "failed"} - if len(body) > fidelityMaxResponseBytes || status < 200 || status >= 300 { - return result - } - trimmed := bytes.TrimSpace(body) - if strings.Contains(strings.ToLower(contentType), "text/event-stream") || - bytes.HasPrefix(trimmed, []byte("data:")) || bytes.HasPrefix(trimmed, []byte("event:")) || - bytes.HasPrefix(trimmed, []byte(":")) { - return replayDiagnosticParseChatSSE(body) - } - object, err := fidelityJSONObject(trimmed) - if err != nil || !replayDiagnosticChatEnvelope(object, "chat.completion", &result) { - return result - } - var choices []json.RawMessage - if json.Unmarshal(object["choices"], &choices) != nil || len(choices) != 1 { - return result - } - choice, err := fidelityJSONObject(choices[0]) - var index int - if err != nil || fidelityNonNull(choice["error"]) || fidelityNonNull(choice["incomplete_details"]) || - !fidelityNonNull(choice["index"]) || json.Unmarshal(choice["index"], &index) != nil || index != 0 { - return result - } - finish, ok := replayDiagnosticChatFinish(choice["finish_reason"]) - if !ok { - result.Status = finish - return result - } - return replayDiagnosticFinishChat(result, choice["message"], finish) -} - -// Usage trailers are validated without inventing zero values for absent -// counters. The harness records the authoritative upstream usage separately. -func replayDiagnosticChatEnvelope(object map[string]json.RawMessage, kind string, result *replayDiagnosticChat) bool { - if fidelityNonNull(object["error"]) || fidelityNonNull(object["incomplete_details"]) { - return false - } - var objectKind, responseStatus, eventType string - if json.Unmarshal(object["object"], &objectKind) != nil || objectKind != kind { - return false - } - if fidelityNonNull(object["status"]) && - (json.Unmarshal(object["status"], &responseStatus) != nil || responseStatus != "completed") { - return false - } - if fidelityNonNull(object["type"]) && - (json.Unmarshal(object["type"], &eventType) != nil || eventType == "error" || - strings.HasSuffix(eventType, "_error") || strings.HasPrefix(eventType, "response.")) { - return false - } - for key, value := range map[string]*string{"id": &result.ID, "model": &result.Model} { - var next string - if json.Unmarshal(object[key], &next) != nil || strings.TrimSpace(next) == "" || (*value != "" && *value != next) { - return false - } - *value = next - } - if !fidelityNonNull(object["usage"]) { - return true - } - usage, err := fidelityJSONObject(object["usage"]) - if err != nil { - return false - } - for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens"} { - var count *int64 - if fidelityReadTokenCount(usage[key], &count) != nil { - return false - } - } - for _, key := range []string{"prompt_tokens_details", "completion_tokens_details"} { - if !fidelityNonNull(usage[key]) { - continue - } - details, err := fidelityJSONObject(usage[key]) - if err != nil { - return false - } - for _, field := range []string{"cached_tokens", "reasoning_tokens", "audio_tokens", "accepted_prediction_tokens", "rejected_prediction_tokens"} { - var count *int64 - if fidelityReadTokenCount(details[field], &count) != nil { - return false - } - } - } - return true -} - -func replayDiagnosticChatFinish(raw json.RawMessage) (string, bool) { - if !fidelityNonNull(raw) { - return "incomplete", false - } - var finish string - if json.Unmarshal(raw, &finish) != nil { - return "failed", false - } - switch finish { - case "stop", "tool_calls": - return finish, true - case "", "length": - return "incomplete", false - default: - return "failed", false - } -} - -func replayDiagnosticFinishChat(result replayDiagnosticChat, message json.RawMessage, finish string) replayDiagnosticChat { - text, calls, valid := replayDiagnosticInspectChatMessage(message) - if !valid || (finish == "tool_calls") != (len(calls) > 0) { - result.Status = "failed" - return result - } - result.Status, result.HasTool = "completed", len(calls) > 0 - result.Message = bytes.Clone(message) - if !result.HasTool { - result.Text = text - } - return result -} - -func replayDiagnosticInspectChatMessage(raw json.RawMessage) (string, []json.RawMessage, bool) { - message, err := fidelityJSONObject(raw) - var role, refusal string - if err != nil || json.Unmarshal(message["role"], &role) != nil || role != "assistant" || - fidelityNonNull(message["function_call"]) || fidelityNonNull(message["error"]) { - return "", nil, false - } - if fidelityNonNull(message["refusal"]) && (json.Unmarshal(message["refusal"], &refusal) != nil || refusal != "") { - return "", nil, false - } - for _, key := range []string{"reasoning_content", "reasoning"} { - var reasoning string - if fidelityNonNull(message[key]) && json.Unmarshal(message[key], &reasoning) != nil { - return "", nil, false - } - } - text, ok := replayDiagnosticChatText(message["content"]) - if !ok { - return "", nil, false - } - var calls []json.RawMessage - if fidelityNonNull(message["tool_calls"]) && - (json.Unmarshal(message["tool_calls"], &calls) != nil || len(calls) > 32) { - return "", nil, false - } - ids := make(map[string]bool, len(calls)) - for index, rawCall := range calls { - call, err := fidelityJSONObject(rawCall) - var id, kind, name, arguments string - if err != nil || json.Unmarshal(call["id"], &id) != nil || strings.TrimSpace(id) == "" || ids[id] || - json.Unmarshal(call["type"], &kind) != nil || kind != "function" { - return "", nil, false - } - if value, exists := call["index"]; exists { - var actual int - if !fidelityNonNull(value) || json.Unmarshal(value, &actual) != nil || actual != index { - return "", nil, false - } - } - function, err := fidelityJSONObject(call["function"]) - if err != nil || json.Unmarshal(function["name"], &name) != nil || strings.TrimSpace(name) == "" || - !fidelityNonNull(function["arguments"]) || json.Unmarshal(function["arguments"], &arguments) != nil { - return "", nil, false - } - if _, err := fidelityJSONObject([]byte(arguments)); err != nil { - return "", nil, false - } - ids[id] = true - } - return text, calls, true -} - -func replayDiagnosticChatText(raw json.RawMessage) (string, bool) { - if !fidelityNonNull(raw) { - return "", true - } - var text string - if json.Unmarshal(raw, &text) == nil { - return text, true - } - var parts []json.RawMessage - if json.Unmarshal(raw, &parts) != nil { - return "", false - } - var output strings.Builder - for _, rawPart := range parts { - part, err := fidelityJSONObject(rawPart) - var kind, value string - if err != nil || json.Unmarshal(part["type"], &kind) != nil || kind != "text" || - !fidelityNonNull(part["text"]) || json.Unmarshal(part["text"], &value) != nil { - return "", false - } - output.WriteString(value) - } - return output.String(), true -} - -type replayDiagnosticChatTool struct { - id, kind, name string - arguments strings.Builder -} - -type replayDiagnosticChatStream struct { - message map[string]json.RawMessage - tools map[int]*replayDiagnosticChatTool -} - -func (s *replayDiagnosticChatStream) applyDelta(raw json.RawMessage) bool { - delta, err := fidelityJSONObject(raw) - if err != nil { - return false - } - for key, value := range delta { - switch key { - case "role": - var role string - if json.Unmarshal(value, &role) != nil || role != "assistant" { - return false - } - s.message[key] = bytes.Clone(value) - case "content", "reasoning_content", "reasoning", "refusal": - if !fidelityNonNull(value) { - if _, exists := s.message[key]; !exists { - s.message[key] = bytes.Clone(value) - } - continue - } - var fragment, previous string - if json.Unmarshal(value, &fragment) != nil || (key == "refusal" && fragment != "") { - return false - } - if fidelityNonNull(s.message[key]) && json.Unmarshal(s.message[key], &previous) != nil { - return false - } - s.message[key], _ = json.Marshal(previous + fragment) - case "tool_calls": - if !fidelityNonNull(value) { - continue - } - var calls []json.RawMessage - if json.Unmarshal(value, &calls) != nil || len(calls) > 32 { - return false - } - seen := make(map[int]bool, len(calls)) - for _, call := range calls { - if !s.applyTool(call, seen) { - return false - } - } - default: - // Unknown delta merge semantics cannot be safely guessed. Fail - // this diagnostic projection instead of silently losing a field. - return false - } - } - return true -} - -func (s *replayDiagnosticChatStream) applyTool(raw json.RawMessage, seen map[int]bool) bool { - call, err := fidelityJSONObject(raw) - var index int - if err != nil || !fidelityNonNull(call["index"]) || json.Unmarshal(call["index"], &index) != nil || - index < 0 || index >= 32 || seen[index] { - return false - } - seen[index] = true - tool := s.tools[index] - if tool == nil { - // The bridge's verified tool projection announces contiguous tool - // indices with complete identity before any arguments-only delta. - // Do not turn unannounced or out-of-order data into an apparent - // protocol success merely because a later chunk fills the holes. - function, functionErr := fidelityJSONObject(call["function"]) - var id, kind, name string - if index != len(s.tools) || functionErr != nil || - json.Unmarshal(call["id"], &id) != nil || strings.TrimSpace(id) == "" || - json.Unmarshal(call["type"], &kind) != nil || kind != "function" || - json.Unmarshal(function["name"], &name) != nil || strings.TrimSpace(name) == "" { - return false - } - tool = &replayDiagnosticChatTool{} - s.tools[index] = tool - } - for key, rawValue := range call { - switch key { - case "index": - case "id", "type": - destination := &tool.id - if key == "type" { - destination = &tool.kind - } - if !replayDiagnosticStableChatScalar(rawValue, destination) || (key == "type" && tool.kind != "function") { - return false - } - case "function": - function, err := fidelityJSONObject(rawValue) - if err != nil { - return false - } - for field, value := range function { - switch field { - case "name": - if !replayDiagnosticStableChatScalar(value, &tool.name) { - return false - } - case "arguments": - var fragment string - if !fidelityNonNull(value) || json.Unmarshal(value, &fragment) != nil { - return false - } - tool.arguments.WriteString(fragment) - default: - return false - } - } - default: - return false - } - } - return true -} - -func replayDiagnosticStableChatScalar(raw json.RawMessage, destination *string) bool { - var value string - if !fidelityNonNull(raw) || json.Unmarshal(raw, &value) != nil || strings.TrimSpace(value) == "" || - (*destination != "" && *destination != value) { - return false - } - *destination = value - return true -} - -func (s *replayDiagnosticChatStream) finalMessage() (json.RawMessage, bool) { - if len(s.tools) > 0 { - calls := make([]any, 0, len(s.tools)) - for index := 0; index < len(s.tools); index++ { - tool := s.tools[index] - if tool == nil { - return nil, false - } - calls = append(calls, map[string]any{"id": tool.id, "type": tool.kind, - "function": map[string]any{"name": tool.name, "arguments": tool.arguments.String()}}) - } - s.message["tool_calls"], _ = json.Marshal(calls) - } - raw, err := json.Marshal(s.message) - return raw, err == nil -} - -func replayDiagnosticParseChatSSE(body []byte) replayDiagnosticChat { - result := replayDiagnosticChat{Status: "incomplete"} - state := replayDiagnosticChatStream{message: make(map[string]json.RawMessage), tools: make(map[int]*replayDiagnosticChatTool)} - finish, eventName := "", "" - var data []byte - hadData, terminal, done, failed := false, false, false, false - apply := func() { - if eventName != "" && eventName != "message" && eventName != "chat.completion.chunk" { - failed = true - return - } - if !hadData { - return - } - payload := bytes.TrimSpace(data) - if bytes.Equal(payload, []byte("[DONE]")) { - if done { - failed = true - } - done = true - return - } - if done { - failed = true - return - } - object, err := fidelityJSONObject(payload) - if err != nil || !replayDiagnosticChatEnvelope(object, "chat.completion.chunk", &result) { - failed = true - return - } - var choices []json.RawMessage - if !fidelityNonNull(object["choices"]) || json.Unmarshal(object["choices"], &choices) != nil { - failed = true - return - } - if len(choices) == 0 { - // A legitimate usage-only trailer can follow the finish chunk. - // It never establishes or repairs completion by itself. - if !fidelityNonNull(object["usage"]) { - failed = true - } - return - } - if terminal || len(choices) != 1 { - failed = true - return - } - choice, err := fidelityJSONObject(choices[0]) - var index int - if err != nil || fidelityNonNull(choice["error"]) || fidelityNonNull(choice["incomplete_details"]) || - !fidelityNonNull(choice["index"]) || json.Unmarshal(choice["index"], &index) != nil || index != 0 || - !state.applyDelta(choice["delta"]) { - failed = true - return - } - if fidelityNonNull(choice["finish_reason"]) { - var valid bool - finish, valid = replayDiagnosticChatFinish(choice["finish_reason"]) - terminal = true - if !valid { - result.Status = finish - } - } - } - scanner := bufio.NewScanner(bytes.NewReader(body)) - scanner.Buffer(make([]byte, 4096), fidelityMaxResponseBytes+1) - scanner.Split(fidelitySSELine) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - apply() - data, eventName, hadData = data[:0], "", false - continue - } - if line[0] == ':' { - continue - } - field, value, _ := bytes.Cut(line, []byte{':'}) - value = bytes.TrimPrefix(value, []byte{' '}) - switch string(field) { - case "event": - eventName = string(value) - case "data": - data = append(data, value...) - data = append(data, '\n') - hadData = true - } - } - if scanner.Err() != nil || failed { - result.Status = "failed" - return result - } - if hadData || eventName != "" || !terminal { - result.Status = "incomplete" - return result - } - if finish != "stop" && finish != "tool_calls" { - return result - } - message, ok := state.finalMessage() - if !ok { - result.Status = "failed" - return result - } - return replayDiagnosticFinishChat(result, message, finish) -} - -func TestReasoningReplayDiagnosticChatParse(t *testing.T) { - const answer = `{"selected":["A","B","E"],"hours":11,"value":36}` - const toolMessage = `{"role":"assistant","content":"loading","reasoning_content":"visible summary","reasoning":"original alias","phase":"commentary","tool_calls":[{"id":"call_real","type":"function","function":{"name":"load_orders","arguments":"{}"}}]}` - document := func(message, finish string) []byte { - return []byte(fmt.Sprintf(`{"object":"chat.completion","id":"chat_real","model":"gpt-5.6-sol","choices":[{"index":0,"message":%s,"finish_reason":%s}]}`, message, finish)) - } - chunk := func(delta, finish string) string { - return fmt.Sprintf("data: {\"object\":\"chat.completion.chunk\",\"id\":\"chat_real\",\"model\":\"gpt-5.6-sol\",\"choices\":[{\"index\":0,\"delta\":%s,\"finish_reason\":%s}]}\n\n", delta, finish) - } - usage := "data: {\"object\":\"chat.completion.chunk\",\"id\":\"chat_real\",\"model\":\"gpt-5.6-sol\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n" - toolStart := chunk(`{"role":"assistant","content":null,"reasoning_content":"visible ","reasoning":"alias ","tool_calls":[{"index":0,"id":"call_real","type":"function","function":{"name":"load_orders","arguments":"{"}}]}`, "null") - toolEnd := chunk(`{"reasoning_content":"summary","reasoning":"summary","tool_calls":[{"index":0,"function":{"arguments":"}"}}]}`, `"tool_calls"`) - toolStream := ": keepalive\n\n" + toolStart + toolEnd + usage + "data: [DONE]\n\n" - answerRaw, _ := json.Marshal(answer) - answerStream := chunk(`{"role":"assistant","reasoning_content":"not final text"}`, "null") + - chunk(`{"content":`+string(answerRaw)+`}`, `"stop"`) + usage + "data: [DONE]\n\n" - - t.Run("complete final answer excludes reasoning", func(t *testing.T) { - for _, tc := range []struct { - body []byte - contentType string - }{ - {document(`{"role":"assistant","content":`+string(answerRaw)+`,"reasoning_content":"not final text"}`, `"stop"`), "application/json"}, - {[]byte(answerStream), "text/event-stream; charset=utf-8"}, - {[]byte(strings.ReplaceAll(answerStream, "\n", "\r\n")), "text/event-stream"}, - {[]byte(strings.ReplaceAll(answerStream, "\n", "\r")), "text/event-stream"}, - } { - got := replayDiagnosticParseChat(tc.body, tc.contentType, 200) - if got.Status != "completed" || got.HasTool || got.ID != "chat_real" || got.Model != "gpt-5.6-sol" || !fidelityScore("tool_final", got.Text) { - t.Fatal("completed final answer was not scored independently of reasoning") - } - } - }) - t.Run("complete tool projection and continuation", func(t *testing.T) { - for _, stream := range []bool{false, true} { - body, contentType := document(toolMessage, `"tool_calls"`), "application/json" - if stream { - body, contentType = []byte(toolStream), "text/event-stream" - } - got := replayDiagnosticParseChat(body, contentType, 200) - if got.Status != "completed" || !got.HasTool || got.Text != "" { - t.Fatal("valid tool turn rejected or reasoning exposed as answer") - } - first := replayDiagnosticChatBody("gpt-5.6-sol", "xhigh", stream) - before, observed := bytes.Clone(first), bytes.Clone(got.Message) - next, ok := replayDiagnosticChatContinuation(first, got) - if !ok || !bytes.Equal(first, before) || !bytes.Equal(got.Message, observed) { - t.Fatal("continuation rejected or mutated its source") - } - request, _ := fidelityJSONObject(next) - var messages []json.RawMessage - _ = json.Unmarshal(request["messages"], &messages) - assistant, _ := fidelityJSONObject(messages[1]) - tool, _ := fidelityJSONObject(messages[2]) - var result, id, summary, alias string - _ = json.Unmarshal(tool["content"], &result) - _ = json.Unmarshal(tool["tool_call_id"], &id) - _ = json.Unmarshal(assistant["reasoning_content"], &summary) - _ = json.Unmarshal(assistant["reasoning"], &alias) - if len(messages) != 3 || result != fidelityOrdersResult || id != "call_real" || summary != "visible summary" || alias == "" || - string(request["tool_choice"]) != `"none"` || string(request["reasoning_effort"]) != `"xhigh"` || - bytes.Count(next, []byte("Remember these constraints")) != 1 || bytes.Contains(next, []byte("encrypted_content")) { - t.Fatal("continuation lost projection, repeated constraints or fabricated state") - } - if !stream && string(assistant["phase"]) != `"commentary"` { - t.Fatal("complete nonstream projection lost an extension field") - } - } - }) - t.Run("explicit finish needs no EOF inference or done marker", func(t *testing.T) { - got := replayDiagnosticParseChat([]byte(toolStart+toolEnd), "text/event-stream", 200) - if got.Status != "completed" { - t.Fatal("authoritative completed finish incorrectly depended on DONE trailer") - } - }) - t.Run("strict failures never yield completed output", func(t *testing.T) { - cases := []struct{ name, body, contentType string }{ - {"bare done", "data: [DONE]\n\n", "text/event-stream"}, - {"partial tool EOF", toolStart, "text/event-stream"}, - {"unterminated final event", toolStart + strings.TrimSuffix(toolEnd, "\n"), "text/event-stream"}, - {"late partial trailer", toolStream + "data: {", "text/event-stream"}, - {"late content", toolStart + toolEnd + chunk(`{"content":"late"}`, "null"), "text/event-stream"}, - {"duplicate finish", toolStart + toolEnd + toolEnd, "text/event-stream"}, - {"late usage after done", toolStream + usage, "text/event-stream"}, - {"error event", toolStart + "event: error\ndata: {\"error\":{\"code\":\"server_error\"}}\n\n" + toolEnd, "text/event-stream"}, - {"error envelope with finish", strings.Replace(toolStart+toolEnd, `"object":"chat.completion.chunk"`, `"error":{"code":"server_error"},"object":"chat.completion.chunk"`, 1), "text/event-stream"}, - {"identity conflict", toolStart + strings.Replace(toolEnd, `"index":0,"function"`, `"index":0,"id":"call_changed","function"`, 1), "text/event-stream"}, - {"missing tool identity", strings.ReplaceAll(toolStart+toolEnd, `"id":"call_real",`, ""), "text/event-stream"}, - {"identity arrives too late", strings.Replace(toolStart, `"id":"call_real",`, "", 1) + strings.Replace(toolEnd, `"index":0,"function"`, `"index":0,"id":"call_real","function"`, 1), "text/event-stream"}, - {"missing tool index", strings.ReplaceAll(toolStart+toolEnd, `"index":0,"id"`, `"id"`), "text/event-stream"}, - {"index gap", strings.ReplaceAll(toolStart+toolEnd, `"index":0,"function"`, `"index":1,"function"`), "text/event-stream"}, - {"partial arguments", toolStart + chunk(`{}`, `"tool_calls"`), "text/event-stream"}, - {"unsupported delta", toolStart + chunk(`{"unknown":{"part":1}}`, `"tool_calls"`), "text/event-stream"}, - {"changed model", toolStart + strings.ReplaceAll(toolEnd, "gpt-5.6-sol", "gpt-6-astra"), "text/event-stream"}, - {"negative usage", strings.Replace(toolStream, `"total_tokens":3`, `"total_tokens":-1`, 1), "text/event-stream"}, - {"stream choice error", strings.Replace(toolStart+toolEnd, `"delta":`, `"error":{"code":"server_error"},"delta":`, 1), "text/event-stream"}, - {"stream choice incomplete", strings.Replace(toolStart+toolEnd, `"delta":`, `"incomplete_details":{"reason":"max_output_tokens"},"delta":`, 1), "text/event-stream"}, - {"no finish", string(document(toolMessage, "null")), "application/json"}, - {"length", string(document(toolMessage, `"length"`)), "application/json"}, - {"filtered", string(document(toolMessage, `"content_filter"`)), "application/json"}, - {"wrong finish", string(document(toolMessage, `"stop"`)), "application/json"}, - {"no tools", string(document(`{"role":"assistant","content":""}`, `"tool_calls"`)), "application/json"}, - {"refusal", string(document(`{"role":"assistant","content":"","refusal":"denied"}`, `"stop"`)), "application/json"}, - {"error with answer", strings.Replace(string(document(`{"role":"assistant","content":"ok"}`, `"stop"`)), `"object":`, `"error":{"code":"server_error"},"object":`, 1), "application/json"}, - {"choice error", strings.Replace(string(document(toolMessage, `"tool_calls"`)), `"message":`, `"error":{"code":"server_error"},"message":`, 1), "application/json"}, - {"choice incomplete", strings.Replace(string(document(toolMessage, `"tool_calls"`)), `"message":`, `"incomplete_details":{"reason":"max_output_tokens"},"message":`, 1), "application/json"}, - {"incomplete envelope", strings.Replace(string(document(toolMessage, `"tool_calls"`)), `"object":`, `"status":"incomplete","object":`, 1), "application/json"}, - {"duplicate object key", string(document(strings.Replace(toolMessage, `"role":"assistant"`, `"role":"assistant","role":"assistant"`, 1), `"tool_calls"`)), "application/json"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := replayDiagnosticParseChat([]byte(tc.body), tc.contentType, 200) - if got.Status == "completed" || got.Text != "" || len(got.Message) != 0 { - t.Fatal("invalid response became a completed replayable message") - } - }) - } - for _, status := range []int{0, 199, 400, 401, 429, 500} { - if replayDiagnosticParseChat(document(toolMessage, `"tool_calls"`), "application/json", status).Status == "completed" { - t.Fatal("HTTP failure accepted as success") - } - } - if replayDiagnosticParseChat(bytes.Repeat([]byte{' '}, fidelityMaxResponseBytes+1), "application/json", 200).Status != "failed" { - t.Fatal("bounded body limit not enforced") - } - }) - t.Run("only exact diagnostic tool executes", func(t *testing.T) { - for _, message := range []string{ - strings.Replace(toolMessage, "load_orders", "another_tool", 1), - strings.Replace(toolMessage, `"arguments":"{}"`, `"arguments":"{\"answer\":true}"`, 1), - strings.Replace(toolMessage, `"arguments":"{}"`, `"arguments":"{\"a\":1,\"a\":2}"`, 1), - strings.Replace(toolMessage, `"arguments":"{}"`, `"arguments":"null"`, 1), - } { - got := replayDiagnosticParseChat(document(message, `"tool_calls"`), "application/json", 200) - if _, ok := replayDiagnosticChatContinuation(replayDiagnosticChatBody("gpt-5.6-sol", "xhigh", false), got); ok { - t.Fatal("unexpected or ambiguous tool call executed") - } - } - }) - t.Run("request contract preserves selected model and strength", func(t *testing.T) { - for _, target := range []struct{ model, effort string }{{"gpt-5.6-sol", "xhigh"}, {"gpt-6-astra", "max"}} { - for _, stream := range []bool{false, true} { - body, _ := fidelityJSONObject(replayDiagnosticChatBody(target.model, target.effort, stream)) - var model, effort string - _ = json.Unmarshal(body["model"], &model) - _ = json.Unmarshal(body["reasoning_effort"], &effort) - if model != target.model || effort != target.effort || fidelityNonNull(body["stream_options"]) != stream { - t.Fatal("model, explicit effort or stream usage contract changed") - } - if stream && !bytes.Equal(body["stream_options"], []byte(`{"include_usage":true}`)) { - t.Fatal("stream usage was not requested") - } - } - } - }) -} diff --git a/backend/internal/service/reasoning_replay_diagnostic_support_test.go b/backend/internal/service/reasoning_replay_diagnostic_support_test.go deleted file mode 100644 index a36443624266..000000000000 --- a/backend/internal/service/reasoning_replay_diagnostic_support_test.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build reasoning_fidelity && reasoning_replay_diagnostic - -package service - -import ( - "context" - "encoding/json" - "errors" - "sync" - "time" - - "github.com/gin-gonic/gin" -) - -// This adapter intentionally never constructs a listener, Redis/SQL client, -// scheduler or billing writer. The service exercises the production cache -// interface while only this diagnostic process owns the payloads. -func ReasoningReplayDiagnosticAttach(s *OpenAIGatewayService) { - s.cache = &reasoningReplayDiagnosticStore{ - batches: make(map[string]reasoningReplayDiagnosticBatch), - rejected: make(map[string]OpenAIRejectedReasoning), - } -} - -func ReasoningReplayDiagnosticTenant(c *gin.Context, group *Group) { - c.Set("api_key", &APIKey{ID: 920000015523, UserID: 920000015522, GroupID: &group.ID, Group: group}) -} - -// Share the exact production structured-rejection contract; the diagnostic -// must not grant a recovery merely because an error string mentions a code. -func ReasoningReplayDiagnosticRejectionCode(payload []byte) string { - rejection, ok := parseOpenAIReasoningRejection(payload) - if !ok { - return "" - } - return rejection.code -} - -func ReasoningReplayDiagnosticObserve(c *gin.Context) (hits, stored int) { - number := func(key string) int { - value, _ := c.Get(key) - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case bool: - if v { - return 1 - } - } - return 0 - } - return number("openai_chat_reasoning_replay_hits"), number("openai_chat_reasoning_replay_stored") -} - -type reasoningReplayDiagnosticBatch struct { - value OpenAIReasoningBatch - expires time.Time -} -type reasoningReplayDiagnosticStore struct { - mu sync.Mutex - batches map[string]reasoningReplayDiagnosticBatch - rejected map[string]OpenAIRejectedReasoning -} - -var _ GatewayCache = (*reasoningReplayDiagnosticStore)(nil) -var _ OpenAIReasoningStateStore = (*reasoningReplayDiagnosticStore)(nil) - -func (*reasoningReplayDiagnosticStore) GetSessionAccountID(context.Context, int64, string) (int64, error) { - return 0, ErrStickySessionNotFound -} -func (*reasoningReplayDiagnosticStore) SetSessionAccountID(context.Context, int64, string, int64, time.Duration) error { - return nil -} -func (*reasoningReplayDiagnosticStore) RefreshSessionTTL(context.Context, int64, string, time.Duration) error { - return nil -} -func (*reasoningReplayDiagnosticStore) DeleteSessionAccountID(context.Context, int64, string) error { - return nil -} -func (*reasoningReplayDiagnosticStore) DeleteSessionAccountIDIfMatches(context.Context, int64, string, int64) (bool, error) { - return false, nil -} -func (*reasoningReplayDiagnosticStore) SetGrokVideoPendingBilling(context.Context, string, []byte, time.Duration) error { - return errors.New("diagnostic_path_unavailable") -} -func (*reasoningReplayDiagnosticStore) GetGrokVideoPendingBilling(context.Context, string) ([]byte, error) { - return nil, nil -} -func (*reasoningReplayDiagnosticStore) ClaimGrokVideoBilled(context.Context, string, time.Duration) (bool, error) { - return false, nil -} -func (*reasoningReplayDiagnosticStore) ReleaseGrokVideoBilled(context.Context, string) error { - return nil -} -func (*reasoningReplayDiagnosticStore) SetReasoningContent(context.Context, string, string, time.Duration) error { - return nil -} -func (*reasoningReplayDiagnosticStore) GetReasoningContent(context.Context, string) (string, error) { - return "", ErrReasoningContentNotFound -} - -func replayDiagnosticCacheKey(scope OpenAIReasoningCacheScope, key string) (string, error) { - if !IsOpenAIReasoningCacheDigest(scope.ScopeHash) || !IsOpenAIReasoningCacheDigest(scope.TenantHash) || !IsOpenAIReasoningCacheDigest(key) { - return "", ErrOpenAIReasoningCacheInput - } - return scope.ScopeHash + ":" + scope.TenantHash + ":" + key, nil -} -func cloneReplayDiagnosticBatch(batch OpenAIReasoningBatch) OpenAIReasoningBatch { - raw, _ := json.Marshal(batch) - var clone OpenAIReasoningBatch - _ = json.Unmarshal(raw, &clone) - clone.PayloadHash = batch.PayloadHash - return clone -} -func (s *reasoningReplayDiagnosticStore) GetOpenAIReasoningBatches(ctx context.Context, scope OpenAIReasoningCacheScope, keys []string) (map[string]OpenAIReasoningBatch, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if len(keys) > OpenAIReasoningStateMaxLookupEntries { - return nil, ErrOpenAIReasoningCacheInput - } - s.mu.Lock() - defer s.mu.Unlock() - out := make(map[string]OpenAIReasoningBatch) - for _, key := range keys { - id, err := replayDiagnosticCacheKey(scope, key) - if err != nil { - return nil, err - } - if value, ok := s.batches[id]; ok && time.Now().Before(value.expires) { - out[key] = cloneReplayDiagnosticBatch(value.value) - } - } - return out, nil -} -func (s *reasoningReplayDiagnosticStore) PutOpenAIReasoningBatch(ctx context.Context, scope OpenAIReasoningCacheScope, key string, batch OpenAIReasoningBatch) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - id, err := replayDiagnosticCacheKey(scope, key) - if err != nil { - return false, err - } - raw, err := json.Marshal(batch) - if err != nil || len(raw) > OpenAIReasoningBatchMaxBytes || len(batch.Output) > OpenAIReasoningBatchMaxItems { - return false, ErrOpenAIReasoningCacheInput - } - s.mu.Lock() - defer s.mu.Unlock() - // A stricter study-only 4 MiB/16-batch cap is sufficient for its four - // function-tool first turns. Redis limits are tested separately offline. - if len(s.batches) >= 16 { - return false, nil - } - batch.PayloadHash = reasoningStateDigest(raw) - s.batches[id] = reasoningReplayDiagnosticBatch{cloneReplayDiagnosticBatch(batch), time.Now().Add(OpenAIReasoningStateTTL)} - return true, nil -} -func (s *reasoningReplayDiagnosticStore) DeleteOpenAIReasoningBatchIfMatch(ctx context.Context, scope OpenAIReasoningCacheScope, key, payloadHash string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - id, err := replayDiagnosticCacheKey(scope, key) - if err != nil { - return false, err - } - s.mu.Lock() - defer s.mu.Unlock() - if entry, ok := s.batches[id]; ok && entry.value.PayloadHash == payloadHash { - delete(s.batches, id) - return true, nil - } - return false, nil -} -func (s *reasoningReplayDiagnosticStore) GetOpenAIRejectedReasoning(ctx context.Context, scope OpenAIReasoningCacheScope, hashes []string) (map[string]OpenAIRejectedReasoning, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if len(hashes) > OpenAIReasoningStateMaxLookupEntries { - return nil, ErrOpenAIReasoningCacheInput - } - s.mu.Lock() - defer s.mu.Unlock() - out := make(map[string]OpenAIRejectedReasoning) - for _, key := range hashes { - id, err := replayDiagnosticCacheKey(scope, key) - if err != nil { - return nil, err - } - if entry, ok := s.rejected[id]; ok && time.Now().Before(entry.ExpiresAt) { - out[key] = entry - } - } - return out, nil -} -func (s *reasoningReplayDiagnosticStore) PutOpenAIRejectedReasoning(ctx context.Context, scope OpenAIReasoningCacheScope, hashes []string) error { - if err := ctx.Err(); err != nil { - return err - } - if len(hashes) > OpenAIReasoningStateMaxLookupEntries { - return ErrOpenAIReasoningCacheInput - } - s.mu.Lock() - defer s.mu.Unlock() - if len(s.rejected)+len(hashes) > 64 { - return errors.New("diagnostic_cache_capacity") - } - now := time.Now() - for _, key := range hashes { - id, err := replayDiagnosticCacheKey(scope, key) - if err != nil { - return err - } - s.rejected[id] = OpenAIRejectedReasoning{RejectedAt: now, ExpiresAt: now.Add(OpenAIReasoningStateTTL)} - } - return nil -} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 86e0fe6bc8c4..fa1b566de796 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -306,7 +306,10 @@ func (s *SchedulerSnapshotService) GetGroupByID(ctx context.Context, groupID int return s.groupRepo.GetByID(ctx, groupID) } -// GetGroupByIDLite returns scheduling fields without account-count aggregation. +// GetGroupByIDLite 获取分组配置但不加载账号计数聚合。 +// 利润门只需要平台、倍率、利润与高峰字段,GetByID 附带的那条账号计数聚合 +// 查询纯属浪费——composite / 模型路由 / fallback 每次装门都要付一次,WS 更是 +// 每个 turn 一次,且发生在「是否启用利润控制」判定之前。 func (s *SchedulerSnapshotService) GetGroupByIDLite(ctx context.Context, groupID int64) (*Group, error) { if s.groupRepo == nil { return nil, nil diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 149f3ccc00c3..dc8fb959d28f 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -172,12 +172,10 @@ const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" type OpenAIRefusalRecoveryRuntime struct { - Enabled bool - CyberFailover bool - APIKeyAlphaSearchResponsesBridge bool - APIKeyPromptCacheKeyNormalization bool - Rewrite bool - Matcher *OpenAIRefusalMatcher + Enabled bool + CyberFailover bool + Rewrite bool + Matcher *OpenAIRefusalMatcher } type cachedOpenAIRefusalRecoveryRuntime struct { @@ -248,8 +246,6 @@ func (s *SettingService) GetOpenAIRefusalRecoveryRuntime(ctx context.Context) Op values, err := s.settingRepo.GetMultiple(dbCtx, []string{ SettingKeyOpenAIRefusalRecoveryEnabled, SettingKeyOpenAICyberFailoverEnabled, - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled, SettingKeyOpenAIRefusalRewriteEnabled, SettingKeyOpenAIRefusalKeywords, SettingKeyOpenAIRefusalReplacement, @@ -276,11 +272,9 @@ func (s *SettingService) GetOpenAIRefusalRecoveryRuntime(ctx context.Context) Op func buildOpenAIRefusalRecoveryRuntime(values map[string]string) OpenAIRefusalRecoveryRuntime { runtime := OpenAIRefusalRecoveryRuntime{ - Enabled: values[SettingKeyOpenAIRefusalRecoveryEnabled] == "true", - CyberFailover: values[SettingKeyOpenAICyberFailoverEnabled] == "true", - APIKeyAlphaSearchResponsesBridge: values[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] == "true", - APIKeyPromptCacheKeyNormalization: values[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] == "true", - Rewrite: values[SettingKeyOpenAIRefusalRewriteEnabled] == "true", + Enabled: values[SettingKeyOpenAIRefusalRecoveryEnabled] == "true", + CyberFailover: values[SettingKeyOpenAICyberFailoverEnabled] == "true", + Rewrite: values[SettingKeyOpenAIRefusalRewriteEnabled] == "true", } if !runtime.Enabled || !runtime.Rewrite { return runtime diff --git a/backend/internal/service/setting_gateway_runtime_refusal_test.go b/backend/internal/service/setting_gateway_runtime_refusal_test.go index ffecfdca4679..3a085875d4d9 100644 --- a/backend/internal/service/setting_gateway_runtime_refusal_test.go +++ b/backend/internal/service/setting_gateway_runtime_refusal_test.go @@ -147,13 +147,11 @@ func (r *openAIRefusalRuntimeRepo) calls() (int, int) { func openAIRefusalRuntimeValues(rewrite bool) map[string]string { values := map[string]string{ - SettingKeyOpenAIRefusalRecoveryEnabled: "true", - SettingKeyOpenAICyberFailoverEnabled: "true", - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "true", - SettingKeyOpenAIRefusalRewriteEnabled: "false", - SettingKeyOpenAIRefusalKeywords: `["cannot"]`, - SettingKeyOpenAIRefusalReplacement: "continue current task", + SettingKeyOpenAIRefusalRecoveryEnabled: "true", + SettingKeyOpenAICyberFailoverEnabled: "true", + SettingKeyOpenAIRefusalRewriteEnabled: "false", + SettingKeyOpenAIRefusalKeywords: `["cannot"]`, + SettingKeyOpenAIRefusalReplacement: "continue current task", } if rewrite { values[SettingKeyOpenAIRefusalRewriteEnabled] = "true" @@ -161,16 +159,6 @@ func openAIRefusalRuntimeValues(rewrite bool) map[string]string { return values } -func TestOpenAIRefusalRuntimeLoadsAPIKeyCompatibilitySwitches(t *testing.T) { - repo := &openAIRefusalRuntimeRepo{values: openAIRefusalRuntimeValues(false)} - svc := &SettingService{settingRepo: repo} - - runtime := svc.GetOpenAIRefusalRecoveryRuntime(context.Background()) - - require.True(t, runtime.APIKeyAlphaSearchResponsesBridge) - require.True(t, runtime.APIKeyPromptCacheKeyNormalization) -} - func expireOpenAIRefusalRuntimeCache(t *testing.T, svc *SettingService) { t.Helper() entry, ok := svc.openAIRefusalRecoveryCache.Load().(*cachedOpenAIRefusalRecoveryRuntime) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 66db3492dcdf..dfeb65bd68da 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -219,16 +219,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyRiskControlEnabled: "false", // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - SettingKeyCyberSessionBlockEnabled: "false", - SettingKeyCyberPolicyUserAllowlist: "", - SettingKeyCyberSessionBlockTTLSeconds: "3600", - SettingKeyOpenAIRefusalRecoveryEnabled: "false", - SettingKeyOpenAICyberFailoverEnabled: "false", - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "false", - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "false", - SettingKeyOpenAIRefusalRewriteEnabled: "false", - SettingKeyOpenAIRefusalKeywords: defaultOpenAIRefusalKeywordsJSON, - SettingKeyOpenAIRefusalReplacement: "", + SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberPolicyUserAllowlist: "", + SettingKeyCyberSessionBlockTTLSeconds: "3600", + SettingKeyOpenAIRefusalRecoveryEnabled: "false", + SettingKeyOpenAICyberFailoverEnabled: "false", + SettingKeyOpenAIRefusalRewriteEnabled: "false", + SettingKeyOpenAIRefusalKeywords: defaultOpenAIRefusalKeywordsJSON, + SettingKeyOpenAIRefusalReplacement: "", // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", @@ -858,8 +856,6 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.OpenAIRefusalRecoveryEnabled = settings[SettingKeyOpenAIRefusalRecoveryEnabled] == "true" result.OpenAICyberFailoverEnabled = settings[SettingKeyOpenAICyberFailoverEnabled] == "true" - result.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled = settings[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] == "true" - result.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled = settings[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] == "true" result.OpenAIRefusalRewriteEnabled = settings[SettingKeyOpenAIRefusalRewriteEnabled] == "true" result.OpenAIRefusalKeywords = parseOpenAIRefusalKeywordsSetting(settings[SettingKeyOpenAIRefusalKeywords]) result.OpenAIRefusalReplacement = settings[SettingKeyOpenAIRefusalReplacement] diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index dd888ed1a53e..7efbc72291af 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -466,8 +466,6 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting } updates[SettingKeyOpenAIRefusalRecoveryEnabled] = strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled) updates[SettingKeyOpenAICyberFailoverEnabled] = strconv.FormatBool(settings.OpenAICyberFailoverEnabled) - updates[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] = strconv.FormatBool(settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled) - updates[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] = strconv.FormatBool(settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled) updates[SettingKeyOpenAIRefusalRewriteEnabled] = strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled) refusalKeywordsJSON, err := json.Marshal(settings.OpenAIRefusalKeywords) if err != nil { @@ -729,13 +727,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { s.openAIRefusalRecoverySF.Forget("openai_refusal_recovery") s.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ runtime: buildOpenAIRefusalRecoveryRuntime(map[string]string{ - SettingKeyOpenAIRefusalRecoveryEnabled: strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled), - SettingKeyOpenAICyberFailoverEnabled: strconv.FormatBool(settings.OpenAICyberFailoverEnabled), - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: strconv.FormatBool(settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled), - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: strconv.FormatBool(settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled), - SettingKeyOpenAIRefusalRewriteEnabled: strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled), - SettingKeyOpenAIRefusalKeywords: string(keywordsJSON), - SettingKeyOpenAIRefusalReplacement: settings.OpenAIRefusalReplacement, + SettingKeyOpenAIRefusalRecoveryEnabled: strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled), + SettingKeyOpenAICyberFailoverEnabled: strconv.FormatBool(settings.OpenAICyberFailoverEnabled), + SettingKeyOpenAIRefusalRewriteEnabled: strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled), + SettingKeyOpenAIRefusalKeywords: string(keywordsJSON), + SettingKeyOpenAIRefusalReplacement: settings.OpenAIRefusalReplacement, }), expiresAt: time.Now().Add(openAIRefusalRecoveryCacheTTL).UnixNano(), }) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fd7846b428c7..81ee43d72452 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -165,27 +165,25 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - RiskControlEnabled bool - CyberSessionBlockEnabled bool - CyberPolicyUserAllowlist string - CyberSessionBlockTTLSeconds int - OpenAIRefusalRecoveryEnabled bool - OpenAICyberFailoverEnabled bool - OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled bool - OpenAIAPIKeyPromptCacheKeyNormalizationEnabled bool - OpenAIRefusalRewriteEnabled bool - OpenAIRefusalKeywords []string - OpenAIRefusalReplacement string - AffiliateEnabled bool - AffiliateRebateRate float64 - AffiliateRebateFreezeHours int - AffiliateRebateDurationDays int - AffiliateRebatePerInviteeCap float64 - AdminRechargeRebateEnabled bool - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + RiskControlEnabled bool + CyberSessionBlockEnabled bool + CyberPolicyUserAllowlist string + CyberSessionBlockTTLSeconds int + OpenAIRefusalRecoveryEnabled bool + OpenAICyberFailoverEnabled bool + OpenAIRefusalRewriteEnabled bool + OpenAIRefusalKeywords []string + OpenAIRefusalReplacement string + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + 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/system_prompt.go b/backend/internal/service/system_prompt.go index b6de2e032cb8..0e3abb7b4e7d 100644 --- a/backend/internal/service/system_prompt.go +++ b/backend/internal/service/system_prompt.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "regexp" + "slices" "strings" "sync/atomic" "time" @@ -18,8 +19,9 @@ import ( ) // System prompts are a small library of named prompts, one global switch and -// one site-wide default. Every account inherits the default unless its -// extra.system_prompt binding turns it off or selects another library prompt. +// one site-wide default. Every account that can take a prompt +// (SystemPromptPlatforms) inherits the default unless its extra.system_prompt +// binding turns it off or selects another library prompt. // The whole configuration is one JSON setting; request paths read an // in-memory snapshot and the binding carried by the scheduled account, so // sending a request never queries the database for prompts. @@ -28,7 +30,11 @@ import ( const SettingKeySystemPrompts = "system_prompts" // AccountExtraSystemPromptKey stores an account's SystemPromptBinding. -// A missing or invalid value means inherit. +// A missing or invalid value means inherit. Only an account that can take a +// prompt holds the key. On an existing account SetBindings is its only writer: +// an account edit keeps the stored value and the key-level writers of extra +// (bulk update, UpdateAccountExtra) drop the key. Account creation keeps a +// given binding for such an account alone. const AccountExtraSystemPromptKey = "system_prompt" const ( @@ -56,6 +62,26 @@ const ( var systemPromptIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) +// SystemPromptPlatforms are the platforms whose requests pass an insertion +// point. Each is forwarded by one of the four gateways that call this service +// on the final body: GatewayService (Anthropic: Messages), OpenAIGatewayService +// (OpenAI, Grok, the CN providers and OpenCode: Responses, Chat Completions or +// Messages), GeminiMessagesCompatService (Gemini) and AntigravityGatewayService +// (Antigravity: the Gemini envelope or Messages). An account on any other +// platform can never receive a prompt, so it takes no binding and is not +// counted. TypeSafe is such a platform: System One, its only protocol, has no +// system or instructions field and ForwardSystemOne relays the request as it is. +var SystemPromptPlatforms = []string{ + PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, +} + +// AccountTakesSystemPrompt reports whether the account's platform has an +// insertion point. +func AccountTakesSystemPrompt(account *Account) bool { + return account != nil && slices.Contains(SystemPromptPlatforms, account.Platform) +} + var ( ErrSystemPromptUnavailable = infraerrors.ServiceUnavailable("SYSTEM_PROMPT_UNAVAILABLE", "system prompt configuration is unavailable") ErrSystemPromptInUse = infraerrors.Conflict("SYSTEM_PROMPT_IN_USE", "a removed prompt is still selected by accounts") @@ -95,7 +121,7 @@ type SystemPromptBindingCount struct { Accounts int64 } -// SystemPromptUsage summarizes how accounts are bound. +// SystemPromptUsage summarizes how the accounts that can take a prompt are bound. type SystemPromptUsage struct { Inherit int64 `json:"inherit"` Off int64 `json:"off"` @@ -108,7 +134,8 @@ type SystemPromptState struct { Usage SystemPromptUsage `json:"usage"` } -// SystemPromptBindingStats counts account bindings for the admin page. +// SystemPromptBindingStats counts the bindings of the accounts on +// SystemPromptPlatforms for the admin page. type SystemPromptBindingStats interface { CountSystemPromptBindings(ctx context.Context) ([]SystemPromptBindingCount, error) } @@ -351,7 +378,9 @@ func (s *SystemPromptService) Save(ctx context.Context, config SystemPromptConfi return SystemPromptState{SystemPromptConfig: normalized, Usage: usage}, nil } -// SetBindings writes one binding to every listed account. +// SetBindings writes one binding to every listed account that can take a +// prompt and returns how many were written. The other listed accounts are +// skipped, like IDs that match no account. func (s *SystemPromptService) SetBindings(ctx context.Context, accountIDs []int64, binding SystemPromptBinding) (int64, error) { if s == nil || s.accounts == nil { return 0, ErrSystemPromptUnavailable @@ -389,6 +418,16 @@ func (s *SystemPromptService) SetBindings(ctx context.Context, accountIDs []int6 default: return 0, systemPromptInvalid("mode must be inherit, off or custom") } + targets, err := s.accounts.GetByIDs(ctx, ids) + if err != nil { + return 0, err + } + ids = ids[:0] + for _, account := range targets { + if AccountTakesSystemPrompt(account) { + ids = append(ids, account.ID) + } + } return s.accounts.BulkUpdate(ctx, ids, AccountBulkUpdate{Extra: map[string]any{AccountExtraSystemPromptKey: value}}) } diff --git a/backend/internal/service/system_prompt_test.go b/backend/internal/service/system_prompt_test.go new file mode 100644 index 000000000000..ddc9bf7765a5 --- /dev/null +++ b/backend/internal/service/system_prompt_test.go @@ -0,0 +1,63 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSystemPromptSetBindingsSkipsAccountsWithoutInsertionPoint(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{getByIDsAccounts: []*Account{ + {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + {ID: 2, Platform: PlatformTypeSafe, Type: AccountTypeAPIKey}, + {ID: 3, Platform: PlatformAntigravity, Type: AccountTypeAPIKey}, + }} + svc := NewSystemPromptService(nil, repo, nil) + off := SystemPromptBinding{Mode: SystemPromptModeOff} + + updated, err := svc.SetBindings(context.Background(), []int64{1, 2, 3, 2}, off) + require.NoError(t, err) + require.Equal(t, int64(2), updated) + require.Equal(t, []int64{1, 2, 3}, repo.getByIDsIDs) + require.Equal(t, []int64{1, 3}, repo.bulkUpdateIDs) + require.Equal(t, map[string]any{AccountExtraSystemPromptKey: off}, repo.lastBulkUpdate.Extra) + + // A selection that holds only such accounts writes nothing. + repo.getByIDsAccounts = repo.getByIDsAccounts[1:2] + updated, err = svc.SetBindings(context.Background(), []int64{2}, off) + require.NoError(t, err) + require.Zero(t, updated) + require.Empty(t, repo.bulkUpdateIDs) +} + +// Besides the binding endpoint only account creation stores a binding, and only +// for an account that can take a prompt: data import and duplicate create from +// a stored extra. The key-level writers of extra store none on any platform. +func TestSystemPromptBindingHasNoOtherWriter(t *testing.T) { + binding := map[string]any{"mode": SystemPromptModeOff} + given := func() map[string]any { + return map[string]any{AccountExtraSystemPromptKey: binding, "ordinary_setting": true} + } + created := func(platform string) map[string]any { + account, err := buildAccountForCreate(&CreateAccountInput{ + Name: "imported", Platform: platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key"}, + }, given()) + require.NoError(t, err) + return account.Extra + } + require.Equal(t, given(), created(PlatformAnthropic)) + require.Equal(t, map[string]any{"ordinary_setting": true}, created(PlatformTypeSafe)) + + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{1: {ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}}} + svc := &adminServiceImpl{accountRepo: repo} + require.NoError(t, svc.UpdateAccountExtra(context.Background(), 1, given())) + require.Equal(t, []map[string]any{{"ordinary_setting": true}}, repo.updates[1]) + _, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{1}, Extra: given()}) + require.NoError(t, err) + require.Len(t, repo.bulkUpdates, 1) + require.Equal(t, map[string]any{"ordinary_setting": true}, repo.bulkUpdates[0].Extra) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index efa655dc3a44..daeb59d613b5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -50,7 +50,8 @@ func ProvideUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, b } // ProvideOpenAIGatewayService keeps the existing constructor signature used by -// tests while wiring the system prompt service into the production gateway. +// tests while wiring the system prompt service and the reasoning recovery +// switch into the production gateway. func ProvideOpenAIGatewayService( accountRepo AccountRepository, usageLogRepo UsageLogRepository, @@ -75,6 +76,7 @@ func ProvideOpenAIGatewayService( settingService *SettingService, userPlatformQuotaRepo UserPlatformQuotaRepository, systemPrompts *SystemPromptService, + reasoningRecovery *ReasoningRecoveryService, ) *OpenAIGatewayService { svc := NewOpenAIGatewayService( accountRepo, usageLogRepo, usageBillingRepo, userRepo, userSubRepo, @@ -84,6 +86,7 @@ func ProvideOpenAIGatewayService( channelService, balanceNotifyService, settingService, userPlatformQuotaRepo, ) svc.SetSystemPromptService(systemPrompts) + svc.SetReasoningRecoveryService(reasoningRecovery) return svc } @@ -1026,6 +1029,7 @@ var ProviderSet = wire.NewSet( NewAdminService, ProvideGatewayService, ProvideSystemPromptService, + ProvideReasoningRecoveryService, ProvideOpenAIGatewayService, ProvideImageStorageSettingService, ProvideImageTaskService, diff --git a/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql b/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql new file mode 100644 index 000000000000..c572ba4ffbeb --- /dev/null +++ b/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql @@ -0,0 +1,29 @@ +-- Delete the two retired OpenAI API-key compatibility settings. +-- +-- openai_apikey_alpha_search_responses_bridge_enabled and +-- openai_apikey_prompt_cache_key_normalization_enabled were the global switches +-- of the API-key Alpha Search bridge and of prompt_cache_key normalization. +-- Their gates and write fields were removed on 2026-09-01 (git cce166e21). The +-- previous release still loaded both rows, showed them read-only on the +-- settings page and wrote the stored value back on every save +-- (v0.2.11-codexrip.9, git 291d25e7f, backend/internal: +-- service/domain_constants.go:247-248, service/setting_update.go:469-470, +-- service/setting_gateway_runtime.go:251-252, +-- handler/admin/setting_handler.go:513-519). This release removes that code, so +-- nothing reads or writes the rows any more. +-- +-- Only these two settings rows are deleted. The account option +-- openai_prompt_cache_key_mode in accounts.extra is live +-- (service/openai_apikey_compat.go) and is not touched; neither is any other +-- setting, nor the audit log. +-- +-- Deleting an absent row is not an error, 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'; + +DELETE FROM settings +WHERE key IN ( + 'openai_apikey_alpha_search_responses_bridge_enabled', + 'openai_apikey_prompt_cache_key_normalization_enabled' +); diff --git a/backend/migrations/267_purge_account_reasoning_options.sql b/backend/migrations/267_purge_account_reasoning_options.sql new file mode 100644 index 000000000000..9b7939c8c868 --- /dev/null +++ b/backend/migrations/267_purge_account_reasoning_options.sql @@ -0,0 +1,28 @@ +-- Remove the two per-account reasoning options from accounts.extra. +-- +-- openai_chat_reasoning_replay_enabled switched Chat reasoning replay and +-- openai_reasoning_signature_recovery_enabled switched the recovery of invalid +-- reasoning ciphertext for one account (previous release v0.2.11-codexrip.9, +-- git 291d25e7f, backend/internal: service/account.go:120-121, read by +-- service/plugin_codex_recovery.go:20-27). Replay is deleted. Recovery has one +-- global switch now (settings.reasoning_recovery_config, on while no row is +-- stored) and no account option, so nothing reads either key any more. +-- +-- Only these two keys are removed, from every account row that holds one of +-- them, deleted accounts included. A stored value is not carried over to the +-- global switch. Rows that hold neither key are not written, and no other +-- table is touched. +-- +-- accounts has no updated_at trigger. Writing extra fires the long-context +-- billing triggers of 175, which keep the stored flag and therefore queue no +-- scheduler_outbox event; the scheduler rebuild at startup reloads cached +-- account payloads, and its projection no longer lists the two keys. The +-- statement is a no-op once the keys are 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 accounts +SET extra = extra - ARRAY['openai_chat_reasoning_replay_enabled', 'openai_reasoning_signature_recovery_enabled'] +WHERE jsonb_typeof(extra) = 'object' + AND extra ?| ARRAY['openai_chat_reasoning_replay_enabled', 'openai_reasoning_signature_recovery_enabled']; diff --git a/backend/migrations/268_purge_system_prompt_bindings_without_insertion_point.sql b/backend/migrations/268_purge_system_prompt_bindings_without_insertion_point.sql new file mode 100644 index 000000000000..7821372f3f8c --- /dev/null +++ b/backend/migrations/268_purge_system_prompt_bindings_without_insertion_point.sql @@ -0,0 +1,41 @@ +-- Delete the system prompt binding of accounts that can never receive a prompt. +-- +-- accounts.extra.system_prompt has an effect only where a request passes an +-- insertion point: the Messages, Chat Completions, Responses and Gemini +-- forwarders of the ten platforms in service.SystemPromptPlatforms +-- (service/system_prompt.go). An account on any other platform has none. +-- TypeSafe is the one such platform the account dialogs can create: System One +-- has no system or instructions field and its forwarder relays the request as +-- it is (service/gateway_systemone.go). +-- +-- Such a row could still hold a binding. The binding endpoint wrote it to every +-- selected account whatever its platform (service/system_prompt.go SetBindings +-- before this release, reached from the account edit dialog, the row menu and +-- the bulk bar); account creation (data import and duplicate included), bulk +-- update and the extra merge stored a given key as it was +-- (service/admin_account.go before this release); and 259 turned a stored +-- prompt_skills "off" into {"mode": "off"} on any platform. It never changed a +-- request. This release writes no binding to these accounts, offers no control +-- for them and leaves them out of the counts of the system prompts page, so the +-- stored value is removed here once instead of being ignored in code. +-- +-- Only the system_prompt key of those rows goes, soft-deleted rows included. +-- The system_prompts setting, every other extra key and every account on the +-- ten platforms are not touched; a row without the key is not written. The +-- platform list is the one of this release: a platform that gains an insertion +-- point later starts without bindings. +-- +-- accounts has no updated_at trigger. Of the two extra triggers of 175, the +-- BEFORE one returns the row unchanged for a platform other than openai and the +-- AFTER one fires for openai only, so no other row is written and no +-- scheduler_outbox event is queued. Running the file again is a no-op. +-- No explicit BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +UPDATE accounts +SET extra = extra - 'system_prompt' +WHERE jsonb_typeof(extra) = 'object' + AND extra ? 'system_prompt' + AND platform NOT IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'); diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 28873f8ed410..ce27e583846d 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -37,6 +37,7 @@ import adminComplianceAPI from './compliance' import auditAPI from './audit' import pluginsAPI from './plugins' import systemPromptsAPI from './systemPrompts' +import reasoningRecoveryAPI from './reasoningRecovery' import accountJobsAPI from './accountJobs' /** @@ -77,6 +78,7 @@ export const adminAPI = { audit: auditAPI, plugins: pluginsAPI, systemPrompts: systemPromptsAPI, + reasoningRecovery: reasoningRecoveryAPI, accountJobs: accountJobsAPI } @@ -115,6 +117,7 @@ export { auditAPI, pluginsAPI, systemPromptsAPI, + reasoningRecoveryAPI, accountJobsAPI } @@ -129,3 +132,4 @@ export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' 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/reasoningRecovery.ts b/frontend/src/api/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..a6d42e92342c --- /dev/null +++ b/frontend/src/api/admin/reasoningRecovery.ts @@ -0,0 +1,14 @@ +import { apiClient } from '../client' + +export interface ReasoningRecoveryConfig { + enabled: boolean +} + +const basePath = '/admin/reasoning-recovery' + +export const reasoningRecoveryAPI = { + get: async () => (await apiClient.get(basePath)).data, + save: async (config: ReasoningRecoveryConfig) => (await apiClient.put(basePath, config)).data +} + +export default reasoningRecoveryAPI diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4bb4dcf70b56..63d87b474098 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -793,9 +793,6 @@ export interface SystemSettings { // Allow user view error requests allow_user_view_error_requests: boolean; - - // Retired switches, echoed read-only by GET (never written): setting key -> stored value - deprecated_settings?: Record; } export interface UpdateSettingsRequest { diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 001668cb5720..73e3f7b138cf 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -31,14 +31,6 @@

- -
p === 'grok') ) const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1) -const allOpenAIReasoningPolicyCapable = computed(() => - targetSelectedPlatforms.value.length > 0 && - targetSelectedTypes.value.length > 0 && - targetSelectedPlatforms.value.every(platform => - targetSelectedTypes.value.every(type => isOpenAIReasoningPolicyApplicable({ platform, type })) - ) -) const allOpenAIPassthroughCapable = computed(() => { return ( @@ -1724,8 +1702,6 @@ const rateMultiplier = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) const openaiPassthroughEnabled = ref(false) -const openAIReasoningPolicy = ref(defaultOpenAIReasoningPolicy()) -const openAIReasoningPolicySelected = ref(emptyOpenAIReasoningPolicySelection()) // Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) @@ -2017,13 +1993,6 @@ const buildUpdatePayload = (): Record | null => { } } - if (allOpenAIReasoningPolicyCapable.value && - (openAIReasoningPolicySelected.value.chatReplay || openAIReasoningPolicySelected.value.signatureRecovery)) { - updates.extra = applyOpenAIReasoningPolicyEdits( - ensureExtra(), openAIReasoningPolicy.value, openAIReasoningPolicySelected.value - ) - } - // 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上 if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) { const extra = ensureExtra() @@ -2240,8 +2209,6 @@ const handleSubmit = async () => { } const hasAnyFieldEnabled = - (allOpenAIReasoningPolicyCapable.value && - (openAIReasoningPolicySelected.value.chatReplay || openAIReasoningPolicySelected.value.signatureRecovery)) || enableBaseUrl.value || enableOpenAIPassthrough.value || enableOpenAIFlattenNamespaces.value || @@ -2400,8 +2367,6 @@ watch( // Reset all values baseUrl.value = '' openaiPassthroughEnabled.value = false - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 080371258831..875746744e0e 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3183,13 +3183,6 @@
- -
| undefin const baseUrlHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') + if (form.platform === 'typesafe') return t('admin.accounts.typesafe.baseUrlHint') if (form.platform === 'grok') return '' return t('admin.accounts.baseUrlHint') }) @@ -4147,6 +4135,7 @@ const baseUrlHint = computed(() => { const apiKeyHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint') + if (form.platform === 'typesafe') return t('admin.accounts.typesafe.apiKeyHint') if (form.platform === 'grok') return '' return t('admin.accounts.apiKeyHint') }) @@ -4459,11 +4448,14 @@ function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol apiKeyBaseUrl.value = preset.url } +// The credentials 同步上游支持的模型 previews with. The preview authenticates with the key of the API-key form (a key +// left there by another account type is not one), and only where the saved account could list its models too. const syncPreviewCredentials = computed(() => { - if (!apiKeyValue.value) return undefined + if (form.type !== 'apikey' || !apiKeyValue.value) return undefined const baseUrl = isMultiProtocolPlatform.value && apiProtocol.value === 'adaptive' ? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim() : apiKeyBaseUrl.value.trim() + if (!supportsUpstreamModelSync(form.platform, form.type, { base_url: baseUrl })) return undefined const apiBaseUrls = apiProtocol.value === 'adaptive' ? Object.fromEntries(Object.entries(adaptiveBaseUrls.value).map(([protocol, value]) => [ protocol, @@ -4570,8 +4562,6 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record) => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) -const openAIReasoningPolicy = ref(defaultOpenAIReasoningPolicy()) -const openAIReasoningPolicySelected = ref(emptyOpenAIReasoningPolicySelection()) // OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) @@ -5068,8 +5058,6 @@ watch( codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false } - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() if (newPlatform !== 'anthropic') { anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' @@ -5479,7 +5467,8 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { Object.values(modelMapping).some((target) => typeof target === 'string' && target.trim() !== '' && !target.includes('*') ) - if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) { + const canSyncUpstreamModels = supportsUpstreamModelSync(payload.platform, payload.type, payload.credentials) + if (canSyncUpstreamModels && (upstreamModelsPreviewed.value || hasConcreteMappedTarget)) { try { const result = await adminAPI.accounts.syncUpstreamModels(account.id) const warnings = result.warnings ?? [] @@ -5582,8 +5571,6 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAILongContextBillingTouched.value = false @@ -5655,7 +5642,7 @@ const buildOpenAIExtra = (base?: Record): Record = { ...(base || {}) } if (accountCategory.value === 'oauth-based') { extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8a645a361441..357730f0e32c 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -44,7 +44,9 @@ ? 'https://cloudcode-pa.googleapis.com' : account.platform === 'grok' ? 'https://api.x.ai/v1' - : 'https://api.anthropic.com' + : account.platform === 'typesafe' + ? 'https://api.typesafe.ai' + : 'https://api.anthropic.com' " />

{{ baseUrlHint }}

@@ -226,7 +228,9 @@ ? 'sk-...' : account.platform === 'grok' ? 'xai-...' - : 'sk-ant-...' + : account.platform === 'typesafe' + ? 'ts-...' + : 'sk-ant-...' " />

{{ t('admin.accounts.leaveEmptyToKeep') }}

@@ -310,7 +314,7 @@ v-model="allowedModels" :model-mappings="modelMappings" :platform="account?.platform || 'anthropic'" - :account-id="account?.id" + :account-id="upstreamSyncAccountId" :synced-models="capacitySyncedModels" @upstream-synced="acceptCapacitySync" /> @@ -806,7 +810,7 @@
{{ t('admin.accounts.mapRequestModels') }}

-
+
- -
{ if (!props.account) return t('admin.accounts.baseUrlHint') if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') + if (props.account.platform === 'typesafe') return t('admin.accounts.typesafe.baseUrlHint') if (props.account.platform === 'grok') return '' return t('admin.accounts.baseUrlHint') }) @@ -3687,8 +3679,6 @@ const grokOAuthBaseUrl = ref('') // Grok Free OAuth accounts use client-tool prompt caching by default. Keep an // explicit false in the account extra as the opt-out signal. const grokClientToolCacheEnabled = ref(true) -const openAIReasoningPolicy = ref(defaultOpenAIReasoningPolicy()) -const openAIReasoningPolicySelected = ref(emptyOpenAIReasoningPolicySelection()) const isGrokOAuthAccount = computed( () => props.account?.platform === 'grok' && props.account?.type === 'oauth' ) @@ -3754,6 +3744,12 @@ const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist' const antigravityWhitelistModels = ref([]) const antigravityModelMappings = ref([]) const isSyncingAntigravityUpstream = ref(false) +// 同步上游支持的模型 reads the saved account, so its stored platform, type and credentials decide whether the dialog +// offers it: the model selectors and the Antigravity button get the account ID only where the sync can work. +const upstreamSyncAccountId = computed(() => { + const account = props.account + return account && supportsUpstreamModelSync(account.platform, account.type, account.credentials) ? account.id : undefined +}) const tempUnschedEnabled = ref(false) const accountSchedulingThresholdOverrideEnabled = ref(false) const accountSchedulingThresholdOverrideValue = ref(100) @@ -4282,8 +4278,6 @@ const syncFormFromAccount = (newAccount: Account | null) => { upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) - openAIReasoningPolicy.value = readOpenAIReasoningPolicy(extra) - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiPassthroughEnabled.value = false openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false @@ -4775,11 +4769,11 @@ const addAntigravityPresetMapping = (from: string, to: string) => { } const syncAntigravityUpstreamModels = async () => { - if (!props.account?.id || isSyncingAntigravityUpstream.value) return + const accountID = upstreamSyncAccountId.value + if (!accountID || isSyncingAntigravityUpstream.value) return isSyncingAntigravityUpstream.value = true try { - const accountID = props.account.id const result = await synchronizeCapacity(() => scopedAccounts().syncUpstreamModels(accountID)) if (!result) return const upstreamModels = result.models.map((model) => model.trim()).filter(Boolean) @@ -6015,14 +6009,6 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - if (isOpenAIReasoningPolicyApplicable(props.account)) { - updatePayload.extra = applyOpenAIReasoningPolicyEdits( - (updatePayload.extra as Record | undefined) ?? props.account.extra, - openAIReasoningPolicy.value, - openAIReasoningPolicySelected.value - ) - } - if (updatePayload.extra) { updatePayload.extra = withoutManagedCapacityExtra(updatePayload.extra as Record) } diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 27963ec58445..75eace190878 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -286,28 +286,9 @@ const normalizedPlatforms = computed(() => { ) }) -const upstreamSyncPlatforms = new Set([ - 'anthropic', - 'openai', - 'gemini', - 'antigravity', - 'grok', - 'kimi', - 'zhipu', - 'deepseek', - 'minimax', - 'opencode_go' -]) -const canSyncUpstream = computed(() => { - if (props.accountId) { - if (normalizedPlatforms.value.length === 0) return true - return normalizedPlatforms.value.some(platform => upstreamSyncPlatforms.has(platform.toLowerCase())) - } - if (props.syncCredentials) { - return upstreamSyncPlatforms.has(props.syncCredentials.platform.toLowerCase()) - } - return false -}) +// The account dialogs pass a saved account's ID or the credentials of an unsaved one only where the backend can list +// the models (utils/upstreamModelSync.ts), so having either one is the whole condition. +const canSyncUpstream = computed(() => Boolean(props.accountId || props.syncCredentials)) interface ModelSelectorOption { value: string diff --git a/frontend/src/components/account/OpenAIReasoningPolicyFields.vue b/frontend/src/components/account/OpenAIReasoningPolicyFields.vue deleted file mode 100644 index 34b906efb4ae..000000000000 --- a/frontend/src/components/account/OpenAIReasoningPolicyFields.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index d6b37ba206d4..e958ee3639f4 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -100,67 +100,6 @@ describe('BulkEditAccountModal', () => { } as any) }) - it('reasoning policy omits unselected switches when changing another field', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) - await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { - extra: { openai_passthrough: false, openai_oauth_passthrough: false } - }) - wrapper.unmount() - }) - - it('reasoning policy applies only a selected switch and accepts explicit false', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['oauth', 'setup-token', 'apikey'] }) - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-selected"]').setValue(true) - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').trigger('click') - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { - extra: { openai_reasoning_signature_recovery_enabled: false } - }) - wrapper.unmount() - }) - - it('reasoning policy applies explicit true to OpenAI filter targets', async () => { - const wrapper = mountModal({ - selectedPlatforms: ['anthropic'], - selectedTypes: ['apikey'], - target: { mode: 'filtered', previewCount: 4, selectedPlatforms: ['openai'], selectedTypes: ['apikey'], filters: { type: 'apikey' } } - }) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-selected"]').setValue(true) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({ - filters: { type: 'apikey' }, - extra: { openai_chat_reasoning_replay_enabled: true } - }) - wrapper.unmount() - }) - - it('reasoning policy drops stale selections if the bulk target becomes inapplicable', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-selected"]').setValue(true) - await wrapper.setProps({ selectedPlatforms: ['grok'] }) - expect(wrapper.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(false) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).not.toHaveBeenCalled() - expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.noFieldsSelected') - wrapper.unmount() - }) - - it('does not expose account-level compatibility selectors for bulk editing', () => { - const wrapper = mountModal({ - selectedPlatforms: ['openai'], - selectedTypes: ['apikey'] - }) - expect(wrapper.find('[data-testid="bulk-edit-openai-alpha-search-mode-select"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="bulk-edit-openai-prompt-cache-key-mode-select"]').exists()).toBe(false) - }) - it('批量修改倍率时提示自动同步账号需要先关闭同步', async () => { const wrapper = mountModal() @@ -673,6 +612,22 @@ describe('BulkEditAccountModal', () => { }) }) + it('目标变化后只剩已隐藏的设置时提示未选择字段,不提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['apikey'] + }) + + await wrapper.get('#bulk-edit-openai-responses-mode-enabled').setValue(true) + await wrapper.setProps({ selectedPlatforms: ['grok'] }) + expect(wrapper.find('#bulk-edit-openai-responses-mode-enabled').exists()).toBe(false) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.noFieldsSelected') + }) + it('至少保留一个端点能力', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 131a3f84c1dc..60fb179277ef 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -526,49 +526,6 @@ describe('CreateAccountModal OpenAI long-context billing', () => { wrapper.unmount() }) - it('reasoning policy defaults on without writing untouched switches during creation', async () => { - const wrapper = await submitApiKeyAccount('openai') - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('true') - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - - it('reasoning policy creates an explicit independent opt-out', async () => { - const wrapper = mountModal() - await selectButtonByText(wrapper, 'OpenAI') - await selectButtonByText(wrapper, 'API Key') - await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') - await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').trigger('click') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_reasoning_signature_recovery_enabled).toBe(false) - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - wrapper.unmount() - }) - - it('reasoning policy preserves untouched import settings and forwards explicit edits', async () => { - const untouched = await openCodexImportStep() - await untouched.get('[data-testid="import-codex-session"]').trigger('click') - await flushPromises() - expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - untouched.unmount() - - const wrapper = mountModal() - await selectButtonByText(wrapper, 'OpenAI') - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') - await flushPromises() - expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_chat_reasoning_replay_enabled).toBe(false) - expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - it('submits the Codex fingerprint mode of an OAuth-based account, explicit off included, and none for an API-key account', async () => { const untouched = await openCodexImportStep() await untouched.get('[data-testid="import-codex-session"]').trigger('click') @@ -617,7 +574,7 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(submitted.credentials).not.toHaveProperty('model_mapping') }) - it('keeps only the transport selector and removes account-level compatibility modes', async () => { + it('leaves the Responses mode on auto when the Laxa base URL is typed instead of taken from the preset', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') await selectButtonByText(wrapper, 'API Key') @@ -632,8 +589,6 @@ describe('CreateAccountModal OpenAI long-context billing', () => { const responses = wrapper.get('[data-testid="openai-responses-mode-select"]') expect(responses.element.value).toBe('auto') - expect(wrapper.find('[data-testid="openai-alpha-search-mode-select"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="openai-prompt-cache-key-mode-select"]').exists()).toBe(false) }) afterEach(() => vi.useRealTimers()) @@ -822,6 +777,34 @@ describe('CreateAccountModal OpenAI long-context billing', () => { ) }) + it('shows the TypeSafe hints and starts no upstream model sync for a TypeSafe account', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'TypeSafe / Jev') + + const inputs = wrapper.findAll('form#create-account-form input') + expect(inputs.some((input) => input.attributes('placeholder') === 'https://api.typesafe.ai')).toBe(true) + expect(wrapper.text()).toContain('admin.accounts.typesafe.baseUrlHint') + expect(wrapper.text()).toContain('admin.accounts.typesafe.apiKeyHint') + expect(wrapper.text()).not.toContain('admin.accounts.baseUrlHint') + expect(wrapper.text()).not.toContain('admin.accounts.apiKeyHint') + + await wrapper.get('form#create-account-form input[type="text"]').setValue('jev account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('ts-test-key') + // System One has no model list: neither the preview nor the sync after the create is offered. + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toBeUndefined() + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock.mock.calls[0]?.[0]).toMatchObject({ + platform: 'typesafe', + type: 'apikey', + credentials: { base_url: 'https://api.typesafe.ai', model_mapping: { 'jev-latest': 'jev-latest' } } + }) + expect(syncUpstreamModelsMock).not.toHaveBeenCalled() + expect(showWarningMock).not.toHaveBeenCalled() + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.accountCreated') + }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { const wrapper = mountModal() diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 849342cdedb5..953447cbffdb 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -250,6 +250,20 @@ function buildOpenAISparkShadowAccount() { } as any } +function buildTypeSafeAccount() { + return { + ...buildAccount(), + id: 8, + name: 'TypeSafe Key', + platform: 'typesafe', + credentials: { + base_url: 'https://api.typesafe.ai', + model_mapping: { 'jev-latest': 'jev-latest' } + }, + credentials_status: { has_api_key: true } + } as any +} + function buildVertexAccount() { return { id: 2, @@ -663,61 +677,55 @@ describe('EditAccountModal', () => { } }) - it.each(['apikey', 'oauth', 'setup-token'])('reasoning policy defaults on and omits untouched edits for %s', async (type) => { - const account = { ...buildAccount(), type } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('true') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock).toHaveBeenCalledTimes(1) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') + it('shows the TypeSafe wording of the Base URL hint and of both placeholders for a TypeSafe account', () => { + const wrapper = mountModal(buildTypeSafeAccount()) + expect(wrapper.get('[data-testid="account-base-url"]').attributes('placeholder')).toBe('https://api.typesafe.ai') + expect(wrapper.text()).toContain('admin.accounts.typesafe.baseUrlHint') + expect(wrapper.text()).not.toContain('admin.accounts.baseUrlHint') + expect(wrapper.findAll('input').some(input => input.attributes('placeholder') === 'ts-...')).toBe(true) wrapper.unmount() }) - it('reasoning policy preserves explicit false and malformed historical values until changed', async () => { - const account = buildAccount() - account.extra = { openai_chat_reasoning_replay_enabled: false, openai_reasoning_signature_recovery_enabled: 'invalid-old-value' } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('false') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('false') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - expect(account.extra.openai_reasoning_signature_recovery_enabled).toBe('invalid-old-value') - wrapper.unmount() - }) - - it('reasoning policy allows an explicit enable without overwriting the other switch', async () => { - const account = buildAccount() - account.extra = { openai_chat_reasoning_replay_enabled: false, openai_reasoning_signature_recovery_enabled: false } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_chat_reasoning_replay_enabled).toBe(true) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) + it('offers the upstream model sync only for an account whose model list the backend can read', () => { + // the model selector offers the sync for the account ID it is given + const selectorAccountId = (account: ReturnType) => { + const wrapper = mountModal(account) + const accountId = wrapper.findComponent(ModelWhitelistSelectorStub).props('accountId') + wrapper.unmount() + return accountId + } + expect(selectorAccountId(buildAccount())).toBe(1) + expect(selectorAccountId(buildTypeSafeAccount())).toBeUndefined() + expect(selectorAccountId(buildVertexAccount())).toBeUndefined() - it('reasoning policy is available for OpenAI accounts but not native Grok', () => { - const openai = mountModal(buildAccount()) - expect(openai.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(true) - openai.unmount() - const grok = mountModal(buildGrokAPIKeyAccount()) - expect(grok.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(false) - grok.unmount() + const antigravityButton = (account: ReturnType) => { + const wrapper = mountModal(account) + const offered = wrapper.findAll('button').some(button => button.text() === 'admin.accounts.syncUpstreamModels') + wrapper.unmount() + return offered + } + const antigravityKey = (baseUrl: string) => ({ + ...buildAntigravityAccount(), + type: 'apikey', + credentials: { base_url: baseUrl }, + credentials_status: { has_api_key: true } + }) + expect(antigravityButton(buildAntigravityAccount())).toBe(true) + expect(antigravityButton(antigravityKey('https://relay.example.com/antigravity/'))).toBe(true) + expect(antigravityButton(antigravityKey('https://cloudcode-pa.googleapis.com'))).toBe(false) }) - it('does not expose account-level compatibility selectors', () => { - const ordinaryWrapper = mountModal(buildAccount()) - expect(ordinaryWrapper.find('[data-testid="openai-alpha-search-mode-select"]').exists()).toBe(false) - expect(ordinaryWrapper.find('[data-testid="openai-prompt-cache-key-mode-select"]').exists()).toBe(false) + it('offers the system prompt binding only for an account that can receive a prompt', () => { + const bindingOffered = (account: ReturnType) => { + const wrapper = mountModal(account) + const offered = wrapper.find('[data-test="account-system-prompt-binding"]').exists() + wrapper.unmount() + return offered + } + expect(bindingOffered(buildAccount())).toBe(true) + expect(bindingOffered(buildAntigravityAccount())).toBe(true) + // System One has no system or instructions field. + expect(bindingOffered(buildTypeSafeAccount())).toBe(false) }) it('account.edit keeps an OpenAI API-key account on the Laxa endpoint editable like any other', async () => { diff --git a/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts b/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts deleted file mode 100644 index 391b52c56be8..000000000000 --- a/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' -import OpenAIReasoningPolicyFields from '../OpenAIReasoningPolicyFields.vue' -import { defaultOpenAIReasoningPolicy, emptyOpenAIReasoningPolicySelection } from '@/utils/openaiReasoningPolicy' - -vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) - -describe('OpenAI reasoning policy fields', () => { - it('renders native controls without requiring an installed plugin', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'availability' } }) - expect(wrapper.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(true) - const toggle = wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]') - expect(toggle.attributes('disabled')).toBeUndefined() - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')).toHaveLength(1) - }) - it('exposes independent accessible switches and tracks only deliberate edits', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { - props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'test' } - }) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{ chatReplay: false, signatureRecovery: true }]) - expect(wrapper.emitted('update:selected')?.[0]).toEqual([{ chatReplay: true, signatureRecovery: false }]) - expect(wrapper.text()).toContain('admin.accounts.openai.reasoningPolicy.boundaryHint') - }) - - it('does not allow bulk toggles to change an unselected setting', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { - props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'bulk', bulk: true } - }) - const toggle = wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]') - expect(toggle.attributes('disabled')).toBeDefined() - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')).toBeUndefined() - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-selected"]').setValue(true) - expect(wrapper.emitted('update:selected')?.[0]).toEqual([{ chatReplay: false, signatureRecovery: true }]) - await wrapper.setProps({ selected: { chatReplay: false, signatureRecovery: true } }) - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{ chatReplay: true, signatureRecovery: false }]) - }) -}) diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 0a99a1874d95..5bee4d6222b3 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -12,7 +12,7 @@

{{ t('common.processing') }}

@@ -84,10 +84,13 @@ import { computed, defineAsyncComponent, ref } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' -import { systemPromptBindingLimit } from '@/utils/systemPromptBinding' +import { systemPromptBindingLimit, takesSystemPrompt } from '@/utils/systemPromptBinding' +import type { Account } from '@/types' const props = defineProps<{ selectedIds: number[] + // The selected accounts the list holds; selectedIds can also name accounts it has not loaded. + selectedAccounts: Pick[] totalResults: number selectingAll: boolean allResultsSelected: boolean @@ -114,5 +117,12 @@ defineEmits([ const { t } = useI18n() const AccountSystemPromptBinding = defineAsyncComponent(() => import('./AccountSystemPromptBinding.vue')) const promptBindingOpen = ref(false) -const promptBindingAvailable = computed(() => props.selectedIds.length > 0 && props.selectedIds.length <= systemPromptBindingLimit) +// 系统提示词 is offered while the selection can hold an account that takes a prompt: not when every selected account is +// loaded and none of them takes one, as the row menu offers none for such an account. A selection with accounts the +// list has not loaded keeps it, and the binding request skips the accounts that take none. +const promptTakers = computed(() => props.selectedAccounts.filter(account => takesSystemPrompt(account.platform)).length) +const promptBindingAvailable = computed(() => { + const selected = props.selectedIds.length + return selected > 0 && selected <= systemPromptBindingLimit && (promptTakers.value > 0 || props.selectedAccounts.length < selected) +}) diff --git a/frontend/src/components/admin/account/AccountSystemPromptBinding.vue b/frontend/src/components/admin/account/AccountSystemPromptBinding.vue index 8908b4a82913..c3c22e9beead 100644 --- a/frontend/src/components/admin/account/AccountSystemPromptBinding.vue +++ b/frontend/src/components/admin/account/AccountSystemPromptBinding.vue @@ -2,7 +2,7 @@

{{ t('admin.systemPrompts.accountPrompts') }}

{{ t('admin.systemPrompts.binding.description') }}

-

{{ t('admin.systemPrompts.binding.bulkHint', { count: accountIds.length }) }}

+

{{ scope }}

-

{{ effect }}

+

{{ effect }}

@@ -35,11 +35,23 @@ import { extractApiErrorMessage } from '@/utils/apiError' import { systemPromptsAPI, type SystemPromptBinding, type SystemPromptBindingMode, type SystemPromptState } from '@/api/admin/systemPrompts' import { readSystemPromptBinding } from '@/utils/systemPromptBinding' -const props = defineProps<{ accountIds: number[]; current?: unknown; embedded?: boolean }>() +// takers: how many of accountIds the caller knows to take a prompt. The account edit dialog and the row menu offer the +// binding only for such an account and leave it out. A bulk selection can also hold accounts that take none and +// accounts the list has not loaded; the request skips every account that takes none. +const props = defineProps<{ accountIds: number[]; takers?: number; current?: unknown; embedded?: boolean }>() const emit = defineEmits<{ changed: [binding: SystemPromptBinding] }>() const { t } = useI18n() const appStore = useAppStore() +// What the binding puts in effect is stated only when an account is known to receive it. +const promptTakers = computed(() => props.takers ?? props.accountIds.length) +// Which accounts the binding reaches: their number for more than one, and that TypeSafe accounts are skipped +// whenever one of them may be skipped, whatever the size of the selection. +const scope = computed(() => { + const count = props.accountIds.length + if (promptTakers.value >= count) return count > 1 ? t('admin.systemPrompts.binding.bulkHint', { count }) : '' + return count > 1 ? t('admin.systemPrompts.binding.bulkSkipHint', { count }) : t('admin.systemPrompts.binding.skipHint') +}) const initial = readSystemPromptBinding(props.current) const state = ref(null) const mode = ref(initial.mode) diff --git a/frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts b/frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts index f28c23e32624..e1b7c2e25f0e 100644 --- a/frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts @@ -48,6 +48,24 @@ const anchorRect = new DOMRect(100, 100, 24, 24) const getBodyText = () => document.body.textContent ?? '' const getBodyButtons = () => Array.from(document.body.querySelectorAll('button')) +describe('AccountActionMenu — 系统提示词入口', () => { + const offered = (platform: Account['platform']) => { + const wrapper = mount(AccountActionMenu, { + props: { show: true, account: makeAccount({ platform, type: 'apikey' }), anchorRect }, + attachTo: document.body, + }) + const found = document.body.querySelector('[data-test="account-prompt-binding-action"]') !== null + wrapper.unmount() + return found + } + + it('只对请求有注入点的平台提供,TypeSafe(System One)账号没有该入口', () => { + expect(offered('openai')).toBe(true) + expect(offered('antigravity')).toBe(true) + expect(offered('typesafe')).toBe(false) + }) +}) + describe('AccountActionMenu — spark shadow 按钮可见性', () => { it('普通账号显示「复制账号」按钮', () => { const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null }) diff --git a/frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts b/frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts index 3c51e637cb2d..b518e56e0f07 100644 --- a/frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts +++ b/frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import AccountBulkActionsBar from '../AccountBulkActionsBar.vue' @@ -8,12 +8,29 @@ vi.mock('vue-i18n', () => ({ t: (key: string) => key }) })) +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showSuccess: vi.fn(), showError: vi.fn() }) +})) +vi.mock('@/api/admin/systemPrompts', () => ({ + systemPromptsAPI: { + get: vi.fn().mockResolvedValue({ + enabled: true, + default_prompt_id: 'p-default', + prompts: [{ id: 'p-default', name: 'Default', body: 'text', position: 'append', role: 'auto' }], + usage: { inherit: 0, off: 0, custom: {} } + }) + } +})) + +// The selected accounts the list holds, one per platform given. +const loaded = (...platforms: string[]) => platforms.map(platform => ({ platform })) describe('AccountBulkActionsBar', () => { it('allows selecting all results before any row is selected', async () => { const wrapper = mount(AccountBulkActionsBar, { props: { selectedIds: [], + selectedAccounts: [], totalResults: 45, selectingAll: false, allResultsSelected: false @@ -33,6 +50,7 @@ describe('AccountBulkActionsBar', () => { const wrapper = mount(AccountBulkActionsBar, { props: { selectedIds: [1], + selectedAccounts: loaded('openai'), totalResults: 45, selectingAll: false, allResultsSelected: false @@ -52,6 +70,7 @@ describe('AccountBulkActionsBar', () => { const wrapper = mount(AccountBulkActionsBar, { props: { selectedIds: [1, 2], + selectedAccounts: loaded('openai', 'openai'), totalResults: 120, selectingAll: false, allResultsSelected: false @@ -74,6 +93,7 @@ describe('AccountBulkActionsBar', () => { const wrapper = mount(AccountBulkActionsBar, { props: { selectedIds: [1], + selectedAccounts: loaded('openai'), totalResults: 1, selectingAll: false, allResultsSelected: false @@ -84,4 +104,51 @@ describe('AccountBulkActionsBar', () => { expect(wrapper.emitted('refresh-tier')).toHaveLength(1) }) + + describe('system prompt binding', () => { + const mountBar = (selectedIds: number[], selectedAccounts: ReturnType) => mount(AccountBulkActionsBar, { + props: { selectedIds, selectedAccounts, totalResults: 45, selectingAll: false, allResultsSelected: false }, + global: { stubs: { BaseDialog: { props: ['show'], template: '
' } } } + }) + + it('is offered unless every selected account is loaded and none of them takes a prompt', () => { + const offered = (selectedIds: number[], selectedAccounts: ReturnType) => + mountBar(selectedIds, selectedAccounts).find('[data-test="account-prompt-binding-bulk"]').exists() + + // TypeSafe accounts only, one or several: the row menu has no entry for such an account either. + expect(offered([1], loaded('typesafe'))).toBe(false) + expect(offered([1, 2], loaded('typesafe', 'typesafe'))).toBe(false) + expect(offered([1], loaded('openai'))).toBe(true) + expect(offered([1, 2], loaded('typesafe', 'antigravity'))).toBe(true) + // The selection names accounts the list has not loaded; the request skips those that take no prompt. + expect(offered([1, 2], loaded('typesafe'))).toBe(true) + expect(offered([1], [])).toBe(true) + }) + + it('says in its dialog which accounts are skipped, and what is in effect only for an account known to take a prompt', async () => { + const dialog = async (selectedIds: number[], selectedAccounts: ReturnType) => { + const wrapper = mountBar(selectedIds, selectedAccounts) + await wrapper.get('[data-test="account-prompt-binding-bulk"]').trigger('click') + await vi.dynamicImportSettled() + await flushPromises() + const scope = wrapper.find('[data-test="system-prompt-binding-scope"]') + const shown = { + scope: scope.exists() ? scope.text() : '', + effect: wrapper.find('[data-test="system-prompt-binding-effect"]').exists() + } + wrapper.unmount() + return shown + } + + expect(await dialog([1], loaded('openai'))).toEqual({ scope: '', effect: true }) + expect(await dialog([1, 2], loaded('openai', 'grok'))) + .toEqual({ scope: 'admin.systemPrompts.binding.bulkHint', effect: true }) + expect(await dialog([1, 2], loaded('openai', 'typesafe'))) + .toEqual({ scope: 'admin.systemPrompts.binding.bulkSkipHint', effect: true }) + // One selected account the list has not loaded: it may be a TypeSafe account. + expect(await dialog([1], [])).toEqual({ scope: 'admin.systemPrompts.binding.skipHint', effect: false }) + expect(await dialog([1, 2], loaded('typesafe'))) + .toEqual({ scope: 'admin.systemPrompts.binding.bulkSkipHint', effect: false }) + }) + }) }) diff --git a/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue b/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue index ccf7778a2d1d..bd55a29ade61 100644 --- a/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue +++ b/frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue @@ -160,7 +160,7 @@ function recoverySummary(recovery: Recovery): string { const parts = [`disposition=${recovery.disposition || ''}`, `retry_attempted=${!!recovery.retry_attempted}`] if (recovery.not_attempted_reason) parts.push(`not_attempted_reason=${recovery.not_attempted_reason}`) if (recovery.cache_skipped_items) parts.push(`cache_skipped_items=${recovery.cache_skipped_items}`) - return text('推理签名恢复:', 'Reasoning recovery: ') + parts.join(' · ') + return text('推理恢复:', 'Reasoning recovery: ') + parts.join(' · ') } const known = (value?: Signal) => !!value && !['missing', 'uninspected', 'null', 'unknown'].includes(value.kind || '') @@ -177,8 +177,7 @@ function explain(diagnostic: Diagnostic): string[] { if (incoming.inspection_limited || wire.inspection_limited || history.scan_limited || error.inspection_limited) lines.push(text('本次结构检查受大小或可读性限制;未检查部分不能当作不存在。', 'Structural inspection was limited by size or readability; uninspected fields are not evidence of absence.')) if (recovery.retry_attempted) lines.push(text('宿主已执行一次受限恢复请求;恢复是否成功须查看后续结果。', 'The host dispatched one bounded recovery request; check the subsequent result for its outcome.')) const reasons: Record = { - disabled: ['恢复能力已关闭或当前账号不在启用范围。', 'Recovery is disabled or outside the enabled account scope.'], - policy_unavailable: ['恢复能力不可用,未启用替代执行路径。', 'Recovery was unavailable.'], + disabled: ['推理恢复开关已关闭,或该账号类型、请求路径不在恢复适用范围。', 'Reasoning recovery is switched off, or this account type or request path is outside what it covers.'], semantic_output_committed: ['已向客户端发送业务输出,宿主禁止重放请求。', 'Semantic output was already committed; the host prohibits replay.'], source_changed: ['请求来源已变化,宿主禁止跨来源恢复。', 'The source changed; the host prohibits recovery across sources.'], request_cancelled: ['请求已取消,未继续恢复。', 'The request was canceled before further recovery.'] diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 2f6bc01cd0af..e3c496d8773e 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -161,6 +161,7 @@ const icons = { signal: 'M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z', clipboardList: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z', receipt: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z', + undo: 'M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3', chevronDoubleLeft: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5', chevronDoubleRight: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5', banknotes: 'M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z', diff --git a/frontend/src/components/icons/lucide.ts b/frontend/src/components/icons/lucide.ts index 7bae2d528810..dcec0bab5d3a 100644 --- a/frontend/src/components/icons/lucide.ts +++ b/frontend/src/components/icons/lucide.ts @@ -128,6 +128,7 @@ const nodes = { 'activity': [['path', { d: 'M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2' }]], 'clipboard-list': [['rect', { width: '8', height: '4', x: '8', y: '2', rx: '1', ry: '1' }], ['path', { d: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2' }], ['path', { d: 'M12 11h4' }], ['path', { d: 'M12 16h4' }], ['path', { d: 'M8 11h.01' }], ['path', { d: 'M8 16h.01' }]], 'receipt-text': [['path', { d: 'M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z' }], ['path', { d: 'M14 8H8' }], ['path', { d: 'M16 12H8' }], ['path', { d: 'M13 16H8' }]], + 'undo-2': [['path', { d: 'M9 14 4 9l5-5' }], ['path', { d: 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11' }]], 'panel-left-close': [['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2' }], ['path', { d: 'M9 3v18' }], ['path', { d: 'm16 15-3-3 3-3' }]], 'panel-left-open': [['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2' }], ['path', { d: 'M9 3v18' }], ['path', { d: 'm14 9 3 3-3 3' }]], 'wallet': [['path', { d: 'M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1' }], ['path', { d: 'M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4' }]], @@ -232,6 +233,7 @@ export const lucideIcons = { signal: glyph('activity'), // channel status: the reference Logs pulse line clipboardList: glyph('clipboard-list'), receipt: glyph('receipt-text'), + undo: glyph('undo-2'), // the 推理恢复 entry: the turn-back arrow, like arrow-uturn-left chevronDoubleLeft: glyph('panel-left-close'), // collapse the sidebar, the reference's glyph chevronDoubleRight: glyph('panel-left-open'), banknotes: glyph('wallet'), // the header balance diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index af61bed833b9..7a97275ba225 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -709,6 +709,21 @@ const ShieldIcon = { ) } +const RecoveryIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3' + }) + ] + ) +} + const PriceTagIcon = { render: () => h( @@ -753,7 +768,7 @@ const consoleThemeIcons: Array<[typeof DashboardIcon, InstanceType[ [BellIcon, 'bell'], [TicketIcon, 'ticket'], [CogIcon, 'cog'], [DocumentIcon, 'document'], [SunIcon, 'sun'], [MoonIcon, 'moon'], [ChevronDoubleLeftIcon, 'chevronDoubleLeft'], [ChevronDoubleRightIcon, 'chevronDoubleRight'], [OrderIcon, 'clipboardList'], [OrderListIcon, 'receipt'], [SignalIcon, 'signal'], [ShieldIcon, 'shield'], - [PriceTagIcon, 'tag'], [ChevronDownIcon, 'chevronDown'] + [RecoveryIcon, 'undo'], [PriceTagIcon, 'tag'], [ChevronDownIcon, 'chevronDown'] ] for (const [icon, name] of consoleThemeIcons) { const upstream = icon.render @@ -838,6 +853,7 @@ function buildExtensionNavItems(includeAdmin: boolean): NavItem[] { if (includeAdmin) { items.push({ path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }) items.push({ path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }) + items.push({ path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon }) } items.push({ path: '/image-studio', label: t('nav.imageStudio'), icon: BatchImageIcon, featureFlag: flagImageStudio }) return items diff --git a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts index b53043c260e2..76845e7edf19 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts @@ -109,14 +109,14 @@ describe('AppSidebar simple mode extensions', () => { it('renders admin extensions and Image Studio for an admin in simple mode', async () => { const wrapper = await renderSidebar({ admin: true, imageStudio: true }) - expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/image-studio']) + expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/admin/reasoning-recovery', '/image-studio']) expect(wrapper.text()).not.toContain('nav.myAccount') }) it('keeps admin extensions visible while hiding Image Studio when its flag is disabled', async () => { const wrapper = await renderSidebar({ admin: true, imageStudio: false }) - expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime']) + expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/admin/reasoning-recovery']) wrapper.unmount() }) }) diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 7fec4d733751..cf40267da05a 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -78,6 +78,20 @@ describe('AppSidebar extensions section', () => { expect(imageStudioItem).toContain('featureFlag: flagImageStudio') expect(imageStudioItem).not.toContain('hideInSimpleMode') }) + + it('gives 推理恢复 an icon no other entry uses, in both themes', () => { + const recoveryItem = componentSource.match(/\{ path: '\/admin\/reasoning-recovery'[^\n]+\}/)?.[0] + + expect(recoveryItem).toContain('icon: RecoveryIcon') + expect(componentSource.match(/icon: RecoveryIcon\b/g)).toHaveLength(1) + // no other inline icon draws the same path + const recoveryPath = componentSource.match(/const RecoveryIcon = \{[\s\S]*?d: '([^']+)'/)?.[1] + expect(recoveryPath).toBeDefined() + expect(componentSource.split(`d: '${recoveryPath}'`)).toHaveLength(2) + // the console theme draws it through Icon.vue: no other inline icon maps to that name + expect(componentSource).toContain("[RecoveryIcon, 'undo']") + expect(componentSource.match(/'undo'/g)).toHaveLength(1) + }) }) describe('AppSidebar subscription feature flag', () => { diff --git a/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts b/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts index 47a0c87c7d3c..9918da7a95e3 100644 --- a/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts +++ b/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts @@ -588,10 +588,10 @@ describe('useModelContextCapacities', () => { model_context_overrides: { 'real-model': 1_000_000 }, upstream_model_context_capacities: { old: true }, upstream_model_metadata: { old: true }, - openai_reasoning_signature_recovery_enabled: false, + openai_long_context_billing_enabled: false, nested: { keep: true } } - expect(withoutManagedCapacityExtra(extra)).toEqual({ openai_reasoning_signature_recovery_enabled: false, nested: { keep: true } }) + expect(withoutManagedCapacityExtra(extra)).toEqual({ openai_long_context_billing_enabled: false, nested: { keep: true } }) expect(extra).toHaveProperty('model_context_overrides') expect(extra).toHaveProperty('upstream_model_context_capacities') expect(extra).toHaveProperty('upstream_model_metadata') diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 36d883bea105..5461d6f5f1d3 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -837,15 +837,7 @@ export default {accounts: { apiKeyHint: 'Your OpenAI API Key', oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: - 'When enabled, this OpenAI account forwards requests/responses as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering. Enabled signature recovery below is an explicit exception and is recorded as lossy recovery.', - reasoningPolicy: { - chatReplay: 'Replay reasoning for Chat tool turns', - chatReplayDesc: 'Enabled by default. Cache completed function-tool batches that can be represented faithfully in Chat for 24 hours. Restore raw reasoning and tool items only when account, user, model, reasoning configuration and history all match. Cache failures do not block requests; full input conversations are not cached.', - signatureRecovery: 'Recover invalid reasoning ciphertext (lossy)', - signatureRecoveryDesc: 'Enabled by default. After an explicit signature rejection and before semantic output, remove only the rejected reasoning ciphertext and retry the same account at most once. Remember that old ciphertext for 24 hours without removing new reasoning. This also applies as a passthrough exception, may incur extra upstream charges, and does not restore the original reasoning chain.', - applyField: 'Apply this setting (otherwise keep existing value)', - boundaryHint: 'Only actual HTTP/SSE native Responses, Compact and Chat-to-Responses paths are eligible; replay is Chat-to-Responses only. Messages, WebSocket (including HTTP ingress routed to WS), and native Chat-only upstreams do not use this recovery. The 24-hour limit is logical; Redis persistence files and backups may retain historical bytes.' - }, + 'When enabled, this OpenAI account forwards requests/responses as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering. The global Reasoning Recovery switch under Extensions still applies to passthrough accounts as an explicit exception.', flattenNamespaces: 'Flatten Codex namespace tools (compatibility)', flattenNamespacesDesc: 'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions... Compaction requests always flatten regardless of this switch.', @@ -886,15 +878,6 @@ export default {accounts: { responsesModeForceChatCompletions: 'Force Chat Completions', responsesModeTextDisabledHint: 'Not applicable when the Responses / Chat Completions endpoint is not enabled.', - alphaSearchMode: 'Alpha Search mode', - alphaSearchModeDesc: 'OpenAI API keys only. Bridge mode also requires the global admin switch; disabled accounts are excluded from search scheduling.', - alphaSearchModeDirect: 'Direct /v1/alpha/search', - alphaSearchModeResponsesWebSearch: 'Responses Web Search bridge', - alphaSearchModeDisabled: 'Disable search', - promptCacheKeyMode: 'Prompt cache key mode', - promptCacheKeyModeDesc: 'OpenAI API keys only. SHA-256 mode also requires the global admin switch and only rewrites values longer than 64 characters.', - promptCacheKeyModePassthrough: 'Pass through', - promptCacheKeyModeSHA25664: 'SHA-256 (64 characters)', imagesUrlToB64Json: 'Image result URL to base64', imagesUrlToB64JsonDesc: 'Only applies to non-streaming Images responses of OpenAI API Key accounts. When an upstream image item has a url but no b64_json, the gateway downloads the url and fills b64_json with its base64 content (url is kept) for clients built on the official API; the response is returned unchanged if the download fails.', @@ -1015,6 +998,11 @@ export default {accounts: { fileReadFailed: 'Failed to read the selected file', noResponseBody: 'No response body from server' }, + // TypeSafe specific hints + typesafe: { + baseUrlHint: 'Leave default for official TypeSafe API', + apiKeyHint: 'Your TypeSafe API Key' + }, anthropic: { apiKeyPassthrough: 'Auto passthrough (auth only)', apiKeyPassthroughDesc: diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts index 4a4564ce91b6..78f8eeb5d16a 100644 --- a/frontend/src/i18n/locales/en/admin/index.ts +++ b/frontend/src/i18n/locales/en/admin/index.ts @@ -7,6 +7,7 @@ import settings from './settings' import audit from './audit' import promptAudit from './promptAudit' import systemPrompts from './systemPrompts' +import reasoningRecovery from './reasoningRecovery' import accountTasks from './accountTasks' import plugins from './plugins' @@ -20,6 +21,7 @@ export default { ...audit, ...promptAudit, ...systemPrompts, + ...reasoningRecovery, ...accountTasks, ...plugins, } diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 8aec554ef13f..ec4028653cb4 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1133,6 +1133,18 @@ export default { peakMultiplier: 'Peak multiplier', multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' }, + profitControl: { + enable: 'Enable profit control', + enabledHint: 'Scheduling only admits accounts whose account multiplier ≤ the request\'s effective downstream multiplier × (1 − min margin − safety buffer). Account multipliers may be maintained manually or synchronized from probes; existing ordering, stickiness and breakers keep working among qualified accounts. Image/video scheduling is not covered yet.', + disabledHint: 'When disabled, scheduling does no profit filtering: accounts whose account multiplier exceeds the downstream multiplier can still be selected, which may produce loss-making requests.', + minMargin: 'Min gross margin (%)', + minMarginHint: 'Percent input, e.g. 30 means 30%; stored as a decimal on the backend', + safetyBuffer: 'Safety buffer (%)', + safetyBufferHint: 'Added to min margin and deducted from the downstream multiplier; defaults to 0', + marginRangeError: 'Min gross margin must be between 0 and 99.99', + bufferRangeError: 'Safety buffer must be between 0 and 99.99', + sumTooHigh: 'Min gross margin plus safety buffer must be less than 100%, otherwise every account would be excluded' + }, modelAllowlist: { title: 'Model Allowlist', hint: 'When enabled, models outside the allowlist are rejected with 404 model_not_found, and model listing endpoints only show allowlisted models. Entries support exact model IDs and * wildcards anywhere (e.g., gpt-*-codex). Note: Claude Code probes with haiku-family models for titles/summaries and /messages/count_tokens is also allowlist-controlled, so make sure the small models you need are selected too.', @@ -1259,15 +1271,6 @@ export default { free: 'Free Fast', freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.' }, - retiredProfitControl: { - title: 'Profit control (retired, read-only, no effect)', - hint: 'Profit control has been removed. These are the stored legacy values; they cannot be changed and do not affect billing or scheduling.', - enabled: 'Enabled', - minMargin: 'Minimum margin', - safetyBuffer: 'Safety buffer', - on: 'On', - off: 'Off' - }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', diff --git a/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..860a74ea291a --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts @@ -0,0 +1,13 @@ +export default { + reasoningRecovery: { + title: 'Reasoning Recovery', + description: 'Whether the gateway recovers automatically when an OpenAI upstream rejects a request because its reasoning ciphertext is no longer valid. Applies to all OpenAI accounts.', + enabled: 'Recover invalid reasoning ciphertext', + enabledHint: 'When on, after an explicit signature-verification error and before any semantic output has been sent, the gateway strips only the ciphertext field of the rejected reasoning items, retries at most once more on the same account, and remembers that old ciphertext for 24 hours; new reasoning is not removed. Auto-passthrough accounts are covered as well. This may add upstream charges and does not restore the original reasoning chain. When off, such requests return the error directly.', + scope: 'Applies only to requests actually sent over HTTP/SSE on the native Responses, Compact and Chat-to-Responses paths. Messages, WebSocket (including HTTP ingress routed to WS) and native Chat-only upstreams are not covered. The 24 hours are a logical limit; Redis persistence files and backups may retain historical bytes.', + save: 'Save', + saved: 'Reasoning recovery setting saved', + loadFailed: 'Loading failed', + saveFailed: 'Saving failed' + } +} diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 365063a0bb77..b87c983157fe 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -558,16 +558,6 @@ export default { reference: 'Reference catalog', sources: 'Sources' }, - deprecatedSettings: { - title: 'Deprecated settings (read-only, no effect)', - hint: 'These switches are retired: saving keeps the stored values, but nothing reads them at runtime. Shown here as stored.', - on: 'On', - off: 'Off', - labels: { - openai_apikey_alpha_search_responses_bridge_enabled: 'OpenAI API-key accounts: Alpha Search via the Responses bridge', - openai_apikey_prompt_cache_key_normalization_enabled: 'OpenAI API-key accounts: prompt_cache_key normalization' - } - }, gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', diff --git a/frontend/src/i18n/locales/en/admin/systemPrompts.ts b/frontend/src/i18n/locales/en/admin/systemPrompts.ts index 29237c82ca04..4fdb845c636d 100644 --- a/frontend/src/i18n/locales/en/admin/systemPrompts.ts +++ b/frontend/src/i18n/locales/en/admin/systemPrompts.ts @@ -1,13 +1,13 @@ export default { systemPrompts: { title: 'System Prompts', - description: 'Keep a small prompt library and choose one site default. Accounts that inherit on every platform receive it; a single account can turn it off or use another library prompt.', + description: 'Keep a small prompt library and choose one site default. Accounts that inherit the default receive it; a single account can turn it off or use another library prompt.', accountPrompts: 'System prompt', enabled: 'Enable system prompts', enabledHint: 'When off, no account is injected.', defaultPrompt: 'Site default prompt', noDefault: 'No default', - defaultHint: 'Accounts in inherit mode (all platforms) use this prompt.', + defaultHint: 'Accounts in inherit mode (all platforms except TypeSafe) use this prompt.', usage: '{inherit} accounts inherit · {off} off · {custom} custom', library: 'Prompt library', add: 'Add prompt', @@ -29,7 +29,7 @@ export default { empty: 'No prompts yet.', invalid: 'Every prompt needs a name and text of at most 64 KiB.', loadFailed: 'Loading failed', - notes: 'Codex Responses Lite and compact requests are never injected; prompt_cache_key is not rewritten; clients see their own instructions echoed. Claude OAuth mimicry blocks are configured in Settings → Gateway.', + notes: 'TypeSafe accounts are never injected and are not counted above: System One requests have no system instructions field. Codex Responses Lite and compact requests are never injected; prompt_cache_key is not rewritten; clients see their own instructions echoed. Claude OAuth mimicry blocks are configured in Settings → Gateway.', binding: { description: 'Inherit uses the site default, Off injects nothing, Custom uses one library prompt.', mode: 'Mode', @@ -40,6 +40,8 @@ export default { apply: 'Apply', applied: 'Updated {count} accounts', bulkHint: 'Applies to the {count} selected accounts.', + bulkSkipHint: 'Applies to the {count} selected accounts; TypeSafe accounts among them are skipped.', + skipHint: 'The selected account is skipped if it is a TypeSafe account.', globalOff: 'System prompts are switched off; nothing is injected.', effectiveDefault: 'In effect: site default "{name}"', effectiveNone: 'No site default is set; nothing is injected.', diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index f2e71830e03c..3eff85ca53a8 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -224,6 +224,7 @@ export default { promptAudit: 'Prompt Audit', auditLogs: 'Audit Logs', systemPrompts: 'System Prompts', + reasoningRecovery: 'Reasoning Recovery', }, // Auth diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 28ec6eb0fc03..e5e5d0cc07fd 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -983,15 +983,7 @@ export default {accounts: { apiKeyHint: '您的 OpenAI API Key', oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: - '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;下方已开启的失效密文恢复是明确例外,会留下有损恢复记录。', - reasoningPolicy: { - chatReplay: 'Chat 工具回合推理回注', - chatReplayDesc: '默认开启。将已完整完成、可无损投影为 Chat 的 function 工具批次缓存 24 小时;仅在账号、用户、模型、推理配置和历史完全匹配时回注原始 reasoning 与工具项。缓存故障不阻断请求,不缓存全会话输入。', - signatureRecovery: '失效推理密文恢复(有损)', - signatureRecoveryDesc: '默认开启。遇到明确验签错误且尚未输出语义内容时,只剥离被拒 reasoning 的密文字段,在同账号最多额外重试一次;记忆该旧密文 24 小时,不删除新推理。包括自动透传的恢复例外,可能增加调用费用,不代表恢复原推理链。', - applyField: '修改此项(未勾选则保持原值)', - boundaryHint: '仅适用于实际 HTTP/SSE 的原生 Responses、Compact 与 Chat→Responses 路径;回注只用于 Chat→Responses。Messages、WebSocket(包括 HTTP 入口转 WS)、原生 Chat-only 上游不启用本轮恢复。24 小时是逻辑期限,Redis 持久化文件与备份可能保留历史字节。' - }, + '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;“扩展功能 → 推理恢复”的全局开关对透传账号同样生效,是明确例外。', flattenNamespaces: '摊平 Codex namespace 工具(兼容)', flattenNamespacesDesc: '默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。', @@ -1026,15 +1018,6 @@ export default {accounts: { responsesModeForceResponses: '强制 Responses', responsesModeForceChatCompletions: '强制 Chat Completions', responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。', - alphaSearchMode: 'Alpha Search 模式', - alphaSearchModeDesc: '仅对 OpenAI API Key 生效。桥接模式还要求管理端全局开关已开启;禁用模式会从搜索调度中排除本账号。', - alphaSearchModeDirect: '直接调用 /v1/alpha/search', - alphaSearchModeResponsesWebSearch: 'Responses Web Search 桥接', - alphaSearchModeDisabled: '禁用搜索', - promptCacheKeyMode: 'Prompt Cache Key 模式', - promptCacheKeyModeDesc: '仅对 OpenAI API Key 生效。SHA-256 模式还要求管理端全局开关已开启,且只改写超过 64 字符的值。', - promptCacheKeyModePassthrough: '原样透传', - promptCacheKeyModeSHA25664: 'SHA-256(64 位)', imagesUrlToB64Json: '生图结果 URL 转 base64', imagesUrlToB64JsonDesc: '仅对 OpenAI API Key 的 Images 非流式响应生效。上游返回的图片缺少 b64_json 但带 url 时,网关下载该 url 并以 base64 回填 b64_json(url 保留),兼容按官方接口实现的客户端;下载失败则原样返回。', @@ -1151,6 +1134,11 @@ export default {accounts: { fileReadFailed: '读取所选文件失败', noResponseBody: '服务器未返回响应体' }, + // TypeSafe specific hints + typesafe: { + baseUrlHint: '留空使用官方 TypeSafe API', + apiKeyHint: '您的 TypeSafe API Key' + }, anthropic: { apiKeyPassthrough: '自动透传(仅替换认证)', apiKeyPassthroughDesc: diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts index 4a4564ce91b6..78f8eeb5d16a 100644 --- a/frontend/src/i18n/locales/zh/admin/index.ts +++ b/frontend/src/i18n/locales/zh/admin/index.ts @@ -7,6 +7,7 @@ import settings from './settings' import audit from './audit' import promptAudit from './promptAudit' import systemPrompts from './systemPrompts' +import reasoningRecovery from './reasoningRecovery' import accountTasks from './accountTasks' import plugins from './plugins' @@ -20,6 +21,7 @@ export default { ...audit, ...promptAudit, ...systemPrompts, + ...reasoningRecovery, ...accountTasks, ...plugins, } diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 09fd67cc6356..deec2a439f75 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1130,6 +1130,18 @@ export default { peakMultiplier: '高峰倍率', multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' }, + profitControl: { + enable: '启用利润控制', + enabledHint: '调度时仅允许"账号倍率 ≤ 请求实际下游倍率 ×(1 − 最低毛利率 − 安全缓冲)"的账号进入候选池;账号倍率可手工维护或由探测同步,既有排序、粘性与熔断在合格账号间照常工作。图片/视频调度暂不参与。', + disabledHint: '关闭后调度不做利润过滤,账号倍率高于下游倍率的账号也会被选中,可能产生亏损请求。', + minMargin: '最低毛利率(%)', + minMarginHint: '百分比输入,如 30 表示 30%;后端按小数存储', + safetyBuffer: '安全缓冲(%)', + safetyBufferHint: '与最低毛利率相加后从下游倍率中扣除,默认 0', + marginRangeError: '最低毛利率应在 0 到 99.99 之间', + bufferRangeError: '安全缓冲应在 0 到 99.99 之间', + sumTooHigh: '最低毛利率与安全缓冲之和必须小于 100%,否则将排除全部账号' + }, modelAllowlist: { title: '模型白名单', hint: '开启后,不在白名单中的模型会被拒绝(404 model_not_found),模型列表接口也只展示白名单内的模型。条目支持精确模型 ID 与任意位置的 * 通配(如 gpt-*-codex)。注意:Claude Code 会用 haiku 系小模型做标题/摘要等探测,/messages/count_tokens 同样受白名单控制,请一并勾选所需的小模型。', @@ -1257,15 +1269,6 @@ export default { free: '免费 Fast', freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。' }, - retiredProfitControl: { - title: '利润控制(已退役,只读,不生效)', - hint: '利润控制功能已经下线,这些是库里保存的旧值,不能修改,也不会影响计费或调度。', - enabled: '启用状态', - minMargin: '最低利润率', - safetyBuffer: '安全余量', - on: '开启', - off: '关闭' - }, invalidRequestFallback: { title: '无效请求兜底分组', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', diff --git a/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..7f249631c772 --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts @@ -0,0 +1,13 @@ +export default { + reasoningRecovery: { + title: '推理恢复', + description: 'OpenAI 上游拒绝请求中已失效的推理密文时,网关是否自动恢复。对全部 OpenAI 账号生效。', + enabled: '失效推理密文恢复', + enabledHint: '开启后,遇到明确的验签错误且尚未输出语义内容时,只剥离被拒 reasoning 的密文字段,在同一账号最多额外重试一次,并记忆该旧密文 24 小时;不删除新推理。自动透传账号同样适用。可能增加调用费用,不代表恢复原推理链。关闭后,这类请求直接返回错误。', + scope: '仅适用于实际 HTTP/SSE 的原生 Responses、Compact 与 Chat→Responses 路径。Messages、WebSocket(包括 HTTP 入口转 WS)、原生 Chat-only 上游不启用。24 小时是逻辑期限,Redis 持久化文件与备份可能保留历史字节。', + save: '保存', + saved: '推理恢复设置已保存', + loadFailed: '加载失败', + saveFailed: '保存失败' + } +} diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index db827fd308b2..fcb8842db0c1 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -551,16 +551,6 @@ export default { reference: '参考目录', sources: '来源' }, - deprecatedSettings: { - title: '已弃用设置(只读,不生效)', - hint: '这些开关已停用:保存设置时保留库里的原值,运行时不再读取。这里只显示当前存储的值。', - on: '开启', - off: '关闭', - labels: { - openai_apikey_alpha_search_responses_bridge_enabled: 'OpenAI API Key 账号:Alpha Search 走 Responses 桥接', - openai_apikey_prompt_cache_key_normalization_enabled: 'OpenAI API Key 账号:prompt_cache_key 归一化' - } - }, gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', diff --git a/frontend/src/i18n/locales/zh/admin/systemPrompts.ts b/frontend/src/i18n/locales/zh/admin/systemPrompts.ts index 7a089940274e..916d90ee9e55 100644 --- a/frontend/src/i18n/locales/zh/admin/systemPrompts.ts +++ b/frontend/src/i18n/locales/zh/admin/systemPrompts.ts @@ -1,13 +1,13 @@ export default { systemPrompts: { title: '系统提示词', - description: '维护一个小提示词库,选一个站点默认。所有平台上继承默认的账号都会注入它;单个账号可以关闭或改用库里的另一条。', + description: '维护一个小提示词库,选一个站点默认。继承默认的账号都会注入它;单个账号可以关闭或改用库里的另一条。', accountPrompts: '系统提示词', enabled: '启用系统提示词', enabledHint: '关闭后所有账号都不注入。', defaultPrompt: '站点默认提示词', noDefault: '不使用默认', - defaultHint: '继承模式的账号(所有平台)使用这一条。', + defaultHint: '继承模式的账号(除 TypeSafe 外的所有平台)使用这一条。', usage: '继承默认 {inherit} 个账号 · 关闭 {off} 个 · 自定义 {custom} 个', library: '提示词库', add: '新增提示词', @@ -29,7 +29,7 @@ export default { empty: '还没有提示词。', invalid: '每条提示词都需要名称和正文,正文不超过 64 KiB。', loadFailed: '加载失败', - notes: 'Codex Responses Lite 与 compact 请求不注入;不改写 prompt_cache_key;返回给客户端的 instructions 保持客户端原值。Claude OAuth 伪装块在“设置 → 网关”中配置。', + notes: 'TypeSafe 账号不注入,也不计入上面的账号数:System One 请求没有系统指令字段。Codex Responses Lite 与 compact 请求不注入;不改写 prompt_cache_key;返回给客户端的 instructions 保持客户端原值。Claude OAuth 伪装块在“设置 → 网关”中配置。', binding: { description: '继承:使用站点默认;关闭:不注入;自定义:使用库中指定的一条。', mode: '方式', @@ -40,6 +40,8 @@ export default { apply: '应用', applied: '已更新 {count} 个账号', bulkHint: '将应用到选中的 {count} 个账号。', + bulkSkipHint: '将应用到选中的 {count} 个账号;其中的 TypeSafe 账号会被跳过。', + skipHint: '选中的账号如果是 TypeSafe 账号,会被跳过。', globalOff: '全局开关已关闭,当前不会注入。', effectiveDefault: '当前生效:站点默认“{name}”', effectiveNone: '站点未设置默认提示词,当前不会注入。', diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index dd4980eec40b..49836cf6b474 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -224,6 +224,7 @@ export default { promptAudit: '提示词审计', auditLogs: '操作日志', systemPrompts: '系统提示词', + reasoningRecovery: '推理恢复', }, // Auth diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1a36474ca597..1efb0dc84f17 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -580,6 +580,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/admin/CodexRuntimeView.vue'), meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex runtime settings', titleKey: 'nav.codexRuntime' } }, + { + path: '/admin/reasoning-recovery', + name: 'AdminReasoningRecovery', + component: () => import('@/views/admin/ReasoningRecoveryView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: 'Reasoning Recovery', titleKey: 'nav.reasoningRecovery' } + }, { path: '/admin/announcements', name: 'AdminAnnouncements', diff --git a/frontend/src/styles/console/pages/settings-ops-public.css b/frontend/src/styles/console/pages/settings-ops-public.css index 982d505a5f98..a18a8cb6d2c4 100644 --- a/frontend/src/styles/console/pages/settings-ops-public.css +++ b/frontend/src/styles/console/pages/settings-ops-public.css @@ -2384,7 +2384,7 @@ html.flat-theme [data-ui="key-usage"] .skeleton { /* end public */ /* settings: 系统设置 (every tab, its sub-panels and dialogs), 系统提示词 (the library and the per-account binding - * views), Codex 运行设置 and 插件管理. Owner: wave 2 P3 (the settings page agent). + * views), Codex 运行设置, 推理恢复 and 插件管理. Owner: wave 2 P3 (the settings page agent). * * Source: the reference console's settings language (artifacts/tmp/ui-el/ref/console/REF-CONSOLE.md §5, §8.3-§8.5, * §8.11, §8.16 and its public recipes: Modal, ConnectModal, DemoBanner, PromoField, TopUpModal, Toggle, AddChoice) @@ -2398,6 +2398,8 @@ html.flat-theme [data-ui="key-usage"] .skeleton { * Template hooks (one attribute each, upstream markup otherwise unchanged): * views/admin/SettingsView.vue:3 data-ui="settings" (page root) * views/admin/SystemPromptsView.vue:3 data-ui="system-prompts" (page root, fork view) + * views/admin/ReasoningRecoveryView.vue:3 data-ui="reasoning-recovery" (page root, fork view; it shares + * the 系统提示词 rules for the head, the card and the switch line) * components/admin/codex/CodexRuntimeSettings.vue:2 data-ui="codex-runtime" (page root, fork component) * views/admin/PluginsView.vue:3 data-ui="plugins" (page root) * views/admin/BackupView.vue:291 data-ui="r2-guide" (the R2 guide dialog's outer layer) @@ -2406,16 +2408,16 @@ html.flat-theme [data-ui="key-usage"] .skeleton { * views/admin/SettingsView.vue (84 switch rows) data-ui="setting-row" (3.1) * views/admin/SettingsView.vue (2 matrix rows) data-ui="quota-row" (6.x the platform quota matrices) * views/admin/SettingsView.vue:1863 data-ui="card-title-icon" (2.1 the glyph before a card title) - * views/admin/SettingsView.vue:6413, 8050, 8103 data-ui="settings-dlg-foot" (10 the inline dialogs' button rows) + * views/admin/SettingsView.vue:6386, 8035, 8088 data-ui="settings-dlg-foot" (10 the inline dialogs' button rows) * views/admin/BackupView.vue:347 data-ui="r2-table-box" (the R2 guide's settings table frame) - * views/admin/SettingsView.vue:7851, BackupView.vue:203 data-ui="settings-list-box" (6.3 the two lists that can be empty) + * views/admin/SettingsView.vue:7836, BackupView.vue:203 data-ui="settings-list-box" (6.3 the two lists that can be empty) * Existing hooks used: .settings-tabs-* (SettingsView), #provider-form (PaymentProviderDialog), data-testid * "official-model-catalog*", "image-tools-settings", "observability-settings", "backup-schedule", "retired-plugins", * data-test="account-system-prompt-binding" (the per-account binding in the accounts dialogs). * Selectors start with html.flat-theme and one of those roots; the theme switched off renders upstream. */ /* ================================================================== settings · 0 metrics */ -html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="codex-runtime"], [data-ui~="plugins"]) { +html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="reasoning-recovery"], [data-ui~="codex-runtime"], [data-ui~="plugins"]) { /* the reference Modal's header (px 18, py 14) and body (px 18, py 16) insets */ --set-px: 1.125rem; --set-head-py: 0.875rem; @@ -2439,14 +2441,14 @@ html.flat-theme [data-ui~="settings"] > form > div:not([class]) > [class~="space margin-top: var(--ui-shell-gap); } -/* The centred narrow pages (settings max-w-6xl, the prompt library max-w-5xl) start at the content gutter like every - console page of the reference, under the header row's title; their maximum width stays (long forms keep a readable - measure; the space on the right of a wide screen is canvas). */ -html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"])[class~="mx-auto"] { +/* The centred narrow pages (settings max-w-6xl; the prompt library and 推理恢复 max-w-5xl) start at the content gutter + like every console page of the reference, under the header row's title; their maximum width stays (long forms keep + a readable measure; the space on the right of a wide screen is canvas). */ +html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="reasoning-recovery"])[class~="mx-auto"] { margin-inline: 0; } -html.flat-theme [data-ui~="system-prompts"][class~="px-1"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"])[class~="px-1"] { padding-inline: 0; } @@ -2597,9 +2599,8 @@ html.flat-theme [data-ui~="settings"] .card :where(label[class~="font-medium"][c } /* 3.4 Field labels. The recipe and the block utility labels are the mono label already (controls.css 3.5, style.css - .input-label); the inline ones that sit right before their field (label[for] + input.mt-2, the grok / TTFT / - retired-switch fields, the small grey labels of the search provider and the backup list) join them, so a form has - one label language. */ + .input-label); the inline ones that sit right before their field (label[for] + input.mt-2, the grok / TTFT fields, + the small grey labels of the search provider and the backup list) join them, so a form has one label language. */ html.flat-theme [data-ui~="settings"] label[class~="text-sm"][class~="font-medium"]:not([class~="block"]):has(+ :is(input, select, textarea, div)), html.flat-theme [data-ui~="settings"] label[class~="text-xs"]:is([class~="text-gray-500"], [class~="text-gray-600"]):has(+ :is(input, select, textarea, div)) { display: block; @@ -3034,11 +3035,12 @@ html.flat-theme [data-ui~="settings"] .card div[class~="overflow-x-auto"] > tabl /* ================================================================== 7 · page heads of the fork pages and plugins */ /* 7.1 The page's own heading repeats the header row's title word for word (系统提示词 in Chinese — its route title is - not translated —, Codex 运行设置, 插件管理), and the plugins intro repeats the header's description word for word. - From 1024px, where the header row shows both, they are kept for assistive technology only; below that the header - hides its title and description and the page's own stay. */ + not translated —, 推理恢复, Codex 运行设置, 插件管理), and the plugins intro repeats the header's description word for + word. From 1024px, where the header row shows both, they are kept for assistive technology only; below that the + header hides its title and description and the page's own stay. */ @media (min-width: 1024px) { html.flat-theme:lang(zh) [data-ui~="system-prompts"] > header h1, + html.flat-theme [data-ui~="reasoning-recovery"] > header h1, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child, html.flat-theme [data-ui~="plugins"] > section:first-child h2, html.flat-theme [data-ui~="plugins"] > section:first-child > div:first-child > p { @@ -3053,7 +3055,7 @@ html.flat-theme [data-ui~="settings"] .card div[class~="overflow-x-auto"] > tabl /* 7.2 The intro under the title: the reference's page intro (13px on a relaxed line, muted, 780px at most); a wrapped intro keeps more than one character on its last line. */ -html.flat-theme [data-ui~="system-prompts"] > header p, +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > header p, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child + p, html.flat-theme [data-ui~="plugins"] > section:first-child > div:first-child > p { max-width: 48.75rem; @@ -3085,20 +3087,23 @@ html.flat-theme :is([data-ui~="system-prompts"] > header h1, [data-ui~="codex-ru /* ================================================================== 8 · 系统提示词 (SystemPromptsView, fork) */ +/* 推理恢复 (ReasoningRecoveryView, fork) is the same page with one card: a head, then a switch line over its notes. + 8.1 and the switch title and note sizes of 8.2 cover both roots. */ + /* 8.1 Rhythm: the head, the switch section and the library sit one block gap apart (the reference's 12). The head is an intro line with its actions, no rule under it; each section is a section card (surface, structural line, radius 8, 16 x 18). */ -html.flat-theme [data-ui~="system-prompts"] > :not([hidden]) ~ :not([hidden]) { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > :not([hidden]) ~ :not([hidden]) { margin-top: var(--ui-shell-gap); } -html.flat-theme [data-ui~="system-prompts"] > header { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > header { align-items: flex-end; border-bottom: 0; padding-bottom: 0; } -html.flat-theme [data-ui~="system-prompts"] > section { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section { border: 1px solid rgb(var(--ui-line)); border-radius: var(--ui-radius-lg); background-color: rgb(var(--ui-surface)); @@ -3107,7 +3112,7 @@ html.flat-theme [data-ui~="system-prompts"] > section { /* 8.2 The switch line (switch, title, hint on one row) and the default prompt: title 13 / 600 ink, hint 12 muted; the field label is the mono label; the usage line in ink-soft with its figures tabular; the notes 12 muted. */ -html.flat-theme [data-ui~="system-prompts"] > section:first-of-type > label:first-child > span[class~="font-medium"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section:first-of-type > label:first-child > span[class~="font-medium"] { font-weight: 600; color: rgb(var(--ui-ink)); } @@ -3145,7 +3150,7 @@ html.flat-theme [data-ui~="system-prompts"] [data-test="system-prompts-usage"] { color: rgb(var(--ui-ink-soft)); } -html.flat-theme [data-ui~="system-prompts"] > section > p[class~="text-xs"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section > p[class~="text-xs"] { font-size: 0.75rem; line-height: 1.125rem; } diff --git a/frontend/src/styles/console/pages/user.css b/frontend/src/styles/console/pages/user.css index c6aa7cd44f64..35b648012291 100644 --- a/frontend/src/styles/console/pages/user.css +++ b/frontend/src/styles/console/pages/user.css @@ -1703,9 +1703,10 @@ html.flat-theme [data-ui="user-orders"] > .table-wrapper .table-body > tr > td[c @media (max-width: 1023.98px) { /* 8.0 Below 1024px the 47px bar prints the page title (shell.css), so a page's own heading that repeats it word for word is kept for assistive technology only, as the pages already do from 1024px: 系统提示词 (its route title is - Chinese), Codex 运行设置, 插件管理, 风控中心, 提示词审计, 渠道监控 (the image studio: 7.2). The intros under them - stay. */ + Chinese), 推理恢复, Codex 运行设置, 插件管理, 风控中心, 提示词审计, 渠道监控 (the image studio: 7.2). The intros + under them stay. */ html.flat-theme:lang(zh) [data-ui~="system-prompts"] > header h1, + html.flat-theme [data-ui~="reasoning-recovery"] > header h1, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child, html.flat-theme [data-ui~="plugins"] > section:first-child h2, html.flat-theme [data-ui~="risk-page"] h1[class~="text-2xl"], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 65d230d9cc02..dc72cfbb556d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -628,9 +628,9 @@ export interface Group { export interface AdminGroup extends Group { force_openai_fast: boolean free_openai_fast: boolean - // Deprecated read-only compatibility fields: model_pricing is always empty; the profit_* values are the - // stored legacy values (retired: writes are rejected and nothing reads them at runtime). model_pricing: import('@/api/admin/channels').ChannelModelPricing[] + // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 + // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 profit_control_enabled: boolean profit_min_margin: number profit_safety_buffer: number @@ -827,6 +827,10 @@ export interface CreateGroupRequest { peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null @@ -889,6 +893,10 @@ export interface UpdateGroupRequest { peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null diff --git a/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts b/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts deleted file mode 100644 index ab84c8934841..000000000000 --- a/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - OPENAI_CHAT_REASONING_REPLAY_KEY, - OPENAI_REASONING_SIGNATURE_RECOVERY_KEY, - applyOpenAIReasoningPolicyEdits, - defaultOpenAIReasoningPolicy, - emptyOpenAIReasoningPolicySelection, - isOpenAIReasoningPolicyApplicable, - readOpenAIReasoningPolicy -} from '../openaiReasoningPolicy' - -describe('OpenAI reasoning policy', () => { - it('matches OpenAI accounts of the accepted account types', () => { - for (const type of ['apikey', 'oauth', 'setup-token']) { - expect(isOpenAIReasoningPolicyApplicable({ platform: 'openai', type })).toBe(true) - } - for (const platform of ['anthropic', 'grok', 'gemini']) { - expect(isOpenAIReasoningPolicyApplicable({ platform, type: 'apikey' })).toBe(false) - } - expect(isOpenAIReasoningPolicyApplicable({ platform: 'openai', type: 'service_account' })).toBe(false) - expect(isOpenAIReasoningPolicyApplicable(null)).toBe(false) - }) - - it('defaults absent keys on but treats malformed historical values as off', () => { - expect(readOpenAIReasoningPolicy(null)).toEqual(defaultOpenAIReasoningPolicy()) - for (const value of [false, null, undefined, 'true', 1, {}, []]) { - expect(readOpenAIReasoningPolicy({ [OPENAI_CHAT_REASONING_REPLAY_KEY]: value })) - .toEqual({ chatReplay: false, signatureRecovery: true }) - } - expect(readOpenAIReasoningPolicy({ [OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]: true })) - .toEqual(defaultOpenAIReasoningPolicy()) - }) - - it('sends only selected edits and never replays stale policy snapshots', () => { - const base = { - unrelated_setting: 'preserved', - [OPENAI_CHAT_REASONING_REPLAY_KEY]: 'bad-old-value', - [OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]: false - } - expect(applyOpenAIReasoningPolicyEdits(base, defaultOpenAIReasoningPolicy(), emptyOpenAIReasoningPolicySelection())) - .toEqual({ unrelated_setting: 'preserved' }) - expect(applyOpenAIReasoningPolicyEdits(base, { chatReplay: false, signatureRecovery: true }, { chatReplay: true, signatureRecovery: false })) - .toEqual({ unrelated_setting: 'preserved', [OPENAI_CHAT_REASONING_REPLAY_KEY]: false }) - expect(base[OPENAI_CHAT_REASONING_REPLAY_KEY]).toBe('bad-old-value') - expect(base[OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]).toBe(false) - }) -}) diff --git a/frontend/src/utils/openaiReasoningPolicy.ts b/frontend/src/utils/openaiReasoningPolicy.ts deleted file mode 100644 index 1c64af2f34bf..000000000000 --- a/frontend/src/utils/openaiReasoningPolicy.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const OPENAI_CHAT_REASONING_REPLAY_KEY = 'openai_chat_reasoning_replay_enabled' -export const OPENAI_REASONING_SIGNATURE_RECOVERY_KEY = 'openai_reasoning_signature_recovery_enabled' - -export interface OpenAIReasoningPolicy { - chatReplay: boolean - signatureRecovery: boolean -} - -export type OpenAIReasoningPolicyField = keyof OpenAIReasoningPolicy - -interface ReasoningPolicyAccount { - platform?: unknown - type?: unknown -} - -const policyKeys = { - chatReplay: OPENAI_CHAT_REASONING_REPLAY_KEY, - signatureRecovery: OPENAI_REASONING_SIGNATURE_RECOVERY_KEY -} as const - -export function isOpenAIReasoningPolicyApplicable(account: ReasoningPolicyAccount | null | undefined): boolean { - if (!account || !['apikey', 'oauth', 'setup-token'].includes(String(account.type))) return false - return typeof account.platform === 'string' && account.platform.trim().toLowerCase() === 'openai' -} - -export function defaultOpenAIReasoningPolicy(): OpenAIReasoningPolicy { - return { chatReplay: true, signatureRecovery: true } -} - -export function emptyOpenAIReasoningPolicySelection(): OpenAIReasoningPolicy { - return { chatReplay: false, signatureRecovery: false } -} - -export function readOpenAIReasoningPolicy(extra: Record | null | undefined): OpenAIReasoningPolicy { - const read = (key: string): boolean => { - if (!extra || !Object.prototype.hasOwnProperty.call(extra, key)) return true - return extra[key] === true - } - return { - chatReplay: read(OPENAI_CHAT_REASONING_REPLAY_KEY), - signatureRecovery: read(OPENAI_REASONING_SIGNATURE_RECOVERY_KEY) - } -} - -// The admin update API preserves omitted policy keys. Send only deliberate edits, -// never replay a modal's stale snapshot (including malformed historical values). -export function applyOpenAIReasoningPolicyEdits( - extra: Record | null | undefined, - values: OpenAIReasoningPolicy, - selected: OpenAIReasoningPolicy -): Record { - const result = { ...extra } - for (const field of Object.keys(policyKeys) as OpenAIReasoningPolicyField[]) { - const key = policyKeys[field] - delete result[key] - if (selected[field]) result[key] = values[field] - } - return result -} diff --git a/frontend/src/utils/systemPromptBinding.ts b/frontend/src/utils/systemPromptBinding.ts index 0756aca577b1..f43859ccb090 100644 --- a/frontend/src/utils/systemPromptBinding.ts +++ b/frontend/src/utils/systemPromptBinding.ts @@ -3,6 +3,21 @@ import type { SystemPromptBinding } from '@/api/admin/systemPrompts' // Largest account selection one binding request accepts. export const systemPromptBindingLimit = 1000 +// The platforms whose requests pass an insertion point: the backend's SystemPromptPlatforms +// (backend/internal/service/system_prompt.go), the platforms forwarded as Messages, Chat Completions, Responses or +// Gemini. TypeSafe is not one of them: System One has no system or instructions field. +const systemPromptPlatforms: ReadonlySet = new Set([ + 'anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go' +]) + +/** + * Whether an account of this platform can receive a system prompt. A binding is offered only where it can: the + * backend stores none for the other accounts and does not count them. + */ +export function takesSystemPrompt(platform: string): boolean { + return systemPromptPlatforms.has(platform) +} + /** Reads an account's extra.system_prompt value; anything else is inherit. */ export function readSystemPromptBinding(value: unknown): SystemPromptBinding { const binding = value && typeof value === 'object' ? value as { mode?: unknown; prompt_id?: unknown } : {} diff --git a/frontend/src/utils/upstreamModelSync.ts b/frontend/src/utils/upstreamModelSync.ts new file mode 100644 index 000000000000..521c5abba5c1 --- /dev/null +++ b/frontend/src/utils/upstreamModelSync.ts @@ -0,0 +1,37 @@ +// The account types whose upstream model list the backend can read, per platform: fetchUpstreamModelList and the +// request builders of backend/internal/service/upstream_models.go refuse every other pair before any upstream is +// called. A platform without an entry has no model list at all (TypeSafe: System One). +const upstreamModelSyncAccountTypes = new Map([ + ['anthropic', ['oauth', 'setup-token', 'apikey']], + ['openai', ['oauth', 'apikey']], + ['gemini', ['oauth', 'apikey']], + ['antigravity', ['oauth', 'upstream', 'apikey']], + ['grok', ['oauth', 'apikey']], + ['kimi', ['apikey']], + ['zhipu', ['apikey']], + ['deepseek', ['apikey']], + ['minimax', ['apikey']], + ['opencode_go', ['apikey']] +]) + +// A credential as Account.GetCredential reads it: text, or a number as text. +function credential(credentials: Record | undefined, key: string): string { + const value = credentials?.[key] + return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '' +} + +/** + * Whether 同步上游支持的模型 can work for an account of this platform, type and credentials. Every place that + * offers or starts a sync asks here, so none is sent that the backend refuses before it reaches the upstream. + * Two pairs depend on the credentials as well, as in the backend: an Antigravity API-key account lists models only + * through a compatible gateway, whose base URL ends in /antigravity (buildAntigravityAPIKeyModelsRequest), and a + * Gemini OAuth account with a project is Code Assist, which has no model list (buildGeminiUpstreamModelsRequest). + */ +export function supportsUpstreamModelSync(platform: string, type: string, credentials?: Record): boolean { + if (!upstreamModelSyncAccountTypes.get(platform)?.includes(type)) return false + if (platform === 'antigravity' && type === 'apikey') { + return /\/antigravity$/i.test(credential(credentials, 'base_url').replace(/\/+$/, '')) + } + if (platform === 'gemini' && type === 'oauth') return credential(credentials, 'project_id') === '' + return true +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 6f810a807480..0b8e4ae25daf 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -246,6 +246,7 @@ accounts.value.filter(a => isSelected(a.id))) const selPlatforms = computed(() => { - const platforms = new Set( - accounts.value - .filter(a => isSelected(a.id)) - .map(a => a.platform) - ) + const platforms = new Set(selAccounts.value.map(a => a.platform)) return [...platforms] }) const selTypes = computed(() => { - const types = new Set( - accounts.value - .filter(a => isSelected(a.id)) - .map(a => a.type) - ) + const types = new Set(selAccounts.value.map(a => a.type)) return [...types] }) const showCreate = ref(false) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 0e328dbef06e..1b2ef8756689 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1227,6 +1227,56 @@
+ +
+ +

+ {{ + createForm.profit_control_enabled + ? t("admin.groups.profitControl.enabledHint") + : t("admin.groups.profitControl.disabledHint") + }} +

+
+
+ + +
+
+ + +
+
+
+
@@ -2222,51 +2272,6 @@ data-tour="group-form-multiplier" />
- -
-
- {{ t("admin.groups.retiredProfitControl.title") }} -
-

- {{ t("admin.groups.retiredProfitControl.hint") }} -

-
-
-
- {{ t("admin.groups.retiredProfitControl.enabled") }} - profit_control_enabled -
-
- {{ - editingGroup.profit_control_enabled - ? t("admin.groups.retiredProfitControl.on") - : t("admin.groups.retiredProfitControl.off") - }} -
-
-
-
- {{ t("admin.groups.retiredProfitControl.minMargin") }} - profit_min_margin -
-
- {{ editingGroup.profit_min_margin }} -
-
-
-
- {{ t("admin.groups.retiredProfitControl.safetyBuffer") }} - profit_safety_buffer -
-
- {{ editingGroup.profit_safety_buffer }} -
-
-
-
+ +
+ +

+ {{ + editForm.profit_control_enabled + ? t("admin.groups.profitControl.enabledHint") + : t("admin.groups.profitControl.disabledHint") + }} +

+
+
+ + +
+
+ + +
+
+
+
@@ -4300,6 +4355,13 @@ import { } from "./groupModelAllowlist"; import { createModelAllowlistCandidatesTracker } from "./modelAllowlistCandidates"; import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes"; +import { + isProfitControlPlatform, + profitPercentToDecimal, + profitDecimalToPercent, + validateProfitControlFormState, + type ProfitControlFormState, +} from "./groupsProfitControl"; import { normalizeReasoningEffortForPlatform, normalizeReasoningEffortOverLimit, @@ -4928,6 +4990,10 @@ const createForm = reactive({ peak_start: "", peak_end: "", peak_rate_multiplier: 1.0, + // 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数 + profit_control_enabled: false, + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -5289,6 +5355,10 @@ const editForm = reactive({ peak_start: "", peak_end: "", peak_rate_multiplier: 1.0, + // 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数 + profit_control_enabled: false, + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -5743,6 +5813,9 @@ const closeCreateModal = () => { createForm.peak_start = ""; createForm.peak_end = ""; createForm.peak_rate_multiplier = 1.0; + createForm.profit_control_enabled = false; + createForm.profit_min_margin_percent = 0; + createForm.profit_safety_buffer_percent = 0; createForm.claude_code_only = false; createForm.fallback_group_id = null; createForm.fallback_group_id_on_invalid_request = null; @@ -5791,6 +5864,19 @@ const normalizeRateMultiplier = ( return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1; }; +// 利润控制表单辅助(换算与校验逻辑见 groupsProfitControl.ts,便于单测)。 +const percentToDecimal = profitPercentToDecimal; +const decimalToPercent = profitDecimalToPercent; + +const validateProfitControlForm = (form: ProfitControlFormState): boolean => { + const errorKey = validateProfitControlFormState(form); + if (errorKey) { + appStore.showError(t(`admin.groups.profitControl.${errorKey}`)); + return false; + } + return true; +}; + const validateGroupReasoningMultipliers = (pricing: PricingFormEntry[]): boolean => { for (const entry of pricing) { const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t); @@ -5814,6 +5900,9 @@ const handleCreateGroup = async () => { ) { return; } + if (!validateProfitControlForm(createForm)) { + return; + } const modelAllowlist = buildModelAllowlistConfig(createModelAllowlistState); if (!validateGroupReasoningMultipliers(createForm.model_pricing)) return; // 校验本次会提交的配置;简单模式不提交隐藏的高级配置。 @@ -5884,7 +5973,17 @@ const handleCreateGroup = async () => { reasoning_effort_mappings: reasoningEffortMappingsToAPI( createForm.reasoning_effort_mappings, ), + // 利润控制:界面百分比转小数提交;仅五个 token 平台可启用 + profit_control_enabled: + isProfitControlPlatform(createForm.platform) && + createForm.profit_control_enabled, + profit_min_margin: percentToDecimal(createForm.profit_min_margin_percent), + profit_safety_buffer: percentToDecimal( + createForm.profit_safety_buffer_percent, + ), }; + delete (requestData as Record).profit_min_margin_percent; + delete (requestData as Record).profit_safety_buffer_percent; // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 const emptyToNull = (v: any) => (v === "" ? null : v); requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd); @@ -6003,6 +6102,13 @@ const handleEdit = async (group: AdminGroup) => { editForm.peak_start = group.peak_start ?? ""; editForm.peak_end = group.peak_end ?? ""; editForm.peak_rate_multiplier = group.peak_rate_multiplier ?? 1.0; + editForm.profit_control_enabled = group.profit_control_enabled ?? false; + editForm.profit_min_margin_percent = decimalToPercent( + group.profit_min_margin ?? 0, + ); + editForm.profit_safety_buffer_percent = decimalToPercent( + group.profit_safety_buffer ?? 0, + ); editForm.claude_code_only = group.claude_code_only || false; editForm.fallback_group_id = group.fallback_group_id; editForm.fallback_group_id_on_invalid_request = @@ -6089,6 +6195,9 @@ const closeEditModal = () => { editForm.peak_start = ""; editForm.peak_end = ""; editForm.peak_rate_multiplier = 1.0; + editForm.profit_control_enabled = false; + editForm.profit_min_margin_percent = 0; + editForm.profit_safety_buffer_percent = 0; editForm.video_rate_independent = false; editForm.video_rate_multiplier = 1; editForm.video_price_480p = null; @@ -6125,6 +6234,9 @@ const handleUpdateGroup = async () => { ) { return; } + if (!validateProfitControlForm(editForm)) { + return; + } const modelAllowlist = buildModelAllowlistConfig(editModelAllowlistState); if (!validateGroupReasoningMultipliers(editForm.model_pricing)) return; // 校验本次会提交的配置;简单模式不提交隐藏的高级配置。 @@ -6212,7 +6324,17 @@ const handleUpdateGroup = async () => { reasoning_effort_mappings: reasoningEffortMappingsToAPI( editForm.reasoning_effort_mappings, ), + // 利润控制:界面百分比转小数提交;仅五个 token 平台可启用 + profit_control_enabled: + isProfitControlPlatform(editForm.platform) && + editForm.profit_control_enabled, + profit_min_margin: percentToDecimal(editForm.profit_min_margin_percent), + profit_safety_buffer: percentToDecimal( + editForm.profit_safety_buffer_percent, + ), }; + delete (payload as Record).profit_min_margin_percent; + delete (payload as Record).profit_safety_buffer_percent; // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 const emptyToNull = (v: any) => (v === "" ? null : v); payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd); @@ -6580,6 +6702,11 @@ watch( if (!supportsLivePlatform(newVal)) { createForm.allow_live = false; } + if (!isProfitControlPlatform(newVal)) { + createForm.profit_control_enabled = false; + createForm.profit_min_margin_percent = 0; + createForm.profit_safety_buffer_percent = 0; + } createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform( newVal, createForm.max_reasoning_effort, @@ -6632,6 +6759,11 @@ watch( if (!supportsLivePlatform(newVal)) { editForm.allow_live = false; } + if (!isProfitControlPlatform(newVal)) { + editForm.profit_control_enabled = false; + editForm.profit_min_margin_percent = 0; + editForm.profit_safety_buffer_percent = 0; + } editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform( newVal, editForm.max_reasoning_effort, diff --git a/frontend/src/views/admin/ReasoningRecoveryView.vue b/frontend/src/views/admin/ReasoningRecoveryView.vue new file mode 100644 index 000000000000..ace8ba5fd39c --- /dev/null +++ b/frontend/src/views/admin/ReasoningRecoveryView.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 151159d1d48a..dde9abe37d33 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5414,33 +5414,6 @@

- -
-
- {{ t("admin.settings.deprecatedSettings.title") }} -
-

- {{ t("admin.settings.deprecatedSettings.hint") }} -

-
-
-
- {{ row.label }} - {{ row.key }} -
-
{{ row.value }}
-
-
-
-
@@ -9388,24 +9361,6 @@ const { copyToClipboard } = useClipboard(); const loading = ref(true); const loadFailed = ref(false); -// GET /admin/settings echoes the retired switches read-only; they are shown, never saved. -const deprecatedSettings = ref>({}); -const deprecatedSettingRows = computed(() => - Object.entries(deprecatedSettings.value).map(([key, value]) => { - const labelKey = `admin.settings.deprecatedSettings.labels.${key}`; - const label = t(labelKey); - return { - key, - label: label === labelKey ? key : label, - value: - value === true - ? t("admin.settings.deprecatedSettings.on") - : value === false - ? t("admin.settings.deprecatedSettings.off") - : JSON.stringify(value), - }; - }), -); const saving = ref(false); const testingSmtp = ref(false); const sendingTestEmail = ref(false); @@ -11416,7 +11371,6 @@ async function loadSettings() { loadFailed.value = false; try { const settings = await adminAPI.settings.getSettings(); - deprecatedSettings.value = settings.deprecated_settings ?? {}; settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || "round-robin"; // Only assign non-null values from backend (null means unconfigured, keep defaults) diff --git a/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts index 4ab5f52a6adc..06a34b0842f5 100644 --- a/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts +++ b/frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts @@ -231,4 +231,22 @@ describe('AccountsView selection scope through real bulk controls', () => { expect(toolbar.props('allResultsSelected')).toBe(true) expect(showError).not.toHaveBeenCalled() }) + + it('offers the system prompt binding from what the list knows about the selected rows', async () => { + const typeSafePage = { ...page([7], 2, 2), items: makeAccounts([7]).map(account => ({ ...account, platform: 'typesafe', type: 'apikey' })) } + listAccounts.mockImplementation(async (currentPage: number) => currentPage === 1 ? typeSafePage : page([99], 2, 2)) + const view = mountView() + await flushPromises() + const offered = () => view.getComponent(AccountBulkActionsBar).find('[data-test="account-prompt-binding-bulk"]').exists() + + // One selected TypeSafe row: no entry, as in its row menu. + await view.get('[data-account="7"] input').setValue(true) + expect(offered()).toBe(false) + // The next page does not hold that row: the selection is no longer known, and the request skips the account. + await view.get('[data-test="page-two"]').trigger('click') + await flushPromises() + expect(offered()).toBe(true) + await view.get('[data-account="99"] input').setValue(true) + expect(offered()).toBe(true) + }) }) diff --git a/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts b/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts new file mode 100644 index 000000000000..70b81f8fd320 --- /dev/null +++ b/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import ReasoningRecoveryView from '../ReasoningRecoveryView.vue' + +const { get, put, showSuccess, useStepUp } = vi.hoisted(() => ({ + get: vi.fn(), + put: vi.fn(), + showSuccess: vi.fn(), + useStepUp: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ apiClient: { get, put } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess }) })) +vi.mock('@/composables/useStepUp', () => ({ useStepUp })) +vi.mock('vue-i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})) + +const path = '/admin/reasoning-recovery' + +async function mountView() { + const wrapper = mount(ReasoningRecoveryView, { + global: { stubs: { AppLayout: { template: '
' } } }, + }) + await flushPromises() + return wrapper +} + +type View = Awaited> +const toggle = (wrapper: View) => wrapper.get('[data-test="reasoning-recovery-enabled"]') +const saveButton = (wrapper: View) => wrapper.get('[data-test="reasoning-recovery-save"]') +// Every control of the page, in document order. +const controls = (wrapper: View) => wrapper.findAll('button').map(button => button.attributes('data-test')) + +describe('ReasoningRecoveryView', () => { + beforeEach(() => { + get.mockReset().mockResolvedValue({ data: { enabled: true } }) + put.mockReset().mockImplementation(async (_url: string, value: unknown) => ({ data: value })) + showSuccess.mockReset() + useStepUp.mockReset() + }) + + it('shows only Save until the stored value is loaded, then the switch, and enables Save only while the draft differs', async () => { + let answer!: (value: { data: { enabled: boolean } }) => void + get.mockReturnValueOnce(new Promise((resolve) => { answer = resolve })) + const wrapper = await mountView() + + expect(get).toHaveBeenCalledWith(path) + expect(wrapper.text()).toContain('common.loading') + expect(controls(wrapper)).toEqual(['reasoning-recovery-save']) + expect(saveButton(wrapper).element.disabled).toBe(true) + + answer({ data: { enabled: true } }) + await flushPromises() + expect(wrapper.text()).not.toContain('common.loading') + expect(controls(wrapper)).toEqual(['reasoning-recovery-save', 'reasoning-recovery-enabled']) + expect(toggle(wrapper).attributes('aria-checked')).toBe('true') + expect(saveButton(wrapper).element.disabled).toBe(true) + + await toggle(wrapper).trigger('click') + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(false) + + await toggle(wrapper).trigger('click') + expect(saveButton(wrapper).element.disabled).toBe(true) + expect(put).not.toHaveBeenCalled() + }) + + it('saves the draft with one PUT and no step-up', async () => { + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(put).toHaveBeenCalledTimes(1) + expect(put).toHaveBeenCalledWith(path, { enabled: false }) + expect(useStepUp).not.toHaveBeenCalled() + expect(wrapper.findComponent({ name: 'TotpStepUpDialog' }).exists()).toBe(false) + expect(showSuccess).toHaveBeenCalledWith('admin.reasoningRecovery.saved') + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(true) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('replaces the draft with the value the server stored', async () => { + put.mockResolvedValue({ data: { enabled: true } }) + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(put).toHaveBeenCalledWith(path, { enabled: false }) + expect(toggle(wrapper).attributes('aria-checked')).toBe('true') + expect(saveButton(wrapper).element.disabled).toBe(true) + }) + + it('reports a failed or unusable load without a switch, and reads again only when the page is opened again', async () => { + get.mockRejectedValueOnce({ message: 'load refused' }) + const refused = await mountView() + + expect(refused.get('[role="alert"]').text()).toBe('load refused') + expect(controls(refused)).toEqual(['reasoning-recovery-save']) + expect(saveButton(refused).element.disabled).toBe(true) + expect(get).toHaveBeenCalledTimes(1) + refused.unmount() + + get.mockResolvedValueOnce({ data: {} }) + const unusable = await mountView() + expect(unusable.get('[role="alert"]').text()).toBe('admin.reasoningRecovery.loadFailed') + expect(controls(unusable)).toEqual(['reasoning-recovery-save']) + unusable.unmount() + + get.mockResolvedValueOnce({ data: { enabled: false } }) + const reopened = await mountView() + expect(get).toHaveBeenCalledTimes(3) + expect(reopened.find('[role="alert"]').exists()).toBe(false) + expect(toggle(reopened).attributes('aria-checked')).toBe('false') + }) + + it('reports a failed save and keeps the draft', async () => { + put.mockRejectedValue({ message: 'save refused' }) + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe('save refused') + expect(showSuccess).not.toHaveBeenCalled() + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(false) + }) +}) diff --git a/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts b/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts new file mode 100644 index 000000000000..45a9262f5f20 --- /dev/null +++ b/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + profitDecimalToPercent, + profitPercentToDecimal, + validateProfitControlFormState, + type ProfitControlFormState, +} from "../groupsProfitControl"; + +const formState = ( + overrides: Partial = {}, +): ProfitControlFormState => ({ + platform: "openai", + profit_control_enabled: true, + profit_min_margin_percent: 30, + profit_safety_buffer_percent: 0, + ...overrides, +}); + +describe("profitPercentToDecimal", () => { + it("converts percent input to backend decimal", () => { + expect(profitPercentToDecimal(30)).toBe(0.3); + expect(profitPercentToDecimal(5)).toBe(0.05); + expect(profitPercentToDecimal(33.33)).toBe(0.3333); + expect(profitPercentToDecimal(99.99)).toBe(0.9999); + }); + + it("rounds to four decimal places matching decimal(10,4) storage", () => { + expect(profitPercentToDecimal(33.333)).toBe(0.3333); + expect(profitPercentToDecimal(0.005)).toBe(0.0001); + }); + + it("treats empty, invalid and non-positive input as zero", () => { + expect(profitPercentToDecimal("")).toBe(0); + expect(profitPercentToDecimal(null)).toBe(0); + expect(profitPercentToDecimal(undefined)).toBe(0); + expect(profitPercentToDecimal("abc")).toBe(0); + expect(profitPercentToDecimal(-5)).toBe(0); + expect(profitPercentToDecimal(0)).toBe(0); + }); +}); + +describe("profitDecimalToPercent", () => { + it("converts backend decimal to percent without float tail noise", () => { + expect(profitDecimalToPercent(0.3)).toBe(30); + expect(profitDecimalToPercent(0.05)).toBe(5); + expect(profitDecimalToPercent(0.3333)).toBe(33.33); + expect(profitDecimalToPercent(0.9999)).toBe(99.99); + }); + + it("treats missing and non-positive values as zero", () => { + expect(profitDecimalToPercent(null)).toBe(0); + expect(profitDecimalToPercent(undefined)).toBe(0); + expect(profitDecimalToPercent(0)).toBe(0); + expect(profitDecimalToPercent(-0.3)).toBe(0); + }); + + it("round-trips representative storage values", () => { + for (const decimal of [0.05, 0.1, 0.3, 0.3333, 0.5, 0.75, 0.9999]) { + expect(profitPercentToDecimal(profitDecimalToPercent(decimal))).toBe( + decimal, + ); + } + }); +}); + +describe("validateProfitControlFormState", () => { + it("passes valid enabled configurations", () => { + expect(validateProfitControlFormState(formState())).toBeNull(); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, + }), + ), + ).toBeNull(); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 60, + profit_safety_buffer_percent: 39.99, + }), + ), + ).toBeNull(); + }); + + it("validates all five supported platforms and skips unsupported platforms", () => { + expect( + validateProfitControlFormState( + formState({ profit_control_enabled: false, profit_min_margin_percent: 200 }), + ), + ).toBeNull(); + expect( + validateProfitControlFormState( + formState({ platform: "anthropic", profit_min_margin_percent: 200 }), + ), + ).toBe("marginRangeError"); + for (const platform of ["openai", "anthropic", "gemini", "grok", "antigravity"]) { + expect(validateProfitControlFormState(formState({ platform }))).toBeNull(); + } + expect( + validateProfitControlFormState( + formState({ platform: "composite", profit_min_margin_percent: 200 }), + ), + ).toBeNull(); + }); + + it("treats empty inputs as zero", () => { + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: "", + profit_safety_buffer_percent: null, + }), + ), + ).toBeNull(); + }); + + it("rejects out-of-range margin and buffer", () => { + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 100 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: -1 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_safety_buffer_percent: 100 }), + ), + ).toBe("bufferRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_safety_buffer_percent: -0.1 }), + ), + ).toBe("bufferRangeError"); + }); + + it("rejects margin plus buffer reaching 100 percent", () => { + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 60, + profit_safety_buffer_percent: 40, + }), + ), + ).toBe("sumTooHigh"); + }); + + // 上界按换算后的小数判定:99.999% 会被 profitPercentToDecimal 四舍五入 + // 进位成 1.0,后端 validProfitControlRatio 要求严格 < 1,前端必须先拦下。 + it("rejects percents that round up to a decimal the backend rejects", () => { + expect(profitPercentToDecimal(99.999)).toBe(1); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 99.999 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 99.999, + }), + ), + ).toBe("bufferRangeError"); + }); + + it("still accepts the largest percent that survives the rounding", () => { + expect(profitPercentToDecimal(99.99)).toBe(0.9999); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 99.99 }), + ), + ).toBeNull(); + }); +}); diff --git a/frontend/src/views/admin/groupsProfitControl.ts b/frontend/src/views/admin/groupsProfitControl.ts new file mode 100644 index 000000000000..71166e820886 --- /dev/null +++ b/frontend/src/views/admin/groupsProfitControl.ts @@ -0,0 +1,69 @@ +// 分组利润控制表单辅助:百分比 <-> 小数换算与提交前校验。 +// 后端按小数存 decimal(10,4)(0.30 = 30%),界面按百分比输入展示; +// 固定 4 位小数精度,避免 0.3 * 100 = 30.000000000000004 之类的浮点尾数回显。 + +export const profitPercentToDecimal = ( + value: number | string | null | undefined, +): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.round(parsed * 100) / 10000; +}; + +export const profitDecimalToPercent = ( + value: number | null | undefined, +): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.round(parsed * 1e6) / 1e4; +}; + +export type ProfitControlFormState = { + platform: string; + profit_control_enabled: boolean; + profit_min_margin_percent: number | string | null; + profit_safety_buffer_percent: number | string | null; +}; + +export const isProfitControlPlatform = (platform: string): boolean => + ["openai", "anthropic", "gemini", "grok", "antigravity"].includes(platform); + +// 提交前校验:margin/buffer 各自 ∈ [0,1),且相加 < 1(否则阈值 <= 0, +// 所有可核价账号都会被排除)。返回 null 表示通过,否则返回错误信息的 i18n key +//(相对 admin.groups.profitControl 前缀)。仅支持平台且开关开启时才校验。 +// +// 上界校验必须落在 profitPercentToDecimal 的换算结果上,而不是界面百分比: +// 后端 validProfitControlRatio 校验的是小数 [0,1),而 99.999% 会被四舍五入 +// 进位成 1.0——按百分比判 `< 100` 会让前端放行、后端 400。 +export const validateProfitControlFormState = ( + form: ProfitControlFormState, +): string | null => { + if (!isProfitControlPlatform(form.platform) || !form.profit_control_enabled) { + return null; + } + const marginPercent = Number(form.profit_min_margin_percent || 0); + const bufferPercent = Number(form.profit_safety_buffer_percent || 0); + if (!Number.isFinite(marginPercent) || marginPercent < 0) { + return "marginRangeError"; + } + if (!Number.isFinite(bufferPercent) || bufferPercent < 0) { + return "bufferRangeError"; + } + // 校验实际提交给后端的小数值,边界按构造对齐。 + const margin = profitPercentToDecimal(marginPercent); + const buffer = profitPercentToDecimal(bufferPercent); + if (margin >= 1) { + return "marginRangeError"; + } + if (buffer >= 1) { + return "bufferRangeError"; + } + if (margin + buffer >= 1) { + return "sumTooHigh"; + } + return null; +};