Przeglądaj źródła

release v0.5.4 (#731)

* fix(proxy): add 'cannot be modified' error detection to thinking signature rectifier

Extend the thinking signature rectifier to detect and handle the
Anthropic API error when thinking/redacted_thinking blocks have been
modified from their original response. This error occurs when clients
inadvertently modify these blocks in multi-turn conversations.

The rectifier will now remove these blocks and retry the request,
similar to how it handles other thinking-related signature errors.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* chore(deps): bump jspdf in the npm_and_yarn group across 1 directory

Bumps the npm_and_yarn group with 1 update in the / directory: [jspdf](https://github.com/parallax/jsPDF).


Updates `jspdf` from 3.0.4 to 4.1.0
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.1.0)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.1.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>

* fix: Hot-reload cache invalidation for Request Filters and Sensitive Words (#710)

* fix: hot-reload request filters via globalThis singleton pattern

EventEmitter and RequestFilterEngine now use globalThis caching to ensure
the same instance is shared across different Next.js worker contexts.
This fixes the issue where filter changes required Docker restart.

Added diagnostic logging for event subscription and propagation.

* fix(redis): wait for subscriber connection ready before subscribe

- ensureSubscriber now returns Promise<Redis>, waits for 'ready' event
- subscribeCacheInvalidation returns null on failure instead of noop
- RequestFilterEngine checks cleanup !== null before logging success
- Fixes false "Subscribed" log when Redis connection fails

* feat(sensitive-words): add hot-reload via Redis pub/sub

Enable real-time cache invalidation for sensitive words detector,
matching the pattern used by request-filter-engine and error-rule-detector.

* fix(redis): harden cache invalidation subscriptions

Ensure sensitive-words CRUD emits update events so hot-reload propagates across workers. Roll back failed pub/sub subscriptions, add retry/timeout coverage, and avoid sticky provider-cache subscription state.

* fix(codex): bump default User-Agent fallback

Update the hardcoded Codex UA used when requests lack an effective user-agent (e.g. filtered out). Keep unit tests in sync with the new default.

* fix(redis): resubscribe cache invalidation after reconnect

Clear cached subscription state on disconnect and resubscribe on ready so cross-worker cache invalidation survives transient Redis reconnects. Add unit coverage, avoid misleading publish logs, track detector cleanup handlers, and translate leftover Russian comments to English.

* fix(sensitive-words): use globalThis singleton detector

Align SensitiveWordDetector with existing __CCH_* singleton pattern to avoid duplicate instances across module reloads. Extend singleton unit tests to cover the detector.

* chore: format code (req-fix-dda97fd)

* fix: address PR review comments

- pubsub.ts: use `once` instead of `on` for ready event to prevent
  duplicate resubscription handlers on reconnect
- forwarder.ts: extract DEFAULT_CODEX_USER_AGENT constant
- provider-cache.ts: wrap subscribeCacheInvalidation in try/catch
- tests: use exported constant instead of hardcoded UA string

* fix(redis): resubscribe across repeated reconnects

Ensure pub/sub resubscribe runs on every reconnect, extend unit coverage, and keep emitRequestFiltersUpdated resilient when logger import fails.

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(logs): make cost column toggleable with improved type safety (#715)

close #713

* fix(proxy): add OpenAI chat completion format support in usage extraction (#705) (#716)

The `extractUsageMetrics` function was missing support for OpenAI chat
completion format fields (`prompt_tokens`/`completion_tokens`), causing
token statistics to not be recorded for OpenAI-compatible providers.

Changes:
- Add `prompt_tokens` -> `input_tokens` mapping
- Add `completion_tokens` -> `output_tokens` mapping
- Preserve priority: Claude > Gemini > OpenAI format
- Add 5 unit tests for OpenAI format handling

Closes #705

Co-authored-by: Claude Opus 4.5 <[email protected]>

* fix(currency): respect system currencyDisplay setting in UI (#717)

Fixes #678 - Currency display unit configuration was not applied.

Root cause:
- `users-page-client.tsx` hardcoded `currencyCode="USD"`
- `UserLimitBadge` and `LimitStatusIndicator` had hardcoded `unit="$"` default
- `big-screen/page.tsx` used hardcoded "$" in multiple places

Changes:
- Add `getCurrencySymbol()` helper function to currency.ts
- Fetch system settings in `users-page-client.tsx` and pass to table
- Pass `currencySymbol` from `user-key-table-row.tsx` to limit badges
- Remove hardcoded "$" defaults from badge components
- Update big-screen page to fetch settings and use dynamic symbol
- Add unit tests for `getCurrencySymbol`

Co-authored-by: Claude Opus 4.5 <[email protected]>

* feat(gemini): add Google Search web access preference for Gemini providers (#721)

* feat(gemini): add Google Search web access preference for Gemini providers

Add provider-level preference for Gemini API type providers to control
Google Search (web access) tool injection:

- inherit: Follow client request (default)
- enabled: Force inject googleSearch tool into request
- disabled: Force remove googleSearch tool from request

Changes:
- Add geminiGoogleSearchPreference field to provider schema
- Add GeminiGoogleSearchPreference type and validation
- Implement applyGeminiGoogleSearchOverride with audit trail
- Add UI controls in provider form (Gemini Overrides section)
- Add i18n translations for 5 languages (en, zh-CN, zh-TW, ja, ru)
- Integrate override logic in proxy forwarder for Gemini requests
- Add 22 unit tests for the override logic

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(gemini): address code review feedback

- Use explicit else-if for disabled preference check (gemini-code-assist)
- Use i18n for SelectValue placeholder instead of hardcoded string (coderabbitai)
- Sync overridden body back to session.request.message for log consistency (coderabbitai)
- Persist Gemini special settings immediately, matching Anthropic pattern (coderabbitai)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(gemini): use strict types for provider config and audit

- Narrow preference type to "enabled" | "disabled" (exclude unreachable "inherit")
- Use ProviderType and GeminiGoogleSearchPreference types instead of string

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>

* fix(api): 透传 /api/actions 认证会话以避免 getUsers 返回空数据 (#720)

* fix(api): 透传 /api/actions 认证会话以避免 getUsers 返回空数据

* fix(auth): 让 scoped session 继承 allowReadOnlyAccess 语义并支持内部降权校验

* chore: format code (dev-93585fa)

* fix: bound SessionTracker active_sessions zsets by env TTL (#718)

* fix(session): bound active_sessions zsets by env ttl

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <[email protected]>

* fix(rate-limit): pass session ttl to lua cleanup

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <[email protected]>

* fix(session): validate SESSION_TTL env and prevent ZSET leak on invalid values

- Add input validation for SESSION_TTL (reject NaN, 0, negative; default 300)
- Guard against invalid TTL in Lua script to prevent clearing all sessions
- Use dynamic EXPIRE based on SESSION_TTL instead of hardcoded 3600s
- Add unit tests for TTL validation and dynamic expiry behavior

---------

Co-authored-by: Sisyphus <[email protected]>

* fix(provider): stop standard-path fallback to legacy provider url

* feat(providers): expose vendor endpoint pools in settings UI (#719)

* feat(providers): add endpoint status mapping

* feat(providers): add endpoint pool hover

* feat(providers): show vendor endpoints in list rows

* feat(providers): extract vendor endpoint CRUD table

* chore(i18n): add provider endpoint UI strings

* fix(providers): integrate endpoint pool into provider form

* fix(provider): wrap endpoint sync in transactions to prevent race conditions (#730)

* fix(provider): wrap provider create/update endpoint sync in transactions

Provider create and update operations now run vendor resolution and
endpoint sync inside database transactions to prevent race conditions
that could leave orphaned or inconsistent endpoint rows.

Key changes:
- createProvider: wrap vendor + insert + endpoint seed in a single tx
- updateProvider: wrap vendor + update + endpoint sync in a single tx
- Add syncProviderEndpointOnProviderEdit for atomic URL/type/vendor
  migration with in-place update, soft-delete, and conflict handling
- Vendor cleanup failures degrade to warnings instead of propagating
- Add comprehensive unit and integration tests for sync edge cases

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(provider): defer endpoint circuit reset until transaction commit

Avoid running endpoint circuit reset side effects inside DB transactions to prevent rollback inconsistency. Run resets only after commit and add regression tests for deferred reset behavior in helper and provider update flows.

* fix(provider): distinguish noop from created-next in endpoint sync action label

When ensureNextEndpointActive() returns "noop" (concurrent transaction
already created an active next endpoint), the action was incorrectly
labelled "kept-previous-and-created-next". Add a new
"kept-previous-and-kept-next" action to ProviderEndpointSyncAction and
use a three-way branch so callers and logs reflect the true outcome.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>

* fix: address review comments from PR #731

- fix(auth): prevent scoped session access widening via ?? -> && guard
- fix(i18n): standardize zh-CN provider terminology to "服务商"
- fix(i18n): use consistent Russian translations for circuit status
- fix(i18n): replace raw formatDistanceToNow with locale-aware RelativeTime
- fix(gemini): log warning for unknown google search preference values
- fix(error-rules): check subscribeCacheInvalidation return value
- fix(test): correct endpoint hover sort test to assert URLs not labels

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: export auth session storage and fix test mock types

- Export authSessionStorage from auth-session-storage.node.ts to prevent
  undefined on named imports; remove duplicate declare global block
- Fix mockEndpoints in provider-endpoint-hover test: remove nonexistent
  lastOk/lastLatencyMs fields, add missing lastProbe* fields, use Date
  objects for createdAt/updatedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: miraserver <[email protected]>
Co-authored-by: John Doe <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: 泠音 <[email protected]>
Co-authored-by: Longlone <[email protected]>
Co-authored-by: Sisyphus <[email protected]>
Ding 6 dni temu
rodzic
commit
9ee6452f17
100 zmienionych plików z 9947 dodań i 1066 usunięć
  1. 1 0
      .gitignore
  2. 1 1
      biome.json
  3. 1 0
      drizzle/0062_aromatic_taskmaster.sql
  4. 2961 0
      drizzle/meta/0062_snapshot.json
  5. 7 0
      drizzle/meta/_journal.json
  6. 3 0
      messages/en/settings/providers/form/errors.json
  7. 16 0
      messages/en/settings/providers/form/sections.json
  8. 12 1
      messages/en/settings/providers/strings.json
  9. 3 0
      messages/ja/settings/providers/form/errors.json
  10. 16 0
      messages/ja/settings/providers/form/sections.json
  11. 12 1
      messages/ja/settings/providers/strings.json
  12. 3 0
      messages/ru/settings/providers/form/errors.json
  13. 16 0
      messages/ru/settings/providers/form/sections.json
  14. 12 1
      messages/ru/settings/providers/strings.json
  15. 4 1
      messages/zh-CN/settings/providers/form/errors.json
  16. 16 0
      messages/zh-CN/settings/providers/form/sections.json
  17. 12 1
      messages/zh-CN/settings/providers/strings.json
  18. 3 0
      messages/zh-TW/settings/providers/form/errors.json
  19. 16 0
      messages/zh-TW/settings/providers/form/sections.json
  20. 12 1
      messages/zh-TW/settings/providers/strings.json
  21. 1 1
      package.json
  22. 9 1
      src/actions/provider-endpoints.ts
  23. 25 6
      src/actions/providers.ts
  24. 0 9
      src/actions/sensitive-words.ts
  25. 1 1
      src/app/[locale]/dashboard/_components/user/limit-status-indicator.tsx
  26. 9 0
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  27. 1 1
      src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx
  28. 1 0
      src/app/[locale]/dashboard/logs/_components/column-visibility-dropdown.tsx
  29. 1 1
      src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx
  30. 53 53
      src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
  31. 13 1
      src/app/[locale]/dashboard/users/users-page-client.tsx
  32. 30 5
      src/app/[locale]/internal/dashboard/big-screen/page.tsx
  33. 107 0
      src/app/[locale]/settings/providers/_components/endpoint-status.ts
  34. 137 9
      src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx
  35. 6 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx
  36. 4 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts
  37. 53 28
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx
  38. 37 0
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  39. 154 0
      src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx
  40. 716 0
      src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx
  41. 24 3
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  42. 9 519
      src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx
  43. 1 0
      src/app/api/actions/[...route]/route.ts
  44. 81 13
      src/app/v1/_lib/proxy/forwarder.ts
  45. 14 0
      src/app/v1/_lib/proxy/response-handler.ts
  46. 16 0
      src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts
  47. 11 0
      src/app/v1/_lib/proxy/thinking-signature-rectifier.ts
  48. 6 0
      src/drizzle/schema.ts
  49. 8 2
      src/lib/api/action-adapter-openapi.ts
  50. 11 0
      src/lib/auth-session-storage.node.ts
  51. 52 0
      src/lib/auth.ts
  52. 25 10
      src/lib/cache/provider-cache.ts
  53. 11 0
      src/lib/column-visibility.test.ts
  54. 4 2
      src/lib/column-visibility.ts
  55. 22 4
      src/lib/emit-event.ts
  56. 7 1
      src/lib/error-rule-detector.ts
  57. 6 1
      src/lib/event-emitter.ts
  58. 142 0
      src/lib/gemini/provider-overrides.ts
  59. 8 2
      src/lib/rate-limit/service.ts
  60. 133 5
      src/lib/redis/__tests__/pubsub.test.ts
  61. 34 24
      src/lib/redis/lua-scripts.ts
  62. 128 26
      src/lib/redis/pubsub.ts
  63. 26 13
      src/lib/request-filter-engine.ts
  64. 57 2
      src/lib/sensitive-word-detector.ts
  65. 25 20
      src/lib/session-tracker.ts
  66. 12 0
      src/lib/utils/currency.ts
  67. 9 0
      src/lib/utils/special-settings.ts
  68. 8 0
      src/lib/validation/schemas.ts
  69. 1 0
      src/repository/_shared/transformers.ts
  70. 476 62
      src/repository/provider-endpoints.ts
  71. 253 186
      src/repository/provider.ts
  72. 9 1
      src/repository/sensitive-words.ts
  73. 12 0
      src/types/provider.ts
  74. 19 1
      src/types/special-settings.ts
  75. 126 0
      tests/api/action-adapter-auth-session.unit.test.ts
  76. 147 0
      tests/integration/provider-endpoint-sync-race.test.ts
  77. 15 7
      tests/unit/actions/providers-recluster.test.ts
  78. 59 0
      tests/unit/actions/providers.test.ts
  79. 26 4
      tests/unit/i18n/zh-tw-providers-strings-quality.test.ts
  80. 6 2
      tests/unit/lib/emit-event.test.ts
  81. 329 0
      tests/unit/lib/gemini/provider-overrides.test.ts
  82. 162 0
      tests/unit/lib/hot-reload-singleton.test.ts
  83. 7 0
      tests/unit/lib/rate-limit/cost-limits.test.ts
  84. 4 0
      tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts
  85. 26 0
      tests/unit/lib/rate-limit/service-extra.test.ts
  86. 294 0
      tests/unit/lib/session-tracker-cleanup.test.ts
  87. 165 0
      tests/unit/lib/session-ttl-validation.test.ts
  88. 36 0
      tests/unit/lib/utils/currency.test.ts
  89. 82 0
      tests/unit/proxy/extract-usage-metrics.test.ts
  90. 139 9
      tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts
  91. 2 4
      tests/unit/proxy/proxy-forwarder.test.ts
  92. 187 0
      tests/unit/repository/provider-create-transaction.test.ts
  93. 292 0
      tests/unit/repository/provider-endpoint-sync-helper.test.ts
  94. 242 0
      tests/unit/repository/provider-endpoint-sync-on-edit.test.ts
  95. 49 20
      tests/unit/repository/provider-endpoints.test.ts
  96. 173 0
      tests/unit/repository/sensitive-words-events.test.ts
  97. 101 0
      tests/unit/settings/providers/endpoint-status.test.ts
  98. 253 0
      tests/unit/settings/providers/provider-endpoint-hover.test.tsx
  99. 567 0
      tests/unit/settings/providers/provider-endpoints-table.test.tsx
  100. 313 0
      tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx

+ 1 - 0
.gitignore

@@ -91,3 +91,4 @@ docs-site/node_modules/
 tmp/
 .trae/
 .sisyphus
+.ace-tool/

+ 1 - 1
biome.json

@@ -1,5 +1,5 @@
 {
-  "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+  "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
   "vcs": {
     "enabled": true,
     "clientKind": "git",

+ 1 - 0
drizzle/0062_aromatic_taskmaster.sql

@@ -0,0 +1 @@
+ALTER TABLE "providers" ADD COLUMN "gemini_google_search_preference" varchar(20);

+ 2961 - 0
drizzle/meta/0062_snapshot.json

@@ -0,0 +1,2961 @@
+{
+  "id": "0bb539db-ea09-4259-9267-2d82d6bfb278",
+  "prevId": "ae09efb5-f924-4c72-b41f-baccc1765fcb",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.error_rules": {
+      "name": "error_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'regex'"
+        },
+        "category": {
+          "name": "category",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_error_rules_enabled": {
+          "name": "idx_error_rules_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "unique_pattern": {
+          "name": "unique_pattern",
+          "columns": [
+            {
+              "expression": "pattern",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_category": {
+          "name": "idx_category",
+          "columns": [
+            {
+              "expression": "category",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_match_type": {
+          "name": "idx_match_type",
+          "columns": [
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "ttfb_ms": {
+          "name": "ttfb_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "special_settings": {
+          "name": "special_settings",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_stack": {
+          "name": "error_stack",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_cause": {
+          "name": "error_cause",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id_prefix": {
+          "name": "idx_message_request_session_id_prefix",
+          "columns": [
+            {
+              "expression": "\"session_id\" varchar_pattern_ops",
+              "asc": true,
+              "isExpression": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_endpoint": {
+          "name": "idx_message_request_endpoint",
+          "columns": [
+            {
+              "expression": "endpoint",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_blocked_by": {
+          "name": "idx_message_request_blocked_by",
+          "columns": [
+            {
+              "expression": "blocked_by",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "source": {
+          "name": "source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'litellm'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_source": {
+          "name": "idx_model_prices_source",
+          "columns": [
+            {
+              "expression": "source",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "use_legacy_mode": {
+          "name": "use_legacy_mode",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_target_bindings": {
+      "name": "notification_target_bindings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "notification_type": {
+          "name": "notification_type",
+          "type": "notification_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "target_id": {
+          "name": "target_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "schedule_cron": {
+          "name": "schedule_cron",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "schedule_timezone": {
+          "name": "schedule_timezone",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "template_override": {
+          "name": "template_override",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "unique_notification_target_binding": {
+          "name": "unique_notification_target_binding",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_type": {
+          "name": "idx_notification_bindings_type",
+          "columns": [
+            {
+              "expression": "notification_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_notification_bindings_target": {
+          "name": "idx_notification_bindings_target",
+          "columns": [
+            {
+              "expression": "target_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "notification_target_bindings_target_id_webhook_targets_id_fk": {
+          "name": "notification_target_bindings_target_id_webhook_targets_id_fk",
+          "tableFrom": "notification_target_bindings",
+          "tableTo": "webhook_targets",
+          "columnsFrom": [
+            "target_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_endpoint_probe_logs": {
+      "name": "provider_endpoint_probe_logs",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "endpoint_id": {
+          "name": "endpoint_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "source": {
+          "name": "source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'scheduled'"
+        },
+        "ok": {
+          "name": "ok",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "latency_ms": {
+          "name": "latency_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_type": {
+          "name": "error_type",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_provider_endpoint_probe_logs_endpoint_created_at": {
+          "name": "idx_provider_endpoint_probe_logs_endpoint_created_at",
+          "columns": [
+            {
+              "expression": "endpoint_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoint_probe_logs_created_at": {
+          "name": "idx_provider_endpoint_probe_logs_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": {
+          "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk",
+          "tableFrom": "provider_endpoint_probe_logs",
+          "tableTo": "provider_endpoints",
+          "columnsFrom": [
+            "endpoint_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_endpoints": {
+      "name": "provider_endpoints",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "vendor_id": {
+          "name": "vendor_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "label": {
+          "name": "label",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "sort_order": {
+          "name": "sort_order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_probed_at": {
+          "name": "last_probed_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_ok": {
+          "name": "last_probe_ok",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_status_code": {
+          "name": "last_probe_status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_latency_ms": {
+          "name": "last_probe_latency_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_error_type": {
+          "name": "last_probe_error_type",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_probe_error_message": {
+          "name": "last_probe_error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "uniq_provider_endpoints_vendor_type_url": {
+          "name": "uniq_provider_endpoints_vendor_type_url",
+          "columns": [
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "url",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_vendor_type": {
+          "name": "idx_provider_endpoints_vendor_type",
+          "columns": [
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"provider_endpoints\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_enabled": {
+          "name": "idx_provider_endpoints_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"provider_endpoints\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_created_at": {
+          "name": "idx_provider_endpoints_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_endpoints_deleted_at": {
+          "name": "idx_provider_endpoints_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "provider_endpoints_vendor_id_provider_vendors_id_fk": {
+          "name": "provider_endpoints_vendor_id_provider_vendors_id_fk",
+          "tableFrom": "provider_endpoints",
+          "tableTo": "provider_vendors",
+          "columnsFrom": [
+            "vendor_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.provider_vendors": {
+      "name": "provider_vendors",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "website_domain": {
+          "name": "website_domain",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "display_name": {
+          "name": "display_name",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "uniq_provider_vendors_website_domain": {
+          "name": "uniq_provider_vendors_website_domain",
+          "columns": [
+            {
+              "expression": "website_domain",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_provider_vendors_created_at": {
+          "name": "idx_provider_vendors_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_vendor_id": {
+          "name": "provider_vendor_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "total_cost_reset_at": {
+          "name": "total_cost_reset_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "first_byte_timeout_streaming_ms": {
+          "name": "first_byte_timeout_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_reasoning_effort_preference": {
+          "name": "codex_reasoning_effort_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_reasoning_summary_preference": {
+          "name": "codex_reasoning_summary_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_text_verbosity_preference": {
+          "name": "codex_text_verbosity_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "codex_parallel_tool_calls_preference": {
+          "name": "codex_parallel_tool_calls_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "anthropic_max_tokens_preference": {
+          "name": "anthropic_max_tokens_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "anthropic_thinking_budget_preference": {
+          "name": "anthropic_thinking_budget_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "gemini_google_search_preference": {
+          "name": "gemini_google_search_preference",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_vendor_type": {
+          "name": "idx_providers_vendor_type",
+          "columns": [
+            {
+              "expression": "provider_vendor_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "providers_provider_vendor_id_provider_vendors_id_fk": {
+          "name": "providers_provider_vendor_id_provider_vendors_id_fk",
+          "tableFrom": "providers",
+          "tableTo": "provider_vendors",
+          "columnsFrom": [
+            "provider_vendor_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "restrict",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "binding_type": {
+          "name": "binding_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'global'"
+        },
+        "provider_ids": {
+          "name": "provider_ids",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_tags": {
+          "name": "group_tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_request_filters_enabled": {
+          "name": "idx_request_filters_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_binding": {
+          "name": "idx_request_filters_binding",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "binding_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "intercept_anthropic_warmup_requests": {
+          "name": "intercept_anthropic_warmup_requests",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_thinking_signature_rectifier": {
+          "name": "enable_thinking_signature_rectifier",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_thinking_budget_rectifier": {
+          "name": "enable_thinking_budget_rectifier",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_codex_session_id_completion": {
+          "name": "enable_codex_session_id_completion",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "enable_response_fixer": {
+          "name": "enable_response_fixer",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "response_fixer_config": {
+          "name": "response_fixer_config",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb"
+        },
+        "quota_db_refresh_interval_seconds": {
+          "name": "quota_db_refresh_interval_seconds",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10
+        },
+        "quota_lease_percent_5h": {
+          "name": "quota_lease_percent_5h",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_daily": {
+          "name": "quota_lease_percent_daily",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_weekly": {
+          "name": "quota_lease_percent_weekly",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_percent_monthly": {
+          "name": "quota_lease_percent_monthly",
+          "type": "numeric(5, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.05'"
+        },
+        "quota_lease_cap_usd": {
+          "name": "quota_lease_cap_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(200)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'default'"
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.webhook_targets": {
+      "name": "webhook_targets",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "webhook_provider_type",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "webhook_url": {
+          "name": "webhook_url",
+          "type": "varchar(1024)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_bot_token": {
+          "name": "telegram_bot_token",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "telegram_chat_id": {
+          "name": "telegram_chat_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "dingtalk_secret": {
+          "name": "dingtalk_secret",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_template": {
+          "name": "custom_template",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "custom_headers": {
+          "name": "custom_headers",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "last_test_at": {
+          "name": "last_test_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "last_test_result": {
+          "name": "last_test_result",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    },
+    "public.notification_type": {
+      "name": "notification_type",
+      "schema": "public",
+      "values": [
+        "circuit_breaker",
+        "daily_leaderboard",
+        "cost_alert"
+      ]
+    },
+    "public.webhook_provider_type": {
+      "name": "webhook_provider_type",
+      "schema": "public",
+      "values": [
+        "wechat",
+        "feishu",
+        "dingtalk",
+        "telegram",
+        "custom"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -435,6 +435,13 @@
       "when": 1769852342867,
       "tag": "0061_exotic_trauma",
       "breakpoints": true
+    },
+    {
+      "idx": 62,
+      "version": "7",
+      "when": 1770185417918,
+      "tag": "0062_aromatic_taskmaster",
+      "breakpoints": true
     }
   ]
 }

+ 3 - 0
messages/en/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "Failed to add provider",
   "deleteFailed": "Failed to delete provider",
   "groupTagTooLong": "Provider group tags are too long (max {max} chars total)",
+  "keyRequired": "Please enter an API key",
+  "nameRequired": "Please enter a provider name",
   "invalidUrl": "Please enter a valid API address",
+  "urlRequired": "Please enter an API address",
   "invalidWebsiteUrl": "Please enter a valid provider website URL",
   "updateFailed": "Failed to update provider"
 }

+ 16 - 0
messages/en/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API Endpoint",
       "desc": "Configure the base URL for API requests"
     },
+    "endpointPool": {
+      "title": "Endpoint Pool",
+      "desc": "Manage vendor endpoints for this provider type"
+    },
     "auth": {
       "title": "Authentication",
       "desc": "Provide your API key for authentication"
@@ -235,6 +239,18 @@
         "maxOutButton": "Max Out (32000)"
       }
     },
+    "geminiOverrides": {
+      "title": "Gemini Parameter Overrides",
+      "desc": "Override Gemini API request parameters at the provider level",
+      "googleSearch": {
+        "label": "Google Search (Web Access)",
+        "options": {
+          "inherit": "No override (follow client)",
+          "enabled": "Force enabled",
+          "disabled": "Force disabled"
+        }
+      }
+    },
     "context1m": {
       "desc": "Configure 1M context window support. Only affects Sonnet models (claude-sonnet-4-5, claude-sonnet-4). Tiered pricing applies when enabled.",
       "label": "1M Context Window",

+ 12 - 1
messages/en/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Label (optional)",
   "endpointLabelPlaceholder": "Production endpoint",
+  "sortOrder": "Sort Order",
   "editEndpoint": "Edit Endpoint",
   "editVendor": "Edit Vendor",
   "vendorName": "Vendor Name",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "Vendor updated successfully",
   "vendorUpdateFailed": "Failed to update vendor",
   "vendorDeleteSuccess": "Vendor deleted successfully",
-  "vendorDeleteFailed": "Failed to delete vendor"
+  "vendorDeleteFailed": "Failed to delete vendor",
+  "endpointStatus": {
+    "viewDetails": "View Details ({count})",
+    "activeEndpoints": "Active Endpoints",
+    "noEndpoints": "No Endpoints",
+    "healthy": "Healthy",
+    "unhealthy": "Unhealthy",
+    "unknown": "Unknown",
+    "circuitOpen": "Circuit Open",
+    "circuitHalfOpen": "Circuit Half-Open"
+  }
 }

+ 3 - 0
messages/ja/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "プロバイダーの追加に失敗しました",
   "deleteFailed": "プロバイダーの削除に失敗しました",
   "groupTagTooLong": "プロバイダーグループが長すぎます(合計{max}文字まで)",
+  "keyRequired": "API キーを入力してください",
+  "nameRequired": "プロバイダー名を入力してください",
   "invalidUrl": "有効な API アドレスを入力してください",
+  "urlRequired": "プロバイダー URL を入力してください",
   "invalidWebsiteUrl": "有効な公式サイト URL を入力してください",
   "updateFailed": "プロバイダーの更新に失敗しました"
 }

+ 16 - 0
messages/ja/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API エンドポイント",
       "desc": "API リクエストのベース URL を設定"
     },
+    "endpointPool": {
+      "title": "エンドポイントプール",
+      "desc": "このプロバイダー種別のエンドポイントを管理"
+    },
     "auth": {
       "title": "認証",
       "desc": "認証用の API キーを入力"
@@ -236,6 +240,18 @@
         "maxOutButton": "最大化 (32000)"
       }
     },
+    "geminiOverrides": {
+      "title": "Gemini パラメータオーバーライド",
+      "desc": "プロバイダーレベルで Gemini API リクエストパラメータを上書きします",
+      "googleSearch": {
+        "label": "Google Search(ウェブアクセス)",
+        "options": {
+          "inherit": "上書きなし(クライアントに従う)",
+          "enabled": "強制有効",
+          "disabled": "強制無効"
+        }
+      }
+    },
     "context1m": {
       "desc": "1M コンテキストウィンドウのサポートを設定します。Sonnet モデル(claude-sonnet-4-5、claude-sonnet-4)にのみ適用されます。有効時は段階的料金が適用されます。",
       "label": "1M コンテキストウィンドウ",

+ 12 - 1
messages/ja/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "ラベル (任意)",
   "endpointLabelPlaceholder": "本番環境",
+  "sortOrder": "並び順",
   "editEndpoint": "エンドポイントを編集",
   "editVendor": "ベンダーを編集",
   "vendorName": "ベンダー名",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "ベンダーを更新しました",
   "vendorUpdateFailed": "ベンダーの更新に失敗しました",
   "vendorDeleteSuccess": "ベンダーを削除しました",
-  "vendorDeleteFailed": "ベンダーの削除に失敗しました"
+  "vendorDeleteFailed": "ベンダーの削除に失敗しました",
+  "endpointStatus": {
+    "viewDetails": "詳細を見る({count})",
+    "activeEndpoints": "有効なエンドポイント",
+    "noEndpoints": "エンドポイントなし",
+    "healthy": "正常",
+    "unhealthy": "異常",
+    "unknown": "不明",
+    "circuitOpen": "サーキットオープン",
+    "circuitHalfOpen": "サーキット半開"
+  }
 }

+ 3 - 0
messages/ru/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "Не удалось добавить провайдера",
   "deleteFailed": "Не удалось удалить провайдера",
   "groupTagTooLong": "Список групп провайдера слишком длинный (макс. {max} символов всего)",
+  "keyRequired": "Введите API-ключ",
+  "nameRequired": "Введите имя провайдера",
   "invalidUrl": "Введите корректный адрес API",
+  "urlRequired": "Введите URL провайдера",
   "invalidWebsiteUrl": "Введите корректный адрес сайта провайдера",
   "updateFailed": "Не удалось обновить провайдера"
 }

+ 16 - 0
messages/ru/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API Endpoint",
       "desc": "Настройте базовый URL для API запросов"
     },
+    "endpointPool": {
+      "title": "Пул эндпоинтов",
+      "desc": "Управляйте эндпоинтами для этого типа провайдера"
+    },
     "auth": {
       "title": "Аутентификация",
       "desc": "Укажите API ключ для аутентификации"
@@ -236,6 +240,18 @@
         "maxOutButton": "Максимум (32000)"
       }
     },
+    "geminiOverrides": {
+      "title": "Переопределение параметров Gemini",
+      "desc": "Переопределение параметров запроса Gemini API на уровне провайдера",
+      "googleSearch": {
+        "label": "Google Search (веб-доступ)",
+        "options": {
+          "inherit": "Без переопределения (следовать клиенту)",
+          "enabled": "Принудительно включено",
+          "disabled": "Принудительно отключено"
+        }
+      }
+    },
     "context1m": {
       "desc": "Настройка поддержки контекстного окна 1M. Применяется только к моделям Sonnet (claude-sonnet-4-5, claude-sonnet-4). При включении применяется многоуровневая тарификация.",
       "label": "Контекстное окно 1M",

+ 12 - 1
messages/ru/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "Метка (необязательно)",
   "endpointLabelPlaceholder": "Продакшн",
+  "sortOrder": "Порядок",
   "editEndpoint": "Редактировать эндпоинт",
   "editVendor": "Редактировать вендора",
   "vendorName": "Название вендора",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "Вендор обновлён",
   "vendorUpdateFailed": "Не удалось обновить вендора",
   "vendorDeleteSuccess": "Вендор удалён",
-  "vendorDeleteFailed": "Не удалось удалить вендора"
+  "vendorDeleteFailed": "Не удалось удалить вендора",
+  "endpointStatus": {
+    "viewDetails": "Подробнее ({count})",
+    "activeEndpoints": "Активные эндпоинты",
+    "noEndpoints": "Нет эндпоинтов",
+    "healthy": "Доступен",
+    "unhealthy": "Недоступен",
+    "unknown": "Неизвестно",
+    "circuitOpen": "Цепь открыта",
+    "circuitHalfOpen": "Цепь полуоткрыта"
+  }
 }

+ 4 - 1
messages/zh-CN/settings/providers/form/errors.json

@@ -1,7 +1,10 @@
 {
   "invalidUrl": "请输入有效的 API 地址",
-  "invalidWebsiteUrl": "请输入有效的供应商官网地址",
+  "invalidWebsiteUrl": "请输入有效的服务商官网地址",
   "groupTagTooLong": "分组标签总长度不能超过 {max} 个字符",
+  "nameRequired": "请输入服务商名称",
+  "urlRequired": "请先填写服务商 URL",
+  "keyRequired": "请输入 API 密钥",
   "addFailed": "添加服务商失败",
   "updateFailed": "更新服务商失败",
   "deleteFailed": "删除服务商失败"

+ 16 - 0
messages/zh-CN/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API 端点",
       "desc": "配置 API 请求的基础 URL"
     },
+    "endpointPool": {
+      "title": "端点池",
+      "desc": "管理该供应商类型的端点池"
+    },
     "auth": {
       "title": "身份认证",
       "desc": "提供用于认证的 API 密钥"
@@ -151,6 +155,18 @@
         "placeholder": "如 10240",
         "maxOutButton": "拉满 (32000)"
       }
+    },
+    "geminiOverrides": {
+      "title": "Gemini 参数覆写",
+      "desc": "在供应商层面覆写 Gemini API 请求参数",
+      "googleSearch": {
+        "label": "Google Search 联网",
+        "options": {
+          "inherit": "不覆写(遵循客户端)",
+          "enabled": "强制开启",
+          "disabled": "强制关闭"
+        }
+      }
     }
   },
   "rateLimit": {

+ 12 - 1
messages/zh-CN/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "标签(可选)",
   "endpointLabelPlaceholder": "生产环境",
+  "sortOrder": "排序",
   "editEndpoint": "编辑端点",
   "editVendor": "编辑服务商",
   "vendorName": "服务商名称",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "服务商更新成功",
   "vendorUpdateFailed": "更新服务商失败",
   "vendorDeleteSuccess": "服务商删除成功",
-  "vendorDeleteFailed": "删除服务商失败"
+  "vendorDeleteFailed": "删除服务商失败",
+  "endpointStatus": {
+    "viewDetails": "查看详情({count})",
+    "activeEndpoints": "启用端点",
+    "noEndpoints": "无端点",
+    "healthy": "健康",
+    "unhealthy": "故障",
+    "unknown": "未知",
+    "circuitOpen": "熔断开启",
+    "circuitHalfOpen": "熔断半开"
+  }
 }

+ 3 - 0
messages/zh-TW/settings/providers/form/errors.json

@@ -2,7 +2,10 @@
   "addFailed": "新增供應商失敗",
   "deleteFailed": "刪除供應商失敗",
   "groupTagTooLong": "分組標籤總長度不能超過 {max} 個字元",
+  "keyRequired": "請輸入 API 金鑰",
+  "nameRequired": "請輸入供應商名稱",
   "invalidUrl": "請輸入有效的 API 位址",
+  "urlRequired": "請先填寫供應商 URL",
   "invalidWebsiteUrl": "請輸入有效的供應商官網",
   "updateFailed": "更新供應商失敗"
 }

+ 16 - 0
messages/zh-TW/settings/providers/form/sections.json

@@ -8,6 +8,10 @@
       "title": "API 端點",
       "desc": "設定 API 請求的基礎 URL"
     },
+    "endpointPool": {
+      "title": "端點池",
+      "desc": "管理此供應商類型的端點池"
+    },
     "auth": {
       "title": "身份驗證",
       "desc": "提供用於驗證的 API 金鑰"
@@ -236,6 +240,18 @@
         "maxOutButton": "拉滿 (32000)"
       }
     },
+    "geminiOverrides": {
+      "title": "Gemini 參數覆寫",
+      "desc": "在供應商層級覆寫 Gemini API 請求參數",
+      "googleSearch": {
+        "label": "Google Search 聯網",
+        "options": {
+          "inherit": "不覆寫(遵循客戶端)",
+          "enabled": "強制開啟",
+          "disabled": "強制關閉"
+        }
+      }
+    },
     "context1m": {
       "desc": "設定 1M 上下文視窗支援。僅對 Sonnet 模型生效(claude-sonnet-4-5、claude-sonnet-4)。啟用後將套用階梯定價。",
       "label": "1M 上下文視窗",

+ 12 - 1
messages/zh-TW/settings/providers/strings.json

@@ -88,6 +88,7 @@
   "endpointUrlPlaceholder": "https://api.example.com/v1",
   "endpointLabelOptional": "標籤(選填)",
   "endpointLabelPlaceholder": "生產環境",
+  "sortOrder": "排序",
   "editEndpoint": "編輯端點",
   "editVendor": "編輯供應商",
   "vendorName": "供應商名稱",
@@ -102,5 +103,15 @@
   "vendorUpdateSuccess": "供應商已更新",
   "vendorUpdateFailed": "更新供應商失敗",
   "vendorDeleteSuccess": "供應商已刪除",
-  "vendorDeleteFailed": "刪除供應商失敗"
+  "vendorDeleteFailed": "刪除供應商失敗",
+  "endpointStatus": {
+    "viewDetails": "檢視詳情({count})",
+    "activeEndpoints": "啟用端點",
+    "noEndpoints": "無端點",
+    "healthy": "健康",
+    "unhealthy": "故障",
+    "unknown": "未知",
+    "circuitOpen": "熔斷開啟",
+    "circuitHalfOpen": "熔斷半開"
+  }
 }

+ 1 - 1
package.json

@@ -81,7 +81,7 @@
     "hono": "^4",
     "html2canvas": "^1",
     "ioredis": "^5",
-    "jspdf": "^3",
+    "jspdf": "^4",
     "lucide-react": "^0.555",
     "next": "^16",
     "next-intl": "^4",

+ 9 - 1
src/actions/provider-endpoints.ts

@@ -333,7 +333,15 @@ export async function removeProviderEndpoint(input: unknown): Promise<ActionResu
     }
 
     // Auto cleanup: if the vendor has no active providers/endpoints, delete it as well.
-    await tryDeleteProviderVendorIfEmpty(endpoint.vendorId);
+    try {
+      await tryDeleteProviderVendorIfEmpty(endpoint.vendorId);
+    } catch (error) {
+      logger.warn("removeProviderEndpoint:vendor_cleanup_failed", {
+        endpointId: parsed.data.endpointId,
+        vendorId: endpoint.vendorId,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
 
     return { ok: true };
   } catch (error) {

+ 25 - 6
src/actions/providers.ts

@@ -280,6 +280,7 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         codexParallelToolCallsPreference: provider.codexParallelToolCallsPreference,
         anthropicMaxTokensPreference: provider.anthropicMaxTokensPreference,
         anthropicThinkingBudgetPreference: provider.anthropicThinkingBudgetPreference,
+        geminiGoogleSearchPreference: provider.geminiGoogleSearchPreference,
         tpm: provider.tpm,
         rpm: provider.rpm,
         rpd: provider.rpd,
@@ -764,7 +765,15 @@ export async function removeProvider(providerId: number): Promise<ActionResult>
 
     // Auto cleanup: delete vendor if it has no active providers/endpoints.
     if (provider?.providerVendorId) {
-      await tryDeleteProviderVendorIfEmpty(provider.providerVendorId);
+      try {
+        await tryDeleteProviderVendorIfEmpty(provider.providerVendorId);
+      } catch (error) {
+        logger.warn("removeProvider:vendor_cleanup_failed", {
+          providerId,
+          vendorId: provider.providerVendorId,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      }
     }
 
     // 广播缓存更新(跨实例即时生效)
@@ -3712,10 +3721,13 @@ export async function reclusterProviderVendors(args: {
           if (!provider) continue;
 
           // Get or create new vendor
-          const newVendorId = await getOrCreateProviderVendorIdFromUrls({
-            providerUrl: provider.url,
-            websiteUrl: provider.websiteUrl ?? null,
-          });
+          const newVendorId = await getOrCreateProviderVendorIdFromUrls(
+            {
+              providerUrl: provider.url,
+              websiteUrl: provider.websiteUrl ?? null,
+            },
+            { tx }
+          );
 
           // Update provider's vendorId
           await tx
@@ -3730,7 +3742,14 @@ export async function reclusterProviderVendors(args: {
 
       // Cleanup empty vendors
       for (const oldVendorId of oldVendorIds) {
-        await tryDeleteProviderVendorIfEmpty(oldVendorId);
+        try {
+          await tryDeleteProviderVendorIfEmpty(oldVendorId);
+        } catch (error) {
+          logger.warn("reclusterProviderVendors:vendor_cleanup_failed", {
+            vendorId: oldVendorId,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
       }
 
       // Publish cache invalidation

+ 0 - 9
src/actions/sensitive-words.ts

@@ -72,9 +72,6 @@ export async function createSensitiveWordAction(data: {
 
     const result = await repo.createSensitiveWord(data);
 
-    // 刷新缓存
-    await sensitiveWordDetector.reload();
-
     revalidatePath("/settings/sensitive-words");
 
     logger.info("[SensitiveWordsAction] Created sensitive word", {
@@ -138,9 +135,6 @@ export async function updateSensitiveWordAction(
       };
     }
 
-    // 刷新缓存
-    await sensitiveWordDetector.reload();
-
     revalidatePath("/settings/sensitive-words");
 
     logger.info("[SensitiveWordsAction] Updated sensitive word", {
@@ -184,9 +178,6 @@ export async function deleteSensitiveWordAction(id: number): Promise<ActionResul
       };
     }
 
-    // 刷新缓存
-    await sensitiveWordDetector.reload();
-
     revalidatePath("/settings/sensitive-words");
 
     logger.info("[SensitiveWordsAction] Deleted sensitive word", {

+ 1 - 1
src/app/[locale]/dashboard/_components/user/limit-status-indicator.tsx

@@ -47,7 +47,7 @@ export function LimitStatusIndicator({
   label,
   variant = "default",
   showPercentage = false,
-  unit = "$",
+  unit = "",
 }: LimitStatusIndicatorProps) {
   const isSet = typeof value === "number" && Number.isFinite(value);
   const hasUsage = typeof usage === "number" && Number.isFinite(usage);

+ 9 - 0
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
 import { useRouter } from "@/i18n/routing";
 import { cn } from "@/lib/utils";
 import { getContrastTextColor, getGroupColor } from "@/lib/utils/color";
+import { getCurrencySymbol } from "@/lib/utils/currency";
 import { formatDate } from "@/lib/utils/date-format";
 import type { UserDisplay } from "@/types/user";
 import { EditKeyDialog } from "./edit-key-dialog";
@@ -203,6 +204,9 @@ export function UserKeyTableRow({
   const limitTotal = normalizeLimitValue(user.limitTotalUsd);
   const limitSessions = normalizeLimitValue(user.limitConcurrentSessions);
 
+  // Convert currencyCode to symbol for display
+  const currencySymbol = getCurrencySymbol(currencyCode);
+
   const handleDeleteKey = (keyId: number) => {
     startTransition(async () => {
       const res = await removeKey(keyId);
@@ -404,6 +408,7 @@ export function UserKeyTableRow({
             limitType="5h"
             limit={limit5h}
             label={translations.columns.limit5h}
+            unit={currencySymbol}
           />
         </div>
 
@@ -414,6 +419,7 @@ export function UserKeyTableRow({
             limitType="daily"
             limit={limitDaily}
             label={translations.columns.limitDaily}
+            unit={currencySymbol}
           />
         </div>
 
@@ -424,6 +430,7 @@ export function UserKeyTableRow({
             limitType="weekly"
             limit={limitWeekly}
             label={translations.columns.limitWeekly}
+            unit={currencySymbol}
           />
         </div>
 
@@ -434,6 +441,7 @@ export function UserKeyTableRow({
             limitType="monthly"
             limit={limitMonthly}
             label={translations.columns.limitMonthly}
+            unit={currencySymbol}
           />
         </div>
 
@@ -444,6 +452,7 @@ export function UserKeyTableRow({
             limitType="total"
             limit={limitTotal}
             label={translations.columns.limitTotal}
+            unit={currencySymbol}
           />
         </div>
 

+ 1 - 1
src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx

@@ -70,7 +70,7 @@ export function UserLimitBadge({
   limitType,
   limit,
   label,
-  unit = "$",
+  unit = "",
 }: UserLimitBadgeProps) {
   const [usageData, setUsageData] = useState<LimitUsageData | null>(null);
   const [isLoading, setIsLoading] = useState(false);

+ 1 - 0
src/app/[locale]/dashboard/logs/_components/column-visibility-dropdown.tsx

@@ -33,6 +33,7 @@ const COLUMN_LABEL_KEYS: Record<LogsTableColumn, string> = {
   sessionId: "logs.columns.sessionId",
   provider: "logs.columns.provider",
   tokens: "logs.columns.tokens",
+  cost: "logs.columns.cost",
   cache: "logs.columns.cache",
   performance: "logs.columns.performance",
 };

+ 1 - 1
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -361,7 +361,7 @@ export function UsageLogsTable({
                         </TooltipProvider>
                       ) : isNonBilling ? (
                         "-"
-                      ) : log.costUsd ? (
+                      ) : log.costUsd != null ? (
                         <TooltipProvider>
                           <Tooltip delayDuration={250}>
                             <TooltipTrigger asChild>

+ 53 - 53
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
 import { RelativeTime } from "@/components/ui/relative-time";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { useVirtualizer } from "@/hooks/use-virtualizer";
+import type { LogsTableColumn } from "@/lib/column-visibility";
 import { cn, formatTokenAmount } from "@/lib/utils";
 import { copyTextToClipboard } from "@/lib/utils/clipboard";
 import type { CurrencyCode } from "@/lib/utils/currency";
@@ -44,15 +45,6 @@ export interface VirtualizedLogsTableFilters {
   minRetryCount?: number;
 }
 
-type VirtualizedLogsTableColumn =
-  | "user"
-  | "key"
-  | "sessionId"
-  | "provider"
-  | "tokens"
-  | "cache"
-  | "performance";
-
 interface VirtualizedLogsTableProps {
   filters: VirtualizedLogsTableFilters;
   currencyCode?: CurrencyCode;
@@ -61,7 +53,7 @@ interface VirtualizedLogsTableProps {
   autoRefreshIntervalMs?: number;
   hideStatusBar?: boolean;
   hideScrollToTop?: boolean;
-  hiddenColumns?: VirtualizedLogsTableColumn[];
+  hiddenColumns?: LogsTableColumn[];
   bodyClassName?: string;
 }
 
@@ -87,6 +79,7 @@ export function VirtualizedLogsTable({
   const hideSessionIdColumn = hiddenColumns?.includes("sessionId") ?? false;
   const hideTokensColumn = hiddenColumns?.includes("tokens") ?? false;
   const hideCacheColumn = hiddenColumns?.includes("cache") ?? false;
+  const hideCostColumn = hiddenColumns?.includes("cost") ?? false;
   const hidePerformanceColumn = hiddenColumns?.includes("performance") ?? false;
 
   // Dialog state for model redirect click and chain item click
@@ -273,12 +266,14 @@ export function VirtualizedLogsTable({
                   {t("logs.columns.cache")}
                 </div>
               )}
-              <div
-                className="flex-[0.7] min-w-[60px] text-right px-1.5 truncate"
-                title={t("logs.columns.cost")}
-              >
-                {t("logs.columns.cost")}
-              </div>
+              {hideCostColumn ? null : (
+                <div
+                  className="flex-[0.7] min-w-[60px] text-right px-1.5 truncate"
+                  title={t("logs.columns.cost")}
+                >
+                  {t("logs.columns.cost")}
+                </div>
+              )}
               {hidePerformanceColumn ? null : (
                 <div
                   className="flex-[0.8] min-w-[80px] text-right px-1.5 truncate"
@@ -612,46 +607,51 @@ export function VirtualizedLogsTable({
                     )}
 
                     {/* Cost */}
-                    <div className="flex-[0.7] min-w-[60px] text-right font-mono text-xs px-1.5">
-                      {isNonBilling ? (
-                        "-"
-                      ) : log.costUsd ? (
-                        <TooltipProvider>
-                          <Tooltip delayDuration={250}>
-                            <TooltipTrigger asChild>
-                              <span className="cursor-help inline-flex items-center gap-1">
-                                {formatCurrency(log.costUsd, currencyCode, 6)}
+                    {hideCostColumn ? null : (
+                      <div className="flex-[0.7] min-w-[60px] text-right font-mono text-xs px-1.5">
+                        {isNonBilling ? (
+                          "-"
+                        ) : log.costUsd != null ? (
+                          <TooltipProvider>
+                            <Tooltip delayDuration={250}>
+                              <TooltipTrigger asChild>
+                                <span className="cursor-help inline-flex items-center gap-1">
+                                  {formatCurrency(log.costUsd, currencyCode, 6)}
+                                  {log.context1mApplied && (
+                                    <Badge
+                                      variant="outline"
+                                      className="text-[10px] leading-tight px-1 bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/30 dark:text-purple-300 dark:border-purple-800"
+                                    >
+                                      1M
+                                    </Badge>
+                                  )}
+                                </span>
+                              </TooltipTrigger>
+                              <TooltipContent
+                                align="end"
+                                className="text-xs space-y-1 max-w-[300px]"
+                              >
                                 {log.context1mApplied && (
-                                  <Badge
-                                    variant="outline"
-                                    className="text-[10px] leading-tight px-1 bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/30 dark:text-purple-300 dark:border-purple-800"
-                                  >
-                                    1M
-                                  </Badge>
+                                  <div className="text-purple-600 dark:text-purple-400 font-medium">
+                                    {t("logs.billingDetails.context1m")}
+                                  </div>
                                 )}
-                              </span>
-                            </TooltipTrigger>
-                            <TooltipContent align="end" className="text-xs space-y-1 max-w-[300px]">
-                              {log.context1mApplied && (
-                                <div className="text-purple-600 dark:text-purple-400 font-medium">
-                                  {t("logs.billingDetails.context1m")}
+                                <div>
+                                  {t("logs.billingDetails.input")}:{" "}
+                                  {formatTokenAmount(log.inputTokens)} tokens
                                 </div>
-                              )}
-                              <div>
-                                {t("logs.billingDetails.input")}:{" "}
-                                {formatTokenAmount(log.inputTokens)} tokens
-                              </div>
-                              <div>
-                                {t("logs.billingDetails.output")}:{" "}
-                                {formatTokenAmount(log.outputTokens)} tokens
-                              </div>
-                            </TooltipContent>
-                          </Tooltip>
-                        </TooltipProvider>
-                      ) : (
-                        "-"
-                      )}
-                    </div>
+                                <div>
+                                  {t("logs.billingDetails.output")}:{" "}
+                                  {formatTokenAmount(log.outputTokens)} tokens
+                                </div>
+                              </TooltipContent>
+                            </Tooltip>
+                          </TooltipProvider>
+                        ) : (
+                          "-"
+                        )}
+                      </div>
+                    )}
 
                     {/* Performance */}
                     {hidePerformanceColumn ? null : (

+ 13 - 1
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -23,6 +23,7 @@ import {
 import { Skeleton } from "@/components/ui/skeleton";
 import { TagInput } from "@/components/ui/tag-input";
 import { useDebounce } from "@/lib/hooks/use-debounce";
+import type { CurrencyCode } from "@/lib/utils/currency";
 import type { User, UserDisplay } from "@/types/user";
 import { AddKeyDialog } from "../_components/user/add-key-dialog";
 import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog";
@@ -191,6 +192,17 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
     enabled: isAdmin,
   });
 
+  // Fetch system settings for currency display
+  const { data: systemSettings } = useQuery({
+    queryKey: ["system-settings"],
+    queryFn: async () => {
+      const response = await fetch("/api/system-settings");
+      if (!response.ok) throw new Error("Failed to fetch settings");
+      return response.json() as Promise<{ currencyDisplay: CurrencyCode }>;
+    },
+    staleTime: 30_000,
+  });
+
   const allUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]);
   const visibleUsers = useMemo(() => {
     if (isAdmin) return allUsers;
@@ -691,7 +703,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
             onLoadMore={fetchNextPage}
             scrollResetKey={scrollResetKey}
             currentUser={currentUser}
-            currencyCode="USD"
+            currencyCode={systemSettings?.currencyDisplay ?? "USD"}
             onCreateUser={isAdmin ? handleCreateUser : handleCreateKey}
             onAddKey={handleAddKey}
             highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined}

+ 30 - 5
src/app/[locale]/internal/dashboard/big-screen/page.tsx

@@ -39,6 +39,7 @@ import useSWR from "swr";
 import { getDashboardRealtimeData } from "@/actions/dashboard-realtime";
 import { type Locale, localeLabels, locales } from "@/i18n/config";
 import { usePathname, useRouter } from "@/i18n/routing";
+import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils/currency";
 
 /**
  * ============================================================================
@@ -411,6 +412,7 @@ const UserRankings = ({
   users,
   theme,
   t,
+  currencySymbol,
 }: {
   users: Array<{
     userId: number;
@@ -420,6 +422,7 @@ const UserRankings = ({
   }>;
   theme: (typeof THEMES)[keyof typeof THEMES];
   t: (key: string) => string;
+  currencySymbol: string;
 }) => {
   return (
     <div className="h-full flex flex-col relative">
@@ -467,7 +470,8 @@ const UserRankings = ({
               <div className="flex justify-between items-center">
                 <span className={`text-xs font-bold truncate ${theme.text}`}>{user.userName}</span>
                 <span className="text-[10px] text-gray-500 font-mono">
-                  ${Number(user.totalCost).toFixed(2)}
+                  {currencySymbol}
+                  {Number(user.totalCost).toFixed(2)}
                 </span>
               </div>
               <div className="flex justify-between items-center mt-1">
@@ -493,6 +497,7 @@ const ProviderRanking = ({
   providers,
   theme,
   t,
+  currencySymbol,
 }: {
   providers: Array<{
     providerId: number;
@@ -502,6 +507,7 @@ const ProviderRanking = ({
   }>;
   theme: (typeof THEMES)[keyof typeof THEMES];
   t: (key: string) => string;
+  currencySymbol: string;
 }) => {
   return (
     <div className="h-full flex flex-col">
@@ -523,7 +529,8 @@ const ProviderRanking = ({
             </div>
             <div className="text-right">
               <div className={`text-xs font-mono ${theme.accent}`}>
-                ${Number(p.totalCost).toFixed(2)}
+                {currencySymbol}
+                {Number(p.totalCost).toFixed(2)}
               </div>
               <div className="text-[9px] text-gray-500">
                 {p.totalTokens.toLocaleString()} Tokens
@@ -741,6 +748,19 @@ export default function BigScreenPage() {
     }
   );
 
+  // Fetch system settings for currency display
+  const { data: systemSettings } = useSWR(
+    "system-settings",
+    async () => {
+      const response = await fetch("/api/system-settings");
+      if (!response.ok) throw new Error("Failed to fetch settings");
+      return response.json() as Promise<{ currencyDisplay: CurrencyCode }>;
+    },
+    { revalidateOnFocus: false }
+  );
+
+  const currencySymbol = CURRENCY_CONFIG[systemSettings?.currencyDisplay ?? "USD"]?.symbol ?? "$";
+
   // 处理数据
   const metrics = data?.metrics || {
     concurrentSessions: 0,
@@ -837,7 +857,7 @@ export default function BigScreenPage() {
           />
           <MetricCard
             title={t("metrics.cost")}
-            value={<CountUp value={metrics.todayCost} prefix="$" decimals={2} />}
+            value={<CountUp value={metrics.todayCost} prefix={currencySymbol} decimals={2} />}
             subValue="Budget"
             type="neutral"
             icon={DollarSign}
@@ -866,10 +886,15 @@ export default function BigScreenPage() {
           {/* LEFT COL */}
           <div className="col-span-3 flex flex-col gap-4 h-full">
             <div className={`flex-[3] ${theme.card} rounded-lg p-4 overflow-hidden`}>
-              <UserRankings users={users} theme={theme} t={t} />
+              <UserRankings users={users} theme={theme} t={t} currencySymbol={currencySymbol} />
             </div>
             <div className={`flex-[2] ${theme.card} rounded-lg p-4 overflow-hidden`}>
-              <ProviderRanking providers={providerRankings} theme={theme} t={t} />
+              <ProviderRanking
+                providers={providerRankings}
+                theme={theme}
+                t={t}
+                currencySymbol={currencySymbol}
+              />
             </div>
           </div>
 

+ 107 - 0
src/app/[locale]/settings/providers/_components/endpoint-status.ts

@@ -0,0 +1,107 @@
+import {
+  AlertTriangle,
+  Ban,
+  CheckCircle2,
+  HelpCircle,
+  type LucideIcon,
+  XCircle,
+} from "lucide-react";
+import type { ProviderEndpoint } from "@/types/provider";
+
+export type EndpointCircuitState = "closed" | "open" | "half-open";
+
+export type EndpointStatusSeverity = "success" | "error" | "warning" | "neutral";
+
+export type EndpointStatusToken =
+  | "healthy"
+  | "unhealthy"
+  | "unknown"
+  | "circuit-open"
+  | "circuit-half-open";
+
+export interface EndpointStatusModel {
+  status: EndpointStatusToken;
+  labelKey: string;
+  severity: EndpointStatusSeverity;
+  icon: LucideIcon;
+  color: string;
+  bgColor: string;
+  borderColor: string;
+}
+
+/**
+ * Determines the UI status model for an endpoint based on its probe snapshot and circuit state.
+ *
+ * Logic:
+ * 1. Circuit Open -> 'circuit-open' (Error)
+ * 2. Circuit Half-Open -> 'circuit-half-open' (Warning)
+ * 3. Circuit Closed (or missing):
+ *    - lastProbeOk === true -> 'healthy' (Success)
+ *    - lastProbeOk === false -> 'unhealthy' (Error)
+ *    - lastProbeOk === null -> 'unknown' (Neutral)
+ */
+export function getEndpointStatusModel(
+  endpoint: Pick<ProviderEndpoint, "lastProbeOk">,
+  circuitState?: EndpointCircuitState | null
+): EndpointStatusModel {
+  // 1. Circuit Breaker Priority
+  if (circuitState === "open") {
+    return {
+      status: "circuit-open",
+      labelKey: "settings.providers.endpointStatus.circuitOpen",
+      severity: "error",
+      icon: Ban,
+      color: "text-rose-500",
+      bgColor: "bg-rose-500/10",
+      borderColor: "border-rose-500/30",
+    };
+  }
+
+  if (circuitState === "half-open") {
+    return {
+      status: "circuit-half-open",
+      labelKey: "settings.providers.endpointStatus.circuitHalfOpen",
+      severity: "warning",
+      icon: AlertTriangle,
+      color: "text-amber-500",
+      bgColor: "bg-amber-500/10",
+      borderColor: "border-amber-500/30",
+    };
+  }
+
+  // 2. Probe Status Fallback (Circuit Closed)
+  if (endpoint.lastProbeOk === true) {
+    return {
+      status: "healthy",
+      labelKey: "settings.providers.endpointStatus.healthy",
+      severity: "success",
+      icon: CheckCircle2,
+      color: "text-emerald-500",
+      bgColor: "bg-emerald-500/10",
+      borderColor: "border-emerald-500/30",
+    };
+  }
+
+  if (endpoint.lastProbeOk === false) {
+    return {
+      status: "unhealthy",
+      labelKey: "settings.providers.endpointStatus.unhealthy",
+      severity: "error",
+      icon: XCircle,
+      color: "text-rose-500",
+      bgColor: "bg-rose-500/10",
+      borderColor: "border-rose-500/30",
+    };
+  }
+
+  // 3. Unknown
+  return {
+    status: "unknown",
+    labelKey: "settings.providers.endpointStatus.unknown",
+    severity: "neutral",
+    icon: HelpCircle,
+    color: "text-slate-400",
+    bgColor: "bg-slate-400/10",
+    borderColor: "border-slate-400/30",
+  };
+}

+ 137 - 9
src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx

@@ -1,8 +1,10 @@
 "use client";
 
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
 import { toast } from "sonner";
+import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints";
 import { addProvider, editProvider, removeProvider } from "@/actions/providers";
 import { getDistinctProviderGroupsAction } from "@/actions/request-filters";
 import {
@@ -18,7 +20,12 @@ import {
 } from "@/components/ui/alert-dialog";
 import { Button } from "@/components/ui/button";
 import { isValidUrl } from "@/lib/utils/validation";
-import type { ProviderDisplay, ProviderType } from "@/types/provider";
+import type {
+  ProviderDisplay,
+  ProviderEndpoint,
+  ProviderType,
+  ProviderVendor,
+} from "@/types/provider";
 import { FormTabNav } from "./components/form-tab-nav";
 import { ProviderFormProvider, useProviderForm } from "./provider-form-context";
 import type { TabId } from "./provider-form-types";
@@ -28,6 +35,29 @@ import { NetworkSection } from "./sections/network-section";
 import { RoutingSection } from "./sections/routing-section";
 import { TestingSection } from "./sections/testing-section";
 
+function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null {
+  const trimmed = rawUrl.trim();
+  if (!trimmed) return null;
+
+  const candidates = [trimmed];
+  if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
+    candidates.push(`https://${trimmed}`);
+  }
+
+  for (const candidate of candidates) {
+    try {
+      const parsed = new URL(candidate);
+      const hostname = parsed.hostname?.toLowerCase();
+      if (!hostname) continue;
+      return hostname.startsWith("www.") ? hostname.slice(4) : hostname;
+    } catch {
+      // ignore
+    }
+  }
+
+  return null;
+}
+
 export interface ProviderFormProps {
   mode: "create" | "edit";
   onSuccess?: () => void;
@@ -61,6 +91,80 @@ function ProviderFormContent({
   const [isPending, startTransition] = useTransition();
   const isEdit = mode === "edit";
 
+  const queryClient = useQueryClient();
+  const { data: vendors = [] } = useQuery<ProviderVendor[]>({
+    queryKey: ["provider-vendors"],
+    queryFn: getProviderVendors,
+  });
+
+  const websiteDomain = useMemo(
+    () => normalizeWebsiteDomainFromUrl(state.basic.websiteUrl),
+    [state.basic.websiteUrl]
+  );
+
+  const resolvedEndpointPoolVendorId = useMemo(() => {
+    // Edit mode: vendor id already attached to provider record
+    if (isEdit) {
+      return provider?.providerVendorId ?? null;
+    }
+
+    // Create/clone: resolve vendor from websiteUrl hostname
+    if (!websiteDomain) return null;
+    const vendor = vendors.find((v) => v.websiteDomain === websiteDomain);
+    return vendor?.id ?? null;
+  }, [isEdit, provider?.providerVendorId, vendors, websiteDomain]);
+
+  const endpointPoolQueryKey = useMemo(() => {
+    if (resolvedEndpointPoolVendorId == null) return null;
+    return [
+      "provider-endpoints",
+      resolvedEndpointPoolVendorId,
+      state.routing.providerType,
+      "provider-form",
+    ] as const;
+  }, [resolvedEndpointPoolVendorId, state.routing.providerType]);
+
+  const { data: endpointPoolEndpoints = [] } = useQuery<ProviderEndpoint[]>({
+    enabled: !hideUrl && endpointPoolQueryKey != null,
+    queryKey: endpointPoolQueryKey ?? ["provider-endpoints", "unresolved", "provider-form"],
+    queryFn: async () => {
+      if (resolvedEndpointPoolVendorId == null) return [];
+      return await getProviderEndpoints({
+        vendorId: resolvedEndpointPoolVendorId,
+        providerType: state.routing.providerType,
+      });
+    },
+  });
+
+  const enabledEndpointPoolEndpoints = useMemo(
+    () => endpointPoolEndpoints.filter((e) => e.isEnabled && !e.deletedAt),
+    [endpointPoolEndpoints]
+  );
+
+  const endpointPoolHasEnabledEndpoints = enabledEndpointPoolEndpoints.length > 0;
+  const endpointPoolPreferredUrl =
+    (enabledEndpointPoolEndpoints[0] ?? endpointPoolEndpoints[0])?.url ?? null;
+
+  const endpointPoolHideLegacyUrlInput =
+    !hideUrl && resolvedEndpointPoolVendorId != null && endpointPoolHasEnabledEndpoints;
+
+  // Keep state.basic.url usable across other sections when legacy URL input is hidden.
+  useEffect(() => {
+    if (isEdit) return;
+    if (hideUrl) return;
+    if (!endpointPoolHideLegacyUrlInput) return;
+    if (!endpointPoolPreferredUrl) return;
+    if (state.basic.url.trim()) return;
+    dispatch({ type: "SET_URL", payload: endpointPoolPreferredUrl });
+  }, [
+    isEdit,
+    hideUrl,
+    endpointPoolHideLegacyUrlInput,
+    endpointPoolPreferredUrl,
+    state.basic.url,
+    dispatch,
+  ]);
+
   // Update URL when resolved URL changes
   useEffect(() => {
     if (resolvedUrl && !state.basic.url && !isEdit) {
@@ -142,12 +246,15 @@ function ProviderFormContent({
     if (!state.basic.name.trim()) {
       return t("errors.nameRequired");
     }
-    if (!hideUrl && !state.basic.url.trim()) {
+
+    const needsLegacyUrl = !hideUrl && !endpointPoolHideLegacyUrlInput;
+    if (needsLegacyUrl && !state.basic.url.trim()) {
       return t("errors.urlRequired");
     }
-    if (!hideUrl && !isValidUrl(state.basic.url)) {
+    if (needsLegacyUrl && !isValidUrl(state.basic.url)) {
       return t("errors.invalidUrl");
     }
+
     if (!isEdit && !state.basic.key.trim()) {
       return t("errors.keyRequired");
     }
@@ -187,9 +294,13 @@ function ProviderFormContent({
         const trimmedKey = state.basic.key.trim();
 
         // Base form data without key (for type safety)
+        const effectiveProviderUrl = endpointPoolHideLegacyUrlInput
+          ? (endpointPoolPreferredUrl ?? state.basic.url).trim()
+          : state.basic.url.trim();
+
         const baseFormData = {
           name: state.basic.name.trim(),
-          url: state.basic.url.trim(),
+          url: effectiveProviderUrl,
           website_url: state.basic.websiteUrl?.trim() || null,
           provider_type: state.routing.providerType,
           preserve_client_ip: state.routing.preserveClientIp,
@@ -208,6 +319,7 @@ function ProviderFormContent({
           codex_parallel_tool_calls_preference: state.routing.codexParallelToolCallsPreference,
           anthropic_max_tokens_preference: state.routing.anthropicMaxTokensPreference,
           anthropic_thinking_budget_preference: state.routing.anthropicThinkingBudgetPreference,
+          gemini_google_search_preference: state.routing.geminiGoogleSearchPreference,
           limit_5h_usd: state.rateLimit.limit5hUsd,
           limit_daily_usd: state.rateLimit.limitDailyUsd,
           daily_reset_mode: state.rateLimit.dailyResetMode,
@@ -248,16 +360,20 @@ function ProviderFormContent({
           const createFormData = { ...baseFormData, key: trimmedKey };
           const res = await addProvider(createFormData);
           if (!res.ok) {
-            toast.error(res.error || t("errors.createFailed"));
+            toast.error(res.error || t("errors.addFailed"));
             return;
           }
+
+          queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
+          queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+
           toast.success(t("success.created"));
           dispatch({ type: "RESET_FORM" });
         }
         onSuccess?.();
       } catch (e) {
         console.error("Form submission error:", e);
-        toast.error(isEdit ? t("errors.updateFailed") : t("errors.createFailed"));
+        toast.error(isEdit ? t("errors.updateFailed") : t("errors.addFailed"));
       }
     });
   };
@@ -303,7 +419,8 @@ function ProviderFormContent({
     const status: Partial<Record<TabId, "default" | "warning" | "configured">> = {};
 
     // Basic - warning if required fields missing
-    if (!state.basic.name.trim() || (!hideUrl && !state.basic.url.trim())) {
+    const needsLegacyUrl = !hideUrl && !endpointPoolHideLegacyUrlInput;
+    if (!state.basic.name.trim() || (needsLegacyUrl && !state.basic.url.trim())) {
       status.basic = "warning";
     }
 
@@ -365,7 +482,18 @@ function ProviderFormContent({
                 sectionRefs.current.basic = el;
               }}
             >
-              <BasicInfoSection autoUrlPending={autoUrlPending} />
+              <BasicInfoSection
+                autoUrlPending={autoUrlPending}
+                endpointPool={
+                  !hideUrl && resolvedEndpointPoolVendorId != null
+                    ? {
+                        vendorId: resolvedEndpointPoolVendorId,
+                        providerType: state.routing.providerType,
+                        hideLegacyUrlInput: endpointPoolHideLegacyUrlInput,
+                      }
+                    : null
+                }
+              />
             </div>
 
             {/* Routing Section */}

+ 6 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx

@@ -61,6 +61,7 @@ export function createInitialState(
       anthropicMaxTokensPreference: sourceProvider?.anthropicMaxTokensPreference ?? "inherit",
       anthropicThinkingBudgetPreference:
         sourceProvider?.anthropicThinkingBudgetPreference ?? "inherit",
+      geminiGoogleSearchPreference: sourceProvider?.geminiGoogleSearchPreference ?? "inherit",
     },
     rateLimit: {
       limit5hUsd: sourceProvider?.limit5hUsd ?? null,
@@ -178,6 +179,11 @@ export function providerFormReducer(
         ...state,
         routing: { ...state.routing, anthropicThinkingBudgetPreference: action.payload },
       };
+    case "SET_GEMINI_GOOGLE_SEARCH":
+      return {
+        ...state,
+        routing: { ...state.routing, geminiGoogleSearchPreference: action.payload },
+      };
 
     // Rate limit
     case "SET_LIMIT_5H_USD":

+ 4 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts

@@ -6,6 +6,7 @@ import type {
   CodexReasoningEffortPreference,
   CodexReasoningSummaryPreference,
   CodexTextVerbosityPreference,
+  GeminiGoogleSearchPreference,
   McpPassthroughType,
   ProviderDisplay,
   ProviderType,
@@ -51,6 +52,8 @@ export interface RoutingState {
   // Anthropic-specific
   anthropicMaxTokensPreference: AnthropicMaxTokensPreference;
   anthropicThinkingBudgetPreference: AnthropicThinkingBudgetPreference;
+  // Gemini-specific
+  geminiGoogleSearchPreference: GeminiGoogleSearchPreference;
 }
 
 export interface RateLimitState {
@@ -125,6 +128,7 @@ export type ProviderFormAction =
   | { type: "SET_CODEX_PARALLEL_TOOL_CALLS"; payload: CodexParallelToolCallsPreference }
   | { type: "SET_ANTHROPIC_MAX_TOKENS"; payload: AnthropicMaxTokensPreference }
   | { type: "SET_ANTHROPIC_THINKING_BUDGET"; payload: AnthropicThinkingBudgetPreference }
+  | { type: "SET_GEMINI_GOOGLE_SEARCH"; payload: GeminiGoogleSearchPreference }
   // Rate limit actions
   | { type: "SET_LIMIT_5H_USD"; payload: number | null }
   | { type: "SET_LIMIT_DAILY_USD"; payload: number | null }

+ 53 - 28
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx

@@ -4,7 +4,9 @@ import { motion } from "framer-motion";
 import { ExternalLink, Eye, EyeOff, Globe, Key, Link2, User } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useEffect, useRef, useState } from "react";
+import { ProviderEndpointsSection } from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
 import { Input } from "@/components/ui/input";
+import type { ProviderType } from "@/types/provider";
 import { UrlPreview } from "../../url-preview";
 import { QuickPasteDialog } from "../components/quick-paste-dialog";
 import { SectionCard, SmartInputWrapper } from "../components/section-card";
@@ -12,9 +14,14 @@ import { useProviderForm } from "../provider-form-context";
 
 interface BasicInfoSectionProps {
   autoUrlPending?: boolean;
+  endpointPool?: {
+    vendorId: number;
+    providerType: ProviderType;
+    hideLegacyUrlInput: boolean;
+  } | null;
 }
 
-export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
+export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSectionProps) {
   const t = useTranslations("settings.providers.form");
   const tProviders = useTranslations("settings.providers");
   const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl } = useProviderForm();
@@ -64,8 +71,50 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
         </div>
       </SectionCard>
 
+      {/* Website URL */}
+      {!hideWebsiteUrl && (
+        <SectionCard
+          title={t("websiteUrl.label")}
+          description={t("websiteUrl.desc")}
+          icon={ExternalLink}
+        >
+          <SmartInputWrapper label={t("websiteUrl.label")}>
+            <div className="relative">
+              <Input
+                id={isEdit ? "edit-website-url" : "website-url"}
+                type="url"
+                value={state.basic.websiteUrl}
+                onChange={(e) => dispatch({ type: "SET_WEBSITE_URL", payload: e.target.value })}
+                placeholder={t("websiteUrl.placeholder")}
+                disabled={state.ui.isPending}
+                className="pr-10"
+              />
+              <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
+            </div>
+          </SmartInputWrapper>
+        </SectionCard>
+      )}
+
+      {/* Endpoint Pool */}
+      {!hideUrl && endpointPool?.vendorId ? (
+        <SectionCard
+          title={t("sections.basic.endpointPool.title")}
+          description={t("sections.basic.endpointPool.desc")}
+          icon={Globe}
+        >
+          <div className="-mx-5 -mb-5">
+            <ProviderEndpointsSection
+              vendorId={endpointPool.vendorId}
+              providerType={endpointPool.providerType}
+              hideTypeColumn={true}
+              queryKeySuffix="provider-form"
+            />
+          </div>
+        </SectionCard>
+      ) : null}
+
       {/* API Endpoint */}
-      {!hideUrl ? (
+      {!hideUrl && !endpointPool?.hideLegacyUrlInput ? (
         <SectionCard
           title={t("sections.basic.endpoint.title")}
           description={t("sections.basic.endpoint.desc")}
@@ -98,7 +147,7 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
             )}
           </div>
         </SectionCard>
-      ) : (
+      ) : hideUrl ? (
         <>
           {/* No endpoints warning */}
           {!isEdit && !autoUrlPending && !state.basic.url.trim() && (
@@ -116,7 +165,7 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
             </div>
           )}
         </>
-      )}
+      ) : null}
 
       {/* Authentication */}
       <SectionCard
@@ -153,30 +202,6 @@ export function BasicInfoSection({ autoUrlPending }: BasicInfoSectionProps) {
           </SmartInputWrapper>
         </div>
       </SectionCard>
-
-      {/* Website URL */}
-      {!hideWebsiteUrl && (
-        <SectionCard
-          title={t("websiteUrl.label")}
-          description={t("websiteUrl.desc")}
-          icon={ExternalLink}
-        >
-          <SmartInputWrapper label={t("websiteUrl.label")}>
-            <div className="relative">
-              <Input
-                id={isEdit ? "edit-website-url" : "website-url"}
-                type="url"
-                value={state.basic.websiteUrl}
-                onChange={(e) => dispatch({ type: "SET_WEBSITE_URL", payload: e.target.value })}
-                placeholder={t("websiteUrl.placeholder")}
-                disabled={state.ui.isPending}
-                className="pr-10"
-              />
-              <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
-            </div>
-          </SmartInputWrapper>
-        </SectionCard>
-      )}
     </motion.div>
   );
 }

+ 37 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -22,6 +22,7 @@ import type {
   CodexReasoningEffortPreference,
   CodexReasoningSummaryPreference,
   CodexTextVerbosityPreference,
+  GeminiGoogleSearchPreference,
   ProviderType,
 } from "@/types/provider";
 import { ModelMultiSelect } from "../../../model-multi-select";
@@ -654,6 +655,42 @@ export function RoutingSection() {
             </div>
           </SectionCard>
         )}
+
+        {/* Gemini Overrides - Gemini type only */}
+        {(state.routing.providerType === "gemini" ||
+          state.routing.providerType === "gemini-cli") && (
+          <SectionCard
+            title={t("sections.routing.geminiOverrides.title")}
+            description={t("sections.routing.geminiOverrides.desc")}
+            icon={Settings}
+          >
+            <SmartInputWrapper label={t("sections.routing.geminiOverrides.googleSearch.label")}>
+              <Select
+                value={state.routing.geminiGoogleSearchPreference}
+                onValueChange={(val) =>
+                  dispatch({
+                    type: "SET_GEMINI_GOOGLE_SEARCH",
+                    payload: val as GeminiGoogleSearchPreference,
+                  })
+                }
+                disabled={state.ui.isPending}
+              >
+                <SelectTrigger className="w-full">
+                  <SelectValue
+                    placeholder={t("sections.routing.geminiOverrides.googleSearch.options.inherit")}
+                  />
+                </SelectTrigger>
+                <SelectContent>
+                  {(["inherit", "enabled", "disabled"] as const).map((val) => (
+                    <SelectItem key={val} value={val}>
+                      {t(`sections.routing.geminiOverrides.googleSearch.options.${val}`)}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </SmartInputWrapper>
+          </SectionCard>
+        )}
       </motion.div>
     </TooltipProvider>
   );

+ 154 - 0
src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx

@@ -0,0 +1,154 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Server } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { getEndpointCircuitInfo, getProviderEndpointsByVendor } from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { getEndpointStatusModel } from "./endpoint-status";
+
+interface ProviderEndpointHoverProps {
+  vendorId: number;
+  providerType: ProviderType;
+}
+
+export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpointHoverProps) {
+  const t = useTranslations("settings.providers");
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: allEndpoints = [] } = useQuery({
+    queryKey: ["provider-endpoints", vendorId],
+    queryFn: async () => getProviderEndpointsByVendor({ vendorId }),
+    staleTime: 1000 * 30,
+  });
+
+  const endpoints = useMemo(() => {
+    return allEndpoints
+      .filter(
+        (ep) => ep.providerType === providerType && ep.isEnabled === true && ep.deletedAt === null
+      )
+      .sort((a, b) => {
+        const getStatusScore = (ok: boolean | null) => {
+          if (ok === true) return 0;
+          if (ok === null) return 1;
+          return 2;
+        };
+        const scoreA = getStatusScore(a.lastProbeOk);
+        const scoreB = getStatusScore(b.lastProbeOk);
+        if (scoreA !== scoreB) return scoreA - scoreB;
+
+        const sortA = a.sortOrder ?? 0;
+        const sortB = b.sortOrder ?? 0;
+        if (sortA !== sortB) return sortA - sortB;
+
+        const latA = a.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        const latB = b.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        if (latA !== latB) return latA - latB;
+
+        return a.id - b.id;
+      });
+  }, [allEndpoints, providerType]);
+
+  const count = endpoints.length;
+
+  return (
+    <TooltipProvider>
+      <Tooltip open={isOpen} onOpenChange={setIsOpen} delayDuration={200}>
+        <TooltipTrigger asChild>
+          <div
+            className="flex items-center gap-1.5 cursor-help opacity-80 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded px-1"
+            tabIndex={0}
+            role="button"
+            aria-label={t("endpointStatus.viewDetails", { count })}
+            data-testid="endpoint-hover-trigger"
+          >
+            <Server className="h-3.5 w-3.5 text-muted-foreground" />
+            <span className="text-xs font-medium text-muted-foreground tabular-nums">{count}</span>
+          </div>
+        </TooltipTrigger>
+        <TooltipContent
+          side="right"
+          className="p-0 border shadow-lg rounded-lg overflow-hidden min-w-[280px] max-w-[320px] bg-popover text-popover-foreground"
+        >
+          <div className="bg-muted/40 px-3 py-2 border-b">
+            <h4 className="text-xs font-semibold text-foreground">
+              {t("endpointStatus.activeEndpoints")} ({count})
+            </h4>
+          </div>
+          <div className="max-h-[300px] overflow-y-auto py-1">
+            {count === 0 ? (
+              <div className="px-3 py-4 text-center text-xs text-muted-foreground">
+                {t("endpointStatus.noEndpoints")}
+              </div>
+            ) : (
+              <div className="flex flex-col gap-0.5">
+                {endpoints.map((endpoint) => (
+                  <EndpointRow key={endpoint.id} endpoint={endpoint} isOpen={isOpen} />
+                ))}
+              </div>
+            )}
+          </div>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}
+
+function EndpointRow({ endpoint, isOpen }: { endpoint: ProviderEndpoint; isOpen: boolean }) {
+  const t = useTranslations("settings.providers");
+
+  const { data: circuitResult } = useQuery({
+    queryKey: ["endpoint-circuit", endpoint.id],
+    queryFn: async () => getEndpointCircuitInfo({ endpointId: endpoint.id }),
+    enabled: isOpen,
+    staleTime: 1000 * 10,
+  });
+
+  const circuitState =
+    circuitResult?.ok && circuitResult.data ? circuitResult.data.health.circuitState : undefined;
+
+  const statusModel = getEndpointStatusModel(endpoint, circuitState);
+  const Icon = statusModel.icon;
+
+  return (
+    <div className="px-3 py-2 hover:bg-muted/50 transition-colors flex items-start gap-3 group">
+      <div className="mt-0.5 shrink-0">
+        <Icon className={cn("h-3.5 w-3.5", statusModel.color)} />
+      </div>
+      <div className="flex-1 min-w-0 space-y-1">
+        <div className="flex items-center justify-between gap-2">
+          <span className="text-xs font-medium truncate text-foreground/90">{endpoint.url}</span>
+          {endpoint.lastProbeLatencyMs != null && (
+            <span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
+              {endpoint.lastProbeLatencyMs}ms
+            </span>
+          )}
+        </div>
+
+        <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
+          <span className={cn("font-medium", statusModel.color)}>
+            {t(statusModel.labelKey.replace("settings.providers.", ""))}
+          </span>
+
+          {(circuitState === "open" || circuitState === "half-open") && (
+            <Badge
+              variant="outline"
+              className={cn(
+                "h-4 px-1 text-[9px] uppercase tracking-wider border-current opacity-80",
+                statusModel.color
+              )}
+            >
+              {circuitState === "open"
+                ? t("endpointStatus.circuitOpen")
+                : t("endpointStatus.circuitHalfOpen")}
+            </Badge>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 716 - 0
src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx

@@ -0,0 +1,716 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Edit2, Loader2, MoreHorizontal, Play, Plus, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import {
+  addProviderEndpoint,
+  editProviderEndpoint,
+  getProviderEndpoints,
+  getProviderEndpointsByVendor,
+  probeProviderEndpoint,
+  removeProviderEndpoint,
+} from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RelativeTime } from "@/components/ui/relative-time";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+  getAllProviderTypes,
+  getProviderTypeConfig,
+  getProviderTypeTranslationKey,
+} from "@/lib/provider-type-utils";
+import { getErrorMessage } from "@/lib/utils/error-messages";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
+import { UrlPreview } from "./forms/url-preview";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderEndpointsTableProps {
+  /** Vendor ID to fetch endpoints for */
+  vendorId: number;
+  /** Optional: filter endpoints by providerType. If undefined, shows all types. */
+  providerType?: ProviderType;
+  /** If true, hides add/edit/delete actions (view-only mode) */
+  readOnly?: boolean;
+  /** If true, hides the type column (useful when filtering by single type) */
+  hideTypeColumn?: boolean;
+  /** Custom query key suffix for cache isolation */
+  queryKeySuffix?: string;
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+/**
+ * Reusable endpoint CRUD table component.
+ * Supports filtering by providerType and read-only mode for ProviderForm reuse.
+ */
+export function ProviderEndpointsTable({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsTableProps) {
+  const t = useTranslations("settings.providers");
+  const tTypes = useTranslations("settings.providers.types");
+
+  // Build query key based on whether we filter by type
+  const queryKey = providerType
+    ? ["provider-endpoints", vendorId, providerType, queryKeySuffix].filter(
+        (value) => value != null
+      )
+    : ["provider-endpoints", vendorId, queryKeySuffix].filter((value) => value != null);
+
+  const { data: rawEndpoints = [], isLoading } = useQuery({
+    queryKey,
+    queryFn: async () => {
+      if (providerType) {
+        return await getProviderEndpoints({ vendorId, providerType });
+      }
+      return await getProviderEndpointsByVendor({ vendorId });
+    },
+  });
+
+  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
+  const endpoints = useMemo(() => {
+    const typeOrder = getAllProviderTypes();
+    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
+
+    return [...rawEndpoints].sort((a, b) => {
+      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
+      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
+      if (aTypeIndex !== bTypeIndex) {
+        return aTypeIndex - bTypeIndex;
+      }
+      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
+    });
+  }, [rawEndpoints]);
+
+  if (isLoading) {
+    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
+  }
+
+  if (endpoints.length === 0) {
+    return (
+      <div className="text-center py-8 border rounded-md border-dashed">
+        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
+        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="border rounded-md">
+      <Table>
+        <TableHeader>
+          <TableRow>
+            {!hideTypeColumn && <TableHead className="w-[60px]">{t("columnType")}</TableHead>}
+            <TableHead>{t("columnUrl")}</TableHead>
+            <TableHead>{t("status")}</TableHead>
+            <TableHead className="w-[220px]">{t("latency")}</TableHead>
+            {!readOnly && <TableHead className="text-right">{t("columnActions")}</TableHead>}
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {endpoints.map((endpoint) => (
+            <EndpointRow
+              key={endpoint.id}
+              endpoint={endpoint}
+              tTypes={tTypes}
+              readOnly={readOnly}
+              hideTypeColumn={hideTypeColumn}
+            />
+          ))}
+        </TableBody>
+      </Table>
+    </div>
+  );
+}
+
+// ============================================================================
+// EndpointRow
+// ============================================================================
+
+function EndpointRow({
+  endpoint,
+  tTypes,
+  readOnly,
+  hideTypeColumn,
+}: {
+  endpoint: ProviderEndpoint;
+  tTypes: ReturnType<typeof useTranslations>;
+  readOnly: boolean;
+  hideTypeColumn: boolean;
+}) {
+  const t = useTranslations("settings.providers");
+  const tCommon = useTranslations("settings.common");
+  const queryClient = useQueryClient();
+  const [isProbing, setIsProbing] = useState(false);
+  const [isToggling, setIsToggling] = useState(false);
+
+  const typeConfig = getProviderTypeConfig(endpoint.providerType);
+  const TypeIcon = typeConfig.icon;
+  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
+  const typeLabel = tTypes(`${typeKey}.label`);
+
+  const probeMutation = useMutation({
+    mutationFn: async () => {
+      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsProbing(true),
+    onSettled: () => setIsProbing(false),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      if (data?.result.ok) {
+        toast.success(t("probeSuccess"));
+      } else {
+        toast.error(
+          data?.result.errorMessage
+            ? `${t("probeFailed")}: ${data.result.errorMessage}`
+            : t("probeFailed")
+        );
+      }
+    },
+    onError: () => {
+      toast.error(t("probeFailed"));
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: async () => {
+      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
+      toast.success(t("endpointDeleteSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointDeleteFailed"));
+    },
+  });
+
+  const toggleMutation = useMutation({
+    mutationFn: async (nextEnabled: boolean) => {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        isEnabled: nextEnabled,
+      });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsToggling(true),
+    onSettled: () => setIsToggling(false),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      toast.success(t("endpointUpdateSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointUpdateFailed"));
+    },
+  });
+
+  return (
+    <TableRow>
+      {!hideTypeColumn && (
+        <TableCell>
+          <TooltipProvider>
+            <Tooltip delayDuration={200}>
+              <TooltipTrigger asChild>
+                <span
+                  className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
+                >
+                  <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
+                </span>
+              </TooltipTrigger>
+              <TooltipContent>{typeLabel}</TooltipContent>
+            </Tooltip>
+          </TooltipProvider>
+        </TableCell>
+      )}
+      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
+        {endpoint.url}
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-2">
+          {endpoint.isEnabled ? (
+            <Badge
+              variant="secondary"
+              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
+            >
+              {t("enabledStatus")}
+            </Badge>
+          ) : (
+            <Badge variant="outline">{t("disabledStatus")}</Badge>
+          )}
+          {!readOnly && (
+            <Switch
+              checked={endpoint.isEnabled}
+              onCheckedChange={(checked) => toggleMutation.mutate(checked)}
+              disabled={isToggling}
+              aria-label={t("enabledStatus")}
+            />
+          )}
+        </div>
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-3">
+          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
+          <RelativeTime
+            date={endpoint.lastProbedAt}
+            format="short"
+            fallback="-"
+            className="text-muted-foreground text-[10px] whitespace-nowrap"
+          />
+        </div>
+      </TableCell>
+      {!readOnly && (
+        <TableCell className="text-right">
+          <div className="flex justify-end gap-2">
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => probeMutation.mutate()}
+              disabled={isProbing}
+            >
+              {isProbing ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                <Play className="h-4 w-4" />
+              )}
+            </Button>
+
+            <EditEndpointDialog endpoint={endpoint} />
+
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" className="h-8 w-8">
+                  <MoreHorizontal className="h-4 w-4" />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem
+                  className="text-destructive focus:text-destructive"
+                  onClick={() => {
+                    if (confirm(t("confirmDeleteEndpoint"))) {
+                      deleteMutation.mutate();
+                    }
+                  }}
+                >
+                  <Trash2 className="mr-2 h-4 w-4" />
+                  {tCommon("delete")}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </TableCell>
+      )}
+    </TableRow>
+  );
+}
+
+// ============================================================================
+// AddEndpointButton
+// ============================================================================
+
+export interface AddEndpointButtonProps {
+  vendorId: number;
+  /** If provided, locks the type selector to this value */
+  providerType?: ProviderType;
+  /** Custom query key suffix for cache invalidation */
+  queryKeySuffix?: string;
+}
+
+export function AddEndpointButton({
+  vendorId,
+  providerType: fixedProviderType,
+  queryKeySuffix,
+}: AddEndpointButtonProps) {
+  const t = useTranslations("settings.providers");
+  const tErrors = useTranslations("errors");
+  const tTypes = useTranslations("settings.providers.types");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [url, setUrl] = useState("");
+  const [label, setLabel] = useState("");
+  const [sortOrder, setSortOrder] = useState(0);
+  const [isEnabled, setIsEnabled] = useState(true);
+  const [providerType, setProviderType] = useState<ProviderType>(fixedProviderType ?? "claude");
+
+  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
+    (type) => !["claude-auth", "gemini-cli"].includes(type)
+  );
+
+  useEffect(() => {
+    if (!open) {
+      setUrl("");
+      setLabel("");
+      setSortOrder(0);
+      setIsEnabled(true);
+      setProviderType(fixedProviderType ?? "claude");
+    }
+  }, [open, fixedProviderType]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const endpointUrl = formData.get("url") as string;
+    const endpointLabel = formData.get("label") as string;
+    const endpointSortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await addProviderEndpoint({
+        vendorId,
+        providerType,
+        url: endpointUrl,
+        label: endpointLabel.trim() || null,
+        sortOrder: endpointSortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointAddSuccess"));
+        setOpen(false);
+        // Invalidate both specific and general queries
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
+        if (fixedProviderType) {
+          queryClient.invalidateQueries({
+            queryKey: ["provider-endpoints", vendorId, fixedProviderType, queryKeySuffix].filter(
+              (value) => value != null
+            ),
+          });
+        }
+      } else {
+        toast.error(
+          res.errorCode
+            ? getErrorMessage(tErrors, res.errorCode, res.errorParams)
+            : t("endpointAddFailed")
+        );
+      }
+    } catch (_err) {
+      toast.error(t("endpointAddFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const showTypeSelector = !fixedProviderType;
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button size="sm" className="h-7 gap-1">
+          <Plus className="h-3.5 w-3.5" />
+          {t("addEndpoint")}
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("addEndpoint")}</DialogTitle>
+          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {showTypeSelector && (
+            <div className="space-y-2">
+              <Label htmlFor="providerType">{t("columnType")}</Label>
+              <Select
+                value={providerType}
+                onValueChange={(value) => setProviderType(value as ProviderType)}
+              >
+                <SelectTrigger id="providerType">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {selectableTypes.map((type) => {
+                    const typeConfig = getProviderTypeConfig(type);
+                    const TypeIcon = typeConfig.icon;
+                    const typeKey = getProviderTypeTranslationKey(type);
+                    const label = tTypes(`${typeKey}.label`);
+                    return (
+                      <SelectItem key={type} value={type}>
+                        <div className="flex items-center gap-2">
+                          <span
+                            className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
+                          >
+                            <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
+                          </span>
+                          {label}
+                        </div>
+                      </SelectItem>
+                    );
+                  })}
+                </SelectContent>
+              </Select>
+            </div>
+          )}
+
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input
+              id="url"
+              name="url"
+              placeholder={t("endpointUrlPlaceholder")}
+              required
+              onChange={(e) => setUrl(e.target.value)}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              value={label}
+              onChange={(e) => setLabel(e.target.value)}
+            />
+          </div>
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                value={sortOrder}
+                onChange={(e) => setSortOrder(Number.parseInt(e.target.value, 10) || 0)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+
+          <UrlPreview baseUrl={url} providerType={providerType} />
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("create")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// EditEndpointDialog
+// ============================================================================
+
+function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
+  const t = useTranslations("settings.providers");
+  const tErrors = useTranslations("errors");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isEnabled, setIsEnabled] = useState(endpoint.isEnabled);
+
+  useEffect(() => {
+    if (open) {
+      setIsEnabled(endpoint.isEnabled);
+    }
+  }, [open, endpoint.isEnabled]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const url = formData.get("url") as string;
+    const label = formData.get("label") as string;
+    const sortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        url,
+        label: label.trim() || null,
+        sortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointUpdateSuccess"));
+        setOpen(false);
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      } else {
+        toast.error(
+          res.errorCode
+            ? getErrorMessage(tErrors, res.errorCode, res.errorParams)
+            : t("endpointUpdateFailed")
+        );
+      }
+    } catch (_err) {
+      toast.error(t("endpointUpdateFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button variant="ghost" size="icon" className="h-8 w-8">
+          <Edit2 className="h-4 w-4" />
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("editEndpoint")}</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input id="url" name="url" defaultValue={endpoint.url} required />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              defaultValue={endpoint.label ?? ""}
+            />
+          </div>
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                defaultValue={endpoint.sortOrder ?? 0}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("save")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// ProviderEndpointsSection (convenience wrapper)
+// ============================================================================
+
+export interface ProviderEndpointsSectionProps {
+  vendorId: number;
+  providerType?: ProviderType;
+  readOnly?: boolean;
+  hideTypeColumn?: boolean;
+  queryKeySuffix?: string;
+}
+
+/**
+ * Section wrapper that includes header with Add button and the table.
+ * Use this for full section rendering (like in VendorCard).
+ */
+export function ProviderEndpointsSection({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsSectionProps) {
+  const t = useTranslations("settings.providers");
+
+  return (
+    <div>
+      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
+        <span>{t("endpoints")}</span>
+        {!readOnly && (
+          <AddEndpointButton
+            vendorId={vendorId}
+            providerType={providerType}
+            queryKeySuffix={queryKeySuffix}
+          />
+        )}
+      </div>
+
+      <div className="p-6">
+        <ProviderEndpointsTable
+          vendorId={vendorId}
+          providerType={providerType}
+          readOnly={readOnly}
+          hideTypeColumn={hideTypeColumn}
+          queryKeySuffix={queryKeySuffix}
+        />
+      </div>
+    </div>
+  );
+}

+ 24 - 3
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -1,5 +1,5 @@
 "use client";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import {
   AlertTriangle,
   CheckCircle,
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
+import { getProviderVendors } from "@/actions/provider-endpoints";
 import {
   editProvider,
   getUnmaskedProviderKey,
@@ -55,6 +56,7 @@ import type { ProviderDisplay, ProviderStatistics } from "@/types/provider";
 import type { User } from "@/types/user";
 import { ProviderForm } from "./forms/provider-form";
 import { InlineEditPopover } from "./inline-edit-popover";
+import { ProviderEndpointHover } from "./provider-endpoint-hover";
 
 interface ProviderRichListItemProps {
   provider: ProviderDisplay;
@@ -95,6 +97,12 @@ export function ProviderRichListItem({
 }: ProviderRichListItemProps) {
   const router = useRouter();
   const queryClient = useQueryClient();
+  const { data: vendors = [] } = useQuery({
+    queryKey: ["provider-vendors"],
+    queryFn: async () => await getProviderVendors(),
+    staleTime: 60000,
+  });
+
   const [openEdit, setOpenEdit] = useState(false);
   const [openClone, setOpenClone] = useState(false);
   const [showKeyDialog, setShowKeyDialog] = useState(false);
@@ -147,6 +155,10 @@ export function ProviderRichListItem({
   const typeLabel = tTypes(`${typeKey}.label`);
   const typeDescription = tTypes(`${typeKey}.description`);
 
+  const vendor = provider.providerVendorId
+    ? vendors.find((v) => v.id === provider.providerVendorId)
+    : undefined;
+
   useEffect(() => {
     setClipboardAvailable(isClipboardSupported());
   }, []);
@@ -445,8 +457,17 @@ export function ProviderRichListItem({
           </div>
 
           <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground flex-wrap">
-            {/* URL */}
-            <span className="truncate max-w-[300px]">{provider.url}</span>
+            {/* Vendor & Endpoints OR Legacy URL */}
+            {vendor ? (
+              <div className="flex items-center gap-2">
+                <span className="truncate max-w-[300px] font-medium text-foreground/80">
+                  {vendor.displayName || vendor.websiteDomain}
+                </span>
+                <ProviderEndpointHover vendorId={vendor.id} providerType={provider.providerType} />
+              </div>
+            ) : (
+              <span className="truncate max-w-[300px]">{provider.url}</span>
+            )}
 
             {/* 官网链接 */}
             {provider.websiteUrl && (

+ 9 - 519
src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx

@@ -1,29 +1,11 @@
 "use client";
 
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { formatDistanceToNow } from "date-fns";
-import {
-  Edit2,
-  ExternalLink,
-  InfoIcon,
-  Loader2,
-  MoreHorizontal,
-  Play,
-  Plus,
-  Trash2,
-} from "lucide-react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ExternalLink, InfoIcon, Loader2, Trash2 } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { toast } from "sonner";
-import {
-  addProviderEndpoint,
-  editProviderEndpoint,
-  getProviderEndpointsByVendor,
-  getProviderVendors,
-  probeProviderEndpoint,
-  removeProviderEndpoint,
-  removeProviderVendor,
-} from "@/actions/provider-endpoints";
+import { getProviderVendors, removeProviderVendor } from "@/actions/provider-endpoints";
 import {
   AlertDialog,
   AlertDialogAction,
@@ -36,59 +18,14 @@ import {
   AlertDialogTrigger,
 } from "@/components/ui/alert-dialog";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-  DialogTrigger,
-} from "@/components/ui/dialog";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import {
-  getAllProviderTypes,
-  getProviderTypeConfig,
-  getProviderTypeTranslationKey,
-} from "@/lib/provider-type-utils";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { getErrorMessage } from "@/lib/utils/error-messages";
-import type {
-  ProviderDisplay,
-  ProviderEndpoint,
-  ProviderType,
-  ProviderVendor,
-} from "@/types/provider";
+import type { ProviderDisplay, ProviderVendor } from "@/types/provider";
 import type { User } from "@/types/user";
-import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
-import { UrlPreview } from "./forms/url-preview";
+import { ProviderEndpointsSection } from "./provider-endpoints-table";
 import { VendorKeysCompactList } from "./vendor-keys-compact-list";
 
 interface ProviderVendorViewProps {
@@ -270,461 +207,14 @@ function VendorCard({
           currencyCode={currencyCode}
         />
 
-        {enableMultiProviderTypes && vendorId > 0 && <VendorEndpointsSection vendorId={vendorId} />}
+        {enableMultiProviderTypes && vendorId > 0 && (
+          <ProviderEndpointsSection vendorId={vendorId} />
+        )}
       </CardContent>
     </Card>
   );
 }
 
-function VendorEndpointsSection({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-
-  return (
-    <div>
-      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
-        <span>{t("endpoints")}</span>
-        <AddEndpointButton vendorId={vendorId} />
-      </div>
-
-      <div className="p-6">
-        <EndpointsTable vendorId={vendorId} />
-      </div>
-    </div>
-  );
-}
-
-function EndpointsTable({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-
-  const { data: rawEndpoints = [], isLoading } = useQuery({
-    queryKey: ["provider-endpoints", vendorId],
-    queryFn: async () => {
-      const endpoints = await getProviderEndpointsByVendor({ vendorId });
-      return endpoints;
-    },
-  });
-
-  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
-  const endpoints = useMemo(() => {
-    const typeOrder = getAllProviderTypes();
-    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
-
-    return [...rawEndpoints].sort((a, b) => {
-      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
-      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
-      if (aTypeIndex !== bTypeIndex) {
-        return aTypeIndex - bTypeIndex;
-      }
-      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
-    });
-  }, [rawEndpoints]);
-
-  if (isLoading) {
-    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
-  }
-
-  if (endpoints.length === 0) {
-    return (
-      <div className="text-center py-8 border rounded-md border-dashed">
-        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
-        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="border rounded-md">
-      <Table>
-        <TableHeader>
-          <TableRow>
-            <TableHead className="w-[60px]">{t("columnType")}</TableHead>
-            <TableHead>{t("columnUrl")}</TableHead>
-            <TableHead>{t("status")}</TableHead>
-            <TableHead className="w-[220px]">{t("latency")}</TableHead>
-            <TableHead className="text-right">{t("columnActions")}</TableHead>
-          </TableRow>
-        </TableHeader>
-        <TableBody>
-          {endpoints.map((endpoint) => (
-            <EndpointRow key={endpoint.id} endpoint={endpoint} tTypes={tTypes} />
-          ))}
-        </TableBody>
-      </Table>
-    </div>
-  );
-}
-
-function EndpointRow({
-  endpoint,
-  tTypes,
-}: {
-  endpoint: ProviderEndpoint;
-  tTypes: ReturnType<typeof useTranslations>;
-}) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const queryClient = useQueryClient();
-  const [isProbing, setIsProbing] = useState(false);
-  const [isToggling, setIsToggling] = useState(false);
-
-  const typeConfig = getProviderTypeConfig(endpoint.providerType);
-  const TypeIcon = typeConfig.icon;
-  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
-  const typeLabel = tTypes(`${typeKey}.label`);
-
-  const probeMutation = useMutation({
-    mutationFn: async () => {
-      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsProbing(true),
-    onSettled: () => setIsProbing(false),
-    onSuccess: (data) => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      if (data?.result.ok) {
-        toast.success(t("probeSuccess"));
-      } else {
-        toast.error(
-          data?.result.errorMessage
-            ? `${t("probeFailed")}: ${data.result.errorMessage}`
-            : t("probeFailed")
-        );
-      }
-    },
-    onError: () => {
-      toast.error(t("probeFailed"));
-    },
-  });
-
-  const deleteMutation = useMutation({
-    mutationFn: async () => {
-      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
-      toast.success(t("endpointDeleteSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointDeleteFailed"));
-    },
-  });
-
-  const toggleMutation = useMutation({
-    mutationFn: async (nextEnabled: boolean) => {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        isEnabled: nextEnabled,
-      });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsToggling(true),
-    onSettled: () => setIsToggling(false),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      toast.success(t("endpointUpdateSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointUpdateFailed"));
-    },
-  });
-
-  return (
-    <TableRow>
-      <TableCell>
-        <TooltipProvider>
-          <Tooltip delayDuration={200}>
-            <TooltipTrigger asChild>
-              <span
-                className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
-              >
-                <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
-              </span>
-            </TooltipTrigger>
-            <TooltipContent>{typeLabel}</TooltipContent>
-          </Tooltip>
-        </TooltipProvider>
-      </TableCell>
-      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
-        {endpoint.url}
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-2">
-          {endpoint.isEnabled ? (
-            <Badge
-              variant="secondary"
-              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
-            >
-              {t("enabledStatus")}
-            </Badge>
-          ) : (
-            <Badge variant="outline">{t("disabledStatus")}</Badge>
-          )}
-          <Switch
-            checked={endpoint.isEnabled}
-            onCheckedChange={(checked) => toggleMutation.mutate(checked)}
-            disabled={isToggling}
-            aria-label={t("enabledStatus")}
-          />
-        </div>
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-3">
-          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
-          {endpoint.lastProbedAt ? (
-            <span className="text-muted-foreground text-[10px] whitespace-nowrap">
-              {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })}
-            </span>
-          ) : (
-            <span className="text-muted-foreground text-[10px]">-</span>
-          )}
-        </div>
-      </TableCell>
-      <TableCell className="text-right">
-        <div className="flex justify-end gap-2">
-          <Button
-            variant="ghost"
-            size="icon"
-            className="h-8 w-8"
-            onClick={() => probeMutation.mutate()}
-            disabled={isProbing}
-          >
-            {isProbing ? (
-              <Loader2 className="h-4 w-4 animate-spin" />
-            ) : (
-              <Play className="h-4 w-4" />
-            )}
-          </Button>
-
-          <EditEndpointDialog endpoint={endpoint} />
-
-          <DropdownMenu>
-            <DropdownMenuTrigger asChild>
-              <Button variant="ghost" size="icon" className="h-8 w-8">
-                <MoreHorizontal className="h-4 w-4" />
-              </Button>
-            </DropdownMenuTrigger>
-            <DropdownMenuContent align="end">
-              <DropdownMenuItem
-                className="text-destructive focus:text-destructive"
-                onClick={() => {
-                  if (confirm(t("confirmDeleteEndpoint"))) {
-                    deleteMutation.mutate();
-                  }
-                }}
-              >
-                <Trash2 className="mr-2 h-4 w-4" />
-                {tCommon("delete")}
-              </DropdownMenuItem>
-            </DropdownMenuContent>
-          </DropdownMenu>
-        </div>
-      </TableCell>
-    </TableRow>
-  );
-}
-
-function AddEndpointButton({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [url, setUrl] = useState("");
-  const [providerType, setProviderType] = useState<ProviderType>("claude");
-
-  // Get provider types for the selector (exclude claude-auth and gemini-cli which are internal)
-  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
-    (type) => !["claude-auth", "gemini-cli"].includes(type)
-  );
-
-  useEffect(() => {
-    if (!open) {
-      setUrl("");
-      setProviderType("claude");
-    }
-  }, [open]);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const endpointUrl = formData.get("url") as string;
-
-    try {
-      const res = await addProviderEndpoint({
-        vendorId,
-        providerType,
-        url: endpointUrl,
-        label: null,
-        sortOrder: 0,
-        isEnabled: true,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointAddSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
-      } else {
-        toast.error(res.error || t("endpointAddFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointAddFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button size="sm" className="h-7 gap-1">
-          <Plus className="h-3.5 w-3.5" />
-          {t("addEndpoint")}
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("addEndpoint")}</DialogTitle>
-          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="providerType">{t("columnType")}</Label>
-            <Select
-              value={providerType}
-              onValueChange={(value) => setProviderType(value as ProviderType)}
-            >
-              <SelectTrigger id="providerType">
-                <SelectValue />
-              </SelectTrigger>
-              <SelectContent>
-                {selectableTypes.map((type) => {
-                  const typeConfig = getProviderTypeConfig(type);
-                  const TypeIcon = typeConfig.icon;
-                  const typeKey = getProviderTypeTranslationKey(type);
-                  const label = tTypes(`${typeKey}.label`);
-                  return (
-                    <SelectItem key={type} value={type}>
-                      <div className="flex items-center gap-2">
-                        <span
-                          className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
-                        >
-                          <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
-                        </span>
-                        {label}
-                      </div>
-                    </SelectItem>
-                  );
-                })}
-              </SelectContent>
-            </Select>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input
-              id="url"
-              name="url"
-              placeholder={t("endpointUrlPlaceholder")}
-              required
-              onChange={(e) => setUrl(e.target.value)}
-            />
-          </div>
-
-          <UrlPreview baseUrl={url} providerType={providerType} />
-
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("create")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
-function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const url = formData.get("url") as string;
-    const isEnabled = formData.get("isEnabled") === "on";
-
-    try {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        url,
-        isEnabled,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointUpdateSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      } else {
-        toast.error(res.error || t("endpointUpdateFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointUpdateFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button variant="ghost" size="icon" className="h-8 w-8">
-          <Edit2 className="h-4 w-4" />
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("editEndpoint")}</DialogTitle>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input id="url" name="url" defaultValue={endpoint.url} required />
-          </div>
-          <div className="flex items-center space-x-2">
-            <Switch id="isEnabled" name="isEnabled" defaultChecked={endpoint.isEnabled} />
-            <Label htmlFor="isEnabled">{t("enabledStatus")}</Label>
-          </div>
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("save")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
 function DeleteVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) {
   const t = useTranslations("settings.providers");
   const tCommon = useTranslations("settings.common");

+ 1 - 0
src/app/api/actions/[...route]/route.ts

@@ -12,6 +12,7 @@
  */
 
 import "@/lib/polyfills/file";
+import "@/lib/auth-session-storage.node";
 import { swaggerUI } from "@hono/swagger-ui";
 import { OpenAPIHono } from "@hono/zod-openapi";
 import { apiReference } from "@scalar/hono-api-reference";

+ 81 - 13
src/app/v1/_lib/proxy/forwarder.ts

@@ -15,6 +15,7 @@ import { getCachedSystemSettings, isHttp2Enabled } from "@/lib/config";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { PROVIDER_DEFAULTS, PROVIDER_LIMITS } from "@/lib/constants/provider.constants";
 import { recordEndpointFailure, recordEndpointSuccess } from "@/lib/endpoint-circuit-breaker";
+import { applyGeminiGoogleSearchOverrideWithAudit } from "@/lib/gemini/provider-overrides";
 import { logger } from "@/lib/logger";
 import { getPreferredProviderEndpoints } from "@/lib/provider-endpoints/endpoint-selector";
 import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent";
@@ -57,6 +58,10 @@ import {
   rectifyAnthropicRequestMessage,
 } from "./thinking-signature-rectifier";
 
+/** Default User-Agent for Codex CLI requests when none is provided */
+export const DEFAULT_CODEX_USER_AGENT =
+  "codex_cli_rs/0.93.0 (Windows 10.0.26200; x86_64) vscode/1.108.1";
+
 const STANDARD_ENDPOINTS = [
   "/v1/messages",
   "/v1/messages/count_tokens",
@@ -65,6 +70,8 @@ const STANDARD_ENDPOINTS = [
   "/v1/models",
 ];
 
+const STRICT_STANDARD_ENDPOINTS = ["/v1/messages", "/v1/responses", "/v1/chat/completions"];
+
 const RETRY_LIMITS = PROVIDER_LIMITS.MAX_RETRY_ATTEMPTS;
 const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环)
 
@@ -224,39 +231,65 @@ export class ProxyForwarder {
       let thinkingBudgetRectifierRetried = false;
 
       const requestPath = session.requestUrl.pathname;
+      const providerVendorId = currentProvider.providerVendorId ?? 0;
       const isMcpRequest =
         currentProvider.providerType !== "gemini" &&
         currentProvider.providerType !== "gemini-cli" &&
         !STANDARD_ENDPOINTS.includes(requestPath);
+      const shouldEnforceStrictEndpointPool =
+        !isMcpRequest && STRICT_STANDARD_ENDPOINTS.includes(requestPath) && providerVendorId > 0;
+      let endpointSelectionError: Error | null = null;
 
       const endpointCandidates: Array<{ endpointId: number | null; baseUrl: string }> = [];
 
       if (isMcpRequest) {
         endpointCandidates.push({ endpointId: null, baseUrl: currentProvider.url });
-      } else if (currentProvider.providerVendorId && currentProvider.providerVendorId > 0) {
+      } else if (providerVendorId > 0) {
         try {
           const preferred = await getPreferredProviderEndpoints({
-            vendorId: currentProvider.providerVendorId,
+            vendorId: providerVendorId,
             providerType: currentProvider.providerType,
           });
           endpointCandidates.push(...preferred.map((e) => ({ endpointId: e.id, baseUrl: e.url })));
         } catch (error) {
+          endpointSelectionError =
+            error instanceof Error
+              ? error
+              : new Error(typeof error === "string" ? error : String(error));
+          logger.warn("[ProxyForwarder] Failed to load provider endpoints", {
+            providerId: currentProvider.id,
+            vendorId: providerVendorId,
+            providerType: currentProvider.providerType,
+            error: endpointSelectionError.message,
+            strictEndpointPolicy: shouldEnforceStrictEndpointPool,
+            reason: "selector_error",
+          });
+        }
+      }
+
+      if (endpointCandidates.length === 0) {
+        if (shouldEnforceStrictEndpointPool) {
           logger.warn(
-            "[ProxyForwarder] Failed to load provider endpoints, fallback to provider.url",
+            "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback",
             {
               providerId: currentProvider.id,
-              vendorId: currentProvider.providerVendorId,
+              vendorId: providerVendorId,
               providerType: currentProvider.providerType,
-              error: error instanceof Error ? error.message : String(error),
+              requestPath,
+              reason: "strict_blocked_legacy_fallback",
+              strictBlockCause: endpointSelectionError
+                ? "selector_error"
+                : "no_endpoint_candidates",
+              selectorError: endpointSelectionError?.message,
             }
           );
+          failedProviderIds.push(currentProvider.id);
+          attemptCount = maxAttemptsPerProvider;
+        } else {
+          endpointCandidates.push({ endpointId: null, baseUrl: currentProvider.url });
         }
       }
 
-      if (endpointCandidates.length === 0) {
-        endpointCandidates.push({ endpointId: null, baseUrl: currentProvider.url });
-      }
-
       // Truncate endpoints to maxRetryAttempts count
       // Ensures only the N lowest-latency endpoints are used (N = maxRetryAttempts)
       // Note: getPreferredProviderEndpoints already returns endpoints sorted by latency (ascending)
@@ -1279,7 +1312,43 @@ export class ProxyForwarder {
       // 1. 直接透传请求体(不转换)- 仅对有 body 的请求
       const hasBody = session.method !== "GET" && session.method !== "HEAD";
       if (hasBody) {
-        const bodyString = JSON.stringify(session.request.message);
+        let bodyToSerialize = session.request.message as Record<string, unknown>;
+
+        // Apply Gemini Google Search override if configured
+        const { request: overriddenBody, audit: googleSearchAudit } =
+          applyGeminiGoogleSearchOverrideWithAudit(provider, bodyToSerialize);
+        if (googleSearchAudit) {
+          session.addSpecialSetting(googleSearchAudit);
+          bodyToSerialize = overriddenBody;
+          session.request.message = overriddenBody;
+
+          // Persist special settings immediately (same pattern as Anthropic overrides)
+          const specialSettings = session.getSpecialSettings();
+          if (session.sessionId) {
+            await SessionManager.storeSessionSpecialSettings(
+              session.sessionId,
+              specialSettings,
+              session.requestSequence
+            ).catch((err) => {
+              logger.error("[ProxyForwarder] Failed to store Gemini special settings", {
+                error: err,
+                sessionId: session.sessionId,
+              });
+            });
+          }
+          if (session.messageContext?.id) {
+            await updateMessageRequestDetails(session.messageContext.id, {
+              specialSettings,
+            }).catch((err) => {
+              logger.error("[ProxyForwarder] Failed to persist Gemini special settings", {
+                error: err,
+                messageRequestId: session.messageContext?.id,
+              });
+            });
+          }
+        }
+
+        const bodyString = JSON.stringify(bodyToSerialize);
         requestBody = bodyString;
       }
 
@@ -2214,10 +2283,9 @@ export class ProxyForwarder {
       // 注意:使用 ?? 而非 || 以确保空字符串 UA 能被正确保留
       let resolvedUA: string;
       if (wasModified) {
-        resolvedUA =
-          filteredUA ?? originalUA ?? "codex_cli_rs/0.55.0 (Mac OS 26.1.0; arm64) vscode/2.0.64";
+        resolvedUA = filteredUA ?? originalUA ?? DEFAULT_CODEX_USER_AGENT;
       } else {
-        resolvedUA = originalUA ?? "codex_cli_rs/0.55.0 (Mac OS 26.1.0; arm64) vscode/2.0.64";
+        resolvedUA = originalUA ?? DEFAULT_CODEX_USER_AGENT;
       }
       overrides["user-agent"] = resolvedUA;
 

+ 14 - 0
src/app/v1/_lib/proxy/response-handler.ts

@@ -1193,6 +1193,13 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     result.output_tokens = usage.candidatesTokenCount;
     hasAny = true;
   }
+
+  // OpenAI chat completion format: prompt_tokens → input_tokens
+  // Priority: Claude (input_tokens) > Gemini (promptTokenCount) > OpenAI (prompt_tokens)
+  if (result.input_tokens === undefined && typeof usage.prompt_tokens === "number") {
+    result.input_tokens = usage.prompt_tokens;
+    hasAny = true;
+  }
   // Gemini 缓存支持
   if (typeof usage.cachedContentTokenCount === "number") {
     result.cache_read_input_tokens = usage.cachedContentTokenCount;
@@ -1278,6 +1285,13 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     hasAny = true;
   }
 
+  // OpenAI chat completion format: completion_tokens → output_tokens
+  // Priority: Claude (output_tokens) > Gemini (candidatesTokenCount/thoughtsTokenCount) > OpenAI (completion_tokens)
+  if (result.output_tokens === undefined && typeof usage.completion_tokens === "number") {
+    result.output_tokens = usage.completion_tokens;
+    hasAny = true;
+  }
+
   if (typeof usage.cache_creation_input_tokens === "number") {
     result.cache_creation_input_tokens = usage.cache_creation_input_tokens;
     hasAny = true;

+ 16 - 0
src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts

@@ -88,6 +88,22 @@ describe("thinking-signature-rectifier", () => {
         detectThinkingSignatureRectifierTrigger("Signature: EXTRA INPUTS ARE NOT PERMITTED")
       ).toBe("invalid_signature_in_thinking_block");
     });
+
+    test("should detect 'cannot be modified' error", () => {
+      // thinking/redacted_thinking block was modified by client
+      expect(
+        detectThinkingSignatureRectifierTrigger(
+          "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."
+        )
+      ).toBe("invalid_signature_in_thinking_block");
+
+      // JSON format with request id
+      expect(
+        detectThinkingSignatureRectifierTrigger(
+          '{"error":{"type":"<nil>","message":"***.***.content.53: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response. (request id: 20260203160354671091064E4xAq0t0)"},"type":"error"}'
+        )
+      ).toBe("invalid_signature_in_thinking_block");
+    });
   });
 
   describe("rectifyAnthropicRequestMessage", () => {

+ 11 - 0
src/app/v1/_lib/proxy/thinking-signature-rectifier.ts

@@ -70,6 +70,17 @@ export function detectThinkingSignatureRectifierTrigger(
     return "invalid_signature_in_thinking_block"; // 复用现有触发类型,整流逻辑相同
   }
 
+  // 检测:thinking/redacted_thinking 块被修改的错误
+  // 场景:客户端回传的 thinking 块内容与原始响应不一致
+  // 错误信息:"thinking or redacted_thinking blocks ... cannot be modified"
+  const looksLikeThinkingBlockModified =
+    (lower.includes("thinking") || lower.includes("redacted_thinking")) &&
+    lower.includes("cannot be modified");
+
+  if (looksLikeThinkingBlockModified) {
+    return "invalid_signature_in_thinking_block"; // 复用现有触发类型,整流逻辑相同
+  }
+
   // 与默认错误规则保持一致(Issue #432 / Rule 6)
   if (/非法请求|illegal request|invalid request/i.test(errorMessage)) {
     return "invalid_request";

+ 6 - 0
src/drizzle/schema.ts

@@ -282,6 +282,12 @@ export const providers = pgTable('providers', {
   anthropicMaxTokensPreference: varchar('anthropic_max_tokens_preference', { length: 20 }),
   anthropicThinkingBudgetPreference: varchar('anthropic_thinking_budget_preference', { length: 20 }),
 
+  // Gemini (generateContent API) parameter overrides (only for gemini/gemini-cli providers)
+  // - 'inherit' or null: follow client request
+  // - 'enabled': force inject googleSearch tool
+  // - 'disabled': force remove googleSearch tool from request
+  geminiGoogleSearchPreference: varchar('gemini_google_search_preference', { length: 20 }),
+
   // 废弃(保留向后兼容,但不再使用)
   tpm: integer('tpm').default(0),
   rpm: integer('rpm').default(0),

+ 8 - 2
src/lib/api/action-adapter-openapi.ts

@@ -12,7 +12,7 @@ import { createRoute, z } from "@hono/zod-openapi";
 import type { Context } from "hono";
 import { getCookie } from "hono/cookie";
 import type { ActionResult } from "@/actions/types";
-import { validateKey } from "@/lib/auth";
+import { runWithAuthSession, validateKey } from "@/lib/auth";
 import { logger } from "@/lib/logger";
 
 function getBearerTokenFromAuthHeader(raw: string | undefined): string | undefined {
@@ -300,6 +300,8 @@ export function createActionRoute(
     const fullPath = `${module}.${actionName}`;
 
     try {
+      let authSession: Awaited<ReturnType<typeof validateKey>> | null = null;
+
       // 0. 认证检查 (如果需要)
       if (requiresAuth) {
         const authToken =
@@ -314,6 +316,7 @@ export function createActionRoute(
           logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 auth-token`);
           return c.json({ ok: false, error: "认证无效或已过期" }, 401);
         }
+        authSession = session;
 
         // 检查角色权限
         if (requiredRole === "admin" && session.user.role !== "admin") {
@@ -358,7 +361,10 @@ export function createActionRoute(
         args = [body];
       }
 
-      const rawResult = await action(...args);
+      const rawResult =
+        authSession != null
+          ? await runWithAuthSession(authSession, () => action(...args), { allowReadOnlyAccess })
+          : await action(...args);
 
       // 2.5. 包装非 ActionResult 格式的返回值
       // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 11 - 0
src/lib/auth-session-storage.node.ts

@@ -0,0 +1,11 @@
+import "server-only";
+
+import { AsyncLocalStorage } from "node:async_hooks";
+import type { AuthSessionStorage, ScopedAuthContext } from "@/lib/auth";
+
+if (!globalThis.__cchAuthSessionStorage) {
+  globalThis.__cchAuthSessionStorage =
+    new AsyncLocalStorage<ScopedAuthContext>() as unknown as AuthSessionStorage;
+}
+
+export const authSessionStorage: AuthSessionStorage = globalThis.__cchAuthSessionStorage;

+ 52 - 0
src/lib/auth.ts

@@ -6,6 +6,26 @@ import { findUserById } from "@/repository/user";
 import type { Key } from "@/types/key";
 import type { User } from "@/types/user";
 
+export type ScopedAuthContext = {
+  session: AuthSession;
+  /**
+   * 本次请求在 adapter 层 validateKey 时使用的 allowReadOnlyAccess 参数。
+   * - true:允许 canLoginWebUi=false 的 key 作为“只读会话”使用
+   * - false:严格要求 canLoginWebUi=true
+   */
+  allowReadOnlyAccess: boolean;
+};
+
+export type AuthSessionStorage = {
+  run<T>(store: ScopedAuthContext, callback: () => T): T;
+  getStore(): ScopedAuthContext | undefined;
+};
+
+declare global {
+  // eslint-disable-next-line no-var
+  var __cchAuthSessionStorage: AuthSessionStorage | undefined;
+}
+
 const AUTH_COOKIE_NAME = "auth-token";
 const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
 
@@ -14,6 +34,26 @@ export interface AuthSession {
   key: Key;
 }
 
+export function runWithAuthSession<T>(
+  session: AuthSession,
+  fn: () => T,
+  options?: { allowReadOnlyAccess?: boolean }
+): T {
+  const storage = globalThis.__cchAuthSessionStorage;
+  if (!storage) return fn();
+  return storage.run({ session, allowReadOnlyAccess: options?.allowReadOnlyAccess ?? false }, fn);
+}
+
+export function getScopedAuthSession(): AuthSession | null {
+  const storage = globalThis.__cchAuthSessionStorage;
+  return storage?.getStore()?.session ?? null;
+}
+
+export function getScopedAuthContext(): ScopedAuthContext | null {
+  const storage = globalThis.__cchAuthSessionStorage;
+  return storage?.getStore() ?? null;
+}
+
 export async function validateKey(
   keyString: string,
   options?: {
@@ -119,6 +159,18 @@ export async function getSession(options?: {
    */
   allowReadOnlyAccess?: boolean;
 }): Promise<AuthSession | null> {
+  // 优先读取 adapter 注入的请求级会话(适配 /api/actions 等非 Next 原生上下文场景)
+  const scoped = getScopedAuthContext();
+  if (scoped) {
+    // 关键:scoped 会话必须遵循其"创建时语义",仅允许内部显式降权(不允许提权)
+    const effectiveAllowReadOnlyAccess =
+      scoped.allowReadOnlyAccess && (options?.allowReadOnlyAccess ?? true);
+    if (!effectiveAllowReadOnlyAccess && !scoped.session.key.canLoginWebUi) {
+      return null;
+    }
+    return scoped.session;
+  }
+
   const keyString = await getAuthToken();
   if (!keyString) {
     return null;

+ 25 - 10
src/lib/cache/provider-cache.ts

@@ -38,6 +38,7 @@ const cache: ProviderCacheState = {
 };
 
 let subscriptionInitialized = false;
+let subscriptionInitPromise: Promise<void> | null = null;
 
 /**
  * 初始化 Redis 订阅
@@ -47,19 +48,33 @@ let subscriptionInitialized = false;
  */
 async function ensureSubscription(): Promise<void> {
   if (subscriptionInitialized) return;
+  if (subscriptionInitPromise) return subscriptionInitPromise;
 
-  // CI/build 阶段跳过
-  if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") {
-    subscriptionInitialized = true;
-    return;
-  }
+  subscriptionInitPromise = (async () => {
+    // CI/build 阶段跳过
+    if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") {
+      subscriptionInitialized = true;
+      return;
+    }
+
+    // pubsub.ts 订阅机制
+    try {
+      const cleanup = await subscribeCacheInvalidation(CHANNEL_PROVIDERS_UPDATED, () => {
+        invalidateCache();
+        logger.debug("[ProviderCache] Cache invalidated via pub/sub");
+      });
 
-  subscriptionInitialized = true;
-  // pubsub.ts 订阅机制
-  await subscribeCacheInvalidation(CHANNEL_PROVIDERS_UPDATED, () => {
-    invalidateCache();
-    logger.debug("[ProviderCache] Cache invalidated via pub/sub");
+      if (!cleanup) return;
+
+      subscriptionInitialized = true;
+    } catch (error) {
+      logger.warn("[ProviderCache] Failed to subscribe to cache invalidation", { error });
+    }
+  })().finally(() => {
+    subscriptionInitPromise = null;
   });
+
+  return subscriptionInitPromise;
 }
 
 /**

+ 11 - 0
src/lib/column-visibility.test.ts

@@ -179,6 +179,16 @@ describe("column-visibility", () => {
       const result3 = toggleColumn(userId, tableId, "user");
       expect(result3).toEqual(["provider"]);
     });
+
+    test("toggles cost column visibility", () => {
+      const hiddenAfterToggle = toggleColumn(userId, tableId, "cost");
+      expect(hiddenAfterToggle).toContain("cost");
+      expect(getVisibleColumns(userId, tableId)).not.toContain("cost");
+
+      const visibleAfterToggleBack = toggleColumn(userId, tableId, "cost");
+      expect(visibleAfterToggleBack).not.toContain("cost");
+      expect(getVisibleColumns(userId, tableId)).toContain("cost");
+    });
   });
 
   describe("resetColumns", () => {
@@ -209,6 +219,7 @@ describe("column-visibility", () => {
       expect(DEFAULT_VISIBLE_COLUMNS).toContain("sessionId");
       expect(DEFAULT_VISIBLE_COLUMNS).toContain("provider");
       expect(DEFAULT_VISIBLE_COLUMNS).toContain("tokens");
+      expect(DEFAULT_VISIBLE_COLUMNS).toContain("cost");
       expect(DEFAULT_VISIBLE_COLUMNS).toContain("cache");
       expect(DEFAULT_VISIBLE_COLUMNS).toContain("performance");
     });

+ 4 - 2
src/lib/column-visibility.ts

@@ -17,7 +17,8 @@ export type LogsTableColumn =
   | "provider"
   | "tokens"
   | "cache"
-  | "performance";
+  | "performance"
+  | "cost";
 
 /**
  * Default visible columns (all visible by default)
@@ -30,12 +31,13 @@ export const DEFAULT_VISIBLE_COLUMNS: LogsTableColumn[] = [
   "tokens",
   "cache",
   "performance",
+  "cost",
 ];
 
 /**
  * Columns that cannot be hidden (always visible)
  */
-export const ALWAYS_VISIBLE_COLUMNS = ["time", "model", "cost", "status"] as const;
+export const ALWAYS_VISIBLE_COLUMNS = ["time", "model", "status"] as const;
 
 /**
  * Get the storage key for a specific user and table

+ 22 - 4
src/lib/emit-event.ts

@@ -39,6 +39,15 @@ export async function emitSensitiveWordsUpdated(): Promise<void> {
     } catch {
       // 忽略导入错误
     }
+
+    try {
+      const { CHANNEL_SENSITIVE_WORDS_UPDATED, publishCacheInvalidation } = await import(
+        "@/lib/redis/pubsub"
+      );
+      await publishCacheInvalidation(CHANNEL_SENSITIVE_WORDS_UPDATED);
+    } catch {
+      // 忽略导入错误
+    }
   }
 }
 
@@ -47,11 +56,19 @@ export async function emitSensitiveWordsUpdated(): Promise<void> {
  */
 export async function emitRequestFiltersUpdated(): Promise<void> {
   if (typeof process !== "undefined" && process.env.NEXT_RUNTIME !== "edge") {
+    let logger: typeof import("@/lib/logger").logger | undefined;
+    try {
+      ({ logger } = await import("@/lib/logger"));
+    } catch {
+      // 忽略导入错误 (silent degrade)
+    }
+
     try {
       const { eventEmitter } = await import("@/lib/event-emitter");
       eventEmitter.emitRequestFiltersUpdated();
-    } catch {
-      // 忽略导入错误
+      logger?.info?.("[emitRequestFiltersUpdated] Local event emitted");
+    } catch (error) {
+      logger?.warn?.("[emitRequestFiltersUpdated] Failed to emit local event", { error });
     }
 
     try {
@@ -59,8 +76,9 @@ export async function emitRequestFiltersUpdated(): Promise<void> {
         "@/lib/redis/pubsub"
       );
       await publishCacheInvalidation(CHANNEL_REQUEST_FILTERS_UPDATED);
-    } catch {
-      // 忽略导入错误
+      logger?.info?.("[emitRequestFiltersUpdated] Redis pub/sub publish attempted");
+    } catch (error) {
+      logger?.warn?.("[emitRequestFiltersUpdated] Failed to publish to Redis", { error });
     }
   }
 }

+ 7 - 1
src/lib/error-rule-detector.ts

@@ -113,7 +113,13 @@ class ErrorRuleDetector {
         const { CHANNEL_ERROR_RULES_UPDATED, subscribeCacheInvalidation } = await import(
           "@/lib/redis/pubsub"
         );
-        await subscribeCacheInvalidation(CHANNEL_ERROR_RULES_UPDATED, handleUpdated);
+        const cleanup = await subscribeCacheInvalidation(
+          CHANNEL_ERROR_RULES_UPDATED,
+          handleUpdated
+        );
+        if (cleanup) {
+          logger.info("[ErrorRuleDetector] Subscribed to Redis pub/sub channel");
+        }
       } catch {
         // 忽略导入错误(可能在 Edge runtime 中)
       }

+ 6 - 1
src/lib/event-emitter.ts

@@ -49,7 +49,12 @@ class GlobalEventEmitter extends NodeEventEmitter {
 
 /**
  * 全局事件发射器单例导出
+ * Use globalThis to guarantee a single instance across workers
  */
-export const eventEmitter = new GlobalEventEmitter();
+const g = globalThis as unknown as { __CCH_EVENT_EMITTER__?: GlobalEventEmitter };
+if (!g.__CCH_EVENT_EMITTER__) {
+  g.__CCH_EVENT_EMITTER__ = new GlobalEventEmitter();
+}
+export const eventEmitter = g.__CCH_EVENT_EMITTER__;
 
 export type { EventMap };

+ 142 - 0
src/lib/gemini/provider-overrides.ts

@@ -0,0 +1,142 @@
+import { logger } from "@/lib/logger";
+import type { GeminiGoogleSearchPreference, ProviderType } from "@/types/provider";
+import type { GeminiGoogleSearchOverrideSpecialSetting } from "@/types/special-settings";
+
+type GeminiProviderOverrideConfig = {
+  id?: number;
+  name?: string;
+  providerType?: ProviderType;
+  geminiGoogleSearchPreference?: GeminiGoogleSearchPreference | null;
+};
+
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Check if a tools array contains a googleSearch tool
+ */
+function hasGoogleSearchTool(tools: unknown[]): boolean {
+  return tools.some((tool) => {
+    if (!isPlainObject(tool)) return false;
+    return "googleSearch" in tool;
+  });
+}
+
+/**
+ * Remove googleSearch tools from a tools array
+ */
+function removeGoogleSearchTools(tools: unknown[]): unknown[] {
+  return tools.filter((tool) => {
+    if (!isPlainObject(tool)) return true;
+    return !("googleSearch" in tool);
+  });
+}
+
+/**
+ * Apply Gemini Google Search provider override to request body.
+ *
+ * Conventions:
+ * - providerType !== "gemini" && providerType !== "gemini-cli" -> no processing
+ * - Preference value null/undefined/"inherit" means "follow client"
+ * - Overrides affect:
+ *   - tools array (inject or remove googleSearch tool)
+ */
+export function applyGeminiGoogleSearchOverride(
+  provider: GeminiProviderOverrideConfig,
+  request: Record<string, unknown>
+): Record<string, unknown> {
+  if (provider.providerType !== "gemini" && provider.providerType !== "gemini-cli") {
+    return request;
+  }
+
+  const preference = provider.geminiGoogleSearchPreference;
+
+  // inherit or not set -> pass through unchanged
+  if (!preference || preference === "inherit") {
+    return request;
+  }
+
+  let output: Record<string, unknown> = request;
+  const ensureCloned = () => {
+    if (output === request) {
+      output = { ...request };
+    }
+  };
+
+  const existingTools = Array.isArray(request.tools) ? request.tools : [];
+  const hadGoogleSearch = hasGoogleSearchTool(existingTools);
+
+  if (preference === "enabled") {
+    // Force inject googleSearch tool if not present
+    if (!hadGoogleSearch) {
+      ensureCloned();
+      output.tools = [...existingTools, { googleSearch: {} }];
+    }
+  } else if (preference === "disabled") {
+    // Force remove googleSearch tool if present
+    if (hadGoogleSearch) {
+      ensureCloned();
+      const filteredTools = removeGoogleSearchTools(existingTools);
+      if (filteredTools.length > 0) {
+        output.tools = filteredTools;
+      } else {
+        // Remove tools array entirely if empty after filtering
+        delete output.tools;
+      }
+    }
+  }
+
+  return output;
+}
+
+/**
+ * Apply Gemini Google Search override with audit trail
+ */
+export function applyGeminiGoogleSearchOverrideWithAudit(
+  provider: GeminiProviderOverrideConfig,
+  request: Record<string, unknown>
+): { request: Record<string, unknown>; audit: GeminiGoogleSearchOverrideSpecialSetting | null } {
+  if (provider.providerType !== "gemini" && provider.providerType !== "gemini-cli") {
+    return { request, audit: null };
+  }
+
+  const preference = provider.geminiGoogleSearchPreference;
+
+  // inherit or not set -> pass through unchanged, no audit
+  if (!preference || preference === "inherit") {
+    return { request, audit: null };
+  }
+
+  const existingTools = Array.isArray(request.tools) ? request.tools : [];
+  const hadGoogleSearch = hasGoogleSearchTool(existingTools);
+
+  // Determine action based on preference and current state
+  let action: "inject" | "remove" | "passthrough";
+  if (preference === "enabled") {
+    action = hadGoogleSearch ? "passthrough" : "inject";
+  } else if (preference === "disabled") {
+    action = hadGoogleSearch ? "remove" : "passthrough";
+  } else {
+    logger.warn("applyGeminiGoogleSearchOverrideWithAudit: unknown preference value", {
+      preference,
+      providerId: provider.id,
+    });
+    return { request, audit: null };
+  }
+
+  const nextRequest = applyGeminiGoogleSearchOverride(provider, request);
+
+  const audit: GeminiGoogleSearchOverrideSpecialSetting = {
+    type: "gemini_google_search_override",
+    scope: "request",
+    hit: true,
+    providerId: provider.id ?? null,
+    providerName: provider.name ?? null,
+    action,
+    preference: preference as "enabled" | "disabled",
+    hadGoogleSearchInRequest: hadGoogleSearch,
+  };
+
+  return { request: nextRequest, audit };
+}

+ 8 - 2
src/lib/rate-limit/service.ts

@@ -91,6 +91,12 @@ import {
   normalizeResetTime,
 } from "./time-utils";
 
+const SESSION_TTL_SECONDS = (() => {
+  const parsed = Number.parseInt(process.env.SESSION_TTL ?? "", 10);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : 300;
+})();
+const SESSION_TTL_MS = SESSION_TTL_SECONDS * 1000;
+
 interface CostLimit {
   amount: number | null;
   period: "5h" | "daily" | "weekly" | "monthly";
@@ -566,14 +572,14 @@ export class RateLimitService {
       const key = `provider:${providerId}:active_sessions`;
       const now = Date.now();
 
-      // 执行 Lua 脚本:原子性检查 + 追踪(TC-041 修复版)
       const result = (await RateLimitService.redis.eval(
         CHECK_AND_TRACK_SESSION,
         1, // KEYS count
         key, // KEYS[1]
         sessionId, // ARGV[1]
         limit.toString(), // ARGV[2]
-        now.toString() // ARGV[3]
+        now.toString(), // ARGV[3]
+        SESSION_TTL_MS.toString() // ARGV[4]
       )) as [number, number, number];
 
       const [allowed, count, tracked] = result;

+ 133 - 5
src/lib/redis/__tests__/pubsub.test.ts

@@ -5,8 +5,10 @@ class MockRedis extends EventEmitter {
   publish = vi.fn();
   subscribe = vi.fn();
   unsubscribe = vi.fn();
+  disconnect = vi.fn();
   quit = vi.fn();
   duplicate = vi.fn();
+  status = "wait";
 }
 
 vi.mock("@/lib/logger", () => ({
@@ -22,7 +24,7 @@ vi.mock("@/lib/redis/client", () => ({
   getRedisClient: vi.fn(),
 }));
 
-describe("Redis Pub/Sub 缓存失效通知", () => {
+describe("Redis Pub/Sub cache invalidation", () => {
   beforeEach(() => {
     vi.resetModules();
   });
@@ -64,7 +66,15 @@ describe("Redis Pub/Sub 缓存失效通知", () => {
     const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
     const onInvalidate = vi.fn();
 
-    const cleanup = await subscribeCacheInvalidation("test-channel", onInvalidate);
+    // Start subscription (will wait for ready)
+    const subscribePromise = subscribeCacheInvalidation("test-channel", onInvalidate);
+
+    // Simulate connection ready
+    subscriber.status = "ready";
+    subscriber.emit("ready");
+
+    const cleanup = await subscribePromise;
+    expect(cleanup).not.toBeNull();
     expect(typeof cleanup).toBe("function");
 
     expect(base.duplicate).toHaveBeenCalledTimes(1);
@@ -73,11 +83,49 @@ describe("Redis Pub/Sub 缓存失效通知", () => {
     subscriber.emit("message", "test-channel", Date.now().toString());
     expect(onInvalidate).toHaveBeenCalledTimes(1);
 
-    cleanup();
+    cleanup!();
     subscriber.emit("message", "test-channel", Date.now().toString());
     expect(onInvalidate).toHaveBeenCalledTimes(1);
   });
 
+  test("subscribeCacheInvalidation: should resubscribe on reconnect", async () => {
+    const base = new MockRedis();
+    const subscriber = new MockRedis();
+    base.duplicate.mockReturnValue(subscriber);
+    subscriber.subscribe.mockResolvedValue(1);
+
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+    const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+    const onInvalidate = vi.fn();
+
+    const subscribePromise = subscribeCacheInvalidation("test-channel", onInvalidate);
+
+    subscriber.status = "ready";
+    subscriber.emit("ready");
+
+    const cleanup = await subscribePromise;
+    expect(cleanup).not.toBeNull();
+    expect(subscriber.subscribe).toHaveBeenCalledTimes(1);
+
+    subscriber.subscribe.mockClear();
+    subscriber.emit("close");
+    subscriber.emit("ready");
+    await new Promise((resolve) => setImmediate(resolve));
+    expect(subscriber.subscribe).toHaveBeenCalledTimes(1);
+    expect(subscriber.subscribe).toHaveBeenCalledWith("test-channel");
+
+    subscriber.subscribe.mockClear();
+    subscriber.emit("close");
+    subscriber.emit("ready");
+    await new Promise((resolve) => setImmediate(resolve));
+    expect(subscriber.subscribe).toHaveBeenCalledTimes(1);
+    expect(subscriber.subscribe).toHaveBeenCalledWith("test-channel");
+
+    cleanup!();
+  });
+
   test("subscribeCacheInvalidation: should handle Redis not configured gracefully", async () => {
     const { getRedisClient } = await import("@/lib/redis/client");
     (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(null);
@@ -85,7 +133,87 @@ describe("Redis Pub/Sub 缓存失效通知", () => {
     const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
     const cleanup = await subscribeCacheInvalidation("test-channel", vi.fn());
 
-    expect(typeof cleanup).toBe("function");
-    expect(() => cleanup()).not.toThrow();
+    expect(cleanup).toBeNull();
+  });
+
+  test("subscribeCacheInvalidation: should return null on connection error", async () => {
+    const base = new MockRedis();
+    const subscriber = new MockRedis();
+    base.duplicate.mockReturnValue(subscriber);
+
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+    const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+    const onInvalidate = vi.fn();
+
+    // Start subscription
+    const subscribePromise = subscribeCacheInvalidation("test-channel", onInvalidate);
+
+    // Simulate connection error
+    subscriber.emit("error", new Error("Connection refused"));
+
+    const cleanup = await subscribePromise;
+    expect(cleanup).toBeNull();
+    expect(onInvalidate).not.toHaveBeenCalled();
+  });
+
+  test("subscribeCacheInvalidation: should rollback callback when subscribe fails and allow retry", async () => {
+    const base = new MockRedis();
+    const subscriber = new MockRedis();
+    base.duplicate.mockReturnValue(subscriber);
+    subscriber.subscribe
+      .mockRejectedValueOnce(new Error("Subscribe failed"))
+      .mockResolvedValueOnce(1);
+
+    const { getRedisClient } = await import("@/lib/redis/client");
+    (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+    const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+
+    const onInvalidateA = vi.fn();
+    const subscribePromiseA = subscribeCacheInvalidation("test-channel", onInvalidateA);
+
+    subscriber.status = "ready";
+    subscriber.emit("ready");
+
+    const cleanupA = await subscribePromiseA;
+    expect(cleanupA).toBeNull();
+
+    const onInvalidateB = vi.fn();
+    const cleanupB = await subscribeCacheInvalidation("test-channel", onInvalidateB);
+    expect(cleanupB).not.toBeNull();
+    expect(typeof cleanupB).toBe("function");
+
+    subscriber.emit("message", "test-channel", Date.now().toString());
+
+    expect(onInvalidateA).not.toHaveBeenCalled();
+    expect(onInvalidateB).toHaveBeenCalledTimes(1);
+    expect(subscriber.subscribe).toHaveBeenCalledTimes(2);
+
+    cleanupB!();
+  });
+
+  test("subscribeCacheInvalidation: should timeout waiting for ready and disconnect subscriber", async () => {
+    vi.useFakeTimers();
+    try {
+      const base = new MockRedis();
+      const subscriber = new MockRedis();
+      base.duplicate.mockReturnValue(subscriber);
+
+      const { getRedisClient } = await import("@/lib/redis/client");
+      (getRedisClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue(base);
+
+      const { subscribeCacheInvalidation } = await import("@/lib/redis/pubsub");
+      const subscribePromise = subscribeCacheInvalidation("test-channel", vi.fn());
+
+      await vi.advanceTimersByTimeAsync(10000);
+
+      const cleanup = await subscribePromise;
+      expect(cleanup).toBeNull();
+      expect(subscriber.disconnect).toHaveBeenCalledTimes(1);
+    } finally {
+      vi.useRealTimers();
+    }
   });
 });

+ 34 - 24
src/lib/redis/lua-scripts.ts

@@ -5,56 +5,66 @@
  */
 
 /**
- * 原子性检查并发限制 + 追踪 Session(TC-041 修复版)
+ * Atomic concurrency check + session tracking (TC-041 fixed version)
  *
- * 功能:
- * 1. 清理过期 session(5 分钟前)
- * 2. 检查 session 是否已追踪(避免重复计数)
- * 3. 检查当前并发数是否超限
- * 4. 如果未超限,追踪新 session(原子操作)
+ * Features:
+ * 1. Cleanup expired sessions (based on TTL window)
+ * 2. Check if session is already tracked (avoid duplicate counting)
+ * 3. Check if current concurrency exceeds limit
+ * 4. If not exceeded, track new session (atomic operation)
  *
  * KEYS[1]: provider:${providerId}:active_sessions
  * ARGV[1]: sessionId
- * ARGV[2]: limit(并发限制)
- * ARGV[3]: now(当前时间戳,毫秒)
+ * ARGV[2]: limit (concurrency limit)
+ * ARGV[3]: now (current timestamp, ms)
+ * ARGV[4]: ttlMs (optional, cleanup window in ms, default 300000)
  *
- * 返回值:
- * - {1, count, 1} - 允许(新追踪),返回新的并发数和 tracked=1
- * - {1, count, 0} - 允许(已追踪),返回当前并发数和 tracked=0
- * - {0, count, 0} - 拒绝(超限),返回当前并发数和 tracked=0
+ * Return:
+ * - {1, count, 1} - allowed (new tracking), returns new count and tracked=1
+ * - {1, count, 0} - allowed (already tracked), returns current count and tracked=0
+ * - {0, count, 0} - rejected (limit reached), returns current count and tracked=0
  */
 export const CHECK_AND_TRACK_SESSION = `
 local provider_key = KEYS[1]
 local session_id = ARGV[1]
 local limit = tonumber(ARGV[2])
 local now = tonumber(ARGV[3])
-local ttl = 300000  -- 5 分钟(毫秒)
+local ttl = tonumber(ARGV[4]) or 300000
 
--- 1. 清理过期 session(5 分钟前)
-local five_minutes_ago = now - ttl
-redis.call('ZREMRANGEBYSCORE', provider_key, '-inf', five_minutes_ago)
+-- Guard against invalid TTL (prevents clearing all sessions)
+if ttl <= 0 then
+  ttl = 300000
+end
+
+-- 1. Cleanup expired sessions (TTL window ago)
+local cutoff = now - ttl
+redis.call('ZREMRANGEBYSCORE', provider_key, '-inf', cutoff)
 
--- 2. 检查 session 是否已追踪
+-- 2. Check if session is already tracked
 local is_tracked = redis.call('ZSCORE', provider_key, session_id)
 
--- 3. 获取当前并发数
+-- 3. Get current concurrency count
 local current_count = redis.call('ZCARD', provider_key)
 
--- 4. 检查限制(排除已追踪的 session)
+-- 4. Check limit (exclude already tracked session)
 if limit > 0 and not is_tracked and current_count >= limit then
   return {0, current_count, 0}  -- {allowed=false, current_count, tracked=0}
 end
 
--- 5. 追踪 session(ZADD 对已存在的成员只更新时间戳)
+-- 5. Track session (ZADD updates timestamp for existing members)
 redis.call('ZADD', provider_key, now, session_id)
-redis.call('EXPIRE', provider_key, 3600)  -- 1 小时兜底 TTL
 
--- 6. 返回成功
+-- 6. Set TTL based on session TTL (at least 1h to cover active sessions)
+local ttl_seconds = math.floor(ttl / 1000)
+local expire_ttl = math.max(3600, ttl_seconds)
+redis.call('EXPIRE', provider_key, expire_ttl)
+
+-- 7. Return success
 if is_tracked then
-  -- 已追踪,计数不变
+  -- Already tracked, count unchanged
   return {1, current_count, 0}  -- {allowed=true, count, tracked=0}
 else
-  -- 新追踪,计数 +1
+  -- New tracking, count +1
   return {1, current_count + 1, 1}  -- {allowed=true, new_count, tracked=1}
 end
 `;

+ 128 - 26
src/lib/redis/pubsub.ts

@@ -6,40 +6,134 @@ import { getRedisClient } from "./client";
 
 export const CHANNEL_ERROR_RULES_UPDATED = "cch:cache:error_rules:updated";
 export const CHANNEL_REQUEST_FILTERS_UPDATED = "cch:cache:request_filters:updated";
+export const CHANNEL_SENSITIVE_WORDS_UPDATED = "cch:cache:sensitive_words:updated";
 
 type CacheInvalidationCallback = () => void;
 
 let subscriberClient: Redis | null = null;
+let subscriberReady: Promise<Redis> | null = null;
 const subscriptions = new Map<string, Set<CacheInvalidationCallback>>();
+const subscribedChannels = new Set<string>();
 
-function ensureSubscriber(baseClient: Redis): Redis {
-  if (subscriberClient) return subscriberClient;
+let resubscribeInFlight: Promise<void> | null = null;
 
-  // 订阅必须使用独立连接(Pub/Sub 模式下连接不能再执行普通命令)
-  subscriberClient = baseClient.duplicate();
+async function resubscribeAll(sub: Redis): Promise<void> {
+  if (resubscribeInFlight) return resubscribeInFlight;
 
-  subscriberClient.on("message", (channel: string) => {
-    const callbacks = subscriptions.get(channel);
-    if (!callbacks || callbacks.size === 0) return;
+  resubscribeInFlight = (async () => {
+    const channelsToSubscribe: string[] = [];
+    for (const [channel, callbacks] of subscriptions) {
+      if (!callbacks || callbacks.size === 0) continue;
+      if (!subscribedChannels.has(channel)) {
+        channelsToSubscribe.push(channel);
+      }
+    }
 
-    for (const cb of callbacks) {
+    if (channelsToSubscribe.length === 0) return;
+
+    let successCount = 0;
+    for (const channel of channelsToSubscribe) {
       try {
-        cb();
+        await sub.subscribe(channel);
+        subscribedChannels.add(channel);
+        successCount++;
       } catch (error) {
-        logger.error("[RedisPubSub] Callback error", { channel, error });
+        logger.warn("[RedisPubSub] Failed to resubscribe channel after reconnect", {
+          channel,
+          error,
+        });
       }
     }
+
+    if (successCount > 0) {
+      logger.info("[RedisPubSub] Resubscribed to channels after reconnect", {
+        count: successCount,
+      });
+    }
+  })().finally(() => {
+    resubscribeInFlight = null;
   });
 
-  subscriberClient.on("error", (error) => {
-    logger.warn("[RedisPubSub] Subscriber connection error", { error });
+  return resubscribeInFlight;
+}
+
+function ensureSubscriber(baseClient: Redis): Promise<Redis> {
+  if (subscriberReady) return subscriberReady;
+
+  subscriberReady = new Promise<Redis>((resolve, reject) => {
+    const sub = baseClient.duplicate();
+    let timeoutId: ReturnType<typeof setTimeout> | null = null;
+
+    function cleanup(): void {
+      sub.off("ready", onReady);
+      sub.off("error", onError);
+
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+        timeoutId = null;
+      }
+    }
+
+    function fail(error: Error): void {
+      cleanup();
+      subscriberReady = null;
+      try {
+        sub.disconnect();
+      } catch {
+        // ignore
+      }
+      reject(error);
+    }
+
+    function onReady(): void {
+      cleanup();
+      subscriberClient = sub;
+      subscribedChannels.clear();
+
+      sub.on("error", (error) =>
+        logger.warn("[RedisPubSub] Subscriber connection error", { error })
+      );
+      sub.on("close", () => subscribedChannels.clear());
+      sub.on("end", () => subscribedChannels.clear());
+      sub.on("ready", () => void resubscribeAll(sub));
+
+      sub.on("message", (channel: string) => {
+        const callbacks = subscriptions.get(channel);
+        if (!callbacks || callbacks.size === 0) return;
+        for (const cb of callbacks) {
+          try {
+            cb();
+          } catch (error) {
+            logger.error("[RedisPubSub] Callback error", { channel, error });
+          }
+        }
+      });
+
+      logger.info("[RedisPubSub] Subscriber connection ready");
+      resolve(sub);
+    }
+
+    function onError(error: Error): void {
+      logger.warn("[RedisPubSub] Subscriber connection error", { error });
+      fail(error);
+    }
+
+    sub.once("ready", onReady);
+    sub.once("error", onError);
+
+    // Timeout 10 seconds
+    timeoutId = setTimeout(() => {
+      if (sub.status !== "ready") {
+        fail(new Error("Redis subscriber connection timeout"));
+      }
+    }, 10000);
   });
 
-  return subscriberClient;
+  return subscriberReady;
 }
 
 /**
- * 发布缓存失效通知(失败不抛错,自动降级)
+ * Publish cache invalidation (silent fail, auto-degrade)
  */
 export async function publishCacheInvalidation(channel: string): Promise<void> {
   const redis = getRedisClient();
@@ -53,28 +147,35 @@ export async function publishCacheInvalidation(channel: string): Promise<void> {
 }
 
 /**
- * 订阅缓存失效通知(失败不抛错,自动降级)
- *
- * 返回取消订阅函数(用于释放回调引用)
+ * Subscribe to cache invalidation
+ * Returns cleanup function on success, null on failure
  */
 export async function subscribeCacheInvalidation(
   channel: string,
   callback: CacheInvalidationCallback
-): Promise<() => void> {
+): Promise<(() => void) | null> {
   const baseClient = getRedisClient();
-  if (!baseClient) return () => {};
+  if (!baseClient) return null;
 
   try {
-    const sub = ensureSubscriber(baseClient);
+    const sub = await ensureSubscriber(baseClient);
 
-    const existing = subscriptions.get(channel);
-    const isFirstSubscriberForChannel = !existing;
-    const callbacks = existing ?? new Set<CacheInvalidationCallback>();
+    const callbacks = subscriptions.get(channel) ?? new Set<CacheInvalidationCallback>();
     callbacks.add(callback);
     subscriptions.set(channel, callbacks);
 
-    if (isFirstSubscriberForChannel) {
-      await sub.subscribe(channel);
+    if (!subscribedChannels.has(channel)) {
+      try {
+        await sub.subscribe(channel);
+        subscribedChannels.add(channel);
+        logger.info("[RedisPubSub] Subscribed to channel", { channel });
+      } catch (error) {
+        callbacks.delete(callback);
+        if (callbacks.size === 0) {
+          subscriptions.delete(channel);
+        }
+        throw error;
+      }
     }
 
     return () => {
@@ -85,6 +186,7 @@ export async function subscribeCacheInvalidation(
 
       if (cbs.size === 0) {
         subscriptions.delete(channel);
+        subscribedChannels.delete(channel);
         if (subscriberClient) {
           void subscriberClient.unsubscribe(channel);
         }
@@ -92,6 +194,6 @@ export async function subscribeCacheInvalidation(
     };
   } catch (error) {
     logger.warn("[RedisPubSub] Failed to subscribe cache invalidation", { channel, error });
-    return () => {};
+    return null;
   }
 }

+ 26 - 13
src/lib/request-filter-engine.ts

@@ -137,9 +137,11 @@ export class RequestFilterEngine {
       try {
         const { eventEmitter } = await import("@/lib/event-emitter");
         const handler = () => {
+          logger.info("[RequestFilterEngine] Received requestFiltersUpdated event, reloading...");
           void this.reload();
         };
         eventEmitter.on("requestFiltersUpdated", handler);
+        logger.info("[RequestFilterEngine] Subscribed to local eventEmitter");
 
         // Store cleanup function
         this.eventEmitterCleanup = () => {
@@ -151,15 +153,20 @@ export class RequestFilterEngine {
           const { CHANNEL_REQUEST_FILTERS_UPDATED, subscribeCacheInvalidation } = await import(
             "@/lib/redis/pubsub"
           );
-          this.redisPubSubCleanup = await subscribeCacheInvalidation(
+          const cleanup = await subscribeCacheInvalidation(
             CHANNEL_REQUEST_FILTERS_UPDATED,
             handler
           );
-        } catch {
-          // 忽略导入错误
+          if (cleanup) {
+            this.redisPubSubCleanup = cleanup;
+            logger.info("[RequestFilterEngine] Subscribed to Redis pub/sub channel");
+          }
+          // If null, pubsub.ts already logged; nothing to do
+        } catch (error) {
+          logger.warn("[RequestFilterEngine] Failed to subscribe to Redis pub/sub", { error });
         }
-      } catch {
-        // 忽略导入错误
+      } catch (error) {
+        logger.warn("[RequestFilterEngine] Failed to setup event listener", { error });
       }
     }
   }
@@ -219,7 +226,7 @@ export class RequestFilterEngine {
         return cached;
       });
 
-      // Разделяем фильтры по типу привязки
+      // Split filters by binding type
       this.globalFilters = cachedFilters
         .filter((f) => f.bindingType === "global" || !f.bindingType)
         .sort((a, b) => a.priority - b.priority || a.id - b.id);
@@ -233,9 +240,10 @@ export class RequestFilterEngine {
 
       this.lastReloadTime = Date.now();
       this.isInitialized = true;
-      logger.info("[RequestFilterEngine] Filters loaded", {
+      logger.info("[RequestFilterEngine] Filters reloaded", {
         globalCount: this.globalFilters.length,
         providerCount: this.providerFilters.length,
+        timestamp: new Date().toISOString(),
       });
     } catch (error) {
       logger.error("[RequestFilterEngine] Failed to reload filters", { error });
@@ -255,7 +263,7 @@ export class RequestFilterEngine {
   }
 
   /**
-   * Применить глобальные фильтры (вызывается ДО выбора провайдера)
+   * Apply global filters (called BEFORE provider selection)
    */
   async applyGlobal(session: ProxySession): Promise<void> {
     // Optimization #4: Early exit if already initialized and empty
@@ -283,7 +291,7 @@ export class RequestFilterEngine {
   }
 
   /**
-   * Применить фильтры для конкретного провайдера (вызывается ПОСЛЕ выбора провайдера)
+   * Apply filters for a specific provider (called AFTER provider selection)
    */
   async applyForProvider(session: ProxySession): Promise<void> {
     // Optimization #4: Early exit if already initialized and empty
@@ -302,7 +310,7 @@ export class RequestFilterEngine {
     }
 
     for (const filter of this.providerFilters) {
-      // Проверяем соответствие привязки
+      // Check binding match
       let matches = false;
 
       if (filter.bindingType === "providers") {
@@ -336,8 +344,8 @@ export class RequestFilterEngine {
   }
 
   /**
-   * @deprecated Используйте applyGlobal() вместо этого метода.
-   * Оставлено для обратной совместимости.
+   * @deprecated Use applyGlobal() instead of this method.
+   * Kept for backward compatibility.
    */
   async apply(session: ProxySession): Promise<void> {
     await this.applyGlobal(session);
@@ -478,4 +486,9 @@ export class RequestFilterEngine {
   }
 }
 
-export const requestFilterEngine = new RequestFilterEngine();
+// Use globalThis to guarantee a single instance across workers
+const g = globalThis as unknown as { __CCH_REQUEST_FILTER_ENGINE__?: RequestFilterEngine };
+if (!g.__CCH_REQUEST_FILTER_ENGINE__) {
+  g.__CCH_REQUEST_FILTER_ENGINE__ = new RequestFilterEngine();
+}
+export const requestFilterEngine = g.__CCH_REQUEST_FILTER_ENGINE__;

+ 57 - 2
src/lib/sensitive-word-detector.ts

@@ -30,6 +30,57 @@ class SensitiveWordCache {
   private lastReloadTime: number = 0;
   private isLoading: boolean = false;
 
+  private eventEmitterCleanup: (() => void) | null = null;
+  private redisPubSubCleanup: (() => void) | null = null;
+
+  constructor() {
+    this.setupEventListener();
+  }
+
+  private async setupEventListener(): Promise<void> {
+    if (typeof process !== "undefined" && process.env.NEXT_RUNTIME !== "edge") {
+      try {
+        const { eventEmitter } = await import("@/lib/event-emitter");
+        const handler = () => {
+          logger.info("[SensitiveWordCache] Received update event, reloading...");
+          void this.reload();
+        };
+        eventEmitter.on("sensitiveWordsUpdated", handler);
+        logger.info("[SensitiveWordCache] Subscribed to local eventEmitter");
+
+        this.eventEmitterCleanup = () => {
+          eventEmitter.off("sensitiveWordsUpdated", handler);
+        };
+
+        try {
+          const { CHANNEL_SENSITIVE_WORDS_UPDATED, subscribeCacheInvalidation } = await import(
+            "@/lib/redis/pubsub"
+          );
+          const cleanup = await subscribeCacheInvalidation(
+            CHANNEL_SENSITIVE_WORDS_UPDATED,
+            handler
+          );
+          if (cleanup) {
+            this.redisPubSubCleanup = cleanup;
+            logger.info("[SensitiveWordCache] Subscribed to Redis pub/sub channel");
+          }
+        } catch (error) {
+          logger.warn("[SensitiveWordCache] Failed to subscribe to Redis pub/sub", { error });
+        }
+      } catch (error) {
+        logger.warn("[SensitiveWordCache] Failed to setup event listener", { error });
+      }
+    }
+  }
+
+  destroy(): void {
+    this.eventEmitterCleanup?.();
+    this.eventEmitterCleanup = null;
+
+    this.redisPubSubCleanup?.();
+    this.redisPubSubCleanup = null;
+  }
+
   /**
    * 从数据库重新加载敏感词列表
    */
@@ -185,5 +236,9 @@ class SensitiveWordCache {
   }
 }
 
-// 单例导出
-export const sensitiveWordDetector = new SensitiveWordCache();
+// Use globalThis to guarantee a single instance across workers
+const g = globalThis as unknown as { __CCH_SENSITIVE_WORD_DETECTOR__?: SensitiveWordCache };
+if (!g.__CCH_SENSITIVE_WORD_DETECTOR__) {
+  g.__CCH_SENSITIVE_WORD_DETECTOR__ = new SensitiveWordCache();
+}
+export const sensitiveWordDetector = g.__CCH_SENSITIVE_WORD_DETECTOR__;

+ 25 - 20
src/lib/session-tracker.ts

@@ -17,7 +17,12 @@ import { getRedisClient } from "./redis";
  * - user:${userId}:active_sessions (ZSET): 同上
  */
 export class SessionTracker {
-  private static readonly SESSION_TTL = 300000; // 5 分钟(毫秒)
+  private static readonly SESSION_TTL_SECONDS = (() => {
+    const parsed = Number.parseInt(process.env.SESSION_TTL ?? "", 10);
+    return Number.isFinite(parsed) && parsed > 0 ? parsed : 300;
+  })();
+  private static readonly SESSION_TTL_MS = SessionTracker.SESSION_TTL_SECONDS * 1000;
+  private static readonly CLEANUP_PROBABILITY = 0.01;
 
   /**
    * 初始化 SessionTracker,自动清理旧格式数据
@@ -174,26 +179,26 @@ export class SessionTracker {
     try {
       const now = Date.now();
       const pipeline = redis.pipeline();
+      const ttlSeconds = SessionTracker.SESSION_TTL_SECONDS;
+      const providerZSetKey = `provider:${providerId}:active_sessions`;
 
-      // 更新所有相关 ZSET 的时间戳(滑动窗口)
       pipeline.zadd("global:active_sessions", now, sessionId);
       pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId);
-      pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId);
+      pipeline.zadd(providerZSetKey, now, sessionId);
+      // Use dynamic TTL based on session TTL (at least 1h to cover active sessions)
+      pipeline.expire(providerZSetKey, Math.max(3600, ttlSeconds));
       if (userId !== undefined) {
         pipeline.zadd(`user:${userId}:active_sessions`, now, sessionId);
       }
 
-      // 修复 Bug:同步刷新 session 绑定信息的 TTL
-      //
-      // 问题:ZSET 条目(上面 zadd)会在每次请求时更新时间戳,但绑定信息 key 的 TTL 不会自动刷新
-      // 导致:session 创建 5 分钟后,ZSET 仍有记录(仍被计为活跃),但绑定信息已过期,造成:
-      //   1. 并发检查被绕过(无法从绑定信息查询 session 所属 provider/key,检查失效)
-      //   2. Session 复用失败(无法确定 session 绑定关系,被迫创建新 session)
-      //
-      // 解决:每次 refreshSession 时同步刷新绑定信息 TTL(与 ZSET 保持 5 分钟生命周期一致)
-      pipeline.expire(`session:${sessionId}:provider`, 300); // 5 分钟(秒)
-      pipeline.expire(`session:${sessionId}:key`, 300);
-      pipeline.setex(`session:${sessionId}:last_seen`, 300, now.toString());
+      pipeline.expire(`session:${sessionId}:provider`, ttlSeconds);
+      pipeline.expire(`session:${sessionId}:key`, ttlSeconds);
+      pipeline.setex(`session:${sessionId}:last_seen`, ttlSeconds, now.toString());
+
+      if (Math.random() < SessionTracker.CLEANUP_PROBABILITY) {
+        const cutoffMs = now - SessionTracker.SESSION_TTL_MS;
+        pipeline.zremrangebyscore(providerZSetKey, "-inf", cutoffMs);
+      }
 
       const results = await pipeline.exec();
 
@@ -374,14 +379,14 @@ export class SessionTracker {
 
     try {
       const now = Date.now();
-      const fiveMinutesAgo = now - SessionTracker.SESSION_TTL;
+      const cutoffMs = now - SessionTracker.SESSION_TTL_MS;
 
       // 第一阶段:批量清理过期 session 并获取 session IDs
       const cleanupPipeline = redis.pipeline();
       for (const providerId of providerIds) {
         const key = `provider:${providerId}:active_sessions`;
         // 清理过期 session
-        cleanupPipeline.zremrangebyscore(key, "-inf", fiveMinutesAgo);
+        cleanupPipeline.zremrangebyscore(key, "-inf", cutoffMs);
         // 获取剩余 session IDs
         cleanupPipeline.zrange(key, 0, -1);
       }
@@ -480,10 +485,10 @@ export class SessionTracker {
         }
 
         const now = Date.now();
-        const fiveMinutesAgo = now - SessionTracker.SESSION_TTL;
+        const cutoffMs = now - SessionTracker.SESSION_TTL_MS;
 
         // 清理过期 session
-        await redis.zremrangebyscore(key, "-inf", fiveMinutesAgo);
+        await redis.zremrangebyscore(key, "-inf", cutoffMs);
 
         // 获取剩余的 session ID
         return await redis.zrange(key, 0, -1);
@@ -514,10 +519,10 @@ export class SessionTracker {
 
     try {
       const now = Date.now();
-      const fiveMinutesAgo = now - SessionTracker.SESSION_TTL;
+      const cutoffMs = now - SessionTracker.SESSION_TTL_MS;
 
       // 1. 清理过期 session(5 分钟前)
-      await redis.zremrangebyscore(key, "-inf", fiveMinutesAgo);
+      await redis.zremrangebyscore(key, "-inf", cutoffMs);
 
       // 2. 获取剩余的 session ID
       const sessionIds = await redis.zrange(key, 0, -1);

+ 12 - 0
src/lib/utils/currency.ts

@@ -132,4 +132,16 @@ export function formatCurrency(
   return `${config.symbol}${formatted}`;
 }
 
+/**
+ * Get currency symbol from currency code
+ * @param currencyCode - Currency code (default "USD")
+ * @returns Currency symbol (e.g., "$", "¥", "€")
+ */
+export function getCurrencySymbol(currencyCode?: CurrencyCode | string): string {
+  if (!currencyCode || !(currencyCode in CURRENCY_CONFIG)) {
+    return CURRENCY_CONFIG.USD.symbol;
+  }
+  return CURRENCY_CONFIG[currencyCode as CurrencyCode].symbol;
+}
+
 export { Decimal };

+ 9 - 0
src/lib/utils/special-settings.ts

@@ -88,6 +88,15 @@ function buildSettingKey(setting: SpecialSetting): string {
         setting.after.maxTokens,
         setting.after.thinkingBudgetTokens,
       ]);
+    case "gemini_google_search_override":
+      return JSON.stringify([
+        setting.type,
+        setting.hit,
+        setting.providerId ?? null,
+        setting.action,
+        setting.preference,
+        setting.hadGoogleSearchInRequest,
+      ]);
     default: {
       // 兜底:保证即使未来扩展类型也不会导致运行时崩溃
       const _exhaustive: never = setting;

+ 8 - 0
src/lib/validation/schemas.ts

@@ -55,6 +55,12 @@ const ANTHROPIC_THINKING_BUDGET_PREFERENCE = z.union([
     ),
 ]);
 
+// Gemini (generateContent API) Google Search preference
+// - 'inherit': follow client request (default)
+// - 'enabled': force inject googleSearch tool
+// - 'disabled': force remove googleSearch tool from request
+const GEMINI_GOOGLE_SEARCH_PREFERENCE = z.enum(["inherit", "enabled", "disabled"]);
+
 /**
  * 用户创建数据验证schema
  */
@@ -477,6 +483,7 @@ export const CreateProviderSchema = z
     anthropic_max_tokens_preference: ANTHROPIC_MAX_TOKENS_PREFERENCE.optional().default("inherit"),
     anthropic_thinking_budget_preference:
       ANTHROPIC_THINKING_BUDGET_PREFERENCE.optional().default("inherit"),
+    gemini_google_search_preference: GEMINI_GOOGLE_SEARCH_PREFERENCE.optional().default("inherit"),
     max_retry_attempts: z.coerce
       .number()
       .int("重试次数必须是整数")
@@ -670,6 +677,7 @@ export const UpdateProviderSchema = z
     codex_parallel_tool_calls_preference: CODEX_PARALLEL_TOOL_CALLS_PREFERENCE.optional(),
     anthropic_max_tokens_preference: ANTHROPIC_MAX_TOKENS_PREFERENCE.optional(),
     anthropic_thinking_budget_preference: ANTHROPIC_THINKING_BUDGET_PREFERENCE.optional(),
+    gemini_google_search_preference: GEMINI_GOOGLE_SEARCH_PREFERENCE.optional(),
     max_retry_attempts: z.coerce
       .number()
       .int("重试次数必须是整数")

+ 1 - 0
src/repository/_shared/transformers.ts

@@ -125,6 +125,7 @@ export function toProvider(dbProvider: any): Provider {
     codexParallelToolCallsPreference: dbProvider?.codexParallelToolCallsPreference ?? null,
     anthropicMaxTokensPreference: dbProvider?.anthropicMaxTokensPreference ?? null,
     anthropicThinkingBudgetPreference: dbProvider?.anthropicThinkingBudgetPreference ?? null,
+    geminiGoogleSearchPreference: dbProvider?.geminiGoogleSearchPreference ?? null,
     tpm: dbProvider?.tpm ?? null,
     rpm: dbProvider?.rpm ?? null,
     rpd: dbProvider?.rpd ?? null,

+ 476 - 62
src/repository/provider-endpoints.ts

@@ -1,6 +1,6 @@
 "use server";
 
-import { and, asc, desc, eq, gt, isNotNull, isNull, or, sql } from "drizzle-orm";
+import { and, asc, desc, eq, gt, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import {
   providerEndpointProbeLogs,
@@ -8,6 +8,7 @@ import {
   providers,
   providerVendors,
 } from "@/drizzle/schema";
+import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker";
 import { logger } from "@/lib/logger";
 import type {
   ProviderEndpoint,
@@ -17,6 +18,41 @@ import type {
   ProviderVendor,
 } from "@/types/provider";
 
+type TransactionExecutor = Parameters<Parameters<typeof db.transaction>[0]>[0];
+type QueryExecutor = Pick<
+  TransactionExecutor,
+  "select" | "insert" | "update" | "delete" | "execute"
+>;
+
+function isUniqueViolationError(error: unknown): boolean {
+  if (!error || typeof error !== "object") {
+    return false;
+  }
+
+  const candidate = error as {
+    code?: string;
+    message?: string;
+    cause?: { code?: string; message?: string };
+  };
+
+  if (candidate.code === "23505") {
+    return true;
+  }
+
+  if (typeof candidate.message === "string" && candidate.message.includes("duplicate key value")) {
+    return true;
+  }
+
+  if (candidate.cause?.code === "23505") {
+    return true;
+  }
+
+  return (
+    typeof candidate.cause?.message === "string" &&
+    candidate.cause.message.includes("duplicate key value")
+  );
+}
+
 function toDate(value: unknown): Date {
   if (value instanceof Date) return value;
   if (typeof value === "string" || typeof value === "number") return new Date(value);
@@ -252,12 +288,17 @@ export async function deleteProviderEndpointProbeLogsBeforeDateBatch(input: {
   return typeof rowCount === "number" ? rowCount : 0;
 }
 
-export async function getOrCreateProviderVendorIdFromUrls(input: {
-  providerUrl: string;
-  websiteUrl?: string | null;
-  faviconUrl?: string | null;
-  displayName?: string | null;
-}): Promise<number> {
+export async function getOrCreateProviderVendorIdFromUrls(
+  input: {
+    providerUrl: string;
+    websiteUrl?: string | null;
+    faviconUrl?: string | null;
+    displayName?: string | null;
+  },
+  options?: { tx?: QueryExecutor }
+): Promise<number> {
+  const executor = options?.tx ?? db;
+
   // Use new computeVendorKey for consistent vendor key calculation
   const websiteDomain = await computeVendorKey({
     providerUrl: input.providerUrl,
@@ -267,7 +308,7 @@ export async function getOrCreateProviderVendorIdFromUrls(input: {
     throw new Error("Failed to resolve provider vendor domain");
   }
 
-  const existing = await db
+  const existing = await executor
     .select({ id: providerVendors.id })
     .from(providerVendors)
     .where(eq(providerVendors.websiteDomain, websiteDomain))
@@ -277,7 +318,7 @@ export async function getOrCreateProviderVendorIdFromUrls(input: {
   }
 
   const now = new Date();
-  const inserted = await db
+  const inserted = await executor
     .insert(providerVendors)
     .values({
       websiteDomain,
@@ -293,7 +334,7 @@ export async function getOrCreateProviderVendorIdFromUrls(input: {
     return inserted[0].id;
   }
 
-  const fallback = await db
+  const fallback = await executor
     .select({ id: providerVendors.id })
     .from(providerVendors)
     .where(eq(providerVendors.websiteDomain, websiteDomain))
@@ -556,57 +597,58 @@ export async function deleteProviderVendor(vendorId: number): Promise<boolean> {
   return deleted;
 }
 
-export async function tryDeleteProviderVendorIfEmpty(vendorId: number): Promise<boolean> {
-  try {
-    return await db.transaction(async (tx) => {
-      // 1) Must have no active providers (soft-deleted rows still exist but should not block).
-      const [activeProvider] = await tx
-        .select({ id: providers.id })
-        .from(providers)
-        .where(and(eq(providers.providerVendorId, vendorId), isNull(providers.deletedAt)))
-        .limit(1);
+export async function tryDeleteProviderVendorIfEmpty(
+  vendorId: number,
+  options?: { tx?: QueryExecutor }
+): Promise<boolean> {
+  const runInTx = async (tx: QueryExecutor): Promise<boolean> => {
+    // 1) Must have no active providers (soft-deleted rows still exist but should not block).
+    const [activeProvider] = await tx
+      .select({ id: providers.id })
+      .from(providers)
+      .where(and(eq(providers.providerVendorId, vendorId), isNull(providers.deletedAt)))
+      .limit(1);
 
-      if (activeProvider) {
-        return false;
-      }
+    if (activeProvider) {
+      return false;
+    }
 
-      // 2) Must have no active endpoints.
-      const [activeEndpoint] = await tx
-        .select({ id: providerEndpoints.id })
-        .from(providerEndpoints)
-        .where(and(eq(providerEndpoints.vendorId, vendorId), isNull(providerEndpoints.deletedAt)))
-        .limit(1);
+    // 2) Must have no active endpoints.
+    const [activeEndpoint] = await tx
+      .select({ id: providerEndpoints.id })
+      .from(providerEndpoints)
+      .where(and(eq(providerEndpoints.vendorId, vendorId), isNull(providerEndpoints.deletedAt)))
+      .limit(1);
 
-      if (activeEndpoint) {
-        return false;
-      }
+    if (activeEndpoint) {
+      return false;
+    }
 
-      // 3) Hard delete soft-deleted providers to satisfy FK `onDelete: restrict`.
-      await tx
-        .delete(providers)
-        .where(and(eq(providers.providerVendorId, vendorId), isNotNull(providers.deletedAt)));
+    // 3) Hard delete soft-deleted providers to satisfy FK `onDelete: restrict`.
+    await tx
+      .delete(providers)
+      .where(and(eq(providers.providerVendorId, vendorId), isNotNull(providers.deletedAt)));
 
-      // 4) Delete vendor. Endpoints will be physically removed by FK cascade.
-      const deleted = await tx
-        .delete(providerVendors)
-        .where(
-          and(
-            eq(providerVendors.id, vendorId),
-            sql`NOT EXISTS (SELECT 1 FROM providers p WHERE p.provider_vendor_id = ${vendorId} AND p.deleted_at IS NULL)`,
-            sql`NOT EXISTS (SELECT 1 FROM provider_endpoints e WHERE e.vendor_id = ${vendorId} AND e.deleted_at IS NULL)`
-          )
+    // 4) Delete vendor. Endpoints will be physically removed by FK cascade.
+    const deleted = await tx
+      .delete(providerVendors)
+      .where(
+        and(
+          eq(providerVendors.id, vendorId),
+          sql`NOT EXISTS (SELECT 1 FROM providers p WHERE p.provider_vendor_id = ${vendorId} AND p.deleted_at IS NULL)`,
+          sql`NOT EXISTS (SELECT 1 FROM provider_endpoints e WHERE e.vendor_id = ${vendorId} AND e.deleted_at IS NULL)`
         )
-        .returning({ id: providerVendors.id });
+      )
+      .returning({ id: providerVendors.id });
 
-      return deleted.length > 0;
-    });
-  } catch (error) {
-    logger.warn("[ProviderVendor] Auto delete failed", {
-      vendorId,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    return false;
+    return deleted.length > 0;
+  };
+
+  if (options?.tx) {
+    return await runInTx(options.tx);
   }
+
+  return await db.transaction(async (tx) => runInTx(tx));
 }
 
 export async function findProviderEndpointsByVendorAndType(
@@ -714,26 +756,31 @@ export async function createProviderEndpoint(payload: {
   return toProviderEndpoint(row);
 }
 
-export async function ensureProviderEndpointExistsForUrl(input: {
-  vendorId: number;
-  providerType: ProviderType;
-  url: string;
-  label?: string | null;
-}): Promise<boolean> {
+export async function ensureProviderEndpointExistsForUrl(
+  input: {
+    vendorId: number;
+    providerType: ProviderType;
+    url: string;
+    label?: string | null;
+  },
+  options?: { tx?: QueryExecutor }
+): Promise<boolean> {
+  const executor = options?.tx ?? db;
+
   const trimmedUrl = input.url.trim();
   if (!trimmedUrl) {
-    return false;
+    throw new Error("[ProviderEndpointEnsure] url is required");
   }
 
   try {
     // eslint-disable-next-line no-new
     new URL(trimmedUrl);
   } catch {
-    return false;
+    throw new Error("[ProviderEndpointEnsure] url must be a valid URL");
   }
 
   const now = new Date();
-  const inserted = await db
+  const inserted = await executor
     .insert(providerEndpoints)
     .values({
       vendorId: input.vendorId,
@@ -750,6 +797,373 @@ export async function ensureProviderEndpointExistsForUrl(input: {
   return inserted.length > 0;
 }
 
+export interface SyncProviderEndpointOnProviderEditInput {
+  providerId: number;
+  vendorId: number;
+  providerType: ProviderType;
+  previousVendorId?: number | null;
+  previousProviderType?: ProviderType | null;
+  previousUrl: string;
+  nextUrl: string;
+  keepPreviousWhenReferenced?: boolean;
+}
+
+type ProviderEndpointSyncAction =
+  | "noop"
+  | "created-next"
+  | "revived-next"
+  | "updated-previous-in-place"
+  | "kept-previous-and-created-next"
+  | "kept-previous-and-revived-next"
+  | "kept-previous-and-kept-next"
+  | "soft-deleted-previous-and-kept-next"
+  | "soft-deleted-previous-and-revived-next";
+
+export interface SyncProviderEndpointOnProviderEditResult {
+  action: ProviderEndpointSyncAction;
+  resetCircuitEndpointId?: number;
+}
+
+export async function syncProviderEndpointOnProviderEdit(
+  input: SyncProviderEndpointOnProviderEditInput,
+  options?: { tx?: QueryExecutor }
+): Promise<SyncProviderEndpointOnProviderEditResult> {
+  const previousUrl = input.previousUrl.trim();
+  const nextUrl = input.nextUrl.trim();
+
+  if (!nextUrl) {
+    throw new Error("[ProviderEndpointSync] nextUrl is required");
+  }
+
+  try {
+    // eslint-disable-next-line no-new
+    new URL(nextUrl);
+  } catch {
+    throw new Error("[ProviderEndpointSync] nextUrl must be a valid URL");
+  }
+
+  const previousVendorId = input.previousVendorId ?? input.vendorId;
+  const previousProviderType = input.previousProviderType ?? input.providerType;
+  const keepPreviousWhenReferenced = input.keepPreviousWhenReferenced !== false;
+
+  const runInTx = async (tx: QueryExecutor): Promise<SyncProviderEndpointOnProviderEditResult> => {
+    const now = new Date();
+
+    const loadEndpoint = async (args: {
+      vendorId: number;
+      providerType: ProviderType;
+      url: string;
+    }): Promise<{ id: number; deletedAt: Date | null; isEnabled: boolean } | null> => {
+      const [row] = await tx
+        .select({
+          id: providerEndpoints.id,
+          deletedAt: providerEndpoints.deletedAt,
+          isEnabled: providerEndpoints.isEnabled,
+        })
+        .from(providerEndpoints)
+        .where(
+          and(
+            eq(providerEndpoints.vendorId, args.vendorId),
+            eq(providerEndpoints.providerType, args.providerType),
+            eq(providerEndpoints.url, args.url)
+          )
+        )
+        .limit(1);
+
+      return row
+        ? {
+            id: row.id,
+            deletedAt: row.deletedAt,
+            isEnabled: row.isEnabled,
+          }
+        : null;
+    };
+
+    const hasActiveReferencesOnPreviousUrl = async (): Promise<boolean> => {
+      const [activeReference] = await tx
+        .select({ id: providers.id })
+        .from(providers)
+        .where(
+          and(
+            eq(providers.providerVendorId, previousVendorId),
+            eq(providers.providerType, previousProviderType),
+            eq(providers.url, previousUrl),
+            isNull(providers.deletedAt),
+            ne(providers.id, input.providerId)
+          )
+        )
+        .limit(1);
+
+      return Boolean(activeReference);
+    };
+
+    const ensureNextEndpointActive = async (options?: {
+      reactivateDisabled?: boolean;
+    }): Promise<"created-next" | "revived-next" | "noop"> => {
+      const reactivateDisabled = options?.reactivateDisabled ?? true;
+      const nextEndpoint = await loadEndpoint({
+        vendorId: input.vendorId,
+        providerType: input.providerType,
+        url: nextUrl,
+      });
+
+      if (!nextEndpoint) {
+        const inserted = await tx
+          .insert(providerEndpoints)
+          .values({
+            vendorId: input.vendorId,
+            providerType: input.providerType,
+            url: nextUrl,
+            label: null,
+            updatedAt: now,
+          })
+          .onConflictDoNothing({
+            target: [
+              providerEndpoints.vendorId,
+              providerEndpoints.providerType,
+              providerEndpoints.url,
+            ],
+          })
+          .returning({ id: providerEndpoints.id });
+
+        if (inserted[0]) {
+          return "created-next";
+        }
+
+        const concurrentEndpoint = await loadEndpoint({
+          vendorId: input.vendorId,
+          providerType: input.providerType,
+          url: nextUrl,
+        });
+
+        if (!concurrentEndpoint) {
+          throw new Error("[ProviderEndpointSync] failed to load next endpoint after conflict");
+        }
+
+        if (concurrentEndpoint.deletedAt !== null) {
+          await tx
+            .update(providerEndpoints)
+            .set({
+              deletedAt: null,
+              isEnabled: true,
+              updatedAt: now,
+            })
+            .where(eq(providerEndpoints.id, concurrentEndpoint.id));
+
+          return "revived-next";
+        }
+
+        if (reactivateDisabled && !concurrentEndpoint.isEnabled) {
+          await tx
+            .update(providerEndpoints)
+            .set({
+              isEnabled: true,
+              updatedAt: now,
+            })
+            .where(eq(providerEndpoints.id, concurrentEndpoint.id));
+
+          return "revived-next";
+        }
+
+        return "noop";
+      }
+
+      if (nextEndpoint.deletedAt !== null) {
+        await tx
+          .update(providerEndpoints)
+          .set({
+            deletedAt: null,
+            isEnabled: true,
+            updatedAt: now,
+          })
+          .where(eq(providerEndpoints.id, nextEndpoint.id));
+
+        return "revived-next";
+      }
+
+      if (reactivateDisabled && !nextEndpoint.isEnabled) {
+        await tx
+          .update(providerEndpoints)
+          .set({
+            isEnabled: true,
+            updatedAt: now,
+          })
+          .where(eq(providerEndpoints.id, nextEndpoint.id));
+
+        return "revived-next";
+      }
+
+      return "noop";
+    };
+
+    const previousKeyEqualsNextKey =
+      previousVendorId === input.vendorId &&
+      previousProviderType === input.providerType &&
+      previousUrl === nextUrl;
+
+    if (previousKeyEqualsNextKey) {
+      const ensureResult = await ensureNextEndpointActive({
+        reactivateDisabled: false,
+      });
+      return { action: ensureResult === "noop" ? "noop" : ensureResult };
+    }
+
+    const previousEndpoint = await loadEndpoint({
+      vendorId: previousVendorId,
+      providerType: previousProviderType,
+      url: previousUrl,
+    });
+
+    const nextEndpoint = await loadEndpoint({
+      vendorId: input.vendorId,
+      providerType: input.providerType,
+      url: nextUrl,
+    });
+
+    if (previousEndpoint && !nextEndpoint) {
+      const previousIsReferenced =
+        keepPreviousWhenReferenced && (await hasActiveReferencesOnPreviousUrl());
+
+      if (!previousIsReferenced) {
+        const updatePreviousEndpointInPlace = async (executor: QueryExecutor): Promise<void> => {
+          await executor
+            .update(providerEndpoints)
+            .set({
+              vendorId: input.vendorId,
+              providerType: input.providerType,
+              url: nextUrl,
+              deletedAt: null,
+              isEnabled: true,
+              lastProbedAt: null,
+              lastProbeOk: null,
+              lastProbeStatusCode: null,
+              lastProbeLatencyMs: null,
+              lastProbeErrorType: null,
+              lastProbeErrorMessage: null,
+              updatedAt: now,
+            })
+            .where(eq(providerEndpoints.id, previousEndpoint.id));
+        };
+
+        let movedInPlace = false;
+        const executorWithSavepoint = tx as QueryExecutor & {
+          transaction?: <T>(runInTx: (nestedTx: TransactionExecutor) => Promise<T>) => Promise<T>;
+        };
+
+        if (typeof executorWithSavepoint.transaction === "function") {
+          try {
+            await executorWithSavepoint.transaction(async (nestedTx) => {
+              await updatePreviousEndpointInPlace(nestedTx);
+            });
+            movedInPlace = true;
+          } catch (error) {
+            if (!isUniqueViolationError(error)) {
+              throw error;
+            }
+          }
+        } else {
+          // No savepoint support means we cannot safely continue after unique violations.
+          await updatePreviousEndpointInPlace(tx);
+          movedInPlace = true;
+        }
+
+        if (movedInPlace) {
+          return {
+            action: "updated-previous-in-place",
+            // Reset is an external side-effect and must run only after transaction commit.
+            resetCircuitEndpointId: previousEndpoint.id,
+          };
+        }
+
+        const ensureResult = await ensureNextEndpointActive();
+
+        await tx
+          .update(providerEndpoints)
+          .set({
+            deletedAt: now,
+            isEnabled: false,
+            updatedAt: now,
+          })
+          .where(
+            and(eq(providerEndpoints.id, previousEndpoint.id), isNull(providerEndpoints.deletedAt))
+          );
+
+        return {
+          action:
+            ensureResult === "revived-next"
+              ? "soft-deleted-previous-and-revived-next"
+              : "soft-deleted-previous-and-kept-next",
+        };
+      }
+
+      const ensureResult = await ensureNextEndpointActive();
+      return {
+        action:
+          ensureResult === "created-next"
+            ? "kept-previous-and-created-next"
+            : ensureResult === "revived-next"
+              ? "kept-previous-and-revived-next"
+              : "kept-previous-and-kept-next",
+      };
+    }
+
+    const ensureResult = await ensureNextEndpointActive();
+
+    if (
+      previousEndpoint &&
+      nextEndpoint &&
+      previousEndpoint.id !== nextEndpoint.id &&
+      previousEndpoint.deletedAt === null
+    ) {
+      const previousIsReferenced =
+        keepPreviousWhenReferenced && (await hasActiveReferencesOnPreviousUrl());
+
+      if (!previousIsReferenced) {
+        await tx
+          .update(providerEndpoints)
+          .set({
+            deletedAt: now,
+            isEnabled: false,
+            updatedAt: now,
+          })
+          .where(
+            and(eq(providerEndpoints.id, previousEndpoint.id), isNull(providerEndpoints.deletedAt))
+          );
+
+        return {
+          action:
+            ensureResult === "revived-next"
+              ? "soft-deleted-previous-and-revived-next"
+              : "soft-deleted-previous-and-kept-next",
+        };
+      }
+    }
+
+    return { action: ensureResult === "noop" ? "noop" : ensureResult };
+  };
+
+  if (options?.tx) {
+    return await runInTx(options.tx);
+  }
+
+  const result = await db.transaction(async (tx) => runInTx(tx));
+
+  if (result.resetCircuitEndpointId != null) {
+    try {
+      await resetEndpointCircuit(result.resetCircuitEndpointId);
+    } catch (error) {
+      logger.warn("syncProviderEndpointOnProviderEdit:reset_endpoint_circuit_failed", {
+        endpointId: result.resetCircuitEndpointId,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+
+    return { action: result.action };
+  }
+
+  return result;
+}
+
 export async function backfillProviderEndpointsFromProviders(): Promise<{
   inserted: number;
   uniqueCandidates: number;

+ 253 - 186
src/repository/provider.ts

@@ -4,6 +4,7 @@ import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { providers } from "@/drizzle/schema";
 import { getCachedProviders } from "@/lib/cache/provider-cache";
+import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker";
 import { logger } from "@/lib/logger";
 import { resolveSystemTimezone } from "@/lib/utils/timezone";
 import type { CreateProviderData, Provider, UpdateProviderData } from "@/types/provider";
@@ -11,22 +12,15 @@ import { toProvider } from "./_shared/transformers";
 import {
   ensureProviderEndpointExistsForUrl,
   getOrCreateProviderVendorIdFromUrls,
+  syncProviderEndpointOnProviderEdit,
   tryDeleteProviderVendorIfEmpty,
 } from "./provider-endpoints";
 
 export async function createProvider(providerData: CreateProviderData): Promise<Provider> {
-  const providerVendorId = await getOrCreateProviderVendorIdFromUrls({
-    providerUrl: providerData.url,
-    websiteUrl: providerData.website_url ?? null,
-    faviconUrl: providerData.favicon_url ?? null,
-    displayName: providerData.name,
-  });
-
   const dbData = {
     name: providerData.name,
     url: providerData.url,
     key: providerData.key,
-    providerVendorId,
     isEnabled: providerData.is_enabled,
     weight: providerData.weight,
     priority: providerData.priority,
@@ -71,85 +65,100 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     codexParallelToolCallsPreference: providerData.codex_parallel_tool_calls_preference ?? null,
     anthropicMaxTokensPreference: providerData.anthropic_max_tokens_preference ?? null,
     anthropicThinkingBudgetPreference: providerData.anthropic_thinking_budget_preference ?? null,
+    geminiGoogleSearchPreference: providerData.gemini_google_search_preference ?? null,
     tpm: providerData.tpm,
     rpm: providerData.rpm,
     rpd: providerData.rpd,
     cc: providerData.cc,
   };
 
-  const [provider] = await db.insert(providers).values(dbData).returning({
-    id: providers.id,
-    name: providers.name,
-    url: providers.url,
-    key: providers.key,
-    providerVendorId: providers.providerVendorId,
-    isEnabled: providers.isEnabled,
-    weight: providers.weight,
-    priority: providers.priority,
-    costMultiplier: providers.costMultiplier,
-    groupTag: providers.groupTag,
-    providerType: providers.providerType,
-    preserveClientIp: providers.preserveClientIp,
-    modelRedirects: providers.modelRedirects,
-    allowedModels: providers.allowedModels,
-    mcpPassthroughType: providers.mcpPassthroughType,
-    mcpPassthroughUrl: providers.mcpPassthroughUrl,
-    limit5hUsd: providers.limit5hUsd,
-    limitDailyUsd: providers.limitDailyUsd,
-    dailyResetMode: providers.dailyResetMode,
-    dailyResetTime: providers.dailyResetTime,
-    limitWeeklyUsd: providers.limitWeeklyUsd,
-    limitMonthlyUsd: providers.limitMonthlyUsd,
-    limitTotalUsd: providers.limitTotalUsd,
-    totalCostResetAt: providers.totalCostResetAt,
-    limitConcurrentSessions: providers.limitConcurrentSessions,
-    maxRetryAttempts: providers.maxRetryAttempts,
-    circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
-    circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration,
-    circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
-    proxyUrl: providers.proxyUrl,
-    proxyFallbackToDirect: providers.proxyFallbackToDirect,
-    firstByteTimeoutStreamingMs: providers.firstByteTimeoutStreamingMs,
-    streamingIdleTimeoutMs: providers.streamingIdleTimeoutMs,
-    requestTimeoutNonStreamingMs: providers.requestTimeoutNonStreamingMs,
-    websiteUrl: providers.websiteUrl,
-    faviconUrl: providers.faviconUrl,
-    cacheTtlPreference: providers.cacheTtlPreference,
-    context1mPreference: providers.context1mPreference,
-    codexReasoningEffortPreference: providers.codexReasoningEffortPreference,
-    codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference,
-    codexTextVerbosityPreference: providers.codexTextVerbosityPreference,
-    codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
-    anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
-    anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
-    tpm: providers.tpm,
-    rpm: providers.rpm,
-    rpd: providers.rpd,
-    cc: providers.cc,
-    createdAt: providers.createdAt,
-    updatedAt: providers.updatedAt,
-    deletedAt: providers.deletedAt,
-  });
-
-  const created = toProvider(provider);
+  return db.transaction(async (tx) => {
+    const providerVendorId = await getOrCreateProviderVendorIdFromUrls(
+      {
+        providerUrl: providerData.url,
+        websiteUrl: providerData.website_url ?? null,
+        faviconUrl: providerData.favicon_url ?? null,
+        displayName: providerData.name,
+      },
+      { tx }
+    );
 
-  if (created.providerVendorId) {
-    try {
-      await ensureProviderEndpointExistsForUrl({
-        vendorId: created.providerVendorId,
-        providerType: created.providerType,
-        url: created.url,
-      });
-    } catch (error) {
-      logger.warn("[Provider] Failed to seed provider endpoint from provider.url", {
+    const [provider] = await tx
+      .insert(providers)
+      .values({
+        ...dbData,
         providerVendorId,
-        providerType: created.providerType,
-        error: error instanceof Error ? error.message : String(error),
+      })
+      .returning({
+        id: providers.id,
+        name: providers.name,
+        url: providers.url,
+        key: providers.key,
+        providerVendorId: providers.providerVendorId,
+        isEnabled: providers.isEnabled,
+        weight: providers.weight,
+        priority: providers.priority,
+        costMultiplier: providers.costMultiplier,
+        groupTag: providers.groupTag,
+        providerType: providers.providerType,
+        preserveClientIp: providers.preserveClientIp,
+        modelRedirects: providers.modelRedirects,
+        allowedModels: providers.allowedModels,
+        mcpPassthroughType: providers.mcpPassthroughType,
+        mcpPassthroughUrl: providers.mcpPassthroughUrl,
+        limit5hUsd: providers.limit5hUsd,
+        limitDailyUsd: providers.limitDailyUsd,
+        dailyResetMode: providers.dailyResetMode,
+        dailyResetTime: providers.dailyResetTime,
+        limitWeeklyUsd: providers.limitWeeklyUsd,
+        limitMonthlyUsd: providers.limitMonthlyUsd,
+        limitTotalUsd: providers.limitTotalUsd,
+        totalCostResetAt: providers.totalCostResetAt,
+        limitConcurrentSessions: providers.limitConcurrentSessions,
+        maxRetryAttempts: providers.maxRetryAttempts,
+        circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
+        circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration,
+        circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
+        proxyUrl: providers.proxyUrl,
+        proxyFallbackToDirect: providers.proxyFallbackToDirect,
+        firstByteTimeoutStreamingMs: providers.firstByteTimeoutStreamingMs,
+        streamingIdleTimeoutMs: providers.streamingIdleTimeoutMs,
+        requestTimeoutNonStreamingMs: providers.requestTimeoutNonStreamingMs,
+        websiteUrl: providers.websiteUrl,
+        faviconUrl: providers.faviconUrl,
+        cacheTtlPreference: providers.cacheTtlPreference,
+        context1mPreference: providers.context1mPreference,
+        codexReasoningEffortPreference: providers.codexReasoningEffortPreference,
+        codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference,
+        codexTextVerbosityPreference: providers.codexTextVerbosityPreference,
+        codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
+        anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
+        anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
+        geminiGoogleSearchPreference: providers.geminiGoogleSearchPreference,
+        tpm: providers.tpm,
+        rpm: providers.rpm,
+        rpd: providers.rpd,
+        cc: providers.cc,
+        createdAt: providers.createdAt,
+        updatedAt: providers.updatedAt,
+        deletedAt: providers.deletedAt,
       });
+
+    const created = toProvider(provider);
+
+    if (created.providerVendorId) {
+      await ensureProviderEndpointExistsForUrl(
+        {
+          vendorId: created.providerVendorId,
+          providerType: created.providerType,
+          url: created.url,
+        },
+        { tx }
+      );
     }
-  }
 
-  return created;
+    return created;
+  });
 }
 
 export async function findProviderList(
@@ -202,6 +211,7 @@ export async function findProviderList(
       codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
       anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
       anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
+      geminiGoogleSearchPreference: providers.geminiGoogleSearchPreference,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -278,6 +288,7 @@ export async function findAllProvidersFresh(): Promise<Provider[]> {
       codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
       anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
       anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
+      geminiGoogleSearchPreference: providers.geminiGoogleSearchPreference,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -358,6 +369,7 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
       anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
       anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
+      geminiGoogleSearchPreference: providers.geminiGoogleSearchPreference,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -381,8 +393,7 @@ export async function updateProvider(
     return findProviderById(id);
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const dbData: any = {
+  const dbData: Partial<typeof providers.$inferInsert> = {
     updatedAt: new Date(),
   };
 
@@ -466,131 +477,187 @@ export async function updateProvider(
   if (providerData.anthropic_thinking_budget_preference !== undefined)
     dbData.anthropicThinkingBudgetPreference =
       providerData.anthropic_thinking_budget_preference ?? null;
+  if (providerData.gemini_google_search_preference !== undefined)
+    dbData.geminiGoogleSearchPreference = providerData.gemini_google_search_preference ?? null;
   if (providerData.tpm !== undefined) dbData.tpm = providerData.tpm;
   if (providerData.rpm !== undefined) dbData.rpm = providerData.rpm;
   if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd;
   if (providerData.cc !== undefined) dbData.cc = providerData.cc;
 
-  let previousVendorId: number | null = null;
-  if (providerData.url !== undefined || providerData.website_url !== undefined) {
-    const [current] = await db
-      .select({
+  const shouldRefreshVendor =
+    providerData.url !== undefined || providerData.website_url !== undefined;
+  const shouldSyncEndpoint = shouldRefreshVendor || providerData.provider_type !== undefined;
+
+  const updateResult = await db.transaction(async (tx) => {
+    let previousVendorId: number | null = null;
+    let previousUrl: string | null = null;
+    let previousProviderType: Provider["providerType"] | null = null;
+    let endpointCircuitResetId: number | null = null;
+
+    if (shouldSyncEndpoint) {
+      const [current] = await tx
+        .select({
+          url: providers.url,
+          websiteUrl: providers.websiteUrl,
+          faviconUrl: providers.faviconUrl,
+          name: providers.name,
+          providerVendorId: providers.providerVendorId,
+          providerType: providers.providerType,
+        })
+        .from(providers)
+        .where(and(eq(providers.id, id), isNull(providers.deletedAt)))
+        .limit(1);
+
+      if (current) {
+        previousVendorId = current.providerVendorId;
+        previousUrl = current.url;
+        previousProviderType = current.providerType;
+
+        if (shouldRefreshVendor) {
+          const providerVendorId = await getOrCreateProviderVendorIdFromUrls(
+            {
+              providerUrl: providerData.url ?? current.url,
+              websiteUrl: providerData.website_url ?? current.websiteUrl,
+              faviconUrl: providerData.favicon_url ?? current.faviconUrl,
+              displayName: providerData.name ?? current.name,
+            },
+            { tx }
+          );
+          dbData.providerVendorId = providerVendorId;
+        }
+      }
+    }
+
+    const [provider] = await tx
+      .update(providers)
+      .set(dbData)
+      .where(and(eq(providers.id, id), isNull(providers.deletedAt)))
+      .returning({
+        id: providers.id,
+        name: providers.name,
         url: providers.url,
+        key: providers.key,
+        providerVendorId: providers.providerVendorId,
+        isEnabled: providers.isEnabled,
+        weight: providers.weight,
+        priority: providers.priority,
+        costMultiplier: providers.costMultiplier,
+        groupTag: providers.groupTag,
+        providerType: providers.providerType,
+        preserveClientIp: providers.preserveClientIp,
+        modelRedirects: providers.modelRedirects,
+        allowedModels: providers.allowedModels,
+        mcpPassthroughType: providers.mcpPassthroughType,
+        mcpPassthroughUrl: providers.mcpPassthroughUrl,
+        limit5hUsd: providers.limit5hUsd,
+        limitDailyUsd: providers.limitDailyUsd,
+        dailyResetMode: providers.dailyResetMode,
+        dailyResetTime: providers.dailyResetTime,
+        limitWeeklyUsd: providers.limitWeeklyUsd,
+        limitMonthlyUsd: providers.limitMonthlyUsd,
+        limitTotalUsd: providers.limitTotalUsd,
+        totalCostResetAt: providers.totalCostResetAt,
+        limitConcurrentSessions: providers.limitConcurrentSessions,
+        maxRetryAttempts: providers.maxRetryAttempts,
+        circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
+        circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration,
+        circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
+        proxyUrl: providers.proxyUrl,
+        proxyFallbackToDirect: providers.proxyFallbackToDirect,
+        firstByteTimeoutStreamingMs: providers.firstByteTimeoutStreamingMs,
+        streamingIdleTimeoutMs: providers.streamingIdleTimeoutMs,
+        requestTimeoutNonStreamingMs: providers.requestTimeoutNonStreamingMs,
         websiteUrl: providers.websiteUrl,
         faviconUrl: providers.faviconUrl,
-        name: providers.name,
-        providerVendorId: providers.providerVendorId,
-      })
-      .from(providers)
-      .where(and(eq(providers.id, id), isNull(providers.deletedAt)))
-      .limit(1);
-
-    if (current) {
-      previousVendorId = current.providerVendorId;
-      const providerVendorId = await getOrCreateProviderVendorIdFromUrls({
-        providerUrl: providerData.url ?? current.url,
-        websiteUrl: providerData.website_url ?? current.websiteUrl,
-        faviconUrl: providerData.favicon_url ?? current.faviconUrl,
-        displayName: providerData.name ?? current.name,
+        cacheTtlPreference: providers.cacheTtlPreference,
+        context1mPreference: providers.context1mPreference,
+        codexReasoningEffortPreference: providers.codexReasoningEffortPreference,
+        codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference,
+        codexTextVerbosityPreference: providers.codexTextVerbosityPreference,
+        codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
+        anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
+        anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
+        geminiGoogleSearchPreference: providers.geminiGoogleSearchPreference,
+        tpm: providers.tpm,
+        rpm: providers.rpm,
+        rpd: providers.rpd,
+        cc: providers.cc,
+        createdAt: providers.createdAt,
+        updatedAt: providers.updatedAt,
+        deletedAt: providers.deletedAt,
       });
-      dbData.providerVendorId = providerVendorId;
+
+    if (!provider) return null;
+    const transformed = toProvider(provider);
+
+    if (shouldSyncEndpoint && transformed.providerVendorId) {
+      if (previousUrl && previousProviderType) {
+        const syncResult = await syncProviderEndpointOnProviderEdit(
+          {
+            providerId: transformed.id,
+            vendorId: transformed.providerVendorId,
+            providerType: transformed.providerType,
+            previousVendorId,
+            previousProviderType,
+            previousUrl,
+            nextUrl: transformed.url,
+            keepPreviousWhenReferenced: true,
+          },
+          { tx }
+        );
+
+        endpointCircuitResetId = syncResult.resetCircuitEndpointId ?? null;
+      } else {
+        await ensureProviderEndpointExistsForUrl(
+          {
+            vendorId: transformed.providerVendorId,
+            providerType: transformed.providerType,
+            url: transformed.url,
+          },
+          { tx }
+        );
+      }
     }
-  }
 
-  const [provider] = await db
-    .update(providers)
-    .set(dbData)
-    .where(and(eq(providers.id, id), isNull(providers.deletedAt)))
-    .returning({
-      id: providers.id,
-      name: providers.name,
-      url: providers.url,
-      key: providers.key,
-      providerVendorId: providers.providerVendorId,
-      isEnabled: providers.isEnabled,
-      weight: providers.weight,
-      priority: providers.priority,
-      costMultiplier: providers.costMultiplier,
-      groupTag: providers.groupTag,
-      providerType: providers.providerType,
-      preserveClientIp: providers.preserveClientIp,
-      modelRedirects: providers.modelRedirects,
-      allowedModels: providers.allowedModels,
-      mcpPassthroughType: providers.mcpPassthroughType,
-      mcpPassthroughUrl: providers.mcpPassthroughUrl,
-      limit5hUsd: providers.limit5hUsd,
-      limitDailyUsd: providers.limitDailyUsd,
-      dailyResetMode: providers.dailyResetMode,
-      dailyResetTime: providers.dailyResetTime,
-      limitWeeklyUsd: providers.limitWeeklyUsd,
-      limitMonthlyUsd: providers.limitMonthlyUsd,
-      limitTotalUsd: providers.limitTotalUsd,
-      totalCostResetAt: providers.totalCostResetAt,
-      limitConcurrentSessions: providers.limitConcurrentSessions,
-      maxRetryAttempts: providers.maxRetryAttempts,
-      circuitBreakerFailureThreshold: providers.circuitBreakerFailureThreshold,
-      circuitBreakerOpenDuration: providers.circuitBreakerOpenDuration,
-      circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
-      proxyUrl: providers.proxyUrl,
-      proxyFallbackToDirect: providers.proxyFallbackToDirect,
-      firstByteTimeoutStreamingMs: providers.firstByteTimeoutStreamingMs,
-      streamingIdleTimeoutMs: providers.streamingIdleTimeoutMs,
-      requestTimeoutNonStreamingMs: providers.requestTimeoutNonStreamingMs,
-      websiteUrl: providers.websiteUrl,
-      faviconUrl: providers.faviconUrl,
-      cacheTtlPreference: providers.cacheTtlPreference,
-      context1mPreference: providers.context1mPreference,
-      codexReasoningEffortPreference: providers.codexReasoningEffortPreference,
-      codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference,
-      codexTextVerbosityPreference: providers.codexTextVerbosityPreference,
-      codexParallelToolCallsPreference: providers.codexParallelToolCallsPreference,
-      anthropicMaxTokensPreference: providers.anthropicMaxTokensPreference,
-      anthropicThinkingBudgetPreference: providers.anthropicThinkingBudgetPreference,
-      tpm: providers.tpm,
-      rpm: providers.rpm,
-      rpd: providers.rpd,
-      cc: providers.cc,
-      createdAt: providers.createdAt,
-      updatedAt: providers.updatedAt,
-      deletedAt: providers.deletedAt,
-    });
+    return {
+      provider: transformed,
+      previousVendorIdToCleanup:
+        previousVendorId && transformed.providerVendorId !== previousVendorId
+          ? previousVendorId
+          : null,
+      endpointCircuitResetId,
+    };
+  });
 
-  if (!provider) return null;
-  const transformed = toProvider(provider);
-
-  if (
-    providerData.url !== undefined ||
-    providerData.provider_type !== undefined ||
-    providerData.website_url !== undefined
-  ) {
-    if (
-      transformed.providerVendorId &&
-      (providerData.url !== undefined ||
-        transformed.providerVendorId !== previousVendorId ||
-        previousVendorId === null)
-    ) {
-      try {
-        await ensureProviderEndpointExistsForUrl({
-          vendorId: transformed.providerVendorId,
-          providerType: transformed.providerType,
-          url: transformed.url,
-        });
-      } catch (error) {
-        logger.warn("[Provider] Failed to seed provider endpoint after provider update", {
-          providerId: transformed.id,
-          providerVendorId: transformed.providerVendorId,
-          providerType: transformed.providerType,
-          error: error instanceof Error ? error.message : String(error),
-        });
-      }
+  if (!updateResult) {
+    return null;
+  }
+
+  if (updateResult.endpointCircuitResetId != null) {
+    try {
+      await resetEndpointCircuit(updateResult.endpointCircuitResetId);
+    } catch (error) {
+      logger.warn("updateProvider:reset_endpoint_circuit_failed", {
+        providerId: updateResult.provider.id,
+        endpointId: updateResult.endpointCircuitResetId,
+        error: error instanceof Error ? error.message : String(error),
+      });
     }
   }
 
-  if (previousVendorId && transformed.providerVendorId !== previousVendorId) {
-    await tryDeleteProviderVendorIfEmpty(previousVendorId);
+  if (updateResult.previousVendorIdToCleanup) {
+    try {
+      await tryDeleteProviderVendorIfEmpty(updateResult.previousVendorIdToCleanup);
+    } catch (error) {
+      logger.warn("updateProvider:vendor_cleanup_failed", {
+        providerId: updateResult.provider.id,
+        previousVendorId: updateResult.previousVendorIdToCleanup,
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
   }
 
-  return transformed;
+  return updateResult.provider;
 }
 
 export async function updateProviderPrioritiesBatch(

+ 9 - 1
src/repository/sensitive-words.ts

@@ -3,6 +3,7 @@
 import { desc, eq } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { sensitiveWords } from "@/drizzle/schema";
+import { emitSensitiveWordsUpdated } from "@/lib/emit-event";
 
 export interface SensitiveWord {
   id: number;
@@ -70,6 +71,8 @@ export async function createSensitiveWord(data: {
     })
     .returning();
 
+  await emitSensitiveWordsUpdated();
+
   return {
     id: result.id,
     word: result.word,
@@ -106,6 +109,8 @@ export async function updateSensitiveWord(
     return null;
   }
 
+  await emitSensitiveWordsUpdated();
+
   return {
     id: result.id,
     word: result.word,
@@ -123,5 +128,8 @@ export async function updateSensitiveWord(
 export async function deleteSensitiveWord(id: number): Promise<boolean> {
   const result = await db.delete(sensitiveWords).where(eq(sensitiveWords.id, id)).returning();
 
-  return result.length > 0;
+  if (result.length === 0) return false;
+
+  await emitSensitiveWordsUpdated();
+  return true;
 }

+ 12 - 0
src/types/provider.ts

@@ -36,6 +36,12 @@ export type CodexParallelToolCallsPreference = "inherit" | "true" | "false";
 export type AnthropicMaxTokensPreference = "inherit" | string;
 export type AnthropicThinkingBudgetPreference = "inherit" | string;
 
+// Gemini (generateContent API) parameter overrides
+// - "inherit": follow client request (default)
+// - "enabled": force inject googleSearch tool
+// - "disabled": force remove googleSearch tool from request
+export type GeminiGoogleSearchPreference = "inherit" | "enabled" | "disabled";
+
 // MCP 透传类型枚举
 export type McpPassthroughType = "none" | "minimax" | "glm" | "custom";
 
@@ -128,6 +134,9 @@ export interface Provider {
   anthropicMaxTokensPreference: AnthropicMaxTokensPreference | null;
   anthropicThinkingBudgetPreference: AnthropicThinkingBudgetPreference | null;
 
+  // Gemini (generateContent API) parameter overrides (only for gemini/gemini-cli providers)
+  geminiGoogleSearchPreference: GeminiGoogleSearchPreference | null;
+
   // 废弃(保留向后兼容,但不再使用)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm: number | null;
@@ -200,6 +209,7 @@ export interface ProviderDisplay {
   codexParallelToolCallsPreference: CodexParallelToolCallsPreference | null;
   anthropicMaxTokensPreference: AnthropicMaxTokensPreference | null;
   anthropicThinkingBudgetPreference: AnthropicThinkingBudgetPreference | null;
+  geminiGoogleSearchPreference: GeminiGoogleSearchPreference | null;
   // 废弃字段(保留向后兼容)
   tpm: number | null;
   rpm: number | null;
@@ -288,6 +298,7 @@ export interface CreateProviderData {
   codex_parallel_tool_calls_preference?: CodexParallelToolCallsPreference | null;
   anthropic_max_tokens_preference?: AnthropicMaxTokensPreference | null;
   anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null;
+  gemini_google_search_preference?: GeminiGoogleSearchPreference | null;
 
   // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
@@ -358,6 +369,7 @@ export interface UpdateProviderData {
   codex_parallel_tool_calls_preference?: CodexParallelToolCallsPreference | null;
   anthropic_max_tokens_preference?: AnthropicMaxTokensPreference | null;
   anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null;
+  gemini_google_search_preference?: GeminiGoogleSearchPreference | null;
 
   // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量

+ 19 - 1
src/types/special-settings.ts

@@ -13,7 +13,8 @@ export type SpecialSetting =
   | ThinkingBudgetRectifierSpecialSetting
   | CodexSessionIdCompletionSpecialSetting
   | AnthropicCacheTtlHeaderOverrideSpecialSetting
-  | AnthropicContext1mHeaderOverrideSpecialSetting;
+  | AnthropicContext1mHeaderOverrideSpecialSetting
+  | GeminiGoogleSearchOverrideSpecialSetting;
 
 export type SpecialSettingChangeValue = string | number | boolean | null;
 
@@ -154,3 +155,20 @@ export type ThinkingBudgetRectifierSpecialSetting = {
     thinkingBudgetTokens: number | null;
   };
 };
+
+/**
+ * Gemini Google Search 覆写审计
+ *
+ * 用于记录:当 Gemini 类型供应商配置了 googleSearch 偏好时,
+ * 系统对请求体中 tools 数组进行注入或移除 googleSearch 工具的行为。
+ */
+export type GeminiGoogleSearchOverrideSpecialSetting = {
+  type: "gemini_google_search_override";
+  scope: "request";
+  hit: boolean;
+  providerId: number | null;
+  providerName: string | null;
+  action: "inject" | "remove" | "passthrough";
+  preference: "enabled" | "disabled";
+  hadGoogleSearchInRequest: boolean;
+};

+ 126 - 0
tests/api/action-adapter-auth-session.unit.test.ts

@@ -0,0 +1,126 @@
+import { describe, expect, test, vi } from "vitest";
+import "@/lib/auth-session-storage.node";
+
+/**
+ * 回归用例:/api/actions adapter 鉴权通过后,action 内部调用 getSession() 仍应拿到会话
+ *
+ * 背景:
+ * - adapter 层使用 hono 读取 Cookie/Authorization 并 validateKey
+ * - action 层传统依赖 next/headers 读取请求上下文
+ * - 某些运行时下 action 读取不到上下文,导致返回 ok 但 data 为空
+ *
+ * 期望:
+ * - adapter 在调用 action 时注入 session(AsyncLocalStorage)
+ * - action 内 getSession() 优先读取注入会话,不触发 next/headers
+ */
+
+vi.mock("next/headers", () => ({
+  cookies: () => {
+    throw new Error("不应在该用例中调用 next/headers.cookies()");
+  },
+  headers: () => ({
+    get: () => null,
+  }),
+}));
+
+describe("Action Adapter:会话透传", () => {
+  test("requiresAuth=true:action 内 getSession() 应返回注入的 session", async () => {
+    vi.resetModules();
+
+    const mockSession = {
+      user: {
+        id: 123,
+        name: "u1",
+        description: "",
+        role: "user" as const,
+        rpm: null,
+        dailyQuota: null,
+        providerGroup: null,
+        tags: [],
+        createdAt: new Date(),
+        updatedAt: new Date(),
+        deletedAt: undefined,
+        dailyResetMode: "fixed" as const,
+        dailyResetTime: "00:00",
+        isEnabled: true,
+        expiresAt: null,
+        allowedClients: [],
+        allowedModels: [],
+      },
+      key: {
+        id: 1,
+        userId: 123,
+        name: "k1",
+        key: "token-1",
+        isEnabled: true,
+        expiresAt: undefined,
+        canLoginWebUi: false,
+        limit5hUsd: null,
+        limitDailyUsd: null,
+        dailyResetMode: "fixed" as const,
+        dailyResetTime: "00:00",
+        limitWeeklyUsd: null,
+        limitMonthlyUsd: null,
+        limitTotalUsd: null,
+        limitConcurrentSessions: 0,
+        providerGroup: null,
+        cacheTtlPreference: null,
+        createdAt: new Date(),
+        updatedAt: new Date(),
+        deletedAt: undefined,
+      },
+    };
+
+    vi.doMock("@/lib/auth", async (importActual) => {
+      const actual = (await importActual()) as typeof import("@/lib/auth");
+      return {
+        ...actual,
+        validateKey: vi.fn(async () => mockSession),
+      };
+    });
+
+    const { createActionRoute } = await import("@/lib/api/action-adapter-openapi");
+    const { getSession, validateKey } = await import("@/lib/auth");
+
+    const action = vi.fn(async () => {
+      const session = await getSession();
+      // 显式降权校验:当 key 为只读(canLoginWebUi=false)时,strict session 应返回 null
+      const strictSession = await getSession({ allowReadOnlyAccess: false });
+      return {
+        ok: true,
+        data: { userId: session?.user.id ?? null, strictUserId: strictSession?.user.id ?? null },
+      };
+    });
+
+    const { handler } = createActionRoute("users", "getUsers", action as any, {
+      requiresAuth: true,
+      allowReadOnlyAccess: true,
+    });
+
+    const response = (await handler({
+      req: {
+        raw: new Request("http://localhost/api/actions/users/getUsers", {
+          headers: new Headers(),
+        }),
+        json: async () => ({}),
+        header: (name: string) => {
+          if (name.toLowerCase() === "authorization") return "Bearer token-1";
+          return undefined;
+        },
+      },
+      json: (payload: unknown, status = 200) =>
+        new Response(JSON.stringify(payload), {
+          status,
+          headers: { "content-type": "application/json" },
+        }),
+    } as any)) as Response;
+
+    expect(validateKey).toHaveBeenCalledTimes(1);
+    expect(action).toHaveBeenCalledTimes(1);
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      ok: true,
+      data: { userId: 123, strictUserId: null },
+    });
+  });
+});

+ 147 - 0
tests/integration/provider-endpoint-sync-race.test.ts

@@ -0,0 +1,147 @@
+import { and, eq, isNull, sql } from "drizzle-orm";
+import { describe, expect, test } from "vitest";
+import { db } from "@/drizzle/db";
+import { providerEndpoints } from "@/drizzle/schema";
+import {
+  createProvider,
+  deleteProvider,
+  findProviderById,
+  updateProvider,
+} from "@/repository/provider";
+import {
+  ensureProviderEndpointExistsForUrl,
+  findProviderEndpointsByVendorAndType,
+  tryDeleteProviderVendorIfEmpty,
+} from "@/repository/provider-endpoints";
+
+const run = process.env.DSN ? describe : describe.skip;
+
+function createDeferred() {
+  let resolve: () => void;
+  const promise = new Promise<void>((res) => {
+    resolve = res;
+  });
+  return {
+    promise,
+    resolve: resolve!,
+  };
+}
+
+run("Provider endpoint sync on edit (integration race)", () => {
+  test("concurrent next-url insert should not break provider edit transaction", async () => {
+    const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+    const oldUrl = `https://race-${suffix}.example.com/v1/messages`;
+    const nextUrl = `https://race-${suffix}.example.com/v2/messages`;
+    const websiteUrl = `https://vendor-${suffix}.example.com`;
+
+    const created = await createProvider({
+      name: `Race Provider ${suffix}`,
+      url: oldUrl,
+      key: `sk-race-${suffix}`,
+      provider_type: "claude",
+      website_url: websiteUrl,
+      favicon_url: null,
+      tpm: null,
+      rpm: null,
+      rpd: null,
+      cc: null,
+    });
+
+    const vendorId = created.providerVendorId;
+    expect(vendorId).not.toBeNull();
+
+    const [previousEndpoint] = await db
+      .select({
+        id: providerEndpoints.id,
+      })
+      .from(providerEndpoints)
+      .where(
+        and(
+          eq(providerEndpoints.vendorId, vendorId!),
+          eq(providerEndpoints.providerType, created.providerType),
+          eq(providerEndpoints.url, oldUrl),
+          isNull(providerEndpoints.deletedAt)
+        )
+      )
+      .limit(1);
+
+    expect(previousEndpoint).toBeDefined();
+
+    const lockAcquired = createDeferred();
+    const releaseLock = createDeferred();
+
+    const lockTask = db.transaction(async (tx) => {
+      await tx.execute(sql`
+        SELECT id
+        FROM provider_endpoints
+        WHERE id = ${previousEndpoint!.id}
+        FOR UPDATE
+      `);
+
+      lockAcquired.resolve();
+      await releaseLock.promise;
+    });
+
+    let updatePromise: Promise<Awaited<ReturnType<typeof updateProvider>>> | null = null;
+
+    try {
+      await lockAcquired.promise;
+
+      updatePromise = updateProvider(created.id, { url: nextUrl });
+
+      await ensureProviderEndpointExistsForUrl({
+        vendorId: vendorId!,
+        providerType: created.providerType,
+        url: nextUrl,
+      });
+
+      releaseLock.resolve();
+      await lockTask;
+
+      const updated = await updatePromise;
+      expect(updated).not.toBeNull();
+      expect(updated?.url).toBe(nextUrl);
+
+      const [previousAfter] = await db
+        .select({
+          id: providerEndpoints.id,
+          url: providerEndpoints.url,
+          deletedAt: providerEndpoints.deletedAt,
+          isEnabled: providerEndpoints.isEnabled,
+        })
+        .from(providerEndpoints)
+        .where(eq(providerEndpoints.id, previousEndpoint!.id))
+        .limit(1);
+
+      expect(previousAfter).toBeDefined();
+      expect(previousAfter?.url).toBe(oldUrl);
+      expect(previousAfter?.deletedAt).toBeTruthy();
+      expect(previousAfter?.isEnabled).toBe(false);
+
+      const activeEndpoints = await findProviderEndpointsByVendorAndType(
+        vendorId!,
+        created.providerType
+      );
+
+      const nextActive = activeEndpoints.filter((endpoint) => endpoint.url === nextUrl);
+      expect(nextActive).toHaveLength(1);
+      expect(nextActive[0]?.isEnabled).toBe(true);
+      expect(activeEndpoints.some((endpoint) => endpoint.url === oldUrl)).toBe(false);
+
+      const providerAfter = await findProviderById(created.id);
+      expect(providerAfter?.url).toBe(nextUrl);
+    } finally {
+      releaseLock.resolve();
+      await lockTask.catch(() => {});
+
+      await deleteProvider(created.id);
+      if (vendorId) {
+        await tryDeleteProviderVendorIfEmpty(vendorId).catch(() => {});
+      }
+
+      if (updatePromise) {
+        await updatePromise.catch(() => {});
+      }
+    }
+  });
+});

+ 15 - 7
tests/unit/actions/providers-recluster.test.ts

@@ -211,14 +211,15 @@ describe("reclusterProviderVendors", () => {
       getOrCreateProviderVendorIdFromUrlsMock.mockResolvedValue(2);
       backfillProviderEndpointsFromProvidersMock.mockResolvedValue({});
       tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true);
-      dbMock.transaction.mockImplementation(async (fn) => {
-        return fn({
-          update: vi.fn().mockReturnValue({
-            set: vi.fn().mockReturnValue({
-              where: vi.fn().mockResolvedValue({}),
-            }),
+      const tx = {
+        update: vi.fn().mockReturnValue({
+          set: vi.fn().mockReturnValue({
+            where: vi.fn().mockResolvedValue({}),
           }),
-        });
+        }),
+      };
+      dbMock.transaction.mockImplementation(async (fn) => {
+        return fn(tx);
       });
 
       const { reclusterProviderVendors } = await import("@/actions/providers");
@@ -229,6 +230,13 @@ describe("reclusterProviderVendors", () => {
         expect(result.data.applied).toBe(true);
       }
       expect(dbMock.transaction).toHaveBeenCalled();
+      expect(getOrCreateProviderVendorIdFromUrlsMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          providerUrl: "http://192.168.1.1:8080/v1/messages",
+          websiteUrl: null,
+        }),
+        { tx }
+      );
     });
 
     it("publishes cache invalidation after apply", async () => {

+ 59 - 0
tests/unit/actions/providers.test.ts

@@ -497,6 +497,65 @@ describe("Provider Actions - Async Optimization", () => {
       expect(result.ok).toBe(true);
       expect(revalidatePathMock).not.toHaveBeenCalled();
     });
+
+    it("editProvider endpoint sync: should forward url/provider_type edits to repository", async () => {
+      const nextUrl = "https://new.example.com/v1/responses";
+      const { editProvider } = await import("@/actions/providers");
+
+      const result = await editProvider(1, {
+        url: nextUrl,
+        provider_type: "codex",
+      });
+
+      expect(result.ok).toBe(true);
+      expect(updateProviderMock).toHaveBeenCalledWith(
+        1,
+        expect.objectContaining({
+          url: nextUrl,
+          provider_type: "codex",
+        })
+      );
+      expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+    });
+
+    it("editProvider endpoint sync: should generate favicon_url when website_url is updated", async () => {
+      const nextUrl = "https://new.example.com/v1/messages";
+      const nextWebsiteUrl = "https://vendor.example.com/home";
+      const { editProvider } = await import("@/actions/providers");
+
+      const result = await editProvider(1, {
+        url: nextUrl,
+        website_url: nextWebsiteUrl,
+      });
+
+      expect(result.ok).toBe(true);
+      expect(updateProviderMock).toHaveBeenCalledWith(
+        1,
+        expect.objectContaining({
+          url: nextUrl,
+          website_url: nextWebsiteUrl,
+          favicon_url: "https://www.google.com/s2/favicons?domain=vendor.example.com&sz=32",
+        })
+      );
+    });
+
+    it("editProvider endpoint sync: should clear favicon_url when website_url is cleared", async () => {
+      const { editProvider } = await import("@/actions/providers");
+
+      const result = await editProvider(1, {
+        url: "https://new.example.com/v1/messages",
+        website_url: null,
+      });
+
+      expect(result.ok).toBe(true);
+      expect(updateProviderMock).toHaveBeenCalledWith(
+        1,
+        expect.objectContaining({
+          website_url: null,
+          favicon_url: null,
+        })
+      );
+    });
   });
 
   describe("deleteProvider", () => {

+ 26 - 4
tests/unit/i18n/zh-tw-providers-strings-quality.test.ts

@@ -5,14 +5,36 @@ import { describe, expect, test } from "vitest";
 const readJson = (relPath: string) => {
   const filePath = path.join(process.cwd(), relPath);
   const text = fs.readFileSync(filePath, "utf8");
-  return JSON.parse(text) as Record<string, string>;
+  return JSON.parse(text) as unknown;
 };
 
+type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[];
+
+function visitStrings(value: JsonValue, visit: (text: string) => void): void {
+  if (typeof value === "string") {
+    visit(value);
+    return;
+  }
+
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      visitStrings(item, visit);
+    }
+    return;
+  }
+
+  if (value && typeof value === "object") {
+    for (const item of Object.values(value)) {
+      visitStrings(item, visit);
+    }
+  }
+}
+
 describe("messages/zh-TW/settings/providers/strings.json", () => {
   test("does not contain placeholder markers, emoji, or halfwidth parentheses", () => {
-    const zhTW = readJson("messages/zh-TW/settings/providers/strings.json");
+    const zhTW = readJson("messages/zh-TW/settings/providers/strings.json") as JsonValue;
 
-    for (const value of Object.values(zhTW)) {
+    visitStrings(zhTW, (value) => {
       expect(value).not.toContain("(繁)");
       expect(value).not.toContain("[JA]");
       expect(value).not.toContain("(TW)");
@@ -24,6 +46,6 @@ describe("messages/zh-TW/settings/providers/strings.json", () => {
       expect(value).not.toContain(")");
 
       expect(value).not.toMatch(/[1-4]\uFE0F\u20E3/);
-    }
+    });
   });
 });

+ 6 - 2
tests/unit/lib/emit-event.test.ts

@@ -20,6 +20,7 @@ vi.mock("@/lib/event-emitter", () => ({
 vi.mock("@/lib/redis/pubsub", () => ({
   CHANNEL_ERROR_RULES_UPDATED: "cch:cache:error_rules:updated",
   CHANNEL_REQUEST_FILTERS_UPDATED: "cch:cache:request_filters:updated",
+  CHANNEL_SENSITIVE_WORDS_UPDATED: "cch:cache:sensitive_words:updated",
   publishCacheInvalidation: mocks.publishCacheInvalidation,
 }));
 
@@ -44,12 +45,15 @@ describe.sequential("emit-event", () => {
     expect(mocks.publishCacheInvalidation).toHaveBeenCalledWith("cch:cache:error_rules:updated");
   });
 
-  test("emitSensitiveWordsUpdated:Node.js runtime 下仅触发本地事件", async () => {
+  test("emitSensitiveWordsUpdated:Node.js runtime 下应触发本地事件并广播缓存失效", async () => {
     const { emitSensitiveWordsUpdated } = await import("@/lib/emit-event");
     await emitSensitiveWordsUpdated();
 
     expect(mocks.emitSensitiveWordsUpdated).toHaveBeenCalledTimes(1);
-    expect(mocks.publishCacheInvalidation).not.toHaveBeenCalled();
+    expect(mocks.publishCacheInvalidation).toHaveBeenCalledTimes(1);
+    expect(mocks.publishCacheInvalidation).toHaveBeenCalledWith(
+      "cch:cache:sensitive_words:updated"
+    );
   });
 
   test("emitRequestFiltersUpdated:Node.js runtime 下应触发本地事件并广播缓存失效", async () => {

+ 329 - 0
tests/unit/lib/gemini/provider-overrides.test.ts

@@ -0,0 +1,329 @@
+import { describe, expect, test } from "vitest";
+import {
+  applyGeminiGoogleSearchOverride,
+  applyGeminiGoogleSearchOverrideWithAudit,
+} from "@/lib/gemini/provider-overrides";
+
+describe("applyGeminiGoogleSearchOverride", () => {
+  describe("non-Gemini providers", () => {
+    test("should return unchanged request for claude provider", () => {
+      const provider = { providerType: "claude" };
+      const request = { tools: [{ codeExecution: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+
+    test("should return unchanged request for codex provider", () => {
+      const provider = { providerType: "codex", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+  });
+
+  describe("inherit preference", () => {
+    test("should pass through unchanged when preference is inherit", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "inherit" };
+      const request = { contents: [], tools: [{ googleSearch: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+
+    test("should pass through unchanged when preference is null", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: null };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+
+    test("should pass through unchanged when preference is undefined", () => {
+      const provider = { providerType: "gemini" };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+  });
+
+  describe("enabled preference", () => {
+    test("should inject googleSearch tool when not present", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).not.toBe(request);
+      expect(result.tools).toEqual([{ googleSearch: {} }]);
+    });
+
+    test("should inject googleSearch tool alongside existing tools", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [], tools: [{ codeExecution: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).not.toBe(request);
+      expect(result.tools).toEqual([{ codeExecution: {} }, { googleSearch: {} }]);
+    });
+
+    test("should not duplicate googleSearch if already present", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [], tools: [{ googleSearch: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+      expect(result.tools).toEqual([{ googleSearch: {} }]);
+    });
+
+    test("should work with gemini-cli provider type", () => {
+      const provider = { providerType: "gemini-cli", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result.tools).toEqual([{ googleSearch: {} }]);
+    });
+  });
+
+  describe("disabled preference", () => {
+    test("should remove googleSearch tool when present", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "disabled" };
+      const request = { contents: [], tools: [{ googleSearch: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).not.toBe(request);
+      expect(result.tools).toBeUndefined();
+    });
+
+    test("should preserve other tools when removing googleSearch", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "disabled" };
+      const request = {
+        contents: [],
+        tools: [{ codeExecution: {} }, { googleSearch: {} }, { functionDeclarations: [] }],
+      };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).not.toBe(request);
+      expect(result.tools).toEqual([{ codeExecution: {} }, { functionDeclarations: [] }]);
+    });
+
+    test("should pass through unchanged when googleSearch not present", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "disabled" };
+      const request = { contents: [], tools: [{ codeExecution: {} }] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+
+    test("should pass through unchanged when no tools array", () => {
+      const provider = { providerType: "gemini", geminiGoogleSearchPreference: "disabled" };
+      const request = { contents: [] };
+
+      const result = applyGeminiGoogleSearchOverride(provider, request);
+
+      expect(result).toBe(request);
+    });
+  });
+});
+
+describe("applyGeminiGoogleSearchOverrideWithAudit", () => {
+  describe("non-Gemini providers", () => {
+    test("should return null audit for non-Gemini provider", () => {
+      const provider = { providerType: "claude", geminiGoogleSearchPreference: "enabled" };
+      const request = { contents: [] };
+
+      const { request: result, audit } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request
+      );
+
+      expect(result).toBe(request);
+      expect(audit).toBeNull();
+    });
+  });
+
+  describe("inherit preference", () => {
+    test("should return null audit when preference is inherit", () => {
+      const provider = {
+        id: 1,
+        name: "Test Gemini",
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "inherit",
+      };
+      const request = { contents: [] };
+
+      const { audit } = applyGeminiGoogleSearchOverrideWithAudit(provider, request);
+
+      expect(audit).toBeNull();
+    });
+  });
+
+  describe("enabled preference", () => {
+    test("should return inject audit when googleSearch is injected", () => {
+      const provider = {
+        id: 1,
+        name: "Test Gemini",
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "enabled",
+      };
+      const request = { contents: [] };
+
+      const { request: result, audit } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request
+      );
+
+      expect(result.tools).toEqual([{ googleSearch: {} }]);
+      expect(audit).toEqual({
+        type: "gemini_google_search_override",
+        scope: "request",
+        hit: true,
+        providerId: 1,
+        providerName: "Test Gemini",
+        action: "inject",
+        preference: "enabled",
+        hadGoogleSearchInRequest: false,
+      });
+    });
+
+    test("should return passthrough audit when googleSearch already present", () => {
+      const provider = {
+        id: 2,
+        name: "Gemini Pro",
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "enabled",
+      };
+      const request = { contents: [], tools: [{ googleSearch: {} }] };
+
+      const { request: result, audit } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request
+      );
+
+      expect(result).toBe(request);
+      expect(audit).toEqual({
+        type: "gemini_google_search_override",
+        scope: "request",
+        hit: true,
+        providerId: 2,
+        providerName: "Gemini Pro",
+        action: "passthrough",
+        preference: "enabled",
+        hadGoogleSearchInRequest: true,
+      });
+    });
+  });
+
+  describe("disabled preference", () => {
+    test("should return remove audit when googleSearch is removed", () => {
+      const provider = {
+        id: 3,
+        name: "Gemini Flash",
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "disabled",
+      };
+      const request = { contents: [], tools: [{ googleSearch: {} }] };
+
+      const { request: result, audit } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request
+      );
+
+      expect(result.tools).toBeUndefined();
+      expect(audit).toEqual({
+        type: "gemini_google_search_override",
+        scope: "request",
+        hit: true,
+        providerId: 3,
+        providerName: "Gemini Flash",
+        action: "remove",
+        preference: "disabled",
+        hadGoogleSearchInRequest: true,
+      });
+    });
+
+    test("should return passthrough audit when no googleSearch to remove", () => {
+      const provider = {
+        id: 4,
+        providerType: "gemini-cli",
+        geminiGoogleSearchPreference: "disabled",
+      };
+      const request = { contents: [], tools: [{ codeExecution: {} }] };
+
+      const { request: result, audit } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request
+      );
+
+      expect(result).toBe(request);
+      expect(audit).toEqual({
+        type: "gemini_google_search_override",
+        scope: "request",
+        hit: true,
+        providerId: 4,
+        providerName: null,
+        action: "passthrough",
+        preference: "disabled",
+        hadGoogleSearchInRequest: false,
+      });
+    });
+  });
+
+  describe("edge cases", () => {
+    test("should handle missing provider id and name", () => {
+      const provider = {
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "enabled",
+      };
+      const request = { contents: [] };
+
+      const { audit } = applyGeminiGoogleSearchOverrideWithAudit(provider, request);
+
+      expect(audit?.providerId).toBeNull();
+      expect(audit?.providerName).toBeNull();
+    });
+
+    test("should handle non-plain object tools", () => {
+      const provider = {
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "disabled",
+      };
+      const request = { contents: [], tools: ["string-tool", 123, null] };
+
+      const { request: result } = applyGeminiGoogleSearchOverrideWithAudit(
+        provider,
+        request as unknown as Record<string, unknown>
+      );
+
+      expect(result).toBe(request);
+    });
+
+    test("should handle googleSearch with extra properties", () => {
+      const provider = {
+        providerType: "gemini",
+        geminiGoogleSearchPreference: "disabled",
+      };
+      const request = {
+        contents: [],
+        tools: [{ googleSearch: { dynamicRetrievalConfig: { threshold: 0.5 } } }],
+      };
+
+      const { request: result } = applyGeminiGoogleSearchOverrideWithAudit(provider, request);
+
+      expect(result.tools).toBeUndefined();
+    });
+  });
+});

+ 162 - 0
tests/unit/lib/hot-reload-singleton.test.ts

@@ -0,0 +1,162 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+/**
+ * Tests for hot-reload singleton pattern (globalThis caching)
+ * Verifies that EventEmitter and RequestFilterEngine use the same instance
+ * across multiple dynamic imports (simulating different worker contexts)
+ */
+
+describe("globalThis singleton pattern", () => {
+  beforeEach(() => {
+    vi.resetModules();
+  });
+
+  afterEach(() => {
+    // Clean up globalThis
+    const g = globalThis as Record<string, unknown>;
+    delete g.__CCH_EVENT_EMITTER__;
+    delete g.__CCH_REQUEST_FILTER_ENGINE__;
+    delete g.__CCH_SENSITIVE_WORD_DETECTOR__;
+  });
+
+  test("eventEmitter: multiple imports return same instance", async () => {
+    // First import
+    const { eventEmitter: emitter1 } = await import("@/lib/event-emitter");
+
+    // Reset module cache to simulate different worker context
+    vi.resetModules();
+
+    // Second import
+    const { eventEmitter: emitter2 } = await import("@/lib/event-emitter");
+
+    // Should be the exact same instance due to globalThis caching
+    expect(emitter1).toBe(emitter2);
+  });
+
+  test("eventEmitter: globalThis stores the singleton", async () => {
+    const g = globalThis as Record<string, unknown>;
+
+    // Before import, should not exist
+    expect(g.__CCH_EVENT_EMITTER__).toBeUndefined();
+
+    // After import, should exist
+    const { eventEmitter } = await import("@/lib/event-emitter");
+    expect(g.__CCH_EVENT_EMITTER__).toBe(eventEmitter);
+  });
+
+  test("requestFilterEngine: multiple imports return same instance", async () => {
+    // First import
+    const { requestFilterEngine: engine1 } = await import("@/lib/request-filter-engine");
+
+    // Reset module cache
+    vi.resetModules();
+
+    // Second import
+    const { requestFilterEngine: engine2 } = await import("@/lib/request-filter-engine");
+
+    // Should be the exact same instance
+    expect(engine1).toBe(engine2);
+  });
+
+  test("requestFilterEngine: globalThis stores the singleton", async () => {
+    const g = globalThis as Record<string, unknown>;
+
+    // Before import, should not exist
+    expect(g.__CCH_REQUEST_FILTER_ENGINE__).toBeUndefined();
+
+    // After import, should exist
+    const { requestFilterEngine } = await import("@/lib/request-filter-engine");
+    expect(g.__CCH_REQUEST_FILTER_ENGINE__).toBe(requestFilterEngine);
+  });
+
+  test("sensitiveWordDetector: multiple imports return same instance", async () => {
+    // First import
+    const { sensitiveWordDetector: detector1 } = await import("@/lib/sensitive-word-detector");
+
+    // Reset module cache
+    vi.resetModules();
+
+    // Second import
+    const { sensitiveWordDetector: detector2 } = await import("@/lib/sensitive-word-detector");
+
+    // Should be the exact same instance
+    expect(detector1).toBe(detector2);
+  });
+
+  test("sensitiveWordDetector: globalThis stores the singleton", async () => {
+    const g = globalThis as Record<string, unknown>;
+
+    // Before import, should not exist
+    expect(g.__CCH_SENSITIVE_WORD_DETECTOR__).toBeUndefined();
+
+    // After import, should exist
+    const { sensitiveWordDetector } = await import("@/lib/sensitive-word-detector");
+    expect(g.__CCH_SENSITIVE_WORD_DETECTOR__).toBe(sensitiveWordDetector);
+  });
+});
+
+describe("event propagation between singleton instances", () => {
+  const prevRuntime = process.env.NEXT_RUNTIME;
+
+  beforeEach(() => {
+    vi.resetModules();
+    process.env.NEXT_RUNTIME = "nodejs";
+    // Clean globalThis
+    const g = globalThis as Record<string, unknown>;
+    delete g.__CCH_EVENT_EMITTER__;
+    delete g.__CCH_REQUEST_FILTER_ENGINE__;
+    delete g.__CCH_SENSITIVE_WORD_DETECTOR__;
+  });
+
+  afterEach(() => {
+    process.env.NEXT_RUNTIME = prevRuntime;
+    const g = globalThis as Record<string, unknown>;
+    delete g.__CCH_EVENT_EMITTER__;
+    delete g.__CCH_REQUEST_FILTER_ENGINE__;
+    delete g.__CCH_SENSITIVE_WORD_DETECTOR__;
+  });
+
+  test("events emitted in one context should be received in another", async () => {
+    const handler = vi.fn();
+
+    // Context A: subscribe to event
+    const { eventEmitter: emitterA } = await import("@/lib/event-emitter");
+    emitterA.on("requestFiltersUpdated", handler);
+
+    // Reset modules to simulate different worker context
+    vi.resetModules();
+
+    // Context B: emit event
+    const { eventEmitter: emitterB } = await import("@/lib/event-emitter");
+    emitterB.emitRequestFiltersUpdated();
+
+    // Handler should be called because both contexts share the same globalThis instance
+    expect(handler).toHaveBeenCalledTimes(1);
+  });
+
+  test("all event types should work with singleton pattern", async () => {
+    const handlers = {
+      errorRules: vi.fn(),
+      sensitiveWords: vi.fn(),
+      requestFilters: vi.fn(),
+    };
+
+    // Subscribe in context A
+    const { eventEmitter: emitterA } = await import("@/lib/event-emitter");
+    emitterA.on("errorRulesUpdated", handlers.errorRules);
+    emitterA.on("sensitiveWordsUpdated", handlers.sensitiveWords);
+    emitterA.on("requestFiltersUpdated", handlers.requestFilters);
+
+    vi.resetModules();
+
+    // Emit in context B
+    const { eventEmitter: emitterB } = await import("@/lib/event-emitter");
+    emitterB.emitErrorRulesUpdated();
+    emitterB.emitSensitiveWordsUpdated();
+    emitterB.emitRequestFiltersUpdated();
+
+    expect(handlers.errorRules).toHaveBeenCalledTimes(1);
+    expect(handlers.sensitiveWords).toHaveBeenCalledTimes(1);
+    expect(handlers.requestFilters).toHaveBeenCalledTimes(1);
+  });
+});

+ 7 - 0
tests/unit/lib/rate-limit/cost-limits.test.ts

@@ -34,6 +34,12 @@ vi.mock("@/lib/redis", () => ({
   getRedisClient: () => redisClient,
 }));
 
+const resolveSystemTimezoneMock = vi.hoisted(() => vi.fn(async () => "Asia/Shanghai"));
+
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: resolveSystemTimezoneMock,
+}));
+
 const statisticsMock = {
   // total cost
   sumKeyTotalCost: vi.fn(async () => 0),
@@ -59,6 +65,7 @@ describe("RateLimitService - cost limits and quota checks", () => {
   beforeEach(() => {
     pipelineCommands.length = 0;
     vi.resetAllMocks();
+    resolveSystemTimezoneMock.mockResolvedValue("Asia/Shanghai");
     vi.useFakeTimers();
     vi.setSystemTime(new Date(nowMs));
   });

+ 4 - 0
tests/unit/lib/rate-limit/rolling-window-cache-warm.test.ts

@@ -31,6 +31,10 @@ vi.mock("@/lib/redis", () => ({
   getRedisClient: () => redisClient,
 }));
 
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"),
+}));
+
 const statisticsMock = {
   sumKeyTotalCost: vi.fn(async () => 0),
   sumUserCostToday: vi.fn(async () => 0),

+ 26 - 0
tests/unit/lib/rate-limit/service-extra.test.ts

@@ -54,6 +54,12 @@ vi.mock("@/lib/redis", () => ({
   getRedisClient: () => redisClientRef,
 }));
 
+const resolveSystemTimezoneMock = vi.hoisted(() => vi.fn(async () => "Asia/Shanghai"));
+
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: resolveSystemTimezoneMock,
+}));
+
 const statisticsMock = {
   // service.ts 顶层静态导入需要这些 export 存在
   sumKeyTotalCost: vi.fn(async () => 0),
@@ -85,6 +91,7 @@ describe("RateLimitService - other quota paths", () => {
 
   beforeEach(() => {
     vi.resetAllMocks();
+    resolveSystemTimezoneMock.mockResolvedValue("Asia/Shanghai");
     pipelineCalls.length = 0;
     vi.useFakeTimers();
     vi.setSystemTime(new Date(nowMs));
@@ -164,6 +171,25 @@ describe("RateLimitService - other quota paths", () => {
     expect(result).toEqual({ allowed: true, count: 1, tracked: true });
   });
 
+  it("checkAndTrackProviderSession: should pass SESSION_TTL_MS as ARGV[4] to Lua script", async () => {
+    const { RateLimitService } = await import("@/lib/rate-limit");
+
+    redisClientRef.eval.mockResolvedValueOnce([1, 1, 1]);
+    await RateLimitService.checkAndTrackProviderSession(9, "sess", 2);
+
+    // Verify eval was called with the correct args including ARGV[4] = SESSION_TTL_MS
+    expect(redisClientRef.eval).toHaveBeenCalledTimes(1);
+
+    const evalCall = redisClientRef.eval.mock.calls[0];
+    // evalCall: [script, numkeys, key, sessionId, limit, now, ttlMs]
+    // Indices:   0        1        2    3          4      5     6
+    expect(evalCall.length).toBe(7); // script + 1 key + 5 ARGV
+
+    // ARGV[4] (index 6) should be SESSION_TTL_MS derived from env (default 300s = 300000ms)
+    const ttlMsArg = evalCall[6];
+    expect(ttlMsArg).toBe("300000");
+  });
+
   it("trackUserDailyCost:fixed 模式应使用 STRING + TTL", async () => {
     const { RateLimitService } = await import("@/lib/rate-limit");
 

+ 294 - 0
tests/unit/lib/session-tracker-cleanup.test.ts

@@ -0,0 +1,294 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let redisClientRef: any;
+const pipelineCalls: Array<unknown[]> = [];
+
+const makePipeline = () => {
+  const pipeline = {
+    zadd: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zadd", ...args]);
+      return pipeline;
+    }),
+    expire: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["expire", ...args]);
+      return pipeline;
+    }),
+    setex: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["setex", ...args]);
+      return pipeline;
+    }),
+    zremrangebyscore: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zremrangebyscore", ...args]);
+      return pipeline;
+    }),
+    zrange: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zrange", ...args]);
+      return pipeline;
+    }),
+    exists: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["exists", ...args]);
+      return pipeline;
+    }),
+    exec: vi.fn(async () => {
+      pipelineCalls.push(["exec"]);
+      return [];
+    }),
+  };
+  return pipeline;
+};
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+    trace: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: () => redisClientRef,
+}));
+
+describe("SessionTracker - TTL and cleanup", () => {
+  const nowMs = 1_700_000_000_000;
+  const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL;
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    vi.resetModules();
+    pipelineCalls.length = 0;
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+
+    redisClientRef = {
+      status: "ready",
+      exists: vi.fn(async () => 1),
+      type: vi.fn(async () => "zset"),
+      del: vi.fn(async () => 1),
+      zremrangebyscore: vi.fn(async () => 0),
+      zrange: vi.fn(async () => []),
+      pipeline: vi.fn(() => makePipeline()),
+    };
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    if (ORIGINAL_SESSION_TTL === undefined) {
+      delete process.env.SESSION_TTL;
+    } else {
+      process.env.SESSION_TTL = ORIGINAL_SESSION_TTL;
+    }
+  });
+
+  describe("env-driven TTL", () => {
+    it("should use SESSION_TTL env (seconds) converted to ms for cutoff calculation", async () => {
+      // Set SESSION_TTL to 600 seconds (10 minutes)
+      process.env.SESSION_TTL = "600";
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      // Should call zremrangebyscore with cutoff = now - 600*1000 = now - 600000
+      const expectedCutoff = nowMs - 600 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+
+    it("should default to 300 seconds (5 min) when SESSION_TTL not set", async () => {
+      delete process.env.SESSION_TTL;
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      // Default: 300 seconds = 300000 ms
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+  });
+
+  describe("refreshSession - provider ZSET EXPIRE", () => {
+    it("should set EXPIRE on provider ZSET with fallback TTL 3600", async () => {
+      process.env.SESSION_TTL = "300";
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      // Check pipeline calls include expire for provider ZSET
+      const providerExpireCall = pipelineCalls.find(
+        (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions")
+      );
+      expect(providerExpireCall).toBeDefined();
+      expect(providerExpireCall![2]).toBe(3600); // fallback TTL
+    });
+
+    it("should use SESSION_TTL when it exceeds 3600s for provider ZSET EXPIRE", async () => {
+      process.env.SESSION_TTL = "7200"; // 2 hours > 3600
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      // Check pipeline calls include expire for provider ZSET with dynamic TTL
+      const providerExpireCall = pipelineCalls.find(
+        (call) => call[0] === "expire" && String(call[1]).includes("provider:42:active_sessions")
+      );
+      expect(providerExpireCall).toBeDefined();
+      expect(providerExpireCall![2]).toBe(7200); // should use SESSION_TTL when > 3600
+    });
+
+    it("should refresh session binding TTLs using env SESSION_TTL (not hardcoded 300)", async () => {
+      process.env.SESSION_TTL = "600"; // 10 minutes
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      // Check expire calls for session bindings use 600 (env value), not 300
+      const providerBindingExpire = pipelineCalls.find(
+        (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:provider"
+      );
+      const keyBindingExpire = pipelineCalls.find(
+        (call) => call[0] === "expire" && String(call[1]) === "session:sess-123:key"
+      );
+      const lastSeenSetex = pipelineCalls.find(
+        (call) => call[0] === "setex" && String(call[1]) === "session:sess-123:last_seen"
+      );
+
+      expect(providerBindingExpire).toBeDefined();
+      expect(providerBindingExpire![2]).toBe(600);
+
+      expect(keyBindingExpire).toBeDefined();
+      expect(keyBindingExpire![2]).toBe(600);
+
+      expect(lastSeenSetex).toBeDefined();
+      expect(lastSeenSetex![2]).toBe(600);
+    });
+  });
+
+  describe("refreshSession - probabilistic cleanup on write path", () => {
+    it("should perform ZREMRANGEBYSCORE cleanup when probability gate hits", async () => {
+      process.env.SESSION_TTL = "300";
+
+      // Mock Math.random to always return 0 (below default 0.01 threshold)
+      vi.spyOn(Math, "random").mockReturnValue(0);
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      // Should have zremrangebyscore call for provider ZSET cleanup
+      const cleanupCall = pipelineCalls.find(
+        (call) =>
+          call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions")
+      );
+      expect(cleanupCall).toBeDefined();
+
+      // Cutoff should be now - SESSION_TTL_MS
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(cleanupCall![2]).toBe("-inf");
+      expect(cleanupCall![3]).toBe(expectedCutoff);
+    });
+
+    it("should skip cleanup when probability gate does not hit", async () => {
+      process.env.SESSION_TTL = "300";
+
+      // Mock Math.random to return 0.5 (above default 0.01 threshold)
+      vi.spyOn(Math, "random").mockReturnValue(0.5);
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      // Should NOT have zremrangebyscore call
+      const cleanupCall = pipelineCalls.find((call) => call[0] === "zremrangebyscore");
+      expect(cleanupCall).toBeUndefined();
+    });
+
+    it("should use env-driven TTL for cleanup cutoff calculation", async () => {
+      process.env.SESSION_TTL = "600"; // 10 minutes
+
+      vi.spyOn(Math, "random").mockReturnValue(0);
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.refreshSession("sess-123", 1, 42);
+
+      const cleanupCall = pipelineCalls.find(
+        (call) =>
+          call[0] === "zremrangebyscore" && String(call[1]).includes("provider:42:active_sessions")
+      );
+      expect(cleanupCall).toBeDefined();
+
+      // Cutoff should be now - 600*1000
+      const expectedCutoff = nowMs - 600 * 1000;
+      expect(cleanupCall![3]).toBe(expectedCutoff);
+    });
+  });
+
+  describe("countFromZSet - env-driven TTL", () => {
+    it("should use env SESSION_TTL for cleanup cutoff in batch count", async () => {
+      process.env.SESSION_TTL = "600";
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      // getProviderSessionCountBatch uses SESSION_TTL internally
+      await SessionTracker.getProviderSessionCountBatch([1, 2]);
+
+      // Check pipeline zremrangebyscore calls use correct cutoff
+      const cleanupCalls = pipelineCalls.filter((call) => call[0] === "zremrangebyscore");
+      expect(cleanupCalls.length).toBeGreaterThan(0);
+
+      const expectedCutoff = nowMs - 600 * 1000;
+      for (const call of cleanupCalls) {
+        expect(call[3]).toBe(expectedCutoff);
+      }
+    });
+  });
+
+  describe("getActiveSessions - env-driven TTL", () => {
+    it("should use env SESSION_TTL for cleanup cutoff", async () => {
+      process.env.SESSION_TTL = "600";
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getActiveSessions();
+
+      const expectedCutoff = nowMs - 600 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+  });
+
+  describe("Fail-Open behavior", () => {
+    it("refreshSession should not throw when Redis is not ready", async () => {
+      redisClientRef.status = "end";
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined();
+    });
+
+    it("refreshSession should not throw when Redis is null", async () => {
+      redisClientRef = null;
+
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await expect(SessionTracker.refreshSession("sess-123", 1, 42)).resolves.toBeUndefined();
+    });
+  });
+});

+ 165 - 0
tests/unit/lib/session-ttl-validation.test.ts

@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Tests for SESSION_TTL environment variable validation
+ *
+ * These tests verify that invalid SESSION_TTL values (NaN, 0, negative)
+ * are properly handled with fallback to default 300 seconds.
+ */
+
+let redisClientRef: any;
+const pipelineCalls: Array<unknown[]> = [];
+
+const makePipeline = () => {
+  const pipeline = {
+    zadd: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zadd", ...args]);
+      return pipeline;
+    }),
+    expire: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["expire", ...args]);
+      return pipeline;
+    }),
+    setex: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["setex", ...args]);
+      return pipeline;
+    }),
+    zremrangebyscore: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zremrangebyscore", ...args]);
+      return pipeline;
+    }),
+    zrange: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["zrange", ...args]);
+      return pipeline;
+    }),
+    exists: vi.fn((...args: unknown[]) => {
+      pipelineCalls.push(["exists", ...args]);
+      return pipeline;
+    }),
+    exec: vi.fn(async () => {
+      pipelineCalls.push(["exec"]);
+      return [];
+    }),
+  };
+  return pipeline;
+};
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+    trace: vi.fn(),
+  },
+}));
+
+vi.mock("@/lib/redis", () => ({
+  getRedisClient: () => redisClientRef,
+}));
+
+describe("SESSION_TTL environment variable validation", () => {
+  const nowMs = 1_700_000_000_000;
+  const ORIGINAL_SESSION_TTL = process.env.SESSION_TTL;
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    vi.resetModules();
+    pipelineCalls.length = 0;
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date(nowMs));
+
+    redisClientRef = {
+      status: "ready",
+      exists: vi.fn(async () => 1),
+      type: vi.fn(async () => "zset"),
+      del: vi.fn(async () => 1),
+      zremrangebyscore: vi.fn(async () => 0),
+      zrange: vi.fn(async () => []),
+      pipeline: vi.fn(() => makePipeline()),
+    };
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    if (ORIGINAL_SESSION_TTL === undefined) {
+      delete process.env.SESSION_TTL;
+    } else {
+      process.env.SESSION_TTL = ORIGINAL_SESSION_TTL;
+    }
+  });
+
+  describe("SessionTracker TTL parsing", () => {
+    it("should use default 300 when SESSION_TTL is empty string", async () => {
+      process.env.SESSION_TTL = "";
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      // Default: 300 seconds = 300000 ms
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+
+    it("should use default 300 when SESSION_TTL is NaN", async () => {
+      process.env.SESSION_TTL = "not-a-number";
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+
+    it("should use default 300 when SESSION_TTL is 0", async () => {
+      process.env.SESSION_TTL = "0";
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+
+    it("should use default 300 when SESSION_TTL is negative", async () => {
+      process.env.SESSION_TTL = "-100";
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      const expectedCutoff = nowMs - 300 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+
+    it("should use provided value when SESSION_TTL is valid positive integer", async () => {
+      process.env.SESSION_TTL = "600";
+      const { SessionTracker } = await import("@/lib/session-tracker");
+
+      await SessionTracker.getGlobalSessionCount();
+
+      // Custom: 600 seconds = 600000 ms
+      const expectedCutoff = nowMs - 600 * 1000;
+      expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith(
+        "global:active_sessions",
+        "-inf",
+        expectedCutoff
+      );
+    });
+  });
+});

+ 36 - 0
tests/unit/lib/utils/currency.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "vitest";
+import { getCurrencySymbol, CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils/currency";
+
+describe("getCurrencySymbol", () => {
+  test("returns correct symbol for valid currency codes", () => {
+    expect(getCurrencySymbol("USD")).toBe("$");
+    expect(getCurrencySymbol("CNY")).toBe("\u00a5");
+    expect(getCurrencySymbol("EUR")).toBe("\u20ac");
+    expect(getCurrencySymbol("JPY")).toBe("\u00a5");
+    expect(getCurrencySymbol("GBP")).toBe("\u00a3");
+    expect(getCurrencySymbol("HKD")).toBe("HK$");
+    expect(getCurrencySymbol("TWD")).toBe("NT$");
+    expect(getCurrencySymbol("KRW")).toBe("\u20a9");
+    expect(getCurrencySymbol("SGD")).toBe("S$");
+  });
+
+  test("returns USD symbol for undefined", () => {
+    expect(getCurrencySymbol()).toBe("$");
+    expect(getCurrencySymbol(undefined)).toBe("$");
+  });
+
+  test("returns USD symbol for invalid currency code", () => {
+    expect(getCurrencySymbol("INVALID")).toBe("$");
+    expect(getCurrencySymbol("")).toBe("$");
+    expect(getCurrencySymbol("usd")).toBe("$"); // case-sensitive
+  });
+
+  test("all CURRENCY_CONFIG entries have valid symbols", () => {
+    const codes: CurrencyCode[] = ["USD", "CNY", "EUR", "JPY", "GBP", "HKD", "TWD", "KRW", "SGD"];
+    for (const code of codes) {
+      const symbol = getCurrencySymbol(code);
+      expect(symbol).toBe(CURRENCY_CONFIG[code].symbol);
+      expect(symbol.length).toBeGreaterThan(0);
+    }
+  });
+});

+ 82 - 0
tests/unit/proxy/extract-usage-metrics.test.ts

@@ -670,4 +670,86 @@ describe("extractUsageMetrics", () => {
       expect(result.usageMetrics).toBeNull();
     });
   });
+
+  describe("OpenAI chat completion format (prompt_tokens/completion_tokens)", () => {
+    it("should extract prompt_tokens as input_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          prompt_tokens: 100,
+          completion_tokens: 50,
+          total_tokens: 150,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(100);
+      expect(result.usageMetrics?.output_tokens).toBe(50);
+    });
+
+    it("should extract completion_tokens as output_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          completion_tokens: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.output_tokens).toBe(200);
+    });
+
+    it("should prefer input_tokens over prompt_tokens (Claude format priority)", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 500,
+          output_tokens: 300,
+          prompt_tokens: 100,
+          completion_tokens: 50,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics?.input_tokens).toBe(500);
+      expect(result.usageMetrics?.output_tokens).toBe(300);
+    });
+
+    it("should handle OpenAI streaming chunk with usage in final event", () => {
+      const sse = [
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"}}]}',
+        "",
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":33,"completion_tokens":31,"total_tokens":64}}',
+        "",
+        "data: [DONE]",
+      ].join("\n");
+
+      const result = parseUsageFromResponseText(sse, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(33);
+      expect(result.usageMetrics?.output_tokens).toBe(31);
+    });
+
+    it("should handle OpenAI completion_tokens_details (reasoning_tokens)", () => {
+      const response = JSON.stringify({
+        usage: {
+          prompt_tokens: 66,
+          completion_tokens: 57,
+          total_tokens: 123,
+          completion_tokens_details: {
+            reasoning_tokens: 0,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai-compatible");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(66);
+      expect(result.usageMetrics?.output_tokens).toBe(57);
+    });
+  });
 });

+ 139 - 9
tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts

@@ -61,6 +61,7 @@ vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
 import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
 import { ProxyError } from "@/app/v1/_lib/proxy/errors";
 import { ProxySession } from "@/app/v1/_lib/proxy/session";
+import { logger } from "@/lib/logger";
 import type { Provider, ProviderEndpoint, ProviderType } from "@/types/provider";
 
 function makeEndpoint(input: {
@@ -335,16 +336,24 @@ describe("ProxyForwarder - endpoint audit", () => {
     }
   });
 
-  test("endpoint 选择失败时应回退到 provider.url,并记录 endpointId=null", async () => {
-    const session = createSession();
+  test("MCP 请求应保持 provider.url 语义,不触发 strict endpoint 拦截", async () => {
+    const requestPath = "/mcp/custom-endpoint";
+    const session = createSession(new URL(`https://example.com${requestPath}`));
     const provider = createProvider({
       providerType: "claude",
       providerVendorId: 123,
-      url: "https://provider.example.com/v1/messages?key=SECRET",
+      url: `https://provider.example.com${requestPath}?key=SECRET`,
     });
     session.setProvider(provider);
 
-    mocks.getPreferredProviderEndpoints.mockRejectedValue(new Error("boom"));
+    mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([
+      makeEndpoint({
+        id: 99,
+        vendorId: 123,
+        providerType: "claude",
+        url: "https://ep99.example.com",
+      }),
+    ]);
 
     const doForward = vi.spyOn(
       ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
@@ -362,17 +371,138 @@ describe("ProxyForwarder - endpoint audit", () => {
 
     const response = await ProxyForwarder.send(session);
     expect(response.status).toBe(200);
+    expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
 
     const chain = session.getProviderChain();
     expect(chain).toHaveLength(1);
-
-    const item = chain[0];
-    expect(item).toEqual(
+    expect(chain[0]).toEqual(
       expect.objectContaining({
         endpointId: null,
+        reason: "request_success",
       })
     );
-    expect(item.endpointUrl).toContain("[REDACTED]");
-    expect(item.endpointUrl).not.toContain("SECRET");
+
+    const warnMessages = vi.mocked(logger.warn).mock.calls.map(([message]) => message);
+    expect(warnMessages).not.toContain(
+      "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback"
+    );
+  });
+
+  test.each([
+    { requestPath: "/v1/messages", providerType: "claude" as const },
+    { requestPath: "/v1/responses", providerType: "codex" as const },
+    { requestPath: "/v1/chat/completions", providerType: "openai-compatible" as const },
+  ])("标准端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", async ({
+    requestPath,
+    providerType,
+  }) => {
+    const session = createSession(new URL(`https://example.com${requestPath}`));
+    const provider = createProvider({
+      providerType,
+      providerVendorId: 123,
+      url: `https://provider.example.com${requestPath}?key=SECRET`,
+    });
+    session.setProvider(provider);
+
+    mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom"));
+
+    const doForward = vi.spyOn(
+      ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
+      "doForward"
+    );
+    doForward.mockResolvedValueOnce(
+      new Response("{}", {
+        status: 200,
+        headers: {
+          "content-type": "application/json",
+          "content-length": "2",
+        },
+      })
+    );
+
+    const rejected = await ProxyForwarder.send(session)
+      .then(() => false)
+      .catch(() => true);
+
+    expect(rejected, `标准端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url`).toBe(
+      true
+    );
+    expect(doForward).not.toHaveBeenCalled();
+
+    expect(logger.warn).toHaveBeenCalledWith(
+      "[ProxyForwarder] Failed to load provider endpoints",
+      expect.objectContaining({
+        providerId: provider.id,
+        vendorId: 123,
+        providerType,
+        strictEndpointPolicy: true,
+        reason: "selector_error",
+        error: "boom",
+      })
+    );
+
+    expect(logger.warn).toHaveBeenCalledWith(
+      "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback",
+      expect.objectContaining({
+        providerId: provider.id,
+        vendorId: 123,
+        providerType,
+        requestPath,
+        reason: "strict_blocked_legacy_fallback",
+        strictBlockCause: "selector_error",
+        selectorError: "boom",
+      })
+    );
+  });
+
+  test("标准端点空候选应记录 no_endpoint_candidates 且不混淆为 selector_error", async () => {
+    const requestPath = "/v1/messages";
+    const providerType = "claude" as const;
+    const session = createSession(new URL(`https://example.com${requestPath}`));
+    const provider = createProvider({
+      providerType,
+      providerVendorId: 123,
+      url: "https://provider.example.com/v1/messages?key=SECRET",
+    });
+    session.setProvider(provider);
+
+    mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
+
+    const doForward = vi.spyOn(
+      ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
+      "doForward"
+    );
+    doForward.mockResolvedValueOnce(
+      new Response("{}", {
+        status: 200,
+        headers: {
+          "content-type": "application/json",
+          "content-length": "2",
+        },
+      })
+    );
+
+    const rejected = await ProxyForwarder.send(session)
+      .then(() => false)
+      .catch(() => true);
+
+    expect(rejected).toBe(true);
+    expect(doForward).not.toHaveBeenCalled();
+
+    expect(logger.warn).toHaveBeenCalledWith(
+      "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback",
+      expect.objectContaining({
+        providerId: provider.id,
+        vendorId: 123,
+        providerType,
+        requestPath,
+        reason: "strict_blocked_legacy_fallback",
+        strictBlockCause: "no_endpoint_candidates",
+        selectorError: undefined,
+      })
+    );
+
+    const warnMessages = vi.mocked(logger.warn).mock.calls.map(([message]) => message);
+    expect(warnMessages).not.toContain("[ProxyForwarder] Failed to load provider endpoints");
   });
 });

+ 2 - 4
tests/unit/proxy/proxy-forwarder.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest";
 import type { Provider } from "@/types/provider";
-import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
+import { DEFAULT_CODEX_USER_AGENT, ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
 import { ProxySession } from "@/app/v1/_lib/proxy/session";
 
 function createSession({
@@ -133,9 +133,7 @@ describe("ProxyForwarder - buildHeaders User-Agent resolution", () => {
     };
     const resultHeaders = buildHeaders(session, provider);
 
-    expect(resultHeaders.get("user-agent")).toBe(
-      "codex_cli_rs/0.55.0 (Mac OS 26.1.0; arm64) vscode/2.0.64"
-    );
+    expect(resultHeaders.get("user-agent")).toBe(DEFAULT_CODEX_USER_AGENT);
   });
 
   it("应该保留过滤器设置的空字符串 user-agent", () => {

+ 187 - 0
tests/unit/repository/provider-create-transaction.test.ts

@@ -0,0 +1,187 @@
+import { describe, expect, test, vi } from "vitest";
+
+type ProviderRow = Record<string, unknown>;
+
+function createProviderRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
+  const now = new Date("2025-01-01T00:00:00.000Z");
+
+  return {
+    id: 101,
+    name: "Provider A",
+    url: "https://new.example.com/v1/messages",
+    key: "test-key",
+    providerVendorId: 11,
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    costMultiplier: "1.0",
+    groupTag: null,
+    providerType: "claude",
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    totalCostResetAt: null,
+    limitConcurrentSessions: 0,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 1800000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 10000,
+    requestTimeoutNonStreamingMs: 600000,
+    websiteUrl: "https://vendor.example.com",
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: now,
+    updatedAt: now,
+    deletedAt: null,
+    ...overrides,
+  };
+}
+
+function createCreateProviderInput(overrides: Record<string, unknown> = {}) {
+  return {
+    name: "Provider A",
+    url: "https://new.example.com/v1/messages",
+    key: "test-key",
+    provider_type: "claude",
+    website_url: "https://vendor.example.com",
+    favicon_url: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    ...overrides,
+  };
+}
+
+function createDbMock(insertedRow: ProviderRow) {
+  const insertReturningMock = vi.fn(async () => [insertedRow]);
+  const insertValuesMock = vi.fn(() => ({ returning: insertReturningMock }));
+  const insertMock = vi.fn(() => ({ values: insertValuesMock }));
+
+  const tx = {
+    insert: insertMock,
+  };
+
+  const transactionMock = vi.fn(async (runInTx: (trx: typeof tx) => Promise<unknown>) => {
+    return runInTx(tx);
+  });
+
+  return {
+    db: {
+      transaction: transactionMock,
+    },
+    mocks: {
+      transactionMock,
+      insertMock,
+    },
+  };
+}
+
+describe("provider repository - createProvider transactional endpoint seeding", () => {
+  test("createProvider should execute vendor resolve + provider insert + endpoint seed in one transaction", async () => {
+    vi.resetModules();
+
+    const dbState = createDbMock(
+      createProviderRow({
+        providerType: "codex",
+        url: "https://new.example.com/v1/responses",
+      })
+    );
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: dbState.db,
+    }));
+
+    const getOrCreateProviderVendorIdFromUrlsMock = vi.fn(async () => 11);
+    const ensureProviderEndpointExistsForUrlMock = vi.fn(async () => true);
+
+    vi.doMock("@/repository/provider-endpoints", () => ({
+      getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock,
+      ensureProviderEndpointExistsForUrl: ensureProviderEndpointExistsForUrlMock,
+      syncProviderEndpointOnProviderEdit: vi.fn(),
+      tryDeleteProviderVendorIfEmpty: vi.fn(),
+    }));
+
+    const { createProvider } = await import("@/repository/provider");
+    const provider = await createProvider(
+      createCreateProviderInput({
+        provider_type: "codex",
+        url: "https://new.example.com/v1/responses",
+      })
+    );
+
+    expect(provider.id).toBe(101);
+    expect(dbState.mocks.transactionMock).toHaveBeenCalledTimes(1);
+    expect(dbState.mocks.insertMock).toHaveBeenCalledTimes(1);
+
+    expect(getOrCreateProviderVendorIdFromUrlsMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        providerUrl: "https://new.example.com/v1/responses",
+      }),
+      expect.objectContaining({ tx: expect.any(Object) })
+    );
+
+    expect(ensureProviderEndpointExistsForUrlMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        vendorId: 11,
+        providerType: "codex",
+        url: "https://new.example.com/v1/responses",
+      }),
+      expect.objectContaining({ tx: expect.any(Object) })
+    );
+  });
+
+  test("createProvider should bubble endpoint seed errors to avoid partial success", async () => {
+    vi.resetModules();
+
+    const dbState = createDbMock(createProviderRow());
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: dbState.db,
+    }));
+
+    const getOrCreateProviderVendorIdFromUrlsMock = vi.fn(async () => 11);
+    const ensureProviderEndpointExistsForUrlMock = vi.fn(async () => {
+      throw new Error("endpoint seed failed");
+    });
+
+    vi.doMock("@/repository/provider-endpoints", () => ({
+      getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock,
+      ensureProviderEndpointExistsForUrl: ensureProviderEndpointExistsForUrlMock,
+      syncProviderEndpointOnProviderEdit: vi.fn(),
+      tryDeleteProviderVendorIfEmpty: vi.fn(),
+    }));
+
+    const { createProvider } = await import("@/repository/provider");
+
+    await expect(createProvider(createCreateProviderInput())).rejects.toThrow(
+      "endpoint seed failed"
+    );
+    expect(dbState.mocks.transactionMock).toHaveBeenCalledTimes(1);
+  });
+});

+ 292 - 0
tests/unit/repository/provider-endpoint-sync-helper.test.ts

@@ -0,0 +1,292 @@
+import { describe, expect, test, vi } from "vitest";
+
+type SelectRow = Record<string, unknown>;
+
+function createTxMock(selectResults: SelectRow[][]) {
+  const queue = [...selectResults];
+
+  const selectLimitMock = vi.fn(async () => queue.shift() ?? []);
+  const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock }));
+  const selectFromMock = vi.fn(() => ({ where: selectWhereMock }));
+  const selectMock = vi.fn(() => ({ from: selectFromMock }));
+
+  const updatePayloads: Array<Record<string, unknown>> = [];
+  const updateWhereMock = vi.fn(async () => []);
+  const updateSetMock = vi.fn((payload: Record<string, unknown>) => {
+    updatePayloads.push(payload);
+    return { where: updateWhereMock };
+  });
+  const updateMock = vi.fn(() => ({ set: updateSetMock }));
+
+  const insertReturningMock = vi.fn(async () => []);
+  const insertOnConflictDoNothingMock = vi.fn(() => ({ returning: insertReturningMock }));
+  const insertValuesMock = vi.fn(() => ({ onConflictDoNothing: insertOnConflictDoNothingMock }));
+  const insertMock = vi.fn(() => ({ values: insertValuesMock }));
+
+  const tx = {
+    select: selectMock,
+    update: updateMock,
+    insert: insertMock,
+  };
+
+  const nestedTransactionMock = vi.fn(
+    async (runInTx: (nestedTx: typeof tx) => Promise<unknown>) => {
+      return runInTx(tx);
+    }
+  );
+
+  const txWithSavepoint = {
+    ...tx,
+    transaction: nestedTransactionMock,
+  };
+
+  return {
+    tx: txWithSavepoint,
+    updatePayloads,
+    mocks: {
+      updateMock,
+      updateWhereMock,
+      insertMock,
+      insertReturningMock,
+      selectLimitMock,
+      nestedTransactionMock,
+    },
+  };
+}
+
+async function arrangeSyncTest(selectResults: SelectRow[][]) {
+  vi.resetModules();
+
+  const txState = createTxMock(selectResults);
+  const transactionMock = vi.fn(async (runInTx: (tx: typeof txState.tx) => Promise<unknown>) => {
+    return runInTx(txState.tx);
+  });
+  const resetEndpointCircuitMock = vi.fn(async () => {});
+
+  vi.doMock("@/drizzle/db", () => ({
+    db: {
+      transaction: transactionMock,
+    },
+  }));
+  vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
+    resetEndpointCircuit: resetEndpointCircuitMock,
+  }));
+
+  const { syncProviderEndpointOnProviderEdit } = await import("@/repository/provider-endpoints");
+
+  return {
+    syncProviderEndpointOnProviderEdit,
+    transactionMock,
+    resetEndpointCircuitMock,
+    ...txState,
+  };
+}
+
+describe("syncProviderEndpointOnProviderEdit", () => {
+  test("invalid next url should throw instead of silent noop", async () => {
+    const { syncProviderEndpointOnProviderEdit, transactionMock, mocks } = await arrangeSyncTest(
+      []
+    );
+
+    await expect(
+      syncProviderEndpointOnProviderEdit({
+        providerId: 1,
+        vendorId: 11,
+        providerType: "claude",
+        previousVendorId: 11,
+        previousProviderType: "claude",
+        previousUrl: "https://old.example.com/v1/messages",
+        nextUrl: "not-a-valid-url",
+        keepPreviousWhenReferenced: true,
+      })
+    ).rejects.toThrow("[ProviderEndpointSync] nextUrl must be a valid URL");
+
+    expect(transactionMock).not.toHaveBeenCalled();
+    expect(mocks.updateMock).not.toHaveBeenCalled();
+    expect(mocks.insertMock).not.toHaveBeenCalled();
+  });
+
+  test("website_url only edit should not revive disabled endpoint when identity is unchanged", async () => {
+    const endpointUrl = "https://same.example.com/v1/messages";
+    const { syncProviderEndpointOnProviderEdit, mocks, resetEndpointCircuitMock } =
+      await arrangeSyncTest([[{ id: 101, deletedAt: null, isEnabled: false }]]);
+
+    const result = await syncProviderEndpointOnProviderEdit({
+      providerId: 1,
+      vendorId: 11,
+      providerType: "claude",
+      previousVendorId: 11,
+      previousProviderType: "claude",
+      previousUrl: endpointUrl,
+      nextUrl: endpointUrl,
+      keepPreviousWhenReferenced: true,
+    });
+
+    expect(result).toEqual({ action: "noop" });
+    expect(mocks.updateMock).not.toHaveBeenCalled();
+    expect(mocks.insertMock).not.toHaveBeenCalled();
+    expect(resetEndpointCircuitMock).not.toHaveBeenCalled();
+  });
+
+  test("in-place url move should clear stale probe snapshot fields", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+    const { syncProviderEndpointOnProviderEdit, updatePayloads, mocks, resetEndpointCircuitMock } =
+      await arrangeSyncTest([[{ id: 7, deletedAt: null, isEnabled: true }], [], []]);
+
+    const result = await syncProviderEndpointOnProviderEdit({
+      providerId: 1,
+      vendorId: 11,
+      providerType: "claude",
+      previousVendorId: 11,
+      previousProviderType: "claude",
+      previousUrl: oldUrl,
+      nextUrl: newUrl,
+      keepPreviousWhenReferenced: true,
+    });
+
+    expect(result).toEqual({ action: "updated-previous-in-place" });
+    expect(mocks.updateMock).toHaveBeenCalledTimes(1);
+    expect(resetEndpointCircuitMock).toHaveBeenCalledTimes(1);
+    expect(resetEndpointCircuitMock).toHaveBeenCalledWith(7);
+    expect(updatePayloads[0]).toEqual(
+      expect.objectContaining({
+        url: newUrl,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeStatusCode: null,
+        lastProbeLatencyMs: null,
+        lastProbeErrorType: null,
+        lastProbeErrorMessage: null,
+      })
+    );
+  });
+
+  test("in-place url move with external tx should defer circuit reset until caller commits", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+    const {
+      syncProviderEndpointOnProviderEdit,
+      tx,
+      transactionMock,
+      resetEndpointCircuitMock,
+      updatePayloads,
+      mocks,
+    } = await arrangeSyncTest([[{ id: 7, deletedAt: null, isEnabled: true }], [], []]);
+
+    const result = await syncProviderEndpointOnProviderEdit(
+      {
+        providerId: 1,
+        vendorId: 11,
+        providerType: "claude",
+        previousVendorId: 11,
+        previousProviderType: "claude",
+        previousUrl: oldUrl,
+        nextUrl: newUrl,
+        keepPreviousWhenReferenced: true,
+      },
+      { tx }
+    );
+
+    expect(result).toEqual({
+      action: "updated-previous-in-place",
+      resetCircuitEndpointId: 7,
+    });
+    expect(mocks.updateMock).toHaveBeenCalledTimes(1);
+    expect(updatePayloads[0]).toEqual(expect.objectContaining({ url: newUrl }));
+    expect(transactionMock).not.toHaveBeenCalled();
+    expect(resetEndpointCircuitMock).not.toHaveBeenCalled();
+  });
+
+  test("concurrent insert conflict should degrade to noop instead of throwing", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+    const { syncProviderEndpointOnProviderEdit, mocks, resetEndpointCircuitMock } =
+      await arrangeSyncTest([[], [], [], [{ id: 201, deletedAt: null, isEnabled: true }]]);
+
+    const result = await syncProviderEndpointOnProviderEdit({
+      providerId: 1,
+      vendorId: 11,
+      providerType: "claude",
+      previousVendorId: 11,
+      previousProviderType: "claude",
+      previousUrl: oldUrl,
+      nextUrl: newUrl,
+      keepPreviousWhenReferenced: true,
+    });
+
+    expect(result).toEqual({ action: "noop" });
+    expect(mocks.insertMock).toHaveBeenCalledTimes(1);
+    expect(mocks.updateMock).not.toHaveBeenCalled();
+    expect(resetEndpointCircuitMock).not.toHaveBeenCalled();
+  });
+
+  test("in-place move unique conflict should fallback to keep-next and soft-delete previous", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+    const { syncProviderEndpointOnProviderEdit, updatePayloads, mocks, resetEndpointCircuitMock } =
+      await arrangeSyncTest([
+        [{ id: 7, deletedAt: null, isEnabled: true }],
+        [],
+        [],
+        [],
+        [{ id: 9, deletedAt: null, isEnabled: true }],
+      ]);
+
+    mocks.updateWhereMock.mockRejectedValueOnce(
+      Object.assign(new Error("duplicate key value violates unique constraint"), {
+        code: "23505",
+      })
+    );
+
+    const result = await syncProviderEndpointOnProviderEdit({
+      providerId: 1,
+      vendorId: 11,
+      providerType: "claude",
+      previousVendorId: 11,
+      previousProviderType: "claude",
+      previousUrl: oldUrl,
+      nextUrl: newUrl,
+      keepPreviousWhenReferenced: true,
+    });
+
+    expect(result).toEqual({ action: "soft-deleted-previous-and-kept-next" });
+    expect(mocks.insertMock).toHaveBeenCalledTimes(1);
+    expect(mocks.updateMock).toHaveBeenCalledTimes(2);
+    expect(updatePayloads[1]).toEqual(
+      expect.objectContaining({
+        isEnabled: false,
+      })
+    );
+    expect(resetEndpointCircuitMock).not.toHaveBeenCalled();
+  });
+
+  test("kept-previous with concurrent noop should return kept-previous-and-kept-next", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+    const { syncProviderEndpointOnProviderEdit, mocks, resetEndpointCircuitMock } =
+      await arrangeSyncTest([
+        [{ id: 7, deletedAt: null, isEnabled: true }],
+        [],
+        [{ id: 99 }],
+        [],
+        [{ id: 9, deletedAt: null, isEnabled: true }],
+      ]);
+
+    const result = await syncProviderEndpointOnProviderEdit({
+      providerId: 1,
+      vendorId: 11,
+      providerType: "claude",
+      previousVendorId: 11,
+      previousProviderType: "claude",
+      previousUrl: oldUrl,
+      nextUrl: newUrl,
+      keepPreviousWhenReferenced: true,
+    });
+
+    expect(result).toEqual({ action: "kept-previous-and-kept-next" });
+    expect(mocks.insertMock).toHaveBeenCalledTimes(1);
+    expect(mocks.updateMock).not.toHaveBeenCalled();
+    expect(resetEndpointCircuitMock).not.toHaveBeenCalled();
+  });
+});

+ 242 - 0
tests/unit/repository/provider-endpoint-sync-on-edit.test.ts

@@ -0,0 +1,242 @@
+import { describe, expect, test, vi } from "vitest";
+
+type ProviderRow = Record<string, unknown>;
+
+function createProviderRow(overrides: Partial<ProviderRow> = {}): ProviderRow {
+  const now = new Date("2025-01-01T00:00:00.000Z");
+
+  return {
+    id: 1,
+    name: "Provider A",
+    url: "https://old.example.com/v1/messages",
+    key: "test-key",
+    providerVendorId: 11,
+    isEnabled: true,
+    weight: 1,
+    priority: 0,
+    costMultiplier: "1.0",
+    groupTag: null,
+    providerType: "claude",
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    totalCostResetAt: null,
+    limitConcurrentSessions: 0,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 5,
+    circuitBreakerOpenDuration: 1800000,
+    circuitBreakerHalfOpenSuccessThreshold: 2,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 30000,
+    streamingIdleTimeoutMs: 10000,
+    requestTimeoutNonStreamingMs: 600000,
+    websiteUrl: "https://vendor.example.com",
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    geminiGoogleSearchPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: now,
+    updatedAt: now,
+    deletedAt: null,
+    ...overrides,
+  };
+}
+
+function createDbMock(currentRow: ProviderRow, updatedRow: ProviderRow) {
+  const selectLimitMock = vi.fn(async () => [currentRow]);
+  const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock }));
+  const selectFromMock = vi.fn(() => ({ where: selectWhereMock }));
+  const selectMock = vi.fn(() => ({ from: selectFromMock }));
+
+  const updateReturningMock = vi.fn(async () => [updatedRow]);
+  const updateWhereMock = vi.fn(() => ({ returning: updateReturningMock }));
+  const updateSetMock = vi.fn(() => ({ where: updateWhereMock }));
+  const updateMock = vi.fn(() => ({ set: updateSetMock }));
+
+  const tx = {
+    select: selectMock,
+    update: updateMock,
+  };
+  const transactionMock = vi.fn(async (runInTx: (trx: typeof tx) => Promise<unknown>) => {
+    return runInTx(tx);
+  });
+
+  return {
+    select: selectMock,
+    update: updateMock,
+    transaction: transactionMock,
+  };
+}
+
+async function arrangeUrlEditRedScenario(input: {
+  oldUrl: string;
+  newUrl: string;
+  previousVendorId?: number;
+  nextVendorId?: number;
+}) {
+  vi.resetModules();
+
+  const previousVendorId = input.previousVendorId ?? 11;
+  const nextVendorId = input.nextVendorId ?? previousVendorId;
+
+  const currentRow = createProviderRow({
+    id: 1,
+    url: input.oldUrl,
+    providerVendorId: previousVendorId,
+    providerType: "claude",
+  });
+  const updatedRow = createProviderRow({
+    id: 1,
+    url: input.newUrl,
+    providerVendorId: nextVendorId,
+    providerType: "claude",
+  });
+
+  const db = createDbMock(currentRow, updatedRow);
+  vi.doMock("@/drizzle/db", () => ({ db }));
+
+  const getOrCreateProviderVendorIdFromUrlsMock = vi.fn(async () => nextVendorId);
+  const ensureProviderEndpointExistsForUrlMock = vi.fn(async () => true);
+  const tryDeleteProviderVendorIfEmptyMock = vi.fn(async () => false);
+  const syncProviderEndpointOnProviderEditMock = vi.fn(
+    async (): Promise<{ action: string; resetCircuitEndpointId?: number }> => ({ action: "noop" })
+  );
+  const resetEndpointCircuitMock = vi.fn(async () => {});
+
+  vi.doMock("@/repository/provider-endpoints", () => ({
+    getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock,
+    ensureProviderEndpointExistsForUrl: ensureProviderEndpointExistsForUrlMock,
+    tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock,
+    syncProviderEndpointOnProviderEdit: syncProviderEndpointOnProviderEditMock,
+  }));
+  vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
+    resetEndpointCircuit: resetEndpointCircuitMock,
+  }));
+
+  const { updateProvider } = await import("@/repository/provider");
+
+  return {
+    updateProvider,
+    mocks: {
+      ensureProviderEndpointExistsForUrlMock,
+      syncProviderEndpointOnProviderEditMock,
+      tryDeleteProviderVendorIfEmptyMock,
+      resetEndpointCircuitMock,
+    },
+  };
+}
+
+describe("provider repository - endpoint sync on edit (#722 RED)", () => {
+  test("old-url exists + new-url absent: should update endpoint row instead of insert-only ensure", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
+    const provider = await updateProvider(1, { url: newUrl });
+
+    expect(provider?.url).toBe(newUrl);
+    expect(mocks.syncProviderEndpointOnProviderEditMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        providerId: 1,
+        vendorId: 11,
+        providerType: "claude",
+        previousUrl: oldUrl,
+        nextUrl: newUrl,
+      }),
+      expect.objectContaining({ tx: expect.any(Object) })
+    );
+  });
+
+  test("sync result with reset endpoint id should reset circuit after update commit", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
+    mocks.syncProviderEndpointOnProviderEditMock.mockResolvedValueOnce({
+      action: "updated-previous-in-place",
+      resetCircuitEndpointId: 7,
+    });
+
+    await updateProvider(1, { url: newUrl });
+
+    expect(mocks.resetEndpointCircuitMock).toHaveBeenCalledTimes(1);
+    expect(mocks.resetEndpointCircuitMock).toHaveBeenCalledWith(7);
+  });
+
+  test("old-url exists + new-url exists: should avoid duplicate accumulation and not call insert-only ensure", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
+    await updateProvider(1, { url: newUrl });
+
+    expect(mocks.ensureProviderEndpointExistsForUrlMock).not.toHaveBeenCalled();
+  });
+
+  test("old-url still referenced by another active provider: should keep old-url endpoint (safe cleanup guard)", async () => {
+    const oldUrl = "https://shared.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
+    await updateProvider(1, { url: newUrl });
+
+    expect(mocks.syncProviderEndpointOnProviderEditMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        previousUrl: oldUrl,
+        nextUrl: newUrl,
+        keepPreviousWhenReferenced: true,
+      }),
+      expect.objectContaining({ tx: expect.any(Object) })
+    );
+    expect(mocks.tryDeleteProviderVendorIfEmptyMock).not.toHaveBeenCalled();
+  });
+
+  test("endpoint sync throw: should bubble error instead of silent partial success", async () => {
+    const oldUrl = "https://old.example.com/v1/messages";
+    const newUrl = "https://new.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({ oldUrl, newUrl });
+    mocks.syncProviderEndpointOnProviderEditMock.mockRejectedValueOnce(new Error("sync failed"));
+
+    await expect(updateProvider(1, { url: newUrl })).rejects.toThrow("sync failed");
+    expect(mocks.tryDeleteProviderVendorIfEmptyMock).not.toHaveBeenCalled();
+  });
+
+  test("vendor cleanup failure should not block provider update", async () => {
+    const oldUrl = "https://old-vendor.example.com/v1/messages";
+    const newUrl = "https://new-vendor.example.com/v1/messages";
+
+    const { updateProvider, mocks } = await arrangeUrlEditRedScenario({
+      oldUrl,
+      newUrl,
+      previousVendorId: 11,
+      nextVendorId: 22,
+    });
+
+    mocks.tryDeleteProviderVendorIfEmptyMock.mockRejectedValueOnce(new Error("cleanup failed"));
+
+    const provider = await updateProvider(1, { url: newUrl });
+    expect(provider?.providerVendorId).toBe(22);
+    expect(mocks.tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(11);
+  });
+});

+ 49 - 20
tests/unit/repository/provider-endpoints.test.ts

@@ -17,7 +17,7 @@ function createThenableQuery<T>(result: T) {
 }
 
 describe("provider-endpoints repository", () => {
-  test("ensureProviderEndpointExistsForUrl: url 为空时返回 false 且不写 DB", async () => {
+  test("ensureProviderEndpointExistsForUrl: url 为空时抛错且不写 DB", async () => {
     vi.resetModules();
 
     const insertMock = vi.fn();
@@ -28,17 +28,17 @@ describe("provider-endpoints repository", () => {
     }));
 
     const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
-    const ok = await ensureProviderEndpointExistsForUrl({
-      vendorId: 1,
-      providerType: "claude",
-      url: "   ",
-    });
-
-    expect(ok).toBe(false);
+    await expect(
+      ensureProviderEndpointExistsForUrl({
+        vendorId: 1,
+        providerType: "claude",
+        url: "   ",
+      })
+    ).rejects.toThrow("[ProviderEndpointEnsure] url is required");
     expect(insertMock).not.toHaveBeenCalled();
   });
 
-  test("ensureProviderEndpointExistsForUrl: url 非法时返回 false 且不写 DB", async () => {
+  test("ensureProviderEndpointExistsForUrl: url 非法时抛错且不写 DB", async () => {
     vi.resetModules();
 
     const insertMock = vi.fn();
@@ -49,13 +49,13 @@ describe("provider-endpoints repository", () => {
     }));
 
     const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
-    const ok = await ensureProviderEndpointExistsForUrl({
-      vendorId: 1,
-      providerType: "claude",
-      url: "not a url",
-    });
-
-    expect(ok).toBe(false);
+    await expect(
+      ensureProviderEndpointExistsForUrl({
+        vendorId: 1,
+        providerType: "claude",
+        url: "not a url",
+      })
+    ).rejects.toThrow("[ProviderEndpointEnsure] url must be a valid URL");
     expect(insertMock).not.toHaveBeenCalled();
   });
 
@@ -122,6 +122,37 @@ describe("provider-endpoints repository", () => {
     expect(ok).toBe(false);
   });
 
+  test("ensureProviderEndpointExistsForUrl: 非编辑路径保持 insert-only 语义(不触发 update/transaction)", async () => {
+    vi.resetModules();
+
+    const returning = vi.fn(async () => []);
+    const onConflictDoNothing = vi.fn(() => ({ returning }));
+    const values = vi.fn(() => ({ onConflictDoNothing }));
+    const insertMock = vi.fn(() => ({ values }));
+    const updateMock = vi.fn();
+    const transactionMock = vi.fn();
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: {
+        insert: insertMock,
+        update: updateMock,
+        transaction: transactionMock,
+      },
+    }));
+
+    const { ensureProviderEndpointExistsForUrl } = await import("@/repository/provider-endpoints");
+    const ok = await ensureProviderEndpointExistsForUrl({
+      vendorId: 1,
+      providerType: "codex",
+      url: "https://api.example.com/v1/responses",
+    });
+
+    expect(ok).toBe(false);
+    expect(insertMock).toHaveBeenCalledTimes(1);
+    expect(updateMock).not.toHaveBeenCalled();
+    expect(transactionMock).not.toHaveBeenCalled();
+  });
+
   test("backfillProviderEndpointsFromProviders: 全部无效时不写 DB", async () => {
     vi.resetModules();
 
@@ -390,7 +421,7 @@ describe("provider-endpoints repository", () => {
     expect(deleteMock).toHaveBeenCalledTimes(2);
   });
 
-  test("tryDeleteProviderVendorIfEmpty: transaction 抛错时返回 false", async () => {
+  test("tryDeleteProviderVendorIfEmpty: transaction 抛错时抛出异常", async () => {
     vi.resetModules();
 
     const transactionMock = vi.fn(async () => {
@@ -404,9 +435,7 @@ describe("provider-endpoints repository", () => {
     }));
 
     const { tryDeleteProviderVendorIfEmpty } = await import("@/repository/provider-endpoints");
-    const ok = await tryDeleteProviderVendorIfEmpty(123);
-
-    expect(ok).toBe(false);
+    await expect(tryDeleteProviderVendorIfEmpty(123)).rejects.toThrow("boom");
   });
 
   test("deleteProviderVendor: vendor 存在时返回 true 且执行级联删除", async () => {

+ 173 - 0
tests/unit/repository/sensitive-words-events.test.ts

@@ -0,0 +1,173 @@
+import { describe, expect, test, vi } from "vitest";
+
+function createDbMock(options: {
+  insertReturning: unknown[];
+  updateReturning: unknown[];
+  deleteReturning: unknown[];
+}) {
+  return {
+    insert: vi.fn(() => ({
+      values: vi.fn(() => ({
+        returning: vi.fn(async () => options.insertReturning),
+      })),
+    })),
+    update: vi.fn(() => ({
+      set: vi.fn(() => ({
+        where: vi.fn(() => ({
+          returning: vi.fn(async () => options.updateReturning),
+        })),
+      })),
+    })),
+    delete: vi.fn(() => ({
+      where: vi.fn(() => ({
+        returning: vi.fn(async () => options.deleteReturning),
+      })),
+    })),
+    query: {
+      sensitiveWords: {
+        findMany: vi.fn(),
+      },
+    },
+  };
+}
+
+describe("Sensitive words repository events", () => {
+  test("createSensitiveWord: should emitSensitiveWordsUpdated", async () => {
+    vi.resetModules();
+
+    const emitSensitiveWordsUpdated = vi.fn(async () => undefined);
+    const row = {
+      id: 1,
+      word: "test",
+      matchType: "contains",
+      description: null,
+      isEnabled: true,
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    };
+
+    const db = createDbMock({
+      insertReturning: [row],
+      updateReturning: [],
+      deleteReturning: [],
+    });
+
+    vi.doMock("@/drizzle/db", () => ({ db }));
+    vi.doMock("@/drizzle/schema", () => ({ sensitiveWords: { id: {} } }));
+    vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})), desc: vi.fn() }));
+    vi.doMock("@/lib/emit-event", () => ({ emitSensitiveWordsUpdated }));
+
+    const repo = await import("@/repository/sensitive-words");
+    await repo.createSensitiveWord({ word: "test", matchType: "contains" });
+
+    expect(emitSensitiveWordsUpdated).toHaveBeenCalledTimes(1);
+  });
+
+  test("updateSensitiveWord: should emitSensitiveWordsUpdated when row found", async () => {
+    vi.resetModules();
+
+    const emitSensitiveWordsUpdated = vi.fn(async () => undefined);
+    const row = {
+      id: 1,
+      word: "test",
+      matchType: "contains",
+      description: null,
+      isEnabled: true,
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    };
+
+    const db = createDbMock({
+      insertReturning: [],
+      updateReturning: [row],
+      deleteReturning: [],
+    });
+
+    vi.doMock("@/drizzle/db", () => ({ db }));
+    vi.doMock("@/drizzle/schema", () => ({ sensitiveWords: { id: {} } }));
+    vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})), desc: vi.fn() }));
+    vi.doMock("@/lib/emit-event", () => ({ emitSensitiveWordsUpdated }));
+
+    const repo = await import("@/repository/sensitive-words");
+    const result = await repo.updateSensitiveWord(1, { word: "updated" });
+
+    expect(result).not.toBeNull();
+    expect(emitSensitiveWordsUpdated).toHaveBeenCalledTimes(1);
+  });
+
+  test("updateSensitiveWord: should not emitSensitiveWordsUpdated when row not found", async () => {
+    vi.resetModules();
+
+    const emitSensitiveWordsUpdated = vi.fn(async () => undefined);
+    const db = createDbMock({
+      insertReturning: [],
+      updateReturning: [],
+      deleteReturning: [],
+    });
+
+    vi.doMock("@/drizzle/db", () => ({ db }));
+    vi.doMock("@/drizzle/schema", () => ({ sensitiveWords: { id: {} } }));
+    vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})), desc: vi.fn() }));
+    vi.doMock("@/lib/emit-event", () => ({ emitSensitiveWordsUpdated }));
+
+    const repo = await import("@/repository/sensitive-words");
+    const result = await repo.updateSensitiveWord(1, { word: "updated" });
+
+    expect(result).toBeNull();
+    expect(emitSensitiveWordsUpdated).not.toHaveBeenCalled();
+  });
+
+  test("deleteSensitiveWord: should emitSensitiveWordsUpdated when row deleted", async () => {
+    vi.resetModules();
+
+    const emitSensitiveWordsUpdated = vi.fn(async () => undefined);
+    const row = {
+      id: 1,
+      word: "test",
+      matchType: "contains",
+      description: null,
+      isEnabled: true,
+      createdAt: new Date(),
+      updatedAt: new Date(),
+    };
+
+    const db = createDbMock({
+      insertReturning: [],
+      updateReturning: [],
+      deleteReturning: [row],
+    });
+
+    vi.doMock("@/drizzle/db", () => ({ db }));
+    vi.doMock("@/drizzle/schema", () => ({ sensitiveWords: { id: {} } }));
+    vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})), desc: vi.fn() }));
+    vi.doMock("@/lib/emit-event", () => ({ emitSensitiveWordsUpdated }));
+
+    const repo = await import("@/repository/sensitive-words");
+    const deleted = await repo.deleteSensitiveWord(1);
+
+    expect(deleted).toBe(true);
+    expect(emitSensitiveWordsUpdated).toHaveBeenCalledTimes(1);
+  });
+
+  test("deleteSensitiveWord: should not emitSensitiveWordsUpdated when row not deleted", async () => {
+    vi.resetModules();
+
+    const emitSensitiveWordsUpdated = vi.fn(async () => undefined);
+    const db = createDbMock({
+      insertReturning: [],
+      updateReturning: [],
+      deleteReturning: [],
+    });
+
+    vi.doMock("@/drizzle/db", () => ({ db }));
+    vi.doMock("@/drizzle/schema", () => ({ sensitiveWords: { id: {} } }));
+    vi.doMock("drizzle-orm", () => ({ eq: vi.fn(() => ({})), desc: vi.fn() }));
+    vi.doMock("@/lib/emit-event", () => ({ emitSensitiveWordsUpdated }));
+
+    const repo = await import("@/repository/sensitive-words");
+    const deleted = await repo.deleteSensitiveWord(1);
+
+    expect(deleted).toBe(false);
+    expect(emitSensitiveWordsUpdated).not.toHaveBeenCalled();
+  });
+});

+ 101 - 0
tests/unit/settings/providers/endpoint-status.test.ts

@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import {
+  type EndpointCircuitState,
+  getEndpointStatusModel,
+} from "@/app/[locale]/settings/providers/_components/endpoint-status";
+import { AlertTriangle, Ban, CheckCircle2, HelpCircle, XCircle } from "lucide-react";
+
+describe("getEndpointStatusModel", () => {
+  const createEndpoint = (lastProbeOk: boolean | null) => ({ lastProbeOk });
+
+  describe("Circuit Breaker Priority", () => {
+    it("should return circuit-open status when circuit is open, regardless of probe", () => {
+      const endpoint = createEndpoint(true); // Probe is OK
+      const result = getEndpointStatusModel(endpoint, "open");
+
+      expect(result).toEqual({
+        status: "circuit-open",
+        labelKey: "settings.providers.endpointStatus.circuitOpen",
+        severity: "error",
+        icon: Ban,
+        color: "text-rose-500",
+        bgColor: "bg-rose-500/10",
+        borderColor: "border-rose-500/30",
+      });
+    });
+
+    it("should return circuit-half-open status when circuit is half-open", () => {
+      const endpoint = createEndpoint(false); // Probe is bad
+      const result = getEndpointStatusModel(endpoint, "half-open");
+
+      expect(result).toEqual({
+        status: "circuit-half-open",
+        labelKey: "settings.providers.endpointStatus.circuitHalfOpen",
+        severity: "warning",
+        icon: AlertTriangle,
+        color: "text-amber-500",
+        bgColor: "bg-amber-500/10",
+        borderColor: "border-amber-500/30",
+      });
+    });
+  });
+
+  describe("Probe Status Fallback (Circuit Closed or Missing)", () => {
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return healthy when probe is ok and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(true);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "healthy",
+        labelKey: "settings.providers.endpointStatus.healthy",
+        severity: "success",
+        icon: CheckCircle2,
+        color: "text-emerald-500",
+        bgColor: "bg-emerald-500/10",
+        borderColor: "border-emerald-500/30",
+      });
+    });
+
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return unhealthy when probe is failed and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(false);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "unhealthy",
+        labelKey: "settings.providers.endpointStatus.unhealthy",
+        severity: "error",
+        icon: XCircle,
+        color: "text-rose-500",
+        bgColor: "bg-rose-500/10",
+        borderColor: "border-rose-500/30",
+      });
+    });
+
+    it.each([
+      { circuit: "closed" as EndpointCircuitState },
+      { circuit: null },
+      { circuit: undefined },
+    ])("should return unknown when probe is null and circuit is $circuit", ({ circuit }) => {
+      const endpoint = createEndpoint(null);
+      const result = getEndpointStatusModel(endpoint, circuit);
+
+      expect(result).toEqual({
+        status: "unknown",
+        labelKey: "settings.providers.endpointStatus.unknown",
+        severity: "neutral",
+        icon: HelpCircle,
+        color: "text-slate-400",
+        bgColor: "bg-slate-400/10",
+        borderColor: "border-slate-400/30",
+      });
+    });
+  });
+});

+ 253 - 0
tests/unit/settings/providers/provider-endpoint-hover.test.tsx

@@ -0,0 +1,253 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderEndpointHover } from "@/app/[locale]/settings/providers/_components/provider-endpoint-hover";
+import type { ProviderEndpoint } from "@/types/provider";
+import enMessages from "../../../../messages/en";
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  Tooltip: ({ children, open }: { children: ReactNode; open: boolean }) => (
+    <div data-testid="tooltip" data-state={open ? "open" : "closed"}>
+      {children}
+    </div>
+  ),
+  TooltipTrigger: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-trigger">{children}</div>
+  ),
+  TooltipContent: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-content">{children}</div>
+  ),
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderEndpointsByVendor: vi.fn(),
+  getEndpointCircuitInfo: vi.fn(),
+}));
+
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  const endpointStatus = {
+    viewDetails: "View Details",
+    activeEndpoints: "Active Endpoints",
+    noEndpoints: "No Endpoints",
+    healthy: "Healthy",
+    unhealthy: "Unhealthy",
+    unknown: "Unknown",
+    circuitOpen: "Circuit Open",
+    circuitHalfOpen: "Circuit Half-Open",
+  };
+
+  return {
+    settings: {
+      ...enMessages.settings,
+      providers: {
+        ...(enMessages.settings.providers || {}),
+        endpointStatus,
+      },
+    },
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderEndpointHover", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  const mockEndpoints: ProviderEndpoint[] = [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v1",
+      label: "Healthy Endpoint",
+      sortOrder: 10,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 100,
+      lastProbeStatusCode: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2024-01-01"),
+      updatedAt: new Date("2024-01-01"),
+      lastProbedAt: null,
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v2",
+      label: "Unhealthy Endpoint",
+      sortOrder: 20,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: false,
+      lastProbeLatencyMs: null,
+      lastProbeStatusCode: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2024-01-01"),
+      updatedAt: new Date("2024-01-01"),
+      lastProbedAt: null,
+    },
+    {
+      id: 3,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v3",
+      label: "Unknown Endpoint",
+      sortOrder: 5,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      lastProbeStatusCode: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2024-01-01"),
+      updatedAt: new Date("2024-01-01"),
+      lastProbedAt: null,
+    },
+    {
+      id: 4,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.com",
+      label: "Wrong Type",
+      sortOrder: 0,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      lastProbeStatusCode: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2024-01-01"),
+      updatedAt: new Date("2024-01-01"),
+      lastProbedAt: null,
+    },
+    {
+      id: 5,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v4",
+      label: "Disabled Endpoint",
+      sortOrder: 0,
+      isEnabled: false,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      lastProbeStatusCode: null,
+      lastProbeErrorType: null,
+      lastProbeErrorMessage: null,
+      createdAt: new Date("2024-01-01"),
+      updatedAt: new Date("2024-01-01"),
+      lastProbedAt: null,
+    },
+  ];
+
+  test("renders trigger with correct count and filters correctly", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount, container } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const triggerText = container.textContent;
+    expect(triggerText).toContain("3");
+
+    unmount();
+  });
+
+  test("sorts endpoints correctly: Healthy > Unknown > Unhealthy", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const tooltipContent = document.querySelector("[data-testid='tooltip-content']");
+    expect(tooltipContent).not.toBeNull();
+
+    const labels = Array.from(
+      document.querySelectorAll("[data-testid='tooltip-content'] span.truncate")
+    ).map((el) => el.textContent);
+
+    expect(labels).toEqual([
+      "https://api.anthropic.com/v1",
+      "https://api.anthropic.com/v3",
+      "https://api.anthropic.com/v2",
+    ]);
+
+    unmount();
+  });
+
+  test("does not fetch circuit info initially (when closed)", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    expect(providerEndpointsActionMocks.getEndpointCircuitInfo).not.toHaveBeenCalled();
+
+    unmount();
+  });
+});

+ 567 - 0
tests/unit/settings/providers/provider-endpoints-table.test.tsx

@@ -0,0 +1,567 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import {
+  ProviderEndpointsTable,
+  AddEndpointButton,
+  ProviderEndpointsSection,
+} from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
+import enMessages from "../../../../messages/en";
+
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+vi.mock("@/components/ui/tooltip", () => ({
+  Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] } })),
+  getProviderEndpoints: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderEndpointsByVendor: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: false,
+      lastProbedAt: "2026-01-01T12:00:00Z",
+      lastProbeOk: true,
+      lastProbeLatencyMs: 150,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderVendors: vi.fn(async () => []),
+  probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })),
+  removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
+  removeProviderVendor: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  return {
+    common: enMessages.common,
+    errors: enMessages.errors,
+    ui: enMessages.ui,
+    forms: enMessages.forms,
+    settings: enMessages.settings,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderEndpointsTable", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders endpoints from getProviderEndpointsByVendor when no providerType filter", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpointsByVendor).toHaveBeenCalledWith({
+      vendorId: 1,
+    });
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+    expect(document.body.textContent || "").toContain("https://api.openai.example.com/v1");
+
+    unmount();
+  });
+
+  test("renders endpoints from getProviderEndpoints when providerType filter is set", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+
+  test("hides type column when hideTypeColumn is true", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} hideTypeColumn={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Type");
+
+    unmount();
+  });
+
+  test("shows type column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides actions column in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Actions");
+
+    const switchElements = document.querySelectorAll("[data-slot='switch']");
+    expect(switchElements.length).toBe(0);
+
+    unmount();
+  });
+
+  test("shows actions column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Actions");
+
+    unmount();
+  });
+
+  test("toggle switch calls editProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const endpointRow = Array.from(document.querySelectorAll("tr")).find((row) =>
+      row.textContent?.includes("https://api.claude.example.com/v1")
+    );
+    expect(endpointRow).toBeDefined();
+
+    const switchEl = endpointRow?.querySelector<HTMLElement>("[data-slot='switch']");
+    expect(switchEl).not.toBeNull();
+    switchEl?.click();
+
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({ endpointId: 1, isEnabled: false })
+    );
+
+    unmount();
+  });
+
+  test("probe button calls probeProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const probeButtons = document.querySelectorAll("button");
+    const probeButton = Array.from(probeButtons).find((btn) =>
+      btn.querySelector("svg.lucide-play")
+    );
+    expect(probeButton).toBeDefined();
+
+    probeButton?.click();
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.probeProviderEndpoint).toHaveBeenCalledWith({
+      endpointId: 1,
+    });
+
+    unmount();
+  });
+
+  test("shows empty state when no endpoints", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("No endpoints");
+
+    unmount();
+  });
+
+  test("displays enabled/disabled badge correctly", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("enabled");
+    expect(document.body.textContent || "").toContain("disabled");
+
+    unmount();
+  });
+
+  test("edit dialog submits with label, sortOrder, and isEnabled", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([
+      {
+        id: 10,
+        vendorId: 1,
+        providerType: "claude",
+        url: "https://original.example.com/v1",
+        label: "Original Label",
+        sortOrder: 3,
+        isEnabled: true,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeLatencyMs: null,
+        createdAt: "2026-01-01",
+        updatedAt: "2026-01-01",
+      },
+    ]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const editButtons = document.querySelectorAll("button");
+    const editButton = Array.from(editButtons).find((btn) => btn.querySelector("svg.lucide-pen"));
+    expect(editButton).toBeDefined();
+
+    act(() => {
+      editButton?.click();
+    });
+
+    await flushTicks(4);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    expect(urlInput?.value).toBe("https://original.example.com/v1");
+    expect(labelInput?.value).toBe("Original Label");
+    expect(sortOrderInput?.value).toBe("3");
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://updated.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Updated Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "10";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        endpointId: 10,
+        url: "https://updated.example.com/v1",
+        label: "Updated Label",
+        sortOrder: 10,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("AddEndpointButton", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders add button", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("opens dialog on click", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("URL");
+
+    unmount();
+  });
+
+  test("shows type selector when no fixed providerType", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides type selector when providerType is fixed", async () => {
+    const { unmount } = renderWithProviders(
+      <AddEndpointButton vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    const labels = Array.from(document.querySelectorAll("label")).map((l) => l.textContent);
+    const hasTypeLabel = labels.some((l) => l === "Type");
+    expect(hasTypeLabel).toBe(false);
+
+    unmount();
+  });
+
+  test("submits with label, sortOrder, and isEnabled fields", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    act(() => {
+      addButton?.click();
+    });
+
+    await flushTicks(2);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://test.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Test Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "5";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        vendorId: 1,
+        url: "https://test.example.com/v1",
+        label: "Test Label",
+        sortOrder: 5,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("ProviderEndpointsSection", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders section header with endpoints label", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Endpoints");
+
+    unmount();
+  });
+
+  test("renders add button in section header", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("hides add button in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").not.toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("renders table with endpoints", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+
+    unmount();
+  });
+
+  test("passes providerType filter to table", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+});

+ 313 - 0
tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx

@@ -0,0 +1,313 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderForm } from "../../../../src/app/[locale]/settings/providers/_components/forms/provider-form";
+import { Dialog } from "../../../../src/components/ui/dialog";
+import enMessages from "../../../../messages/en";
+import type { ProviderEndpoint, ProviderVendor } from "../../../../src/types/provider";
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+  addProvider: vi.fn(async () => ({ ok: true })),
+  editProvider: vi.fn(async () => ({ ok: true })),
+  removeProvider: vi.fn(async () => ({ ok: true })),
+  getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })),
+  getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })),
+  getModelSuggestionsByProviderGroup: vi.fn(async () => []),
+  fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+const requestFiltersActionMocks = vi.hoisted(() => ({
+  getDistinctProviderGroupsAction: vi.fn(async () => ({ ok: true, data: [] })),
+}));
+vi.mock("@/actions/request-filters", () => requestFiltersActionMocks);
+
+const modelPricesActionMocks = vi.hoisted(() => ({
+  getAvailableModelsByProviderType: vi.fn(async () => []),
+}));
+vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderVendors: vi.fn(async (): Promise<ProviderVendor[]> => []),
+  getProviderEndpoints: vi.fn(async (): Promise<ProviderEndpoint[]> => []),
+  getProviderEndpointsByVendor: vi.fn(async (): Promise<ProviderEndpoint[]> => []),
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  probeProviderEndpoint: vi.fn(async () => ({
+    ok: true,
+    data: { endpoint: {}, result: { ok: true } },
+  })),
+  removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  return {
+    common: enMessages.common,
+    errors: enMessages.errors,
+    ui: enMessages.ui,
+    forms: enMessages.forms,
+    settings: enMessages.settings,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          <Dialog open onOpenChange={() => {}}>
+            {node}
+          </Dialog>
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+function setNativeValue(element: HTMLInputElement, value: string) {
+  const prototype = Object.getPrototypeOf(element) as unknown as { value?: unknown };
+  const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
+  if (descriptor?.set) {
+    descriptor.set.call(element, value);
+    return;
+  }
+  element.value = value;
+}
+
+describe("ProviderForm: endpoint pool integration", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("Website URL input should render before provider URL input", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+
+    expect(websiteUrlInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+
+    const relative = websiteUrlInput?.compareDocumentPosition(urlInput as Node) ?? 0;
+    expect((relative & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true);
+
+    unmount();
+  });
+
+  test("When vendor resolves and endpoints exist, should show endpoint pool and hide URL input", async () => {
+    providerEndpointsActionMocks.getProviderVendors.mockResolvedValueOnce([
+      {
+        id: 1,
+        websiteDomain: "example.com",
+        displayName: "Example",
+        websiteUrl: "https://example.com",
+        faviconUrl: null,
+        createdAt: new Date("2026-01-01"),
+        updatedAt: new Date("2026-01-01"),
+      },
+    ]);
+    providerEndpointsActionMocks.getProviderEndpoints.mockResolvedValueOnce([
+      {
+        id: 10,
+        vendorId: 1,
+        providerType: "claude",
+        url: "https://api.example.com/v1",
+        label: null,
+        sortOrder: 0,
+        isEnabled: true,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeStatusCode: null,
+        lastProbeLatencyMs: null,
+        lastProbeErrorType: null,
+        lastProbeErrorMessage: null,
+        createdAt: new Date("2026-01-01T00:00:00Z"),
+        updatedAt: new Date("2026-01-01T00:00:00Z"),
+        deletedAt: null,
+      },
+    ]);
+
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    expect(websiteUrlInput).toBeTruthy();
+
+    await act(async () => {
+      if (!websiteUrlInput) return;
+      setNativeValue(websiteUrlInput, "https://example.com");
+      websiteUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      websiteUrlInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    await flushTicks(6);
+
+    expect(document.getElementById("url")).toBeNull();
+    expect(document.body.textContent || "").toContain("Endpoints");
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("When vendor cannot be resolved, should show URL input and block submit without valid URL", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    expect(nameInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !keyInput) return;
+      setNativeValue(nameInput, "p1");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "k");
+      keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+      keyInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(3);
+
+    expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(0);
+    expect(sonnerMocks.toast.error).toHaveBeenCalled();
+
+    unmount();
+  });
+
+  test("When vendor cannot be resolved but URL provided, should call addProvider", async () => {
+    providerEndpointsActionMocks.getProviderVendors
+      .mockResolvedValueOnce([])
+      .mockResolvedValueOnce([
+        {
+          id: 99,
+          websiteDomain: "example.com",
+          displayName: "Example",
+          websiteUrl: "https://example.com",
+          faviconUrl: null,
+          createdAt: new Date("2026-01-01"),
+          updatedAt: new Date("2026-01-01"),
+        },
+      ]);
+
+    const { unmount } = renderWithProviders(
+      <ProviderForm mode="create" enableMultiProviderTypes />
+    );
+
+    await flushTicks(2);
+
+    const nameInput = document.getElementById("name") as HTMLInputElement | null;
+    const websiteUrlInput = document.getElementById("website-url") as HTMLInputElement | null;
+    const urlInput = document.getElementById("url") as HTMLInputElement | null;
+    const keyInput = document.getElementById("key") as HTMLInputElement | null;
+    expect(nameInput).toBeTruthy();
+    expect(websiteUrlInput).toBeTruthy();
+    expect(urlInput).toBeTruthy();
+    expect(keyInput).toBeTruthy();
+
+    await act(async () => {
+      if (!nameInput || !websiteUrlInput || !urlInput || !keyInput) return;
+      setNativeValue(nameInput, "p2");
+      nameInput.dispatchEvent(new Event("input", { bubbles: true }));
+      nameInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(websiteUrlInput, "https://example.com");
+      websiteUrlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      websiteUrlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(urlInput, "https://api.example.com/v1");
+      urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      urlInput.dispatchEvent(new Event("change", { bubbles: true }));
+
+      setNativeValue(keyInput, "k");
+      keyInput.dispatchEvent(new Event("input", { bubbles: true }));
+      keyInput.dispatchEvent(new Event("change", { bubbles: true }));
+    });
+
+    const form = document.body.querySelector("form") as HTMLFormElement | null;
+    expect(form).toBeTruthy();
+
+    await act(async () => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    for (let i = 0; i < 8; i++) {
+      if (providersActionMocks.addProvider.mock.calls.length > 0) break;
+      await flushTicks(1);
+    }
+
+    expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1);
+
+    await flushTicks(3);
+    expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledTimes(0);
+
+    unmount();
+  });
+});

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików