Compare commits

...
112 Commits
Author SHA1 Message Date
ZyyandCodex 7cee430947 chore(release): v3.42.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:59:00 +08:00
ZyyandCodex ac98a764ff fix(update): discover channels from verified release assets
Publish an immutable channel snapshot with each release and advance a fixed release pointer only after public assets pass verification. Check the exact platform package before notifying clients.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:59:00 +08:00
ZyyandCodex 56ef1bffa2 test(openai): align service tier fixture with OAuth normalization
Use client_metadata to verify nested service tier fields survive fast-mode handling while preserving outbound tier and billing assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:46:54 +08:00
ZyyandCodex e275a6dada chore(release): v3.42.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:21:29 +08:00
555269739c fix(openai): normalize unsupported Codex OAuth request options (#630)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 01:16:25 +08:00
ZyyandCodex d03d8408fc docs: clarify current configuration and update behavior
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:55:33 +08:00
ZyyandCodex 228eea90c1 chore(db): consolidate unreleased migrations
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:48:32 +08:00
ZyyandCodex 0d25061ef0 docs: bound UI verification and generalize AI attribution
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:37:46 +08:00
ZyyandZyy e97b1faf0d feat(openai): derive installation identities from key seeds (#629)
* feat(openai): derive installation identities from key seeds

* docs: explain installation seed migration effects

* docs: fix the key configuration reference

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:33:17 +08:00
ZyyandZyy 90ecac978a feat(accounts): make quota credit use opt-in per account (#628)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:20:43 +08:00
ZyyandZyy 4326eb1e4b fix(update): bound GitHub lookups with a published channel index (#627)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-07 00:20:19 +08:00
ZyyandCodex 2a8d5d8643 ci(release): publish the channel index after verifying release assets
Co-authored-by: Codex <noreply@openai.com>
2026-11-07 00:13:47 +08:00
ZyyandCodex b7fa5872a3 refactor(ui): reuse shared button components
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 23:41:36 +08:00
ZyyandCodex e907f419ab fix(ui): show account group names in popovers
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 23:20:13 +08:00
ZyyandZyy ee7e837d1f feat(scheduling): split queue timeouts and allow account fallback (#625)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-06 23:09:29 +08:00
ZyyandCodex 1240f22580 fix(openai): honor affinity mode for sessionless requests
Use the configured affinity mode for content and cache-key bindings, including strict waiting and binding store failures. Cover queue rejection, cancellation, migration, and preferred spillover, and document the scheduling boundaries.

Refs #623

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 22:05:15 +08:00
ZyyandCodex e390417821 fix(openai): prefer the same account for sessionless requests
Use the client cache key or the opening messages as a tenant-scoped scheduling preference. Keep explicit sessions and continuation ownership separate, and allow spillover when the preferred account is busy.

Refs #622

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 20:01:01 +08:00
ZyyandCodex e3f1299a0c fix(ui): retain custom model choices until save
Keep deselected model options in the editing session and remove the nested model list scrollbar.

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 15:53:42 +08:00
Zyy 4e4cd96478 fix(openai): recheck cross-account capacity while queued 2026-11-06 15:02:16 +08:00
ZyyandZyy 6de1beb1df perf(openai): reduce scheduling reads and stabilize session affinity (#618)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-06 14:44:42 +08:00
ZyyandCodex ba3baddbda chore(release): v3.41.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:21:27 +08:00
ZyyandCodex 09b22a9550 fix(openai): honor account allowlists for unlisted models
Let OpenAI accounts send explicitly allowlisted model IDs upstream while retaining catalog checks for other accounts. Update the account editor and connection test to support custom IDs.

Co-authored-by: Codex <noreply@openai.com>
2026-11-06 12:18:08 +08:00
ZyyandCodex 04a48e848d chore(deps): update mio to 1.2.4
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:25:06 +08:00
ZyyandCodex 1655ecc972 style(usage): clarify tooltip success rate formatting
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:06:22 +08:00
ZyyandCodex 4527db7b5d feat(usage): show cancelled and incomplete requests separately
Co-authored-by: Codex <noreply@openai.com>
2026-11-06 11:04:07 +08:00
ZyyandCodex 2b7a64dae6 docs: preserve task evidence and clarify completion rules
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 22:06:09 +08:00
ZyyandCodex 0c07aac1d6 chore(release): v3.41.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 22:01:26 +08:00
ZyyandCodex 3e33e84a45 fix(observability): unify request error statistics
Count incomplete requests as errors, exclude recovered and cancelled requests from success rates, and preserve gaps for missing samples.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 21:56:12 +08:00
ZyyandCodex c73e7126a2 fix(openai): clarify OAuth connection failures and extend timeouts
Distinguish connection failures from uncertain authorization results and retain redacted diagnostic causes. Increase token connection timeout to 30 seconds and total request timeout to 60 seconds.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 21:50:08 +08:00
ZyyandCodex 1d88c9a4a5 fix(websocket): close idle clients when upstream connections end
Notify idle clients with Close 1000 after upstream loss while preserving settlement and queued requests. Align idle transport termination detection with Codex and document the reconnect contract.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 18:48:38 +08:00
蓝芷怡andCodex 26dbc12010 fix(accounts): show full model names in the account catalog (#616)
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 15:17:55 +08:00
dependabot[bot] 2dc6bf0150 chore(deps): bump the minor-and-patch group in /backend with 4 updates (#614)
Bumps the minor-and-patch group in /backend with 4 updates: [zip](https://github.com/zip-rs/zip2), [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust), [redis](https://github.com/redis-rs/redis-rs) and [crc32fast](https://github.com/srijs/rust-crc32fast).


Updates `zip` from 9.1.3 to 9.2.2
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.1.3...v9.2.2)

Updates `aws-sdk-s3` from 1.156.0 to 1.157.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `redis` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.8.0...redis-1.8.1)

Updates `crc32fast` from 1.5.3 to 1.5.4
- [Commits](https://github.com/srijs/rust-crc32fast/compare/v1.5.3...v1.5.4)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.157.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: crc32fast
  dependency-version: 1.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:11:46 +08:00
dependabot[bot] f7f64ead3b chore(deps): bump the minor-and-patch group in /frontend with 6 updates (#613)
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.66.0` | `1.67.0` |
| [gsap](https://github.com/greensock/GSAP) | `3.15.0` | `3.15.1` |
| [semver](https://github.com/npm/node-semver) | `7.8.5` | `7.8.6` |
| [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) | `11.4.16` | `11.4.18` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.3` | `4.3.4` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.3` | `4.3.4` |


Updates `@lucide/vue` from 1.66.0 to 1.67.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.67.0/packages/vue)

Updates `gsap` from 3.15.0 to 3.15.1
- [Commits](https://github.com/greensock/GSAP/compare/3.15.0...3.15.1)

Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

Updates `vue-i18n` from 11.4.16 to 11.4.18
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/v11.4.18/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v11.4.18/packages/vue-i18n)

Updates `@tailwindcss/vite` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.4/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: gsap
  dependency-version: 3.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vue-i18n
  dependency-version: 11.4.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 4.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:11:02 +08:00
dependabot[bot] 37749001db chore(deps): bump taiki-e/install-action (#612)
Bumps the minor-and-patch group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.88.1 to 2.89.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/68a389c788b2cd04016ce8c80ee23b80d2ab3d68...682ce6c3c696a4cd5168a05f98e51aa02b877941)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.89.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 15:10:31 +08:00
ZyyandCodex 272534a3b6 perf(host): reduce startup and restart latency
Read the local running version during bootstrap without waiting for a
remote release check. Reuse the restart handoff pipe on Unix so the
replacement starts as soon as the old service releases its resources.

Keep shutdown cleanup budgets and bounded listener retries. Update the
handoff regression coverage and lifecycle documentation.

Validation: Rustfmt, strict workspace Clippy, 252 Host and bootstrap
tests, and isolated Linux startup and consecutive restart measurements.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 14:15:58 +08:00
ZyyandCodex b9bcb4e086 ci: streamline builds and release validation
Share binary builds and dependency caches, run Windows platform checks in parallel, and reuse verified CI evidence for version-only releases. Reuse prebuilt CI image inputs and add read-only release preflight.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 13:03:59 +08:00
ZyyandCodex 9039cef3d8 chore(release): v3.41.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 12:18:54 +08:00
a9a77f5f9d fix(accounts): calculate model success rates from request outcomes (#611)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 12:14:27 +08:00
ZyyandCodex 6f7d92594e fix(frontend): defer incomplete configuration errors until submit
Avoid validating model access and plugin forms when switching modes.
Keep error protection drafts quiet until input or submission, and retain
condition identity when editing or removing entries.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 12:11:52 +08:00
6859520969 fix(api): broaden Chat compatibility and capture ingress failures (#610)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 11:53:21 +08:00
ZyyandCodex 21d84af2d8 fix: simplify user-facing messages and error feedback
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 00:48:19 +08:00
ZyyandCodex 311e4d228f docs(contributing): prioritize concise text in development and feedback
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 00:47:45 +08:00
ZyyandCodex d604adfbeb fix(login): center the sign-in form on mobile
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 00:47:45 +08:00
ZyyandCodex 46b4665c5b feat(openai): allow API keys to override the installation ID
Co-authored-by: Codex <noreply@openai.com>
2026-11-05 00:31:37 +08:00
ZyyandCodex 1daa0623a0 fix(openai): preserve client turn state without local ownership records
Preserve client-supplied turn state on full requests with unknown ownership
and remove the process-local proof cache. Keep confirmed account-switch
cleanup and existing continuation authorization boundaries.

Cover HTTP and WebSocket retries, provider recreation, expired affinity,
and conflicting account ownership; update the API and architecture docs.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 00:14:53 +08:00
ZyyandCodex 860fb39622 fix(frontend): satisfy account formatting rules
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 22:49:25 +08:00
f5c5a6e5b7 feat(proxies): test account egress and assign accounts in bulk (#607)
* feat(proxies): test account egress and assign accounts in bulk

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine proxy controls and quota display

Keep egress results with their proxy options and refresh stale proxy revisions before retrying. Compact account assignment and settings controls, and hide missing quota refresh timestamps.

Co-authored-by: Codex <noreply@openai.com>

* fix(proxies): reset account selection without closing dialog

Co-authored-by: Codex <noreply@openai.com>

* fix(proxies): clarify assignment reset and unavailable accounts

Use a red reset button and disabled checkboxes for accounts already using the target proxy.

Co-authored-by: Codex <noreply@openai.com>

* feat(proxies): show account details in assignment dialog

Add platform, subscription, and group columns, remove inline provider icons, and use the existing subtle danger style for reset.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-04 22:42:16 +08:00
ZyyandCodex bdfc201795 fix(peer-link): update account identity on definitive sharing results
Clear the peer badge when the upstream explicitly disables sharing or does not support CPR Link. Preserve identity on transient failures and allow manual rediscovery from the quota panel.

Co-authored-by: Codex <noreply@openai.com>
2026-11-04 19:58:55 +08:00
ZyyandCodex 789609dd53 chore(release): v3.40.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 19:36:41 +08:00
10e25ce25f feat(api): extend Chat Completions compatibility with Responses (#606)
Map tool-result names, inline PDF inputs, and hosted web search to Responses. Project URL citations into Chat messages and preserve the requested output token limit in the OpenAI encoder.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 19:32:42 +08:00
6b362a5029 fix(accounts): keep failed recovery probes from thawing accounts (#605)
Carry the freeze recovery target into provider account selection so probe responses cannot update credentials or clear the active protection. Let the recovery worker postpone or release the same freeze after observing the upstream result.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 19:30:22 +08:00
13374e2a70 fix(api): separate traffic readiness from worker health (#604)
Expose /readyz for the runtime snapshot, PostgreSQL, and Redis. Keep worker and logging diagnostics on /healthz so a maintenance failure can alert without removing a serving instance from service discovery.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 19:29:50 +08:00
ZyyandCodex 19ef484969 feat(peer-link)!: share upstream key concurrency and RPM limits
Use a fresh upstream key concurrency limit as the account default for scheduling, leases, and capacity reporting. Show disclosed limits alongside the upstream pool summary.

BREAKING CHANGE: Both peers must support CPR Link schema v3.

Refs: #587

Co-authored-by: Codex <noreply@openai.com>
2026-11-04 18:32:42 +08:00
242ffb2324 feat(accounts): add a separate model allowlist cleanup button (#600)
* feat(accounts): add manual allowlist cleanup

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine model allowlist cleanup interactions

Co-authored-by: Codex <noreply@openai.com>

* chore(docs): remove PR screenshot assets

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-04 17:15:04 +08:00
ZyyandCodex 53a09bbfe9 docs(skills): require English pull request titles
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 17:03:08 +08:00
239def9a5b fix(store): preserve client key ledgers after deletion (#602)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 17:00:44 +08:00
ZyyandCodex 5eaca14a64 fix(openai): clear client headers when switching accounts
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 15:28:48 +08:00
78e943f406 feat(usage): add snapshot scans and complete grouped statistics (#599)
Expose bounded usage readers through Admin, Store, Runtime, and SDK. Preserve snapshot consistency, coverage metadata, cancellation, and parent-call cleanup.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 15:22:44 +08:00
ZyyandCodex 0663df14df fix(responses): reconnect idle WebSockets for replay regardless of client identity
Refs #596

Co-authored-by: Codex <noreply@openai.com>
2026-11-04 14:14:29 +08:00
39aa20a13a fix(plugins): prefer account-scoped profiles for adapted models (#588)
* fix(plugins): prefer account-scoped model profiles in catalogs

Co-authored-by: Codex <noreply@openai.com>

* fix(models): honor scoped plugin profiles in fallback catalogs

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-04 13:20:08 +08:00
Ke WangandCodex 621b1faceb fix(store): keep usage queries compatible with PostgreSQL 16 (#597)
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 13:17:09 +08:00
28eb4708b5 fix(update): keep read-only official plugin files during extract (#595)
* fix(update): keep read-only official plugin files during extract

Docker Desktop returns EPERM when chmod runs again on an official plugin file that tar already unpacked as mode 0444.

Fixes #594

* test(update): cover writable official plugin archives

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: le-czs <243511553+le-czs@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-04 13:09:57 +08:00
Ke Wang aa003f7d90 fix(build): keep frozen migrations in LF (#589) 2026-11-04 13:02:04 +08:00
ZyyandCodex 4c57a0405d fix(xai): preserve quota snapshots and restore daily usage
Preserve account profiles and quota observations during OAuth refresh and retry deferral. Project rolling daily usage independently of upstream snapshots and expose its period through the existing account usage API.

Keep quota capacity forecasts scoped to weekly and monthly windows. Cover refresh persistence, daily statistics, API projection, and PostgreSQL CAS behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-11-04 12:39:10 +08:00
ZyyandCodex 49cee2397f fix(openai): avoid repeated replay errors after large SSE requests
Keep oversized Codex threads on SSE until a full request falls below 13.5 MiB. After delivering a response that requires full replay, close an idle Codex socket so the next step can send its history directly.

Co-authored-by: Codex <noreply@openai.com>
2026-11-04 12:17:26 +08:00
ZyyandCodex 1915806bd1 perf(theme): load preview content after the canvas
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 21:18:02 +08:00
ZyyandCodex 5100f6333e fix(usage): improve error diagnostics and request inspection
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 21:18:02 +08:00
ZyyandCodex 187af36306 fix(accounts): preserve known peer identity after sync failures
Keep undiscovered upstreams displayed as ordinary API keys and retain known peer errors without revoked shared data. Reuse quota refresh for retries and reset recognition when the connection changes.

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 19:33:24 +08:00
ZyyandCodex be35f8a6ee fix(accounts): refine upstream quota display and layout
Show quota availability consistently for unlimited and stale observations, keep pool summaries at the bottom, and compact plan counts with an inline overflow popover.

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 17:48:47 +08:00
ZyyandCodex 317c05c74a fix(accounts): show OAuth results in notifications
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 15:55:56 +08:00
ZyyandCodex 12c32fcdec chore(release): v3.39.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:47:32 +08:00
Ke WangandCodex 26de671226 fix(runtime): reload full snapshot on config revision races (#584)
* fix(runtime): reload raced snapshot facts before suspending publication

Co-authored-by: Codex <noreply@openai.com>

* fix(runtime): retry extension preparation when its revision becomes stale

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:38:47 +08:00
Ke WangandCodex a92b95c48c fix(proxies): measure latency for the selected successful address family (#585)
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:34:40 +08:00
ZyyandCodex e7c24781fb fix(openai): preserve deployment account routing policy
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:33:53 +08:00
ZyyandCodex 3b4b547e9c fix(store): release proxy locks before returning delete conflicts
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:28 +08:00
ZyyandCodex 2016bda031 fix(openai): preserve client headers and send account installation id
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:16 +08:00
ZyyandCodex 790f3147e0 fix(openai): preserve verified turn state on full requests
Track bounded ownership proofs for upstream turn-state tokens, including failed responses, so same-turn full requests can echo them without trusting unrelated account state.

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 10:19:53 +08:00
ZyyandCodex 96e8443ede chore(release): v3.39.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 19:40:58 +08:00
ZyyandCodex 42badf64ca docs(deploy): document low-memory database tuning
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 16:30:04 +08:00
35afe1123f feat(plugins): edit sensitive configuration per field (#582)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 15:32:32 +08:00
ZyyandCodex 2ac4265832 feat(windows): add builds and online updates
Build x64 and ARM64 release archives with a static MSVC runtime.
Support executable replacement, restart handoff and rollback on Windows.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 14:45:33 +08:00
ZyyandCodex d22ec29824 fix(usage): show CPR badges for linked accounts
Use current peer summaries for request records and account diagnostics without changing historical snapshots or database schema.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 14:39:44 +08:00
ZyyandCodex 7a32c6cec2 chore(release): v3.38.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 13:44:36 +08:00
0ff1a72919 fix(retry): limit upstream Retry-After waits (#578)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 13:07:20 +08:00
79083a5632 fix(openai): reconnect before websocket search activation (#577)
Track hosted search initialization on pooled Codex sockets and retire incompatible connections before sending. Preserve existing client full replay recovery for connection-local deltas.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:28:14 +08:00
ZyyandCodex 90f7456db8 fix(ui): align CPR badge text with its icon
Keep the compact line height and offset the uppercase label by one pixel to align its visible center with the brand glyph across badge sizes.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:27:51 +08:00
ZyyandCodex 6f71b27c3c fix(peer): include quota-backed plans in pool summaries
Reuse account plan resolution when exporting peer pool summaries. Read saved quota observations for missing plans even when quota sharing is disabled, without exposing quota data or mutating account records.

Cover unknown and missing plans, subtype preservation, plan-only limits, and quota refresh from an unknown account plan.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 12:24:57 +08:00
ZyyandCodex 7b05b14a2b fix(openai): narrow model rejection cooldown detection
Require the official model_not_found code and supported status codes for
account-model cooldowns. Treat unsupported_value as a terminal request
error and preserve account eligibility across HTTP, SSE, and WebSocket.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 11:36:58 +08:00
f9b5b5ff77 chore(deps): bump node from 26.12.1-bookworm-slim to 26.13.0-bookworm-slim in /deploy in the minor-and-patch group across 1 directory (#573)
* chore(deps): bump node

Bumps the minor-and-patch group with 1 update in the /deploy directory: node.


Updates `node` from 26.12.1-bookworm-slim to 26.13.0-bookworm-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.13.0-bookworm-slim
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* docs(development): align Node.js version with Docker

Co-authored-by: Codex <noreply@openai.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 10:33:44 +08:00
dependabot[bot] 4d5778a84f chore(deps): bump rust from 48b7f43 to 9181b56 in /deploy (#575)
Bumps rust from `48b7f43` to `9181b56`.

---
updated-dependencies:
- dependency-name: rust
  dependency-version: 1.99.1-bookworm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:32:20 +08:00
dependabot[bot] 6d22d73506 chore(deps): bump debian from 7ff6ba3 to 99c0b25 in /deploy (#574)
Bumps debian from `7ff6ba3` to `99c0b25`.

---
updated-dependencies:
- dependency-name: debian
  dependency-version: bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:32:10 +08:00
dependabot[bot] d8ab207282 chore(deps): bump the minor-and-patch group in /frontend with 7 updates (#572)
Bumps the minor-and-patch group in /frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.61.0` | `1.66.0` |
| [axios](https://github.com/axios/axios) | `1.20.0` | `1.21.0` |
| [es-toolkit](https://github.com/toss/es-toolkit) | `1.53.0` | `1.53.1` |
| [@intlify/unplugin-vue-i18n](https://github.com/intlify/bundle-tools/tree/HEAD/packages/unplugin-vue-i18n) | `11.2.7` | `11.2.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.5` | `26.6.6` |
| [eslint](https://github.com/eslint/eslint) | `10.13.0` | `10.14.0` |
| [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.3.13` | `3.3.14` |


Updates `@lucide/vue` from 1.61.0 to 1.66.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.66.0/packages/vue)

Updates `axios` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.20.0...v1.21.0)

Updates `es-toolkit` from 1.53.0 to 1.53.1
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.53.0...v1.53.1)

Updates `@intlify/unplugin-vue-i18n` from 11.2.7 to 11.2.8
- [Release notes](https://github.com/intlify/bundle-tools/releases)
- [Changelog](https://github.com/intlify/bundle-tools/blob/v11.2.8/CHANGELOG.md)
- [Commits](https://github.com/intlify/bundle-tools/commits/v11.2.8/packages/unplugin-vue-i18n)

Updates `@types/node` from 26.6.5 to 26.6.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.13.0 to 10.14.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.13.0...v10.14.0)

Updates `vue-tsc` from 3.3.13 to 3.3.14
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.14/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: axios
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: es-toolkit
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@intlify/unplugin-vue-i18n"
  dependency-version: 11.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.6.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:31:59 +08:00
dependabot[bot] 3816b46ba1 chore(deps): bump the minor-and-patch group in /backend with 8 updates (#571)
Bumps the minor-and-patch group in /backend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [uuid](https://github.com/uuid-rs/uuid) | `1.28.0` | `1.29.0` |
| [zip](https://github.com/zip-rs/zip2) | `9.0.5` | `9.1.3` |
| [jsonschema](https://github.com/Stranger6667/jsonschema) | `0.60.0` | `0.60.2` |
| [roxmltree](https://github.com/RazrFalcon/roxmltree) | `0.21.1` | `0.21.2` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.155.0` | `1.156.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.19.1` | `1.19.2` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.9.0` | `1.9.1` |
| [redis](https://github.com/redis-rs/redis-rs) | `1.7.1` | `1.8.0` |


Updates `uuid` from 1.28.0 to 1.29.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.28.0...v1.29.0)

Updates `zip` from 9.0.5 to 9.1.3
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.5...v9.1.3)

Updates `jsonschema` from 0.60.0 to 0.60.2
- [Release notes](https://github.com/Stranger6667/jsonschema/releases)
- [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/jsonschema/compare/ruby-v0.60.0...ruby-v0.60.2)

Updates `roxmltree` from 0.21.1 to 0.21.2
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.21.1...v0.21.2)

Updates `aws-sdk-s3` from 1.155.0 to 1.156.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.19.1 to 1.19.2
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `aws-smithy-types` from 1.9.0 to 1.9.1
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `redis` from 1.7.1 to 1.8.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.7.1...redis-1.8.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: zip
  dependency-version: 9.1.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: jsonschema
  dependency-version: 0.60.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: roxmltree
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.156.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.19.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: redis
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:31:48 +08:00
dependabot[bot] 4a7c06f895 chore(deps): bump taiki-e/install-action (#569)
Bumps the minor-and-patch group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.87.31 to 2.88.1
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/install-action/compare/1e29709c0ec63babbe8f266397a465f29a825f91...68a389c788b2cd04016ce8c80ee23b80d2ab3d68)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.88.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 10:31:13 +08:00
ZyyandCodex e0b7a841ca chore(release): v3.38.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-02 00:08:30 +08:00
ZyyandCodex 96a292dd0f fix(providers): align catalog-based account eligibility
Apply valid catalog membership alongside account model access for native OAuth, API key upstreams, and xAI. Preserve upstream validation for missing catalogs and unknown plans.

Co-authored-by: Codex <noreply@openai.com>
2026-11-02 00:02:08 +08:00
ZyyandCodex 6f138c8256 chore(release): v3.38.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 23:32:37 +08:00
ZyyandCodex c8ffb1a8c8 perf(store): batch account lease runtime reads
Read up to 128 accounts per Redis script while preserving input order, pool isolation, shared scheduling timestamps, expiry cleanup, and error propagation. Extend real Redis coverage for mixed lease states and invalid batch data.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 23:23:49 +08:00
ZyyandCodex 79df82a4f0 fix(openai): respect model capabilities in account selection
Filter known OAuth models by fresh plan catalogs and retain account-scoped model rejections. Preserve credential, transport, adapter, and continuation boundaries, and combine cooldown expiry without dropping protection semantics.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 22:39:21 +08:00
ZyyandCodex 62516d9769 fix(ci): reduce Cargo cache snapshot disk usage
Keep Cargo snapshots compressed during transfer, extract directly into BuildKit cache mounts, and release restored runner snapshots. Export archives without an extra copy layer and use a new cache format key.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 22:05:10 +08:00
ZyyandCodex 892cba68ae test: streamline backend tests and speed up validation
Remove obsolete and redundant assertions while preserving current behavior.
Optimize zlib-rs in dev/test builds and use eight concurrent CI test workers.
Use explicit time bases and synchronization for integration fixtures.

Validated 4322 tests, 72 Redis ACL tests, and ten stress iterations of 102
concurrency-related tests. Rustfmt, strict Clippy, and actionlint passed.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 21:31:07 +08:00
ZyyandCodex fada9bc0bd refactor: clarify module boundaries and naming
Organize backend modules by responsibility, simplify runtime layers, and
keep shared contracts with their owning modules. Split frontend account
APIs and separate shared types from request implementations.

Strengthen architecture checks for module dependencies and test layout.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 20:35:59 +08:00
ZyyandCodex 27b9bbe994 fix: persist peer discovery and quiet expected cookie conflicts
Persist unsupported CPR Link detection for each connection and skip periodic
probes across restarts. Retain manual rediscovery, connection invalidation,
retry throttling, and existing quota access protections.

Log stale response-cookie revision conflicts at debug while retaining
warnings for actual storage failures.

Validation: 29 peer-related tests, 14 cookie tests, 16 architecture checks,
strict Clippy, Rustfmt, and diff checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 20:20:45 +08:00
ZyyandCodex db849c1251 feat(peer-link)!: simplify the CPR Link protocol
Make capability blocks optional and represent key budgets as named windows. Centralize validation and preserve unknown states for missing observations.

BREAKING CHANGE: CPR Link requires schema_version 2; v1 snapshots are no longer accepted.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 19:24:17 +08:00
ZyyandCodex c57af9b0dd fix(openai): strip sentry-trace and baggage from outbound requests
Remove tracing headers and their WebSocket metadata copies through the existing correlation cleanup. Cover cache identity stability and WebSocket reuse after privacy header changes.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 14:21:01 +08:00
ZyyandCodex 641f43b303 chore(release): v3.37.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:24 +08:00
ZyyandCodex 6ce723ce93 fix(accounts): show account names in delete confirmation
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:16 +08:00
ZyyandCodex 2ff43c73da fix(accounts): bound discovery refreshes and align import settings
Share a four-request limit across CPR discovery, manual refreshes, and
periodic refreshes, and avoid loading runtime settings for initial reads.

Reuse account name and notes fields for import and editing, apply custom
names only to single-account imports, and match API usage bars to quota
progress styling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:35:29 +08:00
ZyyandCodex cb79f4d5a5 fix(accounts): start peer discovery after credential imports
Reuse quota routing for initial peer observation and preserve discovery state across ordinary account edits. Hide pending subscription badges and remove redundant provider quota labels without adding frontend polling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:10:18 +08:00
ZyyandCodex 1e57805598 chore(release): v3.37.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:20:01 +08:00
ZyyandCodex aca75490cb fix(accounts): preserve migrated identities and restore cumulative usage
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:19:53 +08:00
ZyyandCodex 6a95869a72 docs: fix README links and add UI playground
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 01:59:55 +08:00
664 changed files with 27240 additions and 9025 deletions
+12 -4
View File
@@ -5,6 +5,13 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
# 开发指南
## 首要写作要求:言简意赅
所有面向人的文字都按此要求编写和审查,包括界面、通知、后端错误、任务状态、命令行、日志、注释、文档和交付说明
- 先写读者需要知道的事实或动作。删掉一句不影响理解、判断或操作,就删;标题、正文和通知不重复同一信息
- 保留会改变决定的条件、权限、后果和恢复步骤。错误通知只说已知问题,有明确办法才补一句;后端返回的提示在后端改,前端不重复解释。上游原文、用户内容和诊断事实保持原样
## 先确定领域与必读规范
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
@@ -13,7 +20,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
| --- | --- |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),局部修改同样按影响范围检查组件复用、文案、Token、样式、视觉状态和运行证据 |
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
@@ -27,7 +34,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
## 参考仓库的优先级
@@ -38,8 +45,9 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
6. 按授权完成提交与交付;提交前按[提交约定](../../../CONTRIBUTING.md#项目约定)准备提交信息与实际共同作者署名,提交后核对实际提交消息,PR 的 AI 披露不能替代提交署名
## 测试维护
@@ -4,7 +4,7 @@
## 领域必读
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节,局部修改同样读取。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
## 按改动补齐规范
@@ -21,9 +21,15 @@
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
## 验收范围与收尾
验证前按[界面验证](../../../../CONTRIBUTING.md#界面验证)明确本次受影响的用户路径、状态和通过条件。局部修改围绕受影响区域及相关操作验收;主题、窗口尺寸和空/加载/错误状态仅在受影响时补充,共用主题或基础组件变更按主题规范扩大范围
通过后按[验证停止条件](../../../../CONTRIBUTING.md#验证)收尾;只有新改动、失败或未解疑点时补验相关部分。补交截图或 PR 材料时复用仍有效的运行证据
## 交付前
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
对照最终差异和实际页面核对适用项,不以读取过规范代替检查:
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
+2
View File
@@ -15,6 +15,8 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
为本仓库拟写或更新 PR 标题时,完整标题使用英文,沿用提交信息的 Conventional Commits 格式,例如 `fix(store): preserve client key ledgers after deletion`
涉及上游行为的判断按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行
## 按模式读取
+2 -1
View File
@@ -18,8 +18,9 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 备版只更新源码的构建版本;通道快照是版本 Release 的 `version.yaml` 附件,验收时核对固定预发行页 `update-index` 指向的公开快照
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
@@ -27,7 +27,7 @@
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
发布任务应将该基线的最高公开实验版写入版本 Release 通道快照的 `published[大版本].exp[基线]`,不推进普通通道。
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
@@ -20,7 +20,7 @@
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
@@ -31,7 +31,7 @@ release/publish "$release_version"
```
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只读预检使用 `release/publish --check "$release_version"`。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
@@ -43,7 +43,10 @@ release/publish "$release_version"
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 回读固定预发行页 `update-index`(不是 GitHub Latest),按正文 JSON 的 `current` 下载对应 `v<current>` 的 `version.yaml`。核对快照版本及 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选或丢失其他发行线
- 只有指针写入失败时,在既有授权内执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务与发布共用锁,只核对已公开版本的附件和待发布快照摘要,不重新打包、改版本 tag 或写 main。完成后回读指针与附件;待发布记录不能当作成功。索引附件最多 128 KiB
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -5,7 +5,7 @@
## 基线与版本
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分源码构建版本和 `update-index` 指向的发行附件中的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
@@ -26,6 +26,9 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
+3
View File
@@ -0,0 +1,3 @@
# SQLx migration checksums are byte-sensitive; keep migration inputs portable across hosts.
backend/migrations/*.sql text eol=lf
backend/migrations/.frozen-sha256 text eol=lf
@@ -139,7 +139,6 @@ body:
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
@@ -108,7 +108,6 @@ body:
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
+76
View File
@@ -0,0 +1,76 @@
name: Build binary
description: Build a native release binary with shared dependency caching and timings
inputs:
rust:
description: Pinned Rust toolchain
required: true
target:
description: Rust target triple
required: true
platform:
description: Release platform name, such as linux-amd64
required: true
binary:
description: Executable filename
default: codex-proxy-rs
rust-image:
description: Pinned Linux builder image; empty for native builds
default: ''
runs:
using: composite
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ inputs.rust }}
targets: ${{ inputs.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: release-${{ inputs.target }}-${{ inputs.rust-image }}
cache-bin: false
# 只缓存依赖,避免随版本变化的 workspace 产物挤占缓存
save-if: ${{ github.event_name != 'pull_request' }}
- name: Build binary
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ inputs.target }}
BUILD_PLATFORM: ${{ inputs.platform }}
BUILD_BINARY: ${{ inputs.binary }}
RUST_IMAGE: ${{ inputs.rust-image }}
run: |
set -euo pipefail
if [ -n "$RUST_IMAGE" ]; then
mkdir -p "$HOME/.cargo"
docker run --rm --user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo -e HOME=/tmp \
-e CPR_VERSION -e CPR_GIT_SHA -e CPR_BUILD_TIME -e CPR_BUILD_TYPE \
-v "$HOME/.cargo:/cargo" -v "$PWD/..:/workspace" \
-w /workspace/backend "$RUST_IMAGE" \
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
else
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
fi
"target/$BUILD_TARGET/release/$BUILD_BINARY" --help
"target/$BUILD_TARGET/release/$BUILD_BINARY" --version
mkdir -p "../dist/binaries/$BUILD_PLATFORM"
cp "target/$BUILD_TARGET/release/$BUILD_BINARY" "../dist/binaries/$BUILD_PLATFORM/$BUILD_BINARY"
- name: Upload build timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: build-timings-${{ inputs.platform }}
path: backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
+1 -1
View File
@@ -43,4 +43,4 @@ runs:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
retention-days: 7
-27
View File
@@ -1,27 +0,0 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
ARG CACHE_STAMP
RUN --mount=type=bind,target=/cache \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& cp -a /cache/registry/. /usr/local/cargo/registry/ \
&& cp -a /cache/git/. /usr/local/cargo/git/ \
&& cp -a /cache/target/. /app/backend/target/
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS collect
ARG CACHE_STAMP
# 一次导出三个 mount,避免逐目录创建镜像、载入 Docker 再复制文件。
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& mkdir -p /cache/registry /cache/git /cache/target \
&& cp -a /usr/local/cargo/registry/. /cache/registry/ \
&& cp -a /usr/local/cargo/git/. /cache/git/ \
&& cp -a /app/backend/target/. /cache/target/
FROM scratch AS snapshot
COPY --from=collect /cache/ /
-31
View File
@@ -1,31 +0,0 @@
name: Cargo cache mounts
description: Restore or export a validated Cargo snapshot for a BuildKit builder
inputs:
operation:
description: Cache operation, restore or snapshot
required: true
builder:
description: Buildx builder name
required: true
path:
description: Cargo snapshot directory on the runner
required: true
architecture:
description: Cargo target directory architecture, amd64 or arm64
required: true
outputs:
usable:
description: Whether the restored or exported snapshot contains Cargo dependencies
value: ${{ steps.cache.outputs.usable }}
runs:
using: composite
steps:
- id: cache
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
OPERATION: ${{ inputs.operation }}
BUILDER: ${{ inputs.builder }}
CACHE_PATH: ${{ inputs.path }}
ARCHITECTURE: ${{ inputs.architecture }}
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 4 ]; then
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
exit 2
fi
operation="$1"
builder="$2"
directory="$(realpath -m "$3")"
architecture="$4"
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
usable() {
local root="$1" path crates=false libraries=false
# 目录或 buildstamp 存在不代表编译发生过,必须同时有下载包与目标依赖。
for path in "$root"/registry/cache/*/*.crate; do
if [ -f "$path" ] && [ -s "$path" ]; then crates=true; break; fi
done
for path in "$root"/target/"$architecture"/release/deps/*.rlib; do
if [ -f "$path" ] && [ -s "$path" ]; then libraries=true; break; fi
done
[ "$crates" = true ] && [ "$libraries" = true ]
}
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
# mount 的内容不参与层指纹,用时间戳重跑复制;--no-cache 会重置 cache mount。
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
valid=false
if [ "$operation" = restore ]; then
if usable "$directory"; then
mkdir -p "$directory"/{registry,git,target}
"${command[@]}" --output type=cacheonly "$directory"
valid=true
else
echo "No usable Cargo snapshot; cached image layers may still be available"
fi
else
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
mkdir -p "$(dirname "$directory")"
temporary="$(mktemp -d "${directory}.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
mkdir "$temporary"/{context,snapshot}
# tar 流避开 local exporter 的逐文件同步阻塞,直接解包以免额外落盘整份归档。
# https://github.com/moby/buildkit/issues/2950
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
| tar -xf - -C "$temporary/snapshot"
if usable "$temporary/snapshot"; then
rm -rf "$directory"
mv "$temporary/snapshot" "$directory"
valid=true
fi
fi
echo "Cargo snapshot for $architecture: usable=$valid"
if [ "$valid" = true ]; then du -sh "$directory"; fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
-1
View File
@@ -70,7 +70,6 @@ updates:
- package-ecosystem: docker
directories:
- /deploy
- /.github/actions/cargo-cache
labels: []
schedule:
interval: weekly
+3 -3
View File
@@ -1,10 +1,10 @@
require 'digest'
require 'fileutils'
require 'json'
require_relative 'release-metadata'
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
module ReleasePackage
APP_NAME = 'codex-proxy-rs'
APP_NAME = ReleaseMetadata::APP_NAME
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
def self.install_manifest(directory)
@@ -47,7 +47,7 @@ module ReleasePackage
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
FileUtils.cp(config, "#{root}/deploy")
install_manifest("#{root}/plugins/official")
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
archive = File.join(release_dir, ReleaseMetadata.archive_name(ENV.fetch('VERSION'), platform))
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
end
+170
View File
@@ -0,0 +1,170 @@
require 'base64'
require 'digest'
require 'uri'
require_relative 'release-metadata'
# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要
module PublishedChannels
POINTER = 'update-index'
LIMIT = 128 * 1024
def self.advance(document, version)
version = ReleaseMetadata.version(version)
major, = version.split('.', 2)
core, stage = version.split('+', 2).first.split('-', 2)
channels = (document.fetch('published')[major] ||= {})
if stage&.start_with?('exp.')
channels = (channels['exp'] ||= {})
keys = [core]
else
stages = %w[stable rc beta alpha]
keys = stages.drop(stages.index(stage ? stage.split('.').first : 'stable'))
end
incoming = ReleaseMetadata.sort_key(version)
changed = false
keys.each do |key|
current = channels[key]
if current
comparison = incoming <=> ReleaseMetadata.sort_key(current)
raise "Conflicting release identities: #{current}, #{version}" if comparison.zero? && current != version
next unless comparison.positive?
end
channels[key] = version
changed = true
end
changed
end
class Publisher
def api(endpoint, method: 'GET', data: nil, optional: false)
args = ['gh', 'api', endpoint, '--method', method]
args.concat(%w[--input -]) if data
out, error, status = Open3.capture3(*args, stdin_data: data ? JSON.generate(data) : '')
return nil if optional && !status.success? && error.include?('(HTTP 404)')
raise "Cannot access #{endpoint}: #{error.strip}" unless status.success?
JSON.parse(out)
end
def initialize(repository)
@repository = repository
@base = "repos/#{repository}"
release = api("#{@base}/releases/tags/#{POINTER}", optional: true)
if release
raise 'Invalid update-index release' unless release.fetch('tag_name') == POINTER &&
release.fetch('prerelease') && !release.fetch('draft')
@id = release.fetch('id')
@state = JSON.parse(release.fetch('body'))
@state.fetch('current')
else
@state = { 'current' => nil }
end
end
def save(current:, pending: nil)
state = { 'current' => current }
state['pending'] = pending if pending
body = { body: JSON.generate(state), make_latest: 'false' }
if @id
api("#{@base}/releases/#{@id}", method: 'PATCH', data: body)
else
release = api("#{@base}/releases", method: 'POST', data: body.merge(
tag_name: POINTER, name: '更新通道索引', target_commitish: 'main', prerelease: true
))
@id = release.fetch('id')
end
@state = state
end
def release(version)
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
end
def verify_assets(version, release, index:)
tag = "v#{version}"
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
data = api("#{@base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}")
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
raise "Missing platforms for #{tag}" if platforms.empty?
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
expected.concat(%w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json])
expected << 'version.yaml' if index
assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.to_h { |a| [a.fetch('name'), a] }
missing = expected - assets.keys
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
assets
end
def download(version)
content, error, status = Open3.capture3('gh', 'release', 'download', "v#{version}", '--repo', @repository,
'--pattern', 'version.yaml', '--output', '-')
raise "Cannot download v#{version} snapshot: #{error.strip}" unless status.success?
content
end
def read_snapshot(version, asset)
raise 'Invalid version.yaml size' unless (1..LIMIT).cover?(asset.fetch('size'))
content = download(version)
digest = Digest::SHA256.hexdigest(content)
raise "Snapshot size or digest differs for v#{version}" unless content.bytesize == asset.fetch('size') && asset.fetch('digest') == "sha256:#{digest}"
doc = YAML.safe_load(content)
raise 'Invalid channel snapshot' unless doc.fetch('version') == version && doc.fetch('published').is_a?(Hash)
[doc, digest]
end
def previous
if (current = @state.fetch('current'))
assets = verify_assets(current, release(current), index: true)
return read_snapshot(current, assets.fetch('version.yaml')).first
end
# 首次接入时从实际公开的稳定版引导;没有历史 Release 的仓库从空表开始
doc = { 'published' => {} }
latest = api("#{@base}/releases/latest", optional: true)
if latest
version = ReleaseMetadata.version(latest.fetch('tag_name'))
verify_assets(version, latest, index: false)
PublishedChannels.advance(doc, version)
end
doc
end
# 调用方持有同一仓库的发布锁;快照必须在取锁后生成,不能在并行构建中预先合并
def prepare(version, directory)
if (pending = @state.dig('pending', 'version')) && (old = release(pending)) && !old.fetch('draft')
finish(pending)
end
existing = release(version)
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
doc = previous
doc['version'] = version
PublishedChannels.advance(doc, version)
content = YAML.dump(doc)
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
digest = Digest::SHA256.hexdigest(content)
File.write(File.join(directory, 'version.yaml'), content)
File.open(File.join(directory, 'checksums.txt'), 'a') { |file| file.puts "#{digest} version.yaml" }
save(current: @state.fetch('current'), pending: { 'version' => version, 'sha256' => digest })
end
def finish(version)
assets = verify_assets(version, release(version), index: true)
_, digest = read_snapshot(version, assets.fetch('version.yaml'))
return if @state.fetch('current') == version
unless @state['pending'] == { 'version' => version, 'sha256' => digest }
raise "v#{version} is not the reserved snapshot; refusing to overwrite another publication"
end
save(current: version)
end
end
end
if $PROGRAM_NAME == __FILE__
mode, raw = ARGV
version = ReleaseMetadata.version(raw || '')
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
case mode
when 'prepare' then publisher.prepare(version, 'dist/release')
when 'finish' then publisher.finish(version)
else abort 'usage: published-channels.rb prepare|finish VERSION'
end
end
+5
View File
@@ -62,6 +62,10 @@ module ReleaseMetadata
entries
end
def self.archive_name(version, platform)
"#{APP_NAME}_#{version}_#{platform.fetch('os')}_#{platform.fetch('arch')}.#{platform.fetch('archive')}"
end
def self.outputs(tag)
value = version(YAML.load_file('release/version.yaml').fetch('version'))
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
@@ -82,6 +86,7 @@ module ReleaseMetadata
'platform_matrix' => JSON.generate('include' => entries),
'docker_matrix' => JSON.generate('include' => docker),
'non_docker_matrix' => JSON.generate('include' => native),
'windows_matrix' => JSON.generate('include' => entries.select { |entry| entry['os'] == 'windows' }),
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
'previous_tag' => previous,
'prerelease' => prerelease?(value),
+44
View File
@@ -0,0 +1,44 @@
require 'json'
require 'open3'
# 仅复用同一源码的主分支 CI,缺少证据时完整检查发行提交
module ReleaseQuality
def self.capture(*command)
output, error, status = Open3.capture3(*command)
raise "#{command.first} failed: #{error.strip}" unless status.success?
output
end
def self.evidence(repository)
parents = capture('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
return nil unless parents.length == 2
parent = parents.last
changes = capture('git', 'diff', '--name-only', '-z', parent, 'HEAD').split("\0")
return nil if changes.empty? || (changes - %w[release/version.yaml release/notes.md]).any?
# 按最新一次运行判断,失败或仍在执行时不回退到旧的成功记录
endpoint = "repos/#{repository}/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=#{parent}&per_page=1"
run = JSON.parse(capture('gh', 'api', endpoint)).fetch('workflow_runs').first
return nil unless run && run.fetch('head_sha') == parent && run.fetch('conclusion') == 'success'
return nil unless run.fetch('head_repository').fetch('full_name') == repository
rows = capture('gh', 'api', '--paginate', "repos/#{repository}/actions/runs/#{run.fetch('id')}/jobs?per_page=100", '--jq', '.jobs[] | @json')
job = rows.lines.map { |line| JSON.parse(line) }.find { |entry| entry.fetch('name') == 'quality / backend' }
return nil unless job && job.fetch('conclusion') == 'success'
job.fetch('html_url')
end
end
if $PROGRAM_NAME == __FILE__
begin
evidence = ReleaseQuality.evidence(ENV.fetch('GITHUB_REPOSITORY'))
rescue StandardError => error
warn "CI evidence unavailable; running full release checks: #{error.message}"
evidence = nil
end
puts "run_backend=#{evidence.nil?}"
if ENV['GITHUB_STEP_SUMMARY']
message = evidence ? "后端质量检查复用版本提交父提交的 [CI 结果](#{evidence});仅版本与发布说明不同" : '未找到可复用的后端 CI 结果,完整检查发行提交'
File.open(ENV.fetch('GITHUB_STEP_SUMMARY'), 'a') { |file| file.puts(message) }
end
end
@@ -0,0 +1,115 @@
require 'tmpdir'
require_relative '../published-channels'
# 测试只替换 GitHub 读写边界,执行真实的发布状态转换
class PublishedChannelsTest
def assert_equal(expected, actual)
raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual
end
def assert_rejected
yield
rescue RuntimeError
return
else
raise 'expected publication to be rejected'
end
def initialize
@pointer = nil
@releases = {}
@files = {}
@calls = []
@platforms = [{ 'os' => 'linux', 'arch' => 'amd64', 'archive' => 'tar.gz' }]
end
def api(endpoint, method: 'GET', data: nil, optional: false)
path = endpoint.delete_prefix('repos/test/repo/')
@calls << [method, path]
case [method, path]
when ['GET', 'releases/tags/update-index'] then @pointer
when ['POST', 'releases']
raise 'duplicate pointer' if @pointer
@pointer = data.transform_keys(&:to_s).merge('id' => 12, 'draft' => false)
when ['PATCH', 'releases/12']
@pointer.merge!(data.transform_keys(&:to_s))
when ['GET', 'releases/latest'] then @releases['3.41.2']
else
return @releases[path.delete_prefix('releases/tags/v')] if method == 'GET' && path.start_with?('releases/tags/v')
return { 'content' => Base64.encode64(YAML.dump({ 'platforms' => @platforms })) } if path.start_with?('contents/release/platforms.yaml?ref=v')
raise "unexpected #{method} #{path}"
end
end
def publish_assets(version, content = nil)
names = %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json]
names << ReleaseMetadata.archive_name(version, @platforms.first)
@files[version] = content if content
assets = names.map { |name| { 'name' => name, 'size' => 100, 'state' => 'uploaded' } }
assets << { 'name' => 'version.yaml', 'size' => content.bytesize, 'digest' => "sha256:#{Digest::SHA256.hexdigest(content)}", 'state' => 'uploaded' } if content
@releases[version] = { 'tag_name' => "v#{version}", 'draft' => false, 'prerelease' => ReleaseMetadata.prerelease?(version), 'assets' => assets }
end
def publisher
github = self
files = @files
Class.new(PublishedChannels::Publisher) do
define_method(:api) { |endpoint, **args| github.api(endpoint, **args) }
define_method(:download) { |version| files.fetch(version) }
end.new('test/repo')
end
def prepare(version)
Dir.mktmpdir do |dir|
File.write(File.join(dir, 'checksums.txt'), '')
publisher.prepare(version, dir)
content = File.read(File.join(dir, 'version.yaml'))
assert_equal "#{Digest::SHA256.hexdigest(content)} version.yaml\n", File.read(File.join(dir, 'checksums.txt'))
content
end
end
def current = JSON.parse(@pointer.fetch('body')).fetch('current')
def test_interrupted_publication_recovers_without_losing_another_channel
publish_assets('3.41.2')
stable = prepare('3.42.0')
assert_equal nil, current
assert_rejected { publisher.finish('3.42.0') }
publish_assets('3.42.0', stable)
# 模拟公开后更新指针中断,下一次发布在同一把锁内补写,再合并自己的通道
beta = prepare('3.43.0-beta.1')
assert_equal '3.42.0', current
assert_equal({ 'stable' => '3.42.0', 'rc' => '3.42.0', 'beta' => '3.43.0-beta.1', 'alpha' => '3.43.0-beta.1' },
YAML.safe_load(beta).dig('published', '3'))
publish_assets('3.43.0-beta.1', beta)
publisher.finish('3.43.0-beta.1')
publisher.finish('3.43.0-beta.1')
assert_rejected { publisher.finish('3.42.0') }
assert_equal '3.43.0-beta.1', current
experiment = YAML.safe_load(prepare('3.44.0-exp.1')).dig('published', '3')
assert_equal({ '3.44.0' => '3.44.0-exp.1' }, experiment.fetch('exp'))
assert_equal '3.43.0-beta.1', experiment.fetch('beta')
assert_equal false, @calls.include?(['GET', 'releases'])
end
def test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
publish_assets('3.42.0', prepare('3.42.0'))
publisher.finish('3.42.0')
beta = prepare('3.43.0-beta.1')
publish_assets('3.43.0-beta.1', beta)
release = @releases.fetch('3.43.0-beta.1')
release['draft'] = true
assert_rejected { publisher.finish('3.43.0-beta.1') }
release['draft'] = false
missing = release.fetch('assets').shift
assert_rejected { publisher.finish('3.43.0-beta.1') }
release.fetch('assets') << missing
publish_assets('3.43.0-beta.1', beta.sub('3.42.0', '3.10.0'))
assert_rejected { publisher.finish('3.43.0-beta.1') }
assert_equal '3.42.0', current
end
end
PublishedChannelsTest.new.test_interrupted_publication_recovers_without_losing_another_channel
PublishedChannelsTest.new.test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
puts 'Published channel recovery and integrity checks passed'
+84 -39
View File
@@ -7,13 +7,58 @@ on:
description: Exact commit SHA to build and validate
required: true
type: string
source_build:
description: Validate the complete Docker source build
type: boolean
default: true
permissions:
contents: read
security-events: write
jobs:
frontend:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- uses: ./.github/actions/build-frontend
with:
upload-artifact: 'true'
binary:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/build-binary
with:
rust: ${{ steps.toolchains.outputs.rust }}
target: x86_64-unknown-linux-gnu
platform: linux-amd64
rust-image: ${{ steps.toolchains.outputs.rust_image }}
env:
CPR_GIT_SHA: ${{ inputs.ref }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-linux-amd64
path: dist/binaries/linux-amd64/
if-no-files-found: error
retention-days: 7
container:
needs: [frontend, binary]
if: always() && !cancelled()
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
@@ -21,6 +66,14 @@ jobs:
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
# 上游失败必须传递给已有的容器检查,不能让必需状态变成 skipped
- name: Require prebuilt inputs
if: ${{ !inputs.source_build }}
env:
FRONTEND_RESULT: ${{ needs.frontend.result }}
BINARY_RESULT: ${{ needs.binary.result }}
run: test "$FRONTEND_RESULT" = success && test "$BINARY_RESULT" = success
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -30,39 +83,51 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Restore Cargo snapshot
id: cargo-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Download frontend
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: ${{ runner.temp }}/container-cargo
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
key: container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
restore-keys: |
container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
name: frontend-dist
path: dist/docker/web/dist
- name: Restore Cargo mounts to BuildKit
id: cargo-restore
uses: ./.github/actions/cargo-cache
- name: Download binary
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
operation: restore
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
name: codex-proxy-rs-linux-amd64
path: dist/docker-artifacts/codex-proxy-rs-linux-amd64
- name: Prepare prebuilt image
if: ${{ !inputs.source_build }}
env:
MATRIX_OS: linux
MATRIX_ARCH: amd64
MATRIX_BINARY: codex-proxy-rs
run: |
ruby -r './.github/scripts/release-metadata' -e '
ReleaseMetadata.seal_manifest(
"version" => ReleaseMetadata.version(YAML.load_file("release/version.yaml").fetch("version")),
"git_sha" => ReleaseMetadata.git("rev-parse", "HEAD")
)'
ruby .github/scripts/package-release.rb image
- name: Build runtime image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
target: runtime
target: ${{ inputs.source_build && 'runtime' || 'runtime-prebuilt' }}
build-args: |
CPR_PREBUILT_DIR=dist/docker
CPR_GIT_SHA=${{ inputs.ref }}
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
runtime-prebuilt
cache-from: type=gha,scope=runtime-${{ inputs.source_build && 'source' || 'prebuilt' }}
cache-to: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && format('type=gha,mode=max,scope=runtime-{0}', inputs.source_build && 'source' || 'prebuilt') || '' }}
- name: Verify runtime image
uses: ./.github/actions/verify-container
@@ -70,23 +135,3 @@ jobs:
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
- name: Export Cargo snapshot
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
id: cargo-snapshot
uses: ./.github/actions/cargo-cache
with:
operation: snapshot
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Save Cargo snapshot
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
+22 -6
View File
@@ -28,6 +28,7 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
CARGO_PROFILE_DEV_DEBUG: '1'
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
@@ -111,7 +112,9 @@ jobs:
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
shared-key: quality
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
- name: Rust format
working-directory: backend
@@ -122,7 +125,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -131,7 +134,7 @@ jobs:
working-directory: backend
run: |
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
cargo nextest list --test main --locked --timings --list-type binaries-only --message-format json \
> "$RUNNER_TEMP/nextest-binaries.json"
- name: Configure PostgreSQL and Redis test authentication
@@ -173,10 +176,22 @@ jobs:
- name: Rust tests
working-directory: backend
run: |
cargo nextest run \
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail
--test-threads 8 --no-tests fail --profile ci
- name: Upload test and build reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: backend-quality-reports
path: |
backend/target/nextest/ci/junit.xml
backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
- name: Store tests with Redis ACL authentication
working-directory: backend
@@ -195,7 +210,7 @@ jobs:
cargo nextest run \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
--test-threads 8 --no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
frontend:
if: inputs.run_frontend
@@ -243,4 +258,5 @@ jobs:
for script in .github/**/*.sh; do bash -n "$script"; done
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby .github/scripts/tests/published-channels-test.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
+25 -2
View File
@@ -29,6 +29,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source_container: ${{ steps.filter.outputs.source_container }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -67,10 +68,30 @@ jobs:
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/verify-container/**'
- '.github/actions/cargo-cache/**'
- '.github/actions/build-binary/**'
- '.github/actions/build-frontend/**'
- '.github/scripts/toolchains.rb'
- '.github/scripts/package-release.rb'
- '.github/scripts/release-metadata.rb'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- '!**/*.md'
source_container:
- 'deploy/Dockerfile'
- 'deploy/Dockerfile.dockerignore'
- '.dockerignore'
- 'backend/**/Cargo.toml'
- 'backend/Cargo.lock'
- 'backend/rust-toolchain.toml'
- 'backend/**/build.rs'
- 'backend/.cargo/**'
- 'frontend/package.json'
- 'frontend/pnpm-lock.yaml'
- 'frontend/pnpm-workspace.yaml'
- 'frontend/.npmrc'
- 'frontend/patches/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
quality:
needs: changes
@@ -78,7 +99,8 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
run_backend: ${{ needs.changes.outputs.backend == 'true' }}
run_frontend: ${{ needs.changes.outputs.frontend == 'true' }}
# 预构建容器复用同一次前端检查产物,源码 Docker 构建仍独立验证
run_frontend: ${{ needs.changes.outputs.frontend == 'true' && (needs.changes.outputs.container != 'true' || needs.changes.outputs.source_container == 'true') }}
container:
needs: changes
@@ -89,3 +111,4 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source_build: ${{ needs.changes.outputs.source_container == 'true' }}
+129 -68
View File
@@ -7,6 +7,10 @@ on:
description: 'Tag to release, for example v0.1.0'
required: true
type: string
index_only:
description: 'Recover the index for an already public release without rebuilding'
type: boolean
default: false
permissions:
contents: read
@@ -21,8 +25,12 @@ env:
jobs:
release-info:
if: ${{ !inputs.index_only }}
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: read
actions: read
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
@@ -32,12 +40,14 @@ jobs:
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
windows_matrix: ${{ steps.version.outputs.windows_matrix }}
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
rust: ${{ steps.toolchains.outputs.rust }}
rust_image: ${{ steps.toolchains.outputs.rust_image }}
run_backend: ${{ steps.quality.outputs.run_backend }}
steps:
- name: Checkout
@@ -56,21 +66,88 @@ jobs:
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Check reusable backend CI evidence
id: quality
env:
GH_TOKEN: ${{ github.token }}
run: ruby .github/scripts/release-quality.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
if-no-files-found: error
retention-days: 1
retention-days: 7
quality:
needs: [release-info]
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
run_backend: ${{ needs.release-info.outputs.run_backend == 'true' }}
run_frontend: false
windows:
needs: release-info
name: windows-tests (${{ matrix.arch }})
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.windows_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
RUST_MIN_STACK: '16777216'
RUSTFLAGS: '-C target-feature=+crt-static'
CARGO_PROFILE_DEV_DEBUG: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: platform-tests-${{ matrix.target }}
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-nextest@0.9.143
fallback: none
- name: Verify Windows platform behavior
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ matrix.target }}
# 同一次构建统一两个 package 的依赖特性,保留原有平台用例和静态运行库
run: |
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" --locked --target "$BUILD_TARGET" \
-p gateway-admin -p gateway-host --test main --no-tests fail --profile ci \
-E '(package(=gateway-admin) & test(/^use_case::plugins::official::/)) | (package(=gateway-host) & test(/^system_update::/))'
- name: Upload test report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-tests-${{ matrix.arch }}
path: backend/target/nextest/ci/junit.xml
if-no-files-found: ignore
retention-days: 7
security:
needs: release-info
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
with:
ref: ${{ needs.release-info.outputs.git_sha }}
frontend:
needs: release-info
runs-on: ubuntu-26.04
@@ -103,40 +180,15 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
mkdir -p "$HOME/.cargo" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
docker run --rm \
--platform "${{ matrix.dockerPlatform }}" \
--user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo \
-e HOME=/tmp \
-e CPR_VERSION="${CPR_VERSION}" \
-e CPR_GIT_SHA="${CPR_GIT_SHA}" \
-e CPR_BUILD_TIME="${CPR_BUILD_TIME}" \
-e CPR_BUILD_TYPE="${CPR_BUILD_TYPE}" \
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
"${RUST_IMAGE}" \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
rust-image: ${{ needs.release-info.outputs.rust_image }}
env:
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
@@ -148,7 +200,7 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
build-binary:
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
@@ -165,30 +217,13 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
env:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
@@ -201,7 +236,7 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
@@ -209,8 +244,6 @@ jobs:
timeout-minutes: 20
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Checkout
@@ -244,18 +277,13 @@ jobs:
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
run: ruby .github/scripts/package-release.rb archives
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/release/*
- name: Upload release assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: dist/release
if-no-files-found: error
retention-days: 1
retention-days: 7
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
compression-level: 0
@@ -367,14 +395,14 @@ jobs:
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
path: dist/digests/*.txt
if-no-files-found: error
retention-days: 1
retention-days: 7
publish:
concurrency:
group: release-publication
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
needs: [release-info, quality, windows, security, package-assets, build-docker-image]
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
@@ -510,6 +538,17 @@ jobs:
File.write(output, "#{notes}\n\n#{installation}\n")
RUBY
- name: Prepare versioned channel snapshot under the publication lock
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb prepare "$VERSION"
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: dist/release/*
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
@@ -533,3 +572,25 @@ jobs:
DIGEST: ${{ steps.manifest.outputs.digest }}
RELEASE_ID: ${{ steps.release.outputs.id }}
run: ruby .github/scripts/publish-release.rb
- name: Advance update-index to the verified snapshot
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-info.outputs.version }}
run: ruby .github/scripts/published-channels.rb finish "$VERSION"
recover-index:
if: inputs.index_only
runs-on: ubuntu-26.04
concurrency:
group: release-publication
queue: max
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify public assets and finish pending publication
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: ruby .github/scripts/published-channels.rb finish "$TAG"
+10 -4
View File
@@ -1,6 +1,12 @@
name: Security
on:
workflow_call:
inputs:
ref:
description: Exact commit SHA to audit
required: true
type: string
push:
branches:
- main
@@ -14,7 +20,7 @@ permissions:
contents: read
concurrency:
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
group: security-${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -25,10 +31,10 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -44,7 +50,7 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+9 -2
View File
@@ -12,9 +12,9 @@
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
@@ -23,6 +23,13 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
### 任务延续与收尾
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
<!-- CODEGRAPH_START -->
## CodeGraph
+6 -1
View File
@@ -126,7 +126,8 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并
- AI 参与的提交按实际助手添加 `Co-authored-by: 名称 <邮箱>`,名称与邮箱采用该助手已知的署名身份,并保留其他实际共同作者署名;PR 的 AI 使用披露不替代提交署名
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
@@ -232,6 +233,10 @@ git diff --check
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
常规容器检查与发行构建共用二进制构建入口,并复用已检查的前端产物;Dockerfile、工具链、依赖或构建配置变化时执行完整源码镜像构建,定期安全检查也覆盖源码构建。两条路径均验证容器运行、安全扫描与关闭行为
发行构建与质量检查并行,Windows 两种架构的插件与系统更新测试独立运行。仅当发行提交只修改版本与发布说明,且其父提交有本仓库主分支成功的后端 CI 时复用该结果;缺少证据则完整重跑。对外发布等待质量、Windows 测试、依赖审计和镜像验证全部成功,构建耗时与测试报告保留 7 天
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
+6 -8
View File
@@ -40,6 +40,8 @@
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
@@ -89,7 +91,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
1. 在「账号」中添加账号,完成授权或导入
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
3. 打开密钥的「使用密钥」,复制客户端配置
3. [连接客户端](#客户端接入)
## 客户端接入
@@ -118,18 +120,14 @@ curl http://127.0.0.1:8080/v1/models \
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](modules/plugins/README.md) |
| 开发共用组件 | [组件库说明](modules/ui/README.md) · [组件参考](modules/ui/playground/components/index.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](https://github.com/zyycn/codex-proxy-plugins/blob/main/README.md) |
| 开发共用组件 | [组件库说明](https://github.com/zyycn/codex-proxy-ui/blob/main/README.md) · [组件参考](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/components/index.md) · [Playground](https://zyycn.github.io/codex-proxy-ui/) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
## 社区
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
问题反馈与功能建议请使用 [GitHub Issues](https://github.com/zyycn/codex-proxy-rs/issues),填写要求见 [贡献说明](CONTRIBUTING.md#提交-issue)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
+48 -44
View File
@@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.155.0"
version = "1.157.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
checksum = "6ea9794c741520d8baec9462bcd0ad12164d4f5f78d008a8da9d6a0b7222caa8"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -322,9 +322,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.6.3"
version = "1.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -387,9 +387,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.2"
version = "0.64.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -408,9 +408,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -432,9 +432,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.65.0"
version = "0.65.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -452,9 +452,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.16.1"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -475,9 +475,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.19.1"
version = "1.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -492,9 +492,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api-macros"
version = "1.1.0"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6"
dependencies = [
"proc-macro2",
"quote",
@@ -503,9 +503,9 @@ dependencies = [
[[package]]
name = "aws-smithy-schema"
version = "1.1.0"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -514,9 +514,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80"
dependencies = [
"base64-simd",
"bytes",
@@ -537,9 +537,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.64.0"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -1081,9 +1081,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.3"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -1834,6 +1834,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"wiremock",
"yaml-rust2",
]
[[package]]
@@ -2485,9 +2486,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.60.0"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08"
dependencies = [
"ahash",
"bytecount",
@@ -2513,18 +2514,18 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba"
dependencies = [
"ahash",
"bytecount",
@@ -2729,9 +2730,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
@@ -3578,9 +3579,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.7.1"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
dependencies = [
"arc-swap",
"arcstr",
@@ -3634,9 +3635,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.60.1"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b"
dependencies = [
"ahash",
"fluent-uri",
@@ -3759,9 +3760,9 @@ dependencies = [
[[package]]
name = "roxmltree"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -4953,9 +4954,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
@@ -5052,9 +5053,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.28.0"
version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5646,16 +5647,19 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.5"
version = "9.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"libc",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
]
[[package]]
+4
View File
@@ -49,6 +49,10 @@ opt-level = 2
[profile.dev.package."miniz_oxide@0.9.1"]
opt-level = 2
# workspace 的 WebSocket deflate 特性会让 flate2 选用 zlib-rs
[profile.dev.package.zlib-rs]
opt-level = 2
[profile.release]
debug = false
strip = "symbols"
@@ -146,6 +146,10 @@ async fn execute(
providers.admin,
);
or_shutdown!(plugin_runtime, services.register_usage_data(&usage_data));
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_usage_queries(&usage_data)
);
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
@@ -160,6 +160,10 @@ pub async fn run() -> Result<(), BootstrapError> {
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_usage_queries(&admin.services().usage_data())
);
let mut plan = store.take_worker_contributions();
plan.push(analytics_worker);
@@ -4,7 +4,7 @@ use std::{path::PathBuf, sync::Arc};
use gateway_admin::ports::provider::ProviderAdminRegistry;
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
use gateway_host::{HostBundle, HostConfig, outbound::HttpClient};
use gateway_store::{StoreBundle, StoreConfig};
use super::BootstrapError;
@@ -33,6 +33,8 @@ impl Environment {
let timezone = host.timezone;
let store = store.with_timezone(timezone);
let plugin_cache = host.runtime_data_dir().join("plugins");
// 插件兼容检查只需本地运行版本,管理端版本查询还会等待远端发行服务
let version = host.system_update.version.clone();
let host = match mode {
Mode::Server => gateway_host::initialize(host).await?,
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
@@ -44,12 +46,6 @@ impl Environment {
Mode::Help => gateway_store::initialize_read_only(store).await?,
};
host.report_startup_ready("Store");
let version = host
.system_operations()
.version()
.await
.map_err(|_| ConfigError::InvalidField("host.version"))?
.version;
let plugin_http = Arc::new(HttpClient::new()?);
Ok(Self {
host,
+2 -3
View File
@@ -1,7 +1,6 @@
//! 网关命令行入口,选择启动模式并创建异步运行时
use std::{
env,
error::Error,
io::{self, Write},
process::ExitCode,
@@ -21,7 +20,7 @@ fn main() -> ExitCode {
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
ensure_rustls_provider();
let mut arguments = env::args_os().skip(1);
let mut arguments = gateway_host::system_update::startup_arguments()?;
let command = arguments
.next()
.map(|value| {
@@ -92,7 +91,7 @@ fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> {
if arguments.next().is_some() {
return Err(invalid_cli("unexpected extra command arguments"));
return Err(invalid_cli("too many arguments"));
}
Ok(())
}
+186 -40
View File
@@ -70,6 +70,7 @@ fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
#[test]
fn core_value_owners_do_not_depend_on_execution_or_routing() {
let root = backend_root().join("crates/gateway-core/src");
let reexports = read_root_reexport_owners(&root);
for relative in super::rust_files(&root) {
let allowed: Option<&[&str]> = match relative.to_str().expect("source path") {
"validation.rs" => Some(&[]),
@@ -96,7 +97,7 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
let Some(allowed) = allowed else { continue };
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
let syntax = syn::parse_file(&source).expect("parse core source");
let mut references = CrateReferences::in_module(&relative);
let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports);
references.visit_file(&syntax);
// 相对路径中的 self 指向当前 owner,不是跨 owner 依赖
references.owners.remove(&references.module_path[0]);
@@ -111,17 +112,24 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
}
#[test]
fn core_owner_paths_form_a_dag() {
let root = backend_root().join("crates/gateway-core/src");
fn domain_owner_paths_form_a_dag() {
for member in ["crates/gateway-core", "crates/gateway-admin"] {
assert_owner_paths_form_a_dag(member);
}
}
fn assert_owner_paths_form_a_dag(member: &str) {
let root = backend_root().join(member).join("src");
let reexports = read_root_reexport_owners(&root);
let mut graph = BTreeMap::<String, BTreeSet<String>>::new();
for relative in super::rust_files(&root) {
if relative == Path::new("lib.rs") {
continue;
}
let mut references = CrateReferences::in_module(&relative);
let mut references = CrateReferences::in_module(&relative).with_root_reexports(&reexports);
let owner = references.module_path[0].clone();
let source = fs::read_to_string(root.join(&relative)).expect("read core source");
references.visit_file(&syn::parse_file(&source).expect("parse core source"));
let source = fs::read_to_string(root.join(&relative)).expect("read owner source");
references.visit_file(&syn::parse_file(&source).expect("parse owner source"));
references.owners.remove(&owner);
graph.entry(owner).or_default().extend(references.owners);
}
@@ -131,7 +139,7 @@ fn core_owner_paths_form_a_dag() {
while let Some(dependency) = pending.pop() {
assert_ne!(
&dependency, owner,
"Core owner dependency cycle reaches {owner}"
"{member} owner dependency cycle reaches {owner}"
);
if visited.insert(dependency.clone())
&& let Some(next) = graph.get(&dependency)
@@ -143,7 +151,44 @@ fn core_owner_paths_form_a_dag() {
}
#[test]
fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
fn shared_owners_do_not_depend_on_their_callers() {
let boundaries: &[(&str, &str, &[&str])] = &[
("crates/providers/xai", "transport", &["credential"]),
("crates/providers/openai", "transport", &["provider"]),
("crates/gateway-api", "time", &["admin"]),
(
"crates/gateway-api",
"client_error",
&["admin", "openai", "middleware"],
),
("crates/gateway-api", "middleware", &["openai"]),
("crates/gateway-host", "system_update", &["config"]),
("crates/gateway-plugin/runtime", "package", &["callback"]),
];
for &(member, owner, forbidden) in boundaries {
let root = backend_root().join(member).join("src");
let reexports = read_root_reexport_owners(&root);
for relative in super::rust_files(&root) {
let mut references =
CrateReferences::in_module(&relative).with_root_reexports(&reexports);
if references.module_path.first().map(String::as_str) != Some(owner) {
continue;
}
let source = fs::read_to_string(root.join(&relative)).expect("read owner source");
references.visit_file(&syn::parse_file(&source).expect("parse owner source"));
for dependency in forbidden {
assert!(
!references.owners.contains(*dependency),
"{member}/src/{} must not depend on crate::{dependency}",
relative.display()
);
}
}
}
}
#[test]
fn owner_reference_scan_resolves_groups_reexports_and_relative_paths() {
let syntax = syn::parse_file(
r#"
pub use crate::{engine::AttemptContext, settings::SettingsValues as Values};
@@ -167,13 +212,86 @@ fn core_reference_scan_resolves_groups_reexports_and_relative_paths() {
);
}
#[derive(Default)]
struct CrateReferences {
owners: BTreeSet<String>,
module_path: Vec<String>,
#[test]
fn owner_reference_scan_resolves_crate_root_reexports() {
let root = syn::parse_file(
r#"
mod provider;
mod transport;
mod secret;
pub use provider::{OFFICIAL_URL, Options as ProviderOptions};
pub use self::transport::{self as network, Request};
pub use crate::secret::SecretValue;
pub use external::Unrelated;
"#,
)
.expect("parse root reexport fixture");
let syntax = syn::parse_file(
r#"
use crate::{OFFICIAL_URL, ProviderOptions, network::Client};
use super::super::SecretValue;
fn request() -> crate::Request { unimplemented!() }
"#,
)
.expect("parse reexport consumer fixture");
let reexports = root_reexport_owners(&root);
assert!(!reexports.contains_key("Unrelated"));
let mut references = CrateReferences::in_module(Path::new("consumer/request.rs"))
.with_root_reexports(&reexports);
references.visit_file(&syntax);
assert_eq!(
references.owners,
BTreeSet::from([
"provider".to_owned(),
"transport".to_owned(),
"secret".to_owned(),
])
);
}
impl<'ast> Visit<'ast> for CrateReferences {
fn read_root_reexport_owners(root: &Path) -> BTreeMap<String, String> {
let source = fs::read_to_string(root.join("lib.rs")).expect("read crate root");
root_reexport_owners(&syn::parse_file(&source).expect("parse crate root"))
}
// 只解析根模块的显式重导出,不推断 glob、宏展开或完整 Rust 名称解析
fn root_reexport_owners(syntax: &syn::File) -> BTreeMap<String, String> {
let modules = syntax
.items
.iter()
.filter_map(|item| match item {
Item::Mod(module) => Some(module.ident.to_string()),
_ => None,
})
.collect::<BTreeSet<_>>();
let mut reexports = BTreeMap::new();
for item in &syntax.items {
if let Item::Use(item) = item
&& !matches!(item.vis, syn::Visibility::Inherited)
{
visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, name| {
let owner = path
.iter()
.find(|segment| !matches!(segment.as_str(), "crate" | "self"));
if let (Some(owner), Some(name)) = (owner, name)
&& modules.contains(owner)
{
reexports.insert(name.to_owned(), owner.clone());
}
});
}
}
reexports
}
#[derive(Default)]
struct CrateReferences<'a> {
owners: BTreeSet<String>,
module_path: Vec<String>,
root_reexports: Option<&'a BTreeMap<String, String>>,
}
impl<'ast> Visit<'ast> for CrateReferences<'_> {
fn visit_path(&mut self, path: &'ast syn::Path) {
self.record(
&path
@@ -186,11 +304,13 @@ impl<'ast> Visit<'ast> for CrateReferences {
}
fn visit_item_use(&mut self, item: &'ast syn::ItemUse) {
self.use_tree(&item.tree, &mut Vec::new());
visit_use_leaves(&item.tree, &mut Vec::new(), &mut |path, _| {
self.record(path);
});
}
}
impl CrateReferences {
impl<'a> CrateReferences<'a> {
fn in_module(relative: &Path) -> Self {
let mut module = relative
.with_extension("")
@@ -208,13 +328,26 @@ impl CrateReferences {
Self {
owners: BTreeSet::new(),
module_path: module,
root_reexports: None,
}
}
fn with_root_reexports(mut self, reexports: &'a BTreeMap<String, String>) -> Self {
self.root_reexports = Some(reexports);
self
}
fn root_owner(&self, name: &str) -> String {
self.root_reexports
.and_then(|reexports| reexports.get(name))
.cloned()
.unwrap_or_else(|| name.to_owned())
}
fn record(&mut self, path: &[String]) {
let Some(first) = path.first() else { return };
let owner = if first == "crate" {
path.get(1).cloned()
path.get(1).map(|name| self.root_owner(name))
} else if first == "self" || first == "super" {
let mut module = self.module_path.clone();
let mut rest = path;
@@ -227,7 +360,10 @@ impl CrateReferences {
}
rest = &rest[1..];
}
module.first().or_else(|| rest.first()).cloned()
module
.first()
.cloned()
.or_else(|| rest.first().map(|name| self.root_owner(name)))
} else {
None
};
@@ -235,31 +371,41 @@ impl CrateReferences {
self.owners.insert(owner);
}
}
}
fn use_tree(&mut self, tree: &syn::UseTree, prefix: &mut Vec<String>) {
match tree {
syn::UseTree::Path(path) => {
prefix.push(path.ident.to_string());
self.use_tree(&path.tree, prefix);
prefix.pop();
}
syn::UseTree::Name(name) => {
prefix.push(name.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Rename(rename) => {
prefix.push(rename.ident.to_string());
self.record(prefix);
prefix.pop();
}
syn::UseTree::Group(group) => {
for item in &group.items {
self.use_tree(item, prefix);
}
}
syn::UseTree::Glob(_) => self.record(prefix),
fn visit_use_leaves(
tree: &syn::UseTree,
prefix: &mut Vec<String>,
visit: &mut impl FnMut(&[String], Option<&str>),
) {
match tree {
syn::UseTree::Path(path) => {
prefix.push(path.ident.to_string());
visit_use_leaves(&path.tree, prefix, visit);
prefix.pop();
}
syn::UseTree::Name(name) => {
let name = name.ident.to_string();
let export = if name == "self" {
prefix.last().cloned()
} else {
Some(name.clone())
};
prefix.push(name);
visit(prefix, export.as_deref());
prefix.pop();
}
syn::UseTree::Rename(rename) => {
prefix.push(rename.ident.to_string());
visit(prefix, Some(&rename.rename.to_string()));
prefix.pop();
}
syn::UseTree::Group(group) => {
for item in &group.items {
visit_use_leaves(item, prefix, visit);
}
}
syn::UseTree::Glob(_) => visit(prefix, None),
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/lo
## 打包
先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数:
先构建目标平台的二进制;有管理页面时,也构建静态资源。全部参数见 `cpr-plugin package --help`
```bash
cpr-plugin package \
@@ -11,11 +11,15 @@ use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::{
lifecycle::CancellationToken,
task::{DaemonTask, WorkerDaemonContext, WorkerTaskError},
task::{
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId,
WorkerKind, WorkerRegistration, WorkerRunnable, WorkerTaskError,
},
};
use tracing::{info, warn};
use crate::{
model::AdminError,
model::backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
@@ -34,6 +38,8 @@ impl From<BackupError> for WorkerTaskError {
use super::policy::{BackupSchedule, decide_retention};
const BACKUP_WORKER_OWNER: &str = "backup";
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
/// 每个循环最多执行的保留删除数量
@@ -72,6 +78,22 @@ impl BackupTask {
self.timezone = timezone;
self
}
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(self),
},
)
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
Ok(WorkerContribution::Registration(registration))
}
}
impl DaemonTask for BackupTask {
@@ -0,0 +1,341 @@
//! 管理服务与后台任务的启动组装,窄端口供服务器和 CLI 复用
use std::{path::Path, sync::Arc};
use gateway_core::{engine::probe::AccountProbe, runtime::SnapshotControl};
use crate::{
AccountsService, AdminBundle, AdminConfig, AdminRuntimePorts, AdminServices, ClientConfig,
ClientKeyService, CredentialsService, PluginDistributionPorts, PluginManagementService,
PluginsService, SettingsService, UsageDataService, backup, freeze_recovery,
model::AdminError,
ports::{
self, plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
provider::ProviderAdminRegistry, store::AdminStorePorts,
},
public_service, reset_credit_worker, use_case,
use_case::{
account_groups::DefaultAccountGroupService,
accounts::DefaultAccountsService,
auth::{AuthService, DefaultAuthService},
backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService,
client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService,
settings::DefaultSettingsService,
system::DefaultSystemService,
},
};
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
///
/// # Errors
///
/// 配置非法或默认管理员初始化失败时返回错误
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, None).await
}
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
pub async fn initialize_with_plugin_accounts(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Arc<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
}
async fn initialize_inner(
mut config: AdminConfig,
mut client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
upstream_info,
timezone,
service_middleware,
plugin_preparation,
plugin_management,
published_snapshot,
plugin_distribution,
plugin_inspector,
pricing_source,
providers,
snapshot,
account_probe: probe,
proxy_probe,
client_distribution,
system,
client_key_verifier,
} = runtime;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
client_config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let auth = Arc::new(DefaultAuthService::new(
&config,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier.clone(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
let accounts = initialize_accounts(
store.accounts(),
store.account_runtime(),
store.settings(),
timezone,
providers.clone(),
snapshot.clone(),
probe.clone(),
upstream_info.clone(),
);
let backup_ports = store.backup();
let backups = Arc::new(DefaultBackupService::new(
backup_ports.repository(),
backup_ports.object_store(),
store.auth(),
snapshot.clone(),
timezone,
));
let backup_task = backup::task::BackupTask::new(
backup_ports.repository(),
backup_ports.dump(),
backup_ports.object_store(),
)
.with_timezone(timezone);
let system_preflight = Arc::new(
use_case::system_preflight::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
),
);
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let observations: Arc<dyn ports::store::ObservabilityStore> = Arc::new(
use_case::observability::CachedObservabilityStore::new(store.observability()),
);
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
store.client_keys(),
observations.clone(),
system.clone(),
timezone,
));
let credentials = Arc::new(CredentialsService::new(
providers.clone(),
store.accounts(),
store.proxies(),
snapshot.clone(),
upstream_info.clone(),
));
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
initialize_plugin_accounts(
providers.clone(),
store.accounts(),
snapshot.clone(),
upstream_info.clone(),
)
});
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let settings = initialize_settings(
store.settings(),
snapshot.clone(),
providers.clone(),
pricing_source,
);
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), providers.clone());
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
public_services.register_accounts(&accounts)?;
public_services.register_usage_data(&usage_data)?;
let services = AdminServices {
peer_link: Arc::new(use_case::peer_link::DefaultPeerLinkService::new(
store.client_keys(),
store.accounts(),
store.account_runtime(),
providers.clone(),
)),
timezone,
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
store.plugins(),
published_snapshot.clone(),
)),
plugins: Arc::new(PluginsService::new(
store.plugins(),
plugin_inspector,
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
snapshot.clone(),
plugin_preparation,
published_snapshot,
store.plugin_state(),
)),
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
snapshot.clone(),
providers.clone(),
upstream_info.clone(),
)),
auth,
accounts: accounts.clone(),
account_groups: Arc::new(DefaultAccountGroupService::new(
upstream_info.clone(),
store.account_groups(),
store.account_runtime(),
snapshot.clone(),
)),
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
providers.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
observations.clone(),
store.accounts(),
store.settings(),
providers.clone(),
timezone,
system.clone(),
upstream_info,
)),
settings,
usage_data,
system,
credentials,
_plugin_accounts: plugin_accounts,
import_tasks,
backups,
};
let freeze_recovery =
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
store: store.accounts(),
runtime: store.account_runtime(),
settings: store.settings(),
});
let worker_contributions = vec![
backup_task.into_worker()?,
import_task.into_worker()?,
freeze_recovery.into_worker()?,
reset_credit_worker::ResetCreditAutomationTask {
accounts,
store: store.accounts(),
settings: store.settings(),
}
.into_worker()?,
];
Ok(AdminBundle {
services,
worker_contributions,
})
}
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
pub fn initialize_plugin_accounts(
providers: ports::provider::ProviderAdminRegistry,
accounts: Arc<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers,
accounts,
snapshot,
upstream_info,
))
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
store: Arc<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
)]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
upstream_info,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
#[must_use]
pub fn initialize_usage_data(
store: Arc<dyn ports::usage_data::UsageDataStore>,
settings: Arc<dyn ports::store::SettingsStore>,
providers: ProviderAdminRegistry,
) -> Arc<dyn UsageDataService> {
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
store, settings, providers,
))
}
@@ -7,10 +7,15 @@ use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::account::{AccountErrorProtection, ProviderAccountId};
use gateway_core::engine::probe::AccountProbeTarget;
use gateway_core::routing::UpstreamModelId;
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
use gateway_core::task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind,
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
};
use tracing::warn;
use crate::model::AdminError;
use crate::model::accounts::{
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
};
@@ -42,6 +47,34 @@ impl FreezeRecoveryTask {
Self { deps }
}
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
let id = WorkerId::try_new(
WorkerKind::AccountFreezeRecovery,
FREEZE_RECOVERY_WORKER_OWNER,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
FREEZE_RECOVERY_INTERVAL,
WORKER_INITIAL_BACKOFF,
WORKER_MAXIMUM_BACKOFF,
WORKER_LEASE_TTL,
WORKER_LEASE_RENEWAL,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), WORKER_LEASE_TTL)
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(self),
},
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(WorkerContribution::Registration(registration))
}
/// 无法读取或校验配置时跳过本轮,不能把未知状态当作允许解冻
async fn freeze_policy(&self) -> Option<AccountErrorProtection> {
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
@@ -129,7 +162,7 @@ impl FreezeRecoveryTask {
let probe_succeeded = match self
.deps
.accounts
.test_connection(account.clone(), model)
.test_connection(AccountProbeTarget::FreezeRecovery(account.clone()), model)
.await
{
Ok(events) => drain_probe(events).await,
+14 -414
View File
@@ -2,26 +2,22 @@
//!
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现
use std::{fmt, path::Path, sync::Arc, time::Duration};
use std::{fmt, path::Path, sync::Arc};
use gateway_core::{
engine::execution::ClientKeyVerifier,
engine::probe::AccountProbe,
runtime::SnapshotControl,
task::{
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
WorkerRegistration, WorkerRunnable, WorkerSchedule,
},
engine::execution::ClientKeyVerifier, engine::probe::AccountProbe, runtime::SnapshotControl,
task::WorkerContribution,
};
use secrecy::{ExposeSecret as _, SecretString};
use serde::Deserialize;
pub mod backup;
mod bootstrap;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod public_service;
mod reset_credit_automation;
mod reset_credit_worker;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -44,18 +40,15 @@ pub use use_case::{
usage_data::UsageDataService,
};
use model::AdminError;
pub use bootstrap::{
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
initialize_plugin_resources, initialize_settings, initialize_usage_data,
initialize_with_plugin_accounts,
};
use ports::{
client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess,
plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry,
store::AdminStorePorts, system::SystemOperations,
};
use use_case::{
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
auth::DefaultAuthService, backup::DefaultBackupService,
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
observability::DefaultObservabilityService, settings::DefaultSettingsService,
system::DefaultSystemService,
provider::ProviderAdminRegistry, system::SystemOperations,
};
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
@@ -70,7 +63,6 @@ const WEAK_ADMIN_PASSWORDS: &[&str] = &[
"codex-proxy-rs",
];
const BACKUP_WORKER_OWNER: &str = "backup";
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
/// 只用于首次幂等创建默认管理员的启动密码
@@ -296,8 +288,8 @@ impl AdminServices {
}
#[must_use]
pub fn usage_data(&self) -> &dyn UsageDataService {
self.usage_data.as_ref()
pub fn usage_data(&self) -> Arc<dyn UsageDataService> {
self.usage_data.clone()
}
#[must_use]
@@ -366,395 +358,3 @@ pub struct AdminRuntimePorts {
pub system: Arc<dyn SystemOperations>,
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化
///
/// # Errors
///
/// 配置非法或默认管理员初始化失败时返回错误
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, None).await
}
/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例
pub async fn initialize_with_plugin_accounts(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Arc<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await
}
async fn initialize_inner(
mut config: AdminConfig,
mut client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
upstream_info,
timezone,
service_middleware,
plugin_preparation,
plugin_management,
published_snapshot,
plugin_distribution,
plugin_inspector,
pricing_source,
providers,
snapshot,
account_probe: probe,
proxy_probe,
client_distribution,
system,
client_key_verifier,
} = runtime;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
client_config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
let registry = providers;
let auth = Arc::new(DefaultAuthService::new(
&config,
client_config.session_ttl_minutes,
store.auth(),
client_key_verifier.clone(),
));
auth.ensure_default_admin(config.default_password.expose())
.await?;
let accounts = initialize_accounts(
store.accounts(),
store.account_runtime(),
store.settings(),
timezone,
registry.clone(),
snapshot.clone(),
probe.clone(),
upstream_info.clone(),
);
let backup_ports = store.backup();
let backups = Arc::new(DefaultBackupService::new(
backup_ports.repository(),
backup_ports.object_store(),
store.auth(),
snapshot.clone(),
timezone,
));
let backup_task = backup::task::BackupTask::new(
backup_ports.repository(),
backup_ports.dump(),
backup_ports.object_store(),
)
.with_timezone(timezone);
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let observations: Arc<dyn ports::store::ObservabilityStore> = Arc::new(
use_case::observability::CachedObservabilityStore::new(store.observability()),
);
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
client_key_verifier,
store.client_keys(),
observations.clone(),
system.clone(),
timezone,
));
let credentials = Arc::new(CredentialsService::new(
registry.clone(),
store.accounts(),
store.proxies(),
snapshot.clone(),
upstream_info.clone(),
));
let plugin_accounts = plugin_accounts.unwrap_or_else(|| {
initialize_plugin_accounts(
registry.clone(),
store.accounts(),
snapshot.clone(),
upstream_info.clone(),
)
});
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let settings = initialize_settings(
store.settings(),
snapshot.clone(),
registry.clone(),
pricing_source,
);
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone());
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
public_services.register_accounts(&accounts)?;
public_services.register_usage_data(&usage_data)?;
let services = AdminServices {
peer_link: Arc::new(use_case::peer_link::DefaultPeerLinkService::new(
store.client_keys(),
store.accounts(),
store.account_runtime(),
registry.clone(),
)),
timezone,
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
store.plugins(),
published_snapshot.clone(),
)),
plugins: Arc::new(PluginsService::new(
store.plugins(),
plugin_inspector,
PluginDistributionPorts::new(plugin_distribution, store.proxies()),
snapshot.clone(),
plugin_preparation,
published_snapshot,
store.plugin_state(),
)),
key_usage,
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
snapshot.clone(),
registry.clone(),
upstream_info.clone(),
)),
auth,
accounts: accounts.clone(),
account_groups: Arc::new(DefaultAccountGroupService::new(
store.account_groups(),
store.account_runtime(),
snapshot.clone(),
)),
client_keys: Arc::new(DefaultClientKeyService::new(
store.client_keys(),
snapshot.clone(),
registry.clone(),
)),
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
observability: Arc::new(DefaultObservabilityService::new(
observations.clone(),
store.accounts(),
store.settings(),
registry.clone(),
timezone,
system.clone(),
upstream_info,
)),
settings,
usage_data,
system,
credentials,
_plugin_accounts: plugin_accounts,
import_tasks,
backups,
};
let freeze_recovery =
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
store: store.accounts(),
runtime: store.account_runtime(),
settings: store.settings(),
});
let mut worker_contributions = backup_worker_contribution(backup_task)?;
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(import_task),
},
)
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(60),
Duration::from_secs(90),
Duration::from_secs(30),
)
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
worker_contributions.push(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
accounts,
store: store.accounts(),
settings: store.settings(),
}),
},
)
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
));
Ok(AdminBundle {
services,
worker_contributions,
})
}
/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计
pub fn initialize_plugin_accounts(
providers: ports::provider::ProviderAdminRegistry,
accounts: Arc<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn PluginAccountAccess> {
Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new(
providers,
accounts,
snapshot,
upstream_info,
))
}
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
store: Arc<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
Arc::new(DefaultClientKeyService::new(store, snapshot, providers));
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`
fn backup_worker_contribution(
task: backup::task::BackupTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
.map_err(|_| AdminError::internal("备份 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("备份 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("备份 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定
fn freeze_recovery_worker_contribution(
task: freeze_recovery::FreezeRecoveryTask,
) -> Result<Vec<WorkerContribution>, AdminError> {
let id = WorkerId::try_new(
WorkerKind::AccountFreezeRecovery,
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
freeze_recovery::WORKER_INITIAL_BACKOFF,
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
freeze_recovery::WORKER_LEASE_TTL,
freeze_recovery::WORKER_LEASE_RENEWAL,
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(task),
},
)
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "账号存储、发布、探测与互联分别通过独立端口注入"
)]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
upstream_info: Option<Arc<dyn ports::peer::PeerUpstreamService>>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
upstream_info,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
#[must_use]
pub fn initialize_usage_data(
store: Arc<dyn ports::usage_data::UsageDataStore>,
settings: Arc<dyn ports::store::SettingsStore>,
providers: ProviderAdminRegistry,
) -> Arc<dyn UsageDataService> {
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
store, settings, providers,
))
}
@@ -73,6 +73,7 @@ pub struct AccountGroupUsage {
/// 当前页账号组 membership 对应的持久账号事实;运行态在 Admin query service 合并
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountGroupMemberFact {
pub inherited_version: Option<gateway_core::account::QuotaObservationVersion>,
pub group_id: AccountGroupId,
pub account_id: String,
pub status: AccountStatusFacts,
@@ -21,6 +21,7 @@ pub use gateway_core::account::{
/// 账号可用的管理操作
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAccountCapabilities {
pub quota_credits: bool,
pub quota: bool,
pub quota_refresh: bool,
pub profile: bool,
@@ -30,9 +31,10 @@ pub struct ProviderAccountCapabilities {
pub consume_reset_credit: bool,
}
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
/// 导入时应用的账号设置;名称只覆盖单账号导入,其余设置统一应用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
@@ -109,6 +111,7 @@ pub enum AccountGroupFilter {
/// 账号公共存储投影;Provider 专属字段不进入此结构
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRecord {
pub auto_use_quota_credits: bool,
pub id: String,
pub provider_kind: ProviderKind,
pub groups: Vec<AccountGroupRef>,
@@ -218,7 +221,7 @@ pub struct AccountPageItem {
pub struct AccountCapacity {
/// 实时租约读取失败时为 `None`,不能当作空闲
pub used_slots: Option<u64>,
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限
/// 应用账号覆盖或继承默认值后的普通并发上限;`None` 表示不限
pub total_slots: Option<u64>,
}
@@ -248,6 +251,7 @@ pub struct AccountUpdateResult {
/// 仅修改显式字段的批量账号设置命令
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchUpdateAccounts {
pub auto_use_quota_credits: Option<bool>,
pub account_ids: Vec<String>,
pub enabled: Option<bool>,
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
@@ -472,6 +472,90 @@ impl CostCoverage {
}
}
/// 请求健康计数与服务成功率,调用方错误和取消不计入有效样本
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RequestHealth {
pub success_requests: u64,
pub failed_requests: u64,
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
}
impl RequestHealth {
#[must_use]
pub fn from_counts(
success: u64,
failed: u64,
cancelled: u64,
incomplete: u64,
caller_error: u64,
) -> Self {
Self {
success_requests: success,
failed_requests: failed.saturating_sub(caller_error),
cancelled_requests: cancelled,
incomplete_requests: incomplete,
caller_error_requests: caller_error,
}
}
pub(crate) fn from_metrics(metrics: &RequestMetrics) -> Self {
Self::from_counts(
metrics.success_count,
metrics.failure_count,
metrics.cancelled_count,
metrics.incomplete_count,
metrics.caller_error_count,
)
}
/// 账号与健康时间线使用百分数,洞察接口使用 0 到 1 的比例
#[must_use]
pub fn success_rate(self) -> Option<f64> {
self.success_ratio().map(|ratio| ratio * 100.0)
}
pub(crate) fn success_ratio(self) -> Option<f64> {
let total = self.eligible_requests();
(total > 0).then(|| self.success_requests as f64 / total as f64)
}
pub(crate) fn error_ratio(self) -> Option<f64> {
let total = self.eligible_requests();
(total > 0).then(|| self.non_success_requests() as f64 / total as f64)
}
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
pub(crate) fn non_success_requests(self) -> u64 {
self.failed_requests
.saturating_add(self.incomplete_requests)
}
pub(crate) fn eligible_requests(self) -> u64 {
self.success_requests
.saturating_add(self.non_success_requests())
}
pub(crate) fn add_metrics(&mut self, metrics: &RequestMetrics) {
self.add_window(Self::from_metrics(metrics));
}
pub(crate) fn add_window(&mut self, other: Self) {
self.success_requests = self.success_requests.saturating_add(other.success_requests);
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
self.cancelled_requests = self
.cancelled_requests
.saturating_add(other.cancelled_requests);
self.incomplete_requests = self
.incomplete_requests
.saturating_add(other.incomplete_requests);
self.caller_error_requests = self
.caller_error_requests
.saturating_add(other.caller_error_requests);
}
}
/// 请求级聚合指标
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestMetrics {
@@ -622,7 +706,7 @@ pub struct AccountPoolMetrics {
pub struct AccountModelUsage {
pub model: String,
pub request_count: u64,
pub success_count: u64,
pub health: RequestHealth,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_input_tokens: Option<u64>,
@@ -635,7 +719,7 @@ pub struct AccountModelUsage {
pub total_tokens: Option<u64>,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
pub last_used_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
/// 账号在单小时窗口内的请求数
@@ -684,8 +768,10 @@ pub struct DashboardAccountUsage {
}
/// Dashboard 当前账号池的可重建运行时槽位事实
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardRuntimeSlots {
/// 仅未设置本地覆盖的账号可能继承有效互联观测
pub inherited_versions: Vec<gateway_core::account::QuotaObservationVersion>,
/// 继承运行参数默认并发上限的可调度账号数
pub inherited_accounts: u64,
/// 已配置账号级并发上限的可调度账号槽位总数
@@ -724,6 +810,11 @@ pub struct UsageListRecord {
pub outcome: RequestOutcome,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub error_kind: Option<String>,
pub websocket_close_code: Option<u16>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub recovered: bool,
pub client_status_code: Option<u16>,
pub client_api_key_name: Option<String>,
pub id: String,
@@ -739,6 +830,8 @@ pub struct UsageListRecord {
/// 请求选号时冻结的账号套餐
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
/// 查询时的互联识别结果,缺少当前摘要时为未知
pub provider_account_upstream_peer: Option<bool>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -780,6 +873,23 @@ pub struct UsageListRecord {
pub started_at: DateTime<Utc>,
}
/// 受控请求详情中的上游错误字段,不参与错误分类或普通日志输出
#[derive(Clone, PartialEq)]
pub struct UsageUpstreamError {
pub code: Option<String>,
pub error_type: Option<String>,
pub message: Option<String>,
pub param: Option<String>,
pub close_reason: Option<String>,
pub last_event_type: Option<String>,
}
impl std::fmt::Debug for UsageUpstreamError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("UsageUpstreamError(<restricted>)")
}
}
/// 一次完整模型请求的公共观测记录
#[derive(Debug, Clone, PartialEq)]
pub struct UsageRecord {
@@ -818,6 +928,11 @@ pub struct UsageRecord {
pub error_kind: Option<String>,
pub provider_error_code: Option<String>,
pub error_message: Option<String>,
pub upstream_error: Option<UsageUpstreamError>,
pub websocket_close_code: Option<u16>,
pub continuation_unavailable_reason: Option<String>,
pub upstream_connection_exit_reason: Option<String>,
pub recovered: bool,
pub retry_after_ms: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -976,7 +1091,9 @@ pub struct DiagnosticObservation {
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
@@ -1064,7 +1181,7 @@ pub struct TrendPoint {
pub bucket_start: DateTime<Utc>,
pub granularity: Granularity,
pub metrics: RequestMetrics,
pub service_failure_count: u64,
pub error_count: u64,
pub average_latency_ms: Option<u64>,
pub average_first_token_latency_ms: Option<u64>,
pub cached_token_rate: f64,
@@ -1082,7 +1199,7 @@ pub struct TrendSummary {
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub total_tokens: u64,
pub service_failure_count: u64,
pub error_count: u64,
pub average_latency_ms: Option<u64>,
pub max_latency_ms: Option<u64>,
pub min_latency_ms: Option<u64>,
@@ -1246,7 +1363,7 @@ pub struct UsageInsightsHealthPoint {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
}
/// 洞察健康汇总;失败数已排除调用方错误
@@ -1258,7 +1375,7 @@ pub struct UsageInsightsHealth {
pub cancelled_requests: u64,
pub incomplete_requests: u64,
pub caller_error_requests: u64,
pub success_rate: f64,
pub success_rate: Option<f64>,
pub completion_rate: f64,
pub points: Vec<UsageInsightsHealthPoint>,
}
@@ -1439,11 +1556,13 @@ pub struct DiagnosticsItem {
pub name: String,
pub account_authentication_kind: Option<String>,
pub account_plan_type: Option<String>,
/// 仅账号维度补充当前互联识别结果
pub account_upstream_peer: Option<bool>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
pub error_rate: f64,
pub error_rate: Option<f64>,
pub request_share: f64,
pub average_latency_ms: Option<u64>,
pub latency_p95_ms: Option<u64>,
@@ -17,6 +17,7 @@ pub struct PeerLinkLimits {
pub struct PeerLinkExport {
pub observed_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub key_limits: gateway_core::policy::RateLimits,
pub budget: ClientBudgetStatus,
pub budget_access: ClientBudgetAccess,
pub budget_observed_at: DateTime<Utc>,
@@ -165,8 +165,9 @@ pub struct ConfigurePluginInstance {
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret
pub secrets: Option<BTreeMap<String, SecretString>>,
/// 按字段更新,省略的字段保留,null 清除对应字段
#[serde(default)]
pub secrets: BTreeMap<String, Option<SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
@@ -806,11 +806,12 @@ pub struct ProviderQuotaWindow {
pub provider_data: Option<ProviderDocument>,
}
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板
/// 账号用量统计周期,完整周/月额度优先,随后使用 Provider 提供的本地滚动日统计
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccountUsagePeriod {
Weekly,
Monthly,
Daily,
}
/// Provider 解释 quota 所需的公共请求事实
@@ -998,16 +999,27 @@ impl ProviderQuota {
}
}
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实
/// 选择账号用量统计窗口,同时返回展示所需的周期事实
///
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
/// Provider 投影顺序
/// 没有符合条件的窗口时不回退到短期或历史累计
/// 周/月额度使用边界完整、可归属到整个账号的窗口
/// 周优先于月,同周期保持 Provider 投影顺序
/// Provider 提供的无固定重置点的滚动日统计可独立展示
#[must_use]
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
self.usage_windows()
.filter(|(window, _)| window.local_usage.is_some())
.min_by_key(|(_, period)| *period)
.or_else(|| {
self.windows
.iter()
.find(|window| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds == Some(86_400)
&& window.reset_at.is_none()
&& window.local_usage.is_some()
})
.map(|window| (window, AccountUsagePeriod::Daily))
})
}
/// 互联账号按最长的 Key 消费周期统计本地用量,池内账号窗口不代表此 Key 的周期
@@ -1025,7 +1037,7 @@ impl ProviderQuota {
.max_by_key(|window| window.window_seconds)
}
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量
/// 周/月额度的归属规则,供额度周期统计与容量预测共用
pub(crate) fn usage_windows(
&self,
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
@@ -62,14 +62,15 @@ pub fn account_quota_forecasts(
now: DateTime<Utc>,
samples: &[QuotaForecastSample],
) -> [AccountQuotaForecast; 2] {
[AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| {
[
(AccountUsagePeriod::Weekly, 7 * DAY_SECONDS),
(AccountUsagePeriod::Monthly, 30 * DAY_SECONDS),
]
.map(|(period, target_seconds)| {
let selected = quota_forecast_source_window(quota, period);
let mut forecast = AccountQuotaForecast {
period,
target_seconds: match period {
AccountUsagePeriod::Weekly => 7 * DAY_SECONDS,
AccountUsagePeriod::Monthly => 30 * DAY_SECONDS,
},
target_seconds,
extrapolated: false,
source: None,
unavailable_reason: Some("缺少周/月额度窗口,请刷新账号额度"),
@@ -53,7 +53,10 @@ pub struct RuntimeSettingsValues {
pub max_concurrent_per_account: u32,
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub key_queue_timeout_seconds: u32,
pub account_queue_timeout_seconds: u32,
pub switch_account_on_queue_failure: bool,
pub max_retry_after_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub openai_account_affinity: gateway_core::account::AccountAffinity,
pub max_account_rotations: u32,
@@ -200,7 +203,8 @@ impl RuntimeSettingsValues {
gateway_core::settings::validate_request_limits(
self.max_waiting_per_key,
self.max_waiting_per_account,
self.concurrency_wait_timeout_seconds,
self.key_queue_timeout_seconds,
self.account_queue_timeout_seconds,
self.max_account_rotations,
self.openai_session_affinity_ttl_hours,
)?;
@@ -3,6 +3,14 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 摘要只读取发布索引,完整详情可按请求刷新
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemUpdateCheck {
Summary,
Detail,
Refresh,
}
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
@@ -69,6 +77,7 @@ pub struct SystemUpdateDetail {
pub update_supported: bool,
pub unsupported_reason: Option<String>,
pub warning: Option<String>,
pub retry_at: Option<DateTime<Utc>>,
}
/// 系统操作类型
@@ -4,11 +4,14 @@ use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderUpstreamInfo {
/// 当前连接的互联识别结果,明确关闭共享或不支持协议时清除
pub discovered: bool,
pub observed_at: Option<DateTime<Utc>>,
pub stale: bool,
pub connection_revision: String,
pub last_error: Option<String>,
pub max_concurrency: Option<u64>,
pub requests_per_minute: Option<u64>,
pub pool: Option<UpstreamPool>,
}
@@ -10,6 +10,8 @@ pub enum UsageErrorKind {
Conflict,
CapacityExceeded,
Unavailable,
Cancelled,
Timeout,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -20,3 +20,6 @@ pub use actor::*;
mod aggregate;
pub use aggregate::*;
mod read;
pub use read::*;
@@ -0,0 +1,162 @@
//! 历史用量的窄事实、分组统计与调用内分批读取合同
use super::{UsageCount, UsageMetric, UsageRecordData, UsageRecordFilter};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const USAGE_READ_MAX_BATCH_ROWS: u16 = 1024;
pub const USAGE_READ_MAX_BATCH_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadFilter {
pub records: UsageRecordFilter,
pub routing_group_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScanUsageRecords {
pub filter: UsageReadFilter,
pub batch_size: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageGrouping {
Model,
Key,
KeyModel,
RoutingGroup,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryUsageStatistics {
pub filter: UsageReadFilter,
pub group_by: UsageGrouping,
pub batch_size: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UsageReadQuery {
Records(ScanUsageRecords),
Statistics(QueryUsageStatistics),
}
impl UsageReadQuery {
pub fn filter(&self) -> &UsageReadFilter {
match self {
Self::Records(query) => &query.filter,
Self::Statistics(query) => &query.filter,
}
}
pub fn batch_size(&self) -> u16 {
match self {
Self::Records(query) => query.batch_size,
Self::Statistics(query) => query.batch_size,
}
.unwrap_or(USAGE_READ_MAX_BATCH_ROWS)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageRecordFact {
pub id: String,
pub data: UsageRecordData,
pub user_agent: Option<String>,
pub reasoning_effort: Option<String>,
pub subagent_kind: Option<String>,
pub request_kind: Option<String>,
pub routing_scope: Option<String>,
pub routing_group_refs: Vec<String>,
pub recovered: bool,
/// 由宿主现行规则派生,原始审计中的恢复请求仍可读取
pub request_counted: bool,
/// 包含完整交付、预热排除及恢复关联规则
pub usage_counted: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageStatisticsGroup {
pub client_key_ref: Option<String>,
pub model: Option<String>,
pub routing_group_ref: Option<String>,
pub unassigned_contribution: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageStatisticsCost {
pub currency: String,
pub amount: String,
pub native_amount: Option<String>,
/// 外部修正允许负值,原生费用来源覆盖单独返回
pub contribution_amount: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageStatisticsCostCoverage {
pub provider_reported: UsageCount,
pub calculated: UsageCount,
pub unavailable: UsageCount,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageStatisticsMetrics {
/// 缺失的 Token 指标表示未知,计数指标始终返回
pub values: BTreeMap<UsageMetric, UsageCount>,
pub native_request_count: UsageCount,
pub contribution_count: UsageCount,
pub native_token_samples: BTreeMap<UsageMetric, UsageCount>,
/// 数量表示提供该指标的贡献记录数,不能用作请求覆盖率的分母
pub contribution_token_samples: BTreeMap<UsageMetric, UsageCount>,
pub cost_coverage: UsageStatisticsCostCoverage,
pub costs: Vec<UsageStatisticsCost>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageStatisticsRow {
/// null 表示全局总计,分组重叠不会重复计入该行
pub group: Option<UsageStatisticsGroup>,
pub metrics: UsageStatisticsMetrics,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum UsageReadItem {
Record(Box<UsageRecordFact>),
Statistics(Box<UsageStatisticsRow>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadAnalytics {
pub coverage_start_ms: Option<i64>,
pub coverage_end_ms: Option<i64>,
pub lag_seconds: u64,
pub state: String,
pub generation: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadObservation {
pub generated_at_ms: i64,
pub analytics: Option<UsageReadAnalytics>,
pub total_groups: Option<UsageCount>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadBatch {
pub observation: UsageReadObservation,
pub items: Vec<UsageReadItem>,
pub complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadOpened {
pub reader: Option<String>,
pub batch: UsageReadBatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReadHandle {
pub reader: String,
}
@@ -38,6 +38,13 @@ pub trait PeerTransport: Send + Sync {
/// 账号用例读取公共互联观测,显式刷新等待额度查询完成
#[async_trait]
pub trait PeerUpstreamService: gateway_core::account::UpstreamInfoSource {
/// 仅在指定持久观测与当前调度缓存一致时估算容量
fn observed_concurrency_limit(
&self,
version: &gateway_core::account::QuotaObservationVersion,
now: std::time::SystemTime,
) -> Option<gateway_core::account::AccountConcurrencyLimit>;
async fn account_changed(
&self,
id: &gateway_core::account::ProviderAccountId,
@@ -222,6 +222,15 @@ pub trait ProviderAdmin: Send + Sync {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// Key 可以继承当前全局画像;额外字段的合法性由对应 Provider 判定
fn preview_key_client_profile(
&self,
key: &gateway_core::account::OpaqueProviderData,
_global: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
self.preview_client_profile(key)
}
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
fn plan_type_display(&self, plan_type: &str) -> String {
default_plan_type_display(plan_type)
@@ -162,6 +162,12 @@ pub trait AccountStore:
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
/// 只读取账号记录,不加载运行配置、分组或额度展示
async fn load_account_record(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<crate::model::accounts::AccountRecord>>;
async fn load_account_usage(
&self,
range: TimeRange,
@@ -3,12 +3,13 @@
use std::{pin::Pin, sync::Arc};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::Stream;
use crate::model::Revision;
use crate::model::system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
SystemUpdateStatus, SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
SystemUpdateEvent, SystemUpdateStatus, SystemVersion,
};
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
@@ -43,6 +44,7 @@ pub enum SystemOperationErrorKind {
pub struct SystemOperationError {
kind: SystemOperationErrorKind,
message: String,
retry_at: Option<DateTime<Utc>>,
source: Option<gateway_core::error::ErrorSource>,
sensitive_context_redacted: bool,
}
@@ -53,11 +55,23 @@ impl SystemOperationError {
Self {
kind,
message: message.into(),
retry_at: None,
source: None,
sensitive_context_redacted: false,
}
}
#[must_use]
pub fn with_retry_at(mut self, retry_at: DateTime<Utc>) -> Self {
self.retry_at = Some(retry_at);
self
}
#[must_use]
pub const fn retry_at(&self) -> Option<DateTime<Utc>> {
self.retry_at
}
#[must_use]
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
self.source = Some(source.into());
@@ -135,7 +149,7 @@ pub trait SystemOperations: Send + Sync {
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, SystemOperationError>;
@@ -2,6 +2,14 @@
use crate::model::{auth::AdminAuditEvent, usage_data::*};
use async_trait::async_trait;
use gateway_core::lifecycle::CancellationToken;
#[async_trait]
pub trait UsageReadSession: Send {
async fn next_batch(&mut self) -> UsageDataResult<UsageReadBatch>;
fn is_finished(&self) -> bool;
async fn close(&mut self);
}
pub struct StoredUsageWrite {
pub changes: Vec<(UsageRecordChange, AdminAuditEvent)>,
@@ -13,6 +21,11 @@ pub struct StoredAggregateWrite {
#[async_trait]
pub trait UsageDataStore: Send + Sync {
async fn open_read(
&self,
query: UsageReadQuery,
cancellation: CancellationToken,
) -> UsageDataResult<Box<dyn UsageReadSession>>;
async fn list_aggregates(
&self,
request: ListUsageAggregates,
@@ -1,125 +0,0 @@
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
use crate::{
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
ports::store::{AccountStore, SettingsStore},
use_case::accounts::AccountsService,
};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
time::DeploymentTimeZone,
};
use sha2::{Digest as _, Sha256};
use std::{sync::Arc, time::Duration};
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
pub(crate) fn next_discovery(
now: DateTime<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
let start = timezone.days_after(now, 1)?;
let end = timezone.days_after(now, 2)?;
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
if seconds == 0 {
return None;
}
start.checked_add_signed(chrono::Duration::seconds(
i64::try_from(random % seconds).ok()?,
))
}
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
pub(crate) fn consume_at(
account_id: &str,
credit: &PlannedResetCredit,
lead_minutes: u32,
) -> DateTime<Utc> {
let lead_seconds = i64::from(lead_minutes) * 60;
let mut hash = Sha256::new();
hash.update(account_id.as_bytes());
hash.update([0]);
hash.update(credit.id.as_bytes());
let digest = hash.finalize();
let offset =
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
}
pub(crate) fn next_consumption(
record: &ResetCreditAutomation,
lead_minutes: u32,
) -> Option<DateTime<Utc>> {
record
.state
.credits
.iter()
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
.min()
}
pub(crate) fn reschedule(
record: &mut ResetCreditAutomation,
lead_minutes: u32,
now: DateTime<Utc>,
) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
})
};
}
pub(crate) struct ResetCreditAutomationTask {
pub(crate) accounts: Arc<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ScheduledTask for ResetCreditAutomationTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
let due = self
.store
.due_reset_credit_automations(Utc::now(), 100)
.await
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
let work = futures::stream::iter(due)
.map(|account_id| async move {
let id = ProviderAccountId::new(account_id.clone())
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
tracing::warn!(
account_id,
error_kind = error.kind().as_str(),
"自动重置卡任务失败"
);
}
Ok::<_, WorkerTaskError>(())
})
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
.collect::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -0,0 +1,91 @@
//! 自动重置卡 Worker 的到期扫描、并发预算与取消,消费编排由账号用例负责
use crate::{
model::AdminError,
ports::store::{AccountStore, SettingsStore},
use_case::accounts::AccountsService,
};
use chrono::Utc;
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
task::{
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerId, WorkerKind,
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
},
};
use std::{sync::Arc, time::Duration};
pub(crate) struct ResetCreditAutomationTask {
pub(crate) accounts: Arc<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ResetCreditAutomationTask {
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(60),
Duration::from_secs(90),
Duration::from_secs(30),
)
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(self),
},
)
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?;
Ok(WorkerContribution::Registration(registration))
}
}
impl ScheduledTask for ResetCreditAutomationTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
let due = self
.store
.due_reset_credit_automations(Utc::now(), 100)
.await
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
let work = futures::stream::iter(due)
.map(|account_id| async move {
let id = ProviderAccountId::new(account_id.clone())
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
tracing::warn!(
account_id,
error_kind = error.kind().as_str(),
"自动重置卡任务失败"
);
}
Ok::<_, WorkerTaskError>(())
})
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
.collect::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -60,6 +60,7 @@ pub trait AccountGroupService: Send + Sync {
}
pub(crate) struct DefaultAccountGroupService {
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
store: Arc<dyn AccountGroupStore>,
runtime: Arc<dyn AccountRuntimeStore>,
snapshot: Arc<dyn SnapshotControl>,
@@ -68,11 +69,13 @@ pub(crate) struct DefaultAccountGroupService {
impl DefaultAccountGroupService {
#[must_use]
pub(crate) fn new(
upstream_info: Option<Arc<dyn crate::ports::peer::PeerUpstreamService>>,
store: Arc<dyn AccountGroupStore>,
runtime: Arc<dyn AccountRuntimeStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
upstream_info,
store,
runtime,
snapshot,
@@ -105,7 +108,7 @@ impl DefaultAccountGroupService {
Default::default()
}
};
project_group_runtime(records, &members, &runtime);
project_group_runtime(records, &members, &runtime, self.upstream_info.as_deref());
Ok(())
}
@@ -215,6 +218,7 @@ fn project_group_runtime(
records: &mut [AccountGroupRecord],
members: &[AccountGroupMemberFact],
runtime: &crate::model::accounts::AccountRuntimeSnapshot,
upstream: Option<&dyn crate::ports::peer::PeerUpstreamService>,
) {
let now = SystemTime::now();
let mut status_by_account = BTreeMap::new();
@@ -228,7 +232,14 @@ fn project_group_runtime(
.or_insert_with(|| resolve_account_status(&status, now).status);
slots_by_account
.entry(member.account_id.as_str())
.or_insert(member.total_slots);
.or_insert_with(|| {
member
.inherited_version
.as_ref()
.and_then(|version| upstream?.observed_concurrency_limit(version, now))
.map(|limit| u64::from(limit.get()))
.or(member.total_slots)
});
accounts_by_group
.entry(member.group_id.as_str())
.or_default()
@@ -12,7 +12,7 @@ use chrono::{Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
engine::probe::{AccountProbe, AccountProbeRequest},
engine::probe::{AccountProbe, AccountProbeRequest, AccountProbeTarget},
routing::{ProviderKind, UpstreamModelId},
runtime::SnapshotControl,
};
@@ -178,7 +178,7 @@ pub trait AccountsService: Send + Sync {
async fn test_connection(
&self,
account_id: ProviderAccountId,
target: AccountProbeTarget,
upstream_model: UpstreamModelId,
) -> Result<AccountConnectionTestEventStream, AdminError>;
}
@@ -504,7 +504,11 @@ impl DefaultAccountsService {
});
Ok(AccountDirectoryItem {
protection_rules: stored.protection_rules,
capacity: stored.capacity,
capacity: super::peer_link::upstream::account_capacity(
&stored.account,
stored.capacity,
&quota,
),
capabilities: provider
.account_capabilities(account_id, &stored.account.authentication_kind),
plan_type_display: self.providers.resolve_account_plan(
@@ -618,7 +622,11 @@ impl AccountsService for DefaultAccountsService {
used_slots: in_flight
.as_ref()
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
..item.capacity
..super::peer_link::upstream::account_capacity(
&item.account,
item.capacity,
&quota,
)
},
capabilities,
plan_type_display: providers.resolve_account_plan(
@@ -751,6 +759,7 @@ impl AccountsService for DefaultAccountsService {
self.accounts
.batch_update_accounts(
BatchUpdateAccounts {
auto_use_quota_credits: None,
account_ids: vec![account_id.to_string()],
enabled: Some(true),
concurrency_limit: None,
@@ -829,15 +838,28 @@ impl AccountsService for DefaultAccountsService {
(
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
Vec<ProviderAccountId>,
Vec<ProviderAccountId>,
),
>::new();
for account_id in &account_ids {
let (item, provider) = self.provider_for_account(account_id).await?;
providers
if command.auto_use_quota_credits.is_some()
&& !provider
.account_capabilities(account_id, &item.account.authentication_kind)
.quota_credits
{
return Err(AdminError::invalid("该账号不支持使用额度点数"));
}
let (_, selected, exhausted) = providers
.entry(item.account.provider_kind)
.or_insert_with(|| (provider, Vec::new()))
.1
.push(account_id.clone());
.or_insert_with(|| (provider, Vec::new(), Vec::new()));
selected.push(account_id.clone());
if command.auto_use_quota_credits == Some(true)
&& !item.account.auto_use_quota_credits
&& item.account.quota.is_exhausted()
{
exhausted.push(account_id.clone());
}
}
let enabled = command.enabled;
let result = self
@@ -845,7 +867,7 @@ impl AccountsService for DefaultAccountsService {
.batch_update_accounts(command, context)
.await
.map_err(|error| map_store_error(error, "provider accounts"))?;
for (provider, provider_ids) in providers.values() {
for (provider, provider_ids, _) in providers.values() {
if enabled == Some(false) {
for account_id in provider_ids {
super::notify_account_unavailable(
@@ -864,6 +886,18 @@ impl AccountsService for DefaultAccountsService {
.await;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
for (provider, _, exhausted) in providers.into_values() {
if exhausted.is_empty() {
continue;
}
super::credential_mutation::observe_account_quotas(
self.upstream_info.as_ref(),
&provider,
&self.accounts,
&exhausted,
context.request_id.as_str(),
);
}
Ok(result)
}
@@ -1123,10 +1157,10 @@ impl AccountsService for DefaultAccountsService {
async fn test_connection(
&self,
account_id: ProviderAccountId,
target: AccountProbeTarget,
upstream_model: UpstreamModelId,
) -> Result<AccountConnectionTestEventStream, AdminError> {
let (stored, provider) = self.provider_for_account(&account_id).await?;
let (stored, provider) = self.provider_for_account(target.account_id()).await?;
let account = stored.account;
let model = upstream_model.as_str().to_owned();
let operation = provider
@@ -1149,7 +1183,7 @@ impl AccountsService for DefaultAccountsService {
let result = probe
.probe(
AccountProbeRequest {
account_id,
target,
provider_kind: account.provider_kind,
upstream_model,
operation,
@@ -1,7 +1,13 @@
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
//! 重置卡查询、消费与自动使用排程,统一账号锁、持久幂等键和重试编排
use super::*;
use crate::model::{MutationActor, reset_credit_automation::*};
use chrono::{DateTime, Utc};
use gateway_core::time::DeploymentTimeZone;
use rand_core::{OsRng, RngCore as _};
use sha2::{Digest as _, Sha256};
impl DefaultAccountsService {
pub(super) async fn reset_credits_upstream(
&self,
@@ -51,13 +57,6 @@ impl DefaultAccountsService {
}
}
use crate::{
model::{MutationActor, reset_credit_automation::*},
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
};
use chrono::{DateTime, Utc};
use rand_core::{OsRng, RngCore as _};
impl DefaultAccountsService {
async fn automation_settings(
&self,
@@ -511,3 +510,54 @@ impl DefaultAccountsService {
Ok(())
}
}
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
fn next_discovery(
now: DateTime<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
let start = timezone.days_after(now, 1)?;
let end = timezone.days_after(now, 2)?;
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
if seconds == 0 {
return None;
}
start.checked_add_signed(chrono::Duration::seconds(
i64::try_from(random % seconds).ok()?,
))
}
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
fn consume_at(account_id: &str, credit: &PlannedResetCredit, lead_minutes: u32) -> DateTime<Utc> {
let lead_seconds = i64::from(lead_minutes) * 60;
let mut hash = Sha256::new();
hash.update(account_id.as_bytes());
hash.update([0]);
hash.update(credit.id.as_bytes());
let digest = hash.finalize();
let offset =
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
}
fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option<DateTime<Utc>> {
record
.state
.credits
.iter()
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
.min()
}
fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime<Utc>) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
})
};
}
@@ -162,7 +162,7 @@ impl BackupService for DefaultBackupService {
ok: false,
stage: "putObject",
code: Some(code::INVALID_CONFIG),
message: "备份存储配置不完整,无法执行连接测试".to_owned(),
message: "备份存储配置不完整".to_owned(),
});
};
let result = self
@@ -198,14 +198,10 @@ impl BackupService for DefaultBackupService {
.await
.map_err(|error| map_store_error(error, "backup settings"))?;
if !settings.storage_configured() {
return Err(AdminError::bad_request(
"备份存储配置不完整,请先保存存储配置",
));
return Err(AdminError::bad_request("请先补全并保存备份存储配置"));
}
if !settings.storage_verified() {
return Err(AdminError::bad_request(
"备份存储尚未通过连接测试,请先测试存储配置",
));
return Err(AdminError::bad_request("请先通过备份存储连接测试"));
}
let prefix = settings.prefix.as_deref().unwrap_or_default();
let seed = build_backup_seed(
@@ -241,9 +237,7 @@ impl BackupService for DefaultBackupService {
) -> Result<DownloadUrlResult, AdminError> {
let record = self.require_record(backup_id).await?;
if record.status != BackupStatus::Completed {
return Err(AdminError::conflict(
"只有 completed 的备份才能创建下载地址",
));
return Err(AdminError::conflict("只能下载已完成的备份"));
}
let settings = self
.repository
@@ -280,9 +274,7 @@ impl BackupService for DefaultBackupService {
) -> Result<BackupRecord, AdminError> {
let record = self.require_record(backup_id).await?;
if !record.status.can_be_deleted() {
return Err(AdminError::conflict(
"只有 completed 或 failed 的备份才能删除",
));
return Err(AdminError::conflict("只能删除已完成或失败的备份"));
}
let now = Utc::now();
let deleting = self
@@ -290,7 +282,7 @@ impl BackupService for DefaultBackupService {
.transition_to_deleting(backup_id, now, Some(context))
.await
.map_err(|error| map_store_error(error, "backup record"))?
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
.ok_or_else(|| AdminError::conflict("备份状态已变更,请刷新列表"))?;
Ok(deleting)
}
}
@@ -162,7 +162,7 @@ impl ClientKeyService for DefaultClientKeyService {
for (provider, profile) in &command.request_profile_overrides {
self.providers
.require(provider)
.and_then(|provider| provider.preview_client_profile(profile))
.and_then(|provider| provider.preview_key_client_profile(profile, None))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
validate_peer_sharing(command.peer_sharing, &command.group_ids)?;
@@ -209,7 +209,7 @@ impl ClientKeyService for DefaultClientKeyService {
if let Some(profile) = profile {
self.providers
.require(provider)
.and_then(|provider| provider.preview_client_profile(profile))
.and_then(|provider| provider.preview_key_client_profile(profile, None))
.map_err(|error| super::map_provider_error(error, "client profile"))?;
}
}
@@ -19,41 +19,75 @@ use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::S
use super::{map_store_error, publish_committed};
pub(super) async fn publish_credentials_and_observe_quota(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
accounts: &std::sync::Arc<dyn AccountStore>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
notify_account_facts_changed(peer, provider.as_ref(), account_ids).await;
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
observe_account_quotas(peer, provider, accounts, account_ids, request_id);
Ok(())
}
pub(super) fn observe_account_quotas(
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
provider: &std::sync::Arc<dyn ProviderAdmin>,
accounts: &std::sync::Arc<dyn AccountStore>,
account_ids: &[ProviderAccountId],
request_id: &str,
) {
// 配置已提交,额度查询失败不回滚配置
let provider = provider.clone();
let peer = peer.cloned();
let accounts = accounts.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
let request = ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
};
let result = if let Some(peer) = peer.as_deref() {
let account = match accounts.load_account_record(&account_id).await {
Ok(Some(account)) => account,
Ok(None) => continue,
Err(error) => {
tracing::warn!(request_id, account_id = %account_id, error = %error, "quota observation account lookup failed");
continue;
}
};
if !account.enabled || &account.provider_kind != provider.provider_kind() {
continue;
}
// 后台查询与手动刷新共用认证能力分派,互联协议由 Admin 解释
super::peer_link::upstream::read_quota(
Some(peer),
provider.as_ref(),
&account,
request,
)
.await
{
} else {
provider.quota(request).await
};
if let Err(error) = result {
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
account_id = %account_id.as_str(),
quota_error = ?error.kind(),
"initial quota observation failed"
"account quota observation failed"
);
}
}
});
Ok(())
}
pub(super) async fn required_credential(
@@ -92,8 +92,9 @@ impl ProviderCredentials {
.map_err(|error| map_store_error(error, "provider credential import"))?;
drop(proxy_reservation);
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&self.provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
@@ -249,8 +250,9 @@ impl super::CredentialsService {
let result = outcome.result;
if outcome.newly_committed {
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -9,7 +9,10 @@ use std::{
use chrono::Utc;
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
use gateway_core::task::{
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId, WorkerKind,
WorkerRegistration, WorkerRunnable, WorkerTaskError,
};
use tokio::{sync::Notify, time::Instant};
use uuid::Uuid;
@@ -204,7 +207,7 @@ impl DefaultImportTasksService {
}
Err(_) => {
item.status = ImportItemStatus::Unknown;
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
item.message = Some("导入结果未知,重新导入前请核对账号列表".to_owned());
}
}
entry.finish_if_done();
@@ -328,6 +331,24 @@ impl ImportTasksService for DefaultImportTasksService {
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
impl ImportTaskWorker {
pub(crate) fn into_worker(self) -> Result<WorkerContribution, AdminError> {
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
let registration = WorkerRegistration::try_new(
id,
WorkerRunnable::Daemon {
restart,
task: Box::new(self),
},
)
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
Ok(WorkerContribution::Registration(registration))
}
}
impl DaemonTask for ImportTaskWorker {
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
@@ -14,11 +14,11 @@ pub mod peer_link;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub(crate) mod plugin_update;
pub mod plugins;
pub mod proxies;
pub mod settings;
pub mod system;
pub(crate) mod system_preflight;
pub mod usage_data;
mod credential_mutation;
@@ -8,6 +8,7 @@ use std::{
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use futures::StreamExt as _;
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
use crate::{
@@ -18,8 +19,8 @@ use crate::{
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
TrendSummary, UsageBilling, UsageBillingProjection, UsageCacheEfficiency,
RequestHealth, RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind,
TrendPoint, TrendSummary, UsageBilling, UsageBillingProjection, UsageCacheEfficiency,
UsageCalculatedBillingFact, UsageConcurrencyPoint, UsageCostComparison,
UsageCostSummary, UsageDetail, UsageFilter, UsageInsight, UsageInsightData,
UsageInsightMetric, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
@@ -132,8 +133,9 @@ impl DefaultObservabilityService {
self.settings.load_runtime_settings(),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
let runtime_slots = observation.runtime_slots;
self.enrich_list_records(&mut observation.recent_requests);
let runtime_slots = observation.runtime_slots.as_ref();
self.enrich_list_records(&mut observation.recent_requests)
.await;
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -178,11 +180,19 @@ impl DefaultObservabilityService {
.providers
.dashboard_wire_profiles(&settings.request_profiles);
let max_concurrent_per_account = u64::from(settings.values.max_concurrent_per_account);
let (inherited_accounts, overridden_slots) = runtime_slots
.as_ref()
let (mut inherited_accounts, mut overridden_slots) = runtime_slots
.map_or((observation.provider_accounts.normal, 0), |slots| {
(slots.inherited_accounts, slots.overridden_slots)
});
if let (Some(peer), Some(slots)) = (&self.upstream_info, runtime_slots) {
let now = std::time::SystemTime::now();
for version in &slots.inherited_versions {
if let Some(limit) = peer.observed_concurrency_limit(version, now) {
inherited_accounts -= 1;
overridden_slots = overridden_slots.saturating_add(u64::from(limit.get()));
}
}
}
let total_slots = (max_concurrent_per_account > 0 || inherited_accounts == 0).then(|| {
inherited_accounts
.saturating_mul(max_concurrent_per_account)
@@ -259,7 +269,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_records(&mut page.items);
self.enrich_list_records(&mut page.items).await;
Ok(page)
}
@@ -336,15 +346,29 @@ impl ObservabilityService for DefaultObservabilityService {
.usage_diagnostics(range, filter, dimension, 100)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let peers = if dimension == DiagnosticDimension::Account {
self.current_upstream_peers(observation.items.iter().map(|item| item.key.as_str()))
.await
} else {
HashMap::new()
};
let items = observation
.items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let health = RequestHealth::from_counts(
item.success_count,
item.failure_count,
item.cancelled_count,
item.incomplete_count,
item.caller_error_count,
);
let non_completion_count =
item.cancelled_count.saturating_add(item.incomplete_count);
let non_completion_rate = rate_or_zero(non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
account_upstream_peer: peers.get(&item.key).copied(),
key: item.key,
name: item.name,
account_authentication_kind: item.account_authentication_kind,
@@ -357,8 +381,8 @@ impl ObservabilityService for DefaultObservabilityService {
account_plan_type: item.account_plan_type,
request_count: item.request_count,
success_count: item.success_count,
error_count: item.failure_count,
error_rate,
error_count: health.non_success_requests(),
error_rate: health.error_ratio(),
request_share: rate_or_zero(
item.request_count,
observation.total_request_count,
@@ -366,7 +390,7 @@ impl ObservabilityService for DefaultObservabilityService {
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
non_completion_count: item.non_completion_count,
non_completion_count,
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
@@ -557,9 +581,8 @@ fn build_usage_insights(
}
let requests = &overview.requests;
let health_window = HealthWindow::from_metrics(requests);
let health_window = RequestHealth::from_metrics(requests);
let failed_requests = health_window.failed_requests;
let health_requests = health_window.eligible_requests();
let health = UsageInsightsHealth {
total_requests: requests.request_count,
success_requests: requests.success_count,
@@ -567,7 +590,7 @@ fn build_usage_insights(
cancelled_requests: requests.cancelled_count,
incomplete_requests: requests.incomplete_count,
caller_error_requests: requests.caller_error_count,
success_rate: rate_or_zero(requests.success_count, health_requests),
success_rate: health_window.success_ratio(),
completion_rate: rate_or_zero(
requests
.success_count
@@ -577,7 +600,7 @@ fn build_usage_insights(
points: trend
.iter()
.map(|point| {
let health_window = HealthWindow::from_metrics(&point.metrics);
let health_window = RequestHealth::from_metrics(&point.metrics);
UsageInsightsHealthPoint {
total_requests: point.metrics.request_count,
bucket_start: point.bucket_start,
@@ -586,10 +609,7 @@ fn build_usage_insights(
cancelled_requests: point.metrics.cancelled_count,
incomplete_requests: point.metrics.incomplete_count,
caller_error_requests: point.metrics.caller_error_count,
error_rate: rate_or_zero(
health_window.non_success_requests(),
health_window.eligible_requests(),
),
error_rate: health_window.error_ratio(),
}
})
.collect(),
@@ -912,6 +932,29 @@ fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
}
impl DefaultObservabilityService {
async fn current_upstream_peers<'a>(
&self,
ids: impl Iterator<Item = &'a str>,
) -> HashMap<String, bool> {
let Some(peer) = self.upstream_info.as_deref() else {
return HashMap::new();
};
let ids = ids
.map(str::to_owned)
.collect::<std::collections::BTreeSet<_>>();
// 同一页重复账号只读取一次摘要,单个账号不存在或读取失败不阻断历史记录
futures::stream::iter(ids)
.map(|id: String| async move {
let account_id = ProviderAccountId::new(id.clone()).ok()?;
let upstream = peer.read_summary(&account_id).await.ok()??.upstream?;
Some((id, upstream.discovered && upstream.observed_at.is_some()))
})
.buffer_unordered(8)
.filter_map(|value| async move { value })
.collect()
.await
}
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
if accounts.is_empty() {
return;
@@ -976,8 +1019,22 @@ impl DefaultObservabilityService {
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
async fn enrich_list_records(
&self,
records: &mut [crate::model::observability::UsageListRecord],
) {
let peers = self
.current_upstream_peers(
records
.iter()
.filter_map(|record| record.provider_account_ref.as_deref()),
)
.await;
for record in records {
record.provider_account_upstream_peer = record
.provider_account_ref
.as_ref()
.and_then(|id| peers.get(id).copied());
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
@@ -1071,7 +1128,7 @@ pub(super) fn health_timeline_at(
.map(|index| {
(
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
HealthWindow::default(),
RequestHealth::default(),
)
})
.collect::<Vec<_>>();
@@ -1097,7 +1154,7 @@ pub(super) fn health_timeline_at(
let totals = buckets
.iter()
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
.fold(RequestHealth::default(), |mut totals, (_, bucket)| {
totals.add_window(*bucket);
totals
});
@@ -1161,11 +1218,9 @@ fn trend(
}
}
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
let service_failure_count = service_failure_count(&metrics);
let success_rate = ratio(
metrics.success_count,
HealthWindow::from_metrics(&metrics).eligible_requests(),
);
let health = RequestHealth::from_metrics(&metrics);
let error_count = health.non_success_requests();
let success_rate = health.success_ratio();
let cache_hit_request_rate = ratio(
metrics.cache_hit_request_count,
metrics.cache_eligible_request_count,
@@ -1194,7 +1249,7 @@ fn trend(
output_tokens: metrics.output_tokens,
cached_input_tokens: metrics.cached_input_tokens,
total_tokens: metrics.total_tokens,
service_failure_count,
error_count,
average_latency_ms,
max_latency_ms: metrics.max_latency_ms,
min_latency_ms: metrics.min_latency_ms,
@@ -1213,11 +1268,11 @@ fn trend(
}
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
let service_failure_count = service_failure_count(&point.metrics);
let health = RequestHealth::from_metrics(&point.metrics);
TrendPoint {
bucket_start: point.bucket_start,
granularity: point.granularity,
service_failure_count,
error_count: health.non_success_requests(),
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
average_first_token_latency_ms: average(
point.metrics.first_token_latency_sum_ms,
@@ -1231,10 +1286,7 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
point.metrics.cache_hit_request_count,
point.metrics.cache_eligible_request_count,
),
success_rate: ratio(
point.metrics.success_count,
HealthWindow::from_metrics(&point.metrics).eligible_requests(),
),
success_rate: health.success_ratio(),
metrics: point.metrics,
cost_coverage: point.cost_coverage,
costs: point.costs,
@@ -1318,57 +1370,7 @@ fn rate_or_zero(numerator: u64, denominator: u64) -> f64 {
ratio(numerator, denominator).unwrap_or(0.0)
}
#[derive(Debug, Clone, Copy, Default)]
struct HealthWindow {
success_requests: u64,
failed_requests: u64,
cancelled_requests: u64,
incomplete_requests: u64,
caller_error_requests: u64,
}
impl HealthWindow {
fn from_metrics(metrics: &RequestMetrics) -> Self {
Self {
success_requests: metrics.success_count,
failed_requests: service_failure_count(metrics),
cancelled_requests: metrics.cancelled_count,
incomplete_requests: metrics.incomplete_count,
caller_error_requests: metrics.caller_error_count,
}
}
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
fn non_success_requests(self) -> u64 {
self.failed_requests
.saturating_add(self.incomplete_requests)
}
fn eligible_requests(self) -> u64 {
self.success_requests
.saturating_add(self.non_success_requests())
}
fn add_metrics(&mut self, metrics: &RequestMetrics) {
self.add_window(Self::from_metrics(metrics));
}
fn add_window(&mut self, other: Self) {
self.success_requests = self.success_requests.saturating_add(other.success_requests);
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
self.cancelled_requests = self
.cancelled_requests
.saturating_add(other.cancelled_requests);
self.incomplete_requests = self
.incomplete_requests
.saturating_add(other.incomplete_requests);
self.caller_error_requests = self
.caller_error_requests
.saturating_add(other.caller_error_requests);
}
}
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
fn health_status(bucket: RequestHealth, is_future: bool) -> HealthStatus {
let eligible_requests = bucket.eligible_requests();
if is_future {
HealthStatus::Future
@@ -1389,14 +1391,6 @@ fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
}
}
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
let eligible_requests = bucket.eligible_requests();
(eligible_requests > 0)
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
}
fn service_failure_count(metrics: &RequestMetrics) -> u64 {
metrics
.failure_count
.saturating_sub(metrics.caller_error_count)
fn health_reliability(bucket: RequestHealth) -> Option<f64> {
bucket.success_rate()
}
@@ -15,7 +15,7 @@ use crate::{
model::{
AdminError,
peer_link::{PeerLinkExport, PeerLinkLimits},
provider_credentials::ProviderQuotaReadRequest,
provider_credentials::{ProviderQuotaReadRequest, explicit_plan_type},
},
ports::{
provider::ProviderAdminRegistry,
@@ -117,6 +117,7 @@ impl PeerLinkService for DefaultPeerLinkService {
.budget
.limits
.access(key.budget.daily_used_usd, key.budget.weekly_used_usd),
key_limits: key.limits,
budget: key.budget,
budget_observed_at,
pool: None,
@@ -175,10 +176,16 @@ impl PeerLinkService for DefaultPeerLinkService {
.await
.map_err(|error| map_store_error(error, "peer account runtime"))?;
let mut quotas = BTreeMap::new();
if sharing.share_quota {
if sharing.share_quota || sharing.share_plans {
let mut window_count = 0usize;
let mut by_provider = BTreeMap::new();
for account in &accounts {
// 仅公开套餐时只补读缺失套餐的本地额度,不回源刷新或扩大公开范围
if !sharing.share_quota
&& explicit_plan_type(account.plan_type.as_deref()).is_some()
{
continue;
}
by_provider
.entry(&account.provider_kind)
.or_insert_with(Vec::new)
@@ -200,9 +207,11 @@ impl PeerLinkService for DefaultPeerLinkService {
.zip(provider.read_quotas(&requests).await)
{
if let Ok(quota) = quota {
window_count = window_count.saturating_add(quota.windows.len());
if window_count > limits.max_quota_windows {
return Err(PeerLinkError::SnapshotTooLarge);
if sharing.share_quota {
window_count = window_count.saturating_add(quota.windows.len());
if window_count > limits.max_quota_windows {
return Err(PeerLinkError::SnapshotTooLarge);
}
}
quotas.insert(account.id.clone(), quota);
}
@@ -215,6 +224,7 @@ impl PeerLinkService for DefaultPeerLinkService {
if sharing.share_pool {
result.pool = Some(projection::pool_summary(
&accounts,
&quotas,
&runtime,
&self.providers,
sharing.share_plans,
@@ -25,6 +25,7 @@ use crate::{
pub(super) fn pool_summary(
accounts: &[AccountRecord],
quotas: &BTreeMap<String, ProviderQuota>,
runtime: &AccountRuntimeSnapshot,
providers: &ProviderAdminRegistry,
share_plans: bool,
@@ -43,12 +44,13 @@ pub(super) fn pool_summary(
AccountStatus::Error => summary.error += 1,
}
if let Some(plans) = &mut summary.plans
&& let (Some(plan), Ok(provider)) = (
&account.plan_type,
providers.require(&account.provider_kind),
&& let Some(plan) = providers.resolve_account_plan(
account.provider_kind.as_str(),
&mut account.plan_type.clone(),
quotas.get(&account.id),
)
{
*plans.entry(provider.plan_type_display(plan)).or_default() += 1;
*plans.entry(plan).or_default() += 1;
}
}
summary
@@ -5,7 +5,7 @@ use super::{
};
use crate::model::provider_credentials::ProviderQuota;
use gateway_core::{
account::{QuotaObservation, UpstreamConnection},
account::{QuotaObservation, UpstreamConnection, UpstreamInfoFailure},
error::StoreError,
};
use std::{collections::BTreeMap, sync::Arc, time::SystemTime};
@@ -56,6 +56,7 @@ impl PeerInfoService {
checked_at: now,
scheduling: None,
summary: None,
stored_failure: None,
},
));
}
@@ -85,22 +86,24 @@ impl PeerInfoService {
connections
.iter()
.map(|connection| {
let cached = observations
.get(&connection.account_id)
.filter(|cached| cached.connection == *connection);
(
connection.clone(),
observations
.get(&connection.account_id)
.filter(|cached| cached.connection == *connection)
.and_then(|cached| cached.summary.clone()),
cached.and_then(|cached| cached.summary.clone()),
cached.and_then(|cached| cached.stored_failure),
)
})
.collect::<Vec<_>>()
};
Ok(summaries
.into_iter()
.map(|(connection, peer)| {
.map(|(connection, peer, stored_failure)| {
let quota = Self::project_peer(
&connection,
peer.as_deref(),
stored_failure,
self.last_error(&connection),
now,
);
@@ -132,6 +135,8 @@ impl PeerInfoService {
checked_at: now,
scheduling,
summary,
stored_failure: observation
.and_then(|value| snapshot::stored_failure(&value.quota, connection)),
};
let previous = self
.observations
@@ -140,4 +145,15 @@ impl PeerInfoService {
.insert(connection.account_id.clone(), cached);
drop(previous);
}
pub(super) fn unsupported(&self, connection: &UpstreamConnection) -> bool {
self.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&connection.account_id)
.is_some_and(|cached| {
cached.connection == *connection
&& cached.stored_failure == Some(UpstreamInfoFailure::Unsupported)
})
}
}
@@ -25,6 +25,7 @@ use gateway_core::{
error::StoreError,
task::{WorkerContribution, WorkerDefinitionError},
};
pub(crate) use projection::account_capacity;
use service::PeerInfoService;
/// 组合根独立注册公共互联 Worker,不属于任何具体 Provider 的任务集合
@@ -62,6 +63,24 @@ pub fn initialize(
#[async_trait::async_trait]
impl PeerUpstreamService for PeerInfoService {
fn observed_concurrency_limit(
&self,
version: &gateway_core::account::QuotaObservationVersion,
now: std::time::SystemTime,
) -> Option<gateway_core::account::AccountConcurrencyLimit> {
let observations = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cached = observations.get(&version.account_id)?;
if cached.observed_at != Some(version.observed_at)
|| cached.connection.credential_revision != version.credential_revision
{
return None;
}
cached.scheduling.as_ref()?.concurrency_limit(now)
}
async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
PeerInfoService::account_changed(self, id).await
}
@@ -12,16 +12,35 @@ use crate::model::{
},
};
use gateway_core::account::{QuotaAccessState, UpstreamConnection, UpstreamInfoFailure};
use gateway_protocol::peer::{
PeerComponentStatus, PeerLink, PeerPoolDisclosure, PeerQuotaDisclosure,
};
use gateway_protocol::peer::{PeerComponentStatus, PeerLink};
use super::{PeerInfoService, quota, snapshot};
pub(crate) fn account_capacity(
account: &crate::model::accounts::AccountRecord,
mut capacity: crate::model::accounts::AccountCapacity,
quota: &ProviderQuota,
) -> crate::model::accounts::AccountCapacity {
if account.concurrency_limit.is_none()
&& let Some(info) = &quota.upstream
&& !info.stale
&& info
.observed_at
.is_some_and(|at| snapshot::fresh_at(at.into(), SystemTime::now()))
&& let Some(limit) = info
.max_concurrency
.and_then(gateway_core::account::AccountConcurrencyLimit::from_key_limit)
{
capacity.total_slots = Some(u64::from(limit.get()));
}
capacity
}
impl PeerInfoService {
pub(super) fn project_peer(
connection: &UpstreamConnection,
peer: Option<&PeerLink>,
stored_failure: Option<UpstreamInfoFailure>,
last_error: Option<UpstreamInfoFailure>,
now: SystemTime,
) -> ProviderQuota {
@@ -31,19 +50,24 @@ impl PeerInfoService {
let stale = peer.as_ref().is_none_or(|peer| {
!snapshot::component_fresh(Some(&peer.observed_at), Some(&peer.expires_at), now)
});
let limits = peer.and_then(|peer| peer.key_limits);
let info = ProviderUpstreamInfo {
discovered: peer.is_some(),
discovered: peer.is_some() || stored_failure == Some(UpstreamInfoFailure::Unauthorized),
observed_at,
stale,
connection_revision: connection.revision.clone(),
last_error: last_error.map(|error| error.as_str().to_owned()),
last_error: last_error
.or(stored_failure)
.map(|error| error.as_str().to_owned()),
max_concurrency: limits.and_then(|limits| limits.max_concurrency),
requests_per_minute: limits.and_then(|limits| limits.requests_per_minute),
pool: peer
.as_ref()
.filter(|peer| peer.pool.disclosure == PeerPoolDisclosure::Summary)
.and_then(|peer| {
let states = peer.pool.states.as_ref()?;
Some(UpstreamPool {
account_count: peer.pool.account_count?,
.and_then(|peer| peer.pool.as_ref())
.map(|pool| {
let states = &pool.states;
UpstreamPool {
account_count: pool.account_count,
states: UpstreamPoolStates {
ready: states.ready,
cooldown: states.cooldown,
@@ -52,8 +76,7 @@ impl PeerInfoService {
error: states.error,
unknown: states.unknown,
},
plans: peer
.pool
plans: pool
.plans
.as_deref()
.unwrap_or_default()
@@ -63,19 +86,18 @@ impl PeerInfoService {
count: plan.count,
})
.collect(),
})
}
}),
};
let mut windows = Vec::new();
if let Some(peer) = &peer {
if peer.usage.status == PeerComponentStatus::Available
&& let Some(usage) = &peer.usage.data
if let Some(component) = &peer.usage
&& component.status == PeerComponentStatus::Available
&& let Some(usage) = &component.data
&& let Some(unit) = &usage.unit
{
for (key, label, seconds, window) in [
("daily", "日额度", 86400, &usage.daily),
("weekly", "周额度", 604800, &usage.weekly),
] {
// 不限额仍有日、周账本,只是不计算占用百分比
for window in &usage.windows {
// 不限额仍展示已提供的账本窗口,只是不计算占用百分比
let total = window
.total
.as_deref()
@@ -84,19 +106,17 @@ impl PeerInfoService {
let used_percent = snapshot::decimal(&window.used)
.zip(total)
.map(|(used, total)| (used / total * 100.0).clamp(0.0, 100.0));
let window_observed = peer
.usage
let window_observed = component
.observed_at
.as_deref()
.and_then(snapshot::datetime);
let window_stale = stale
|| !peer
.usage
|| !component
.observed_at
.as_deref()
.is_some_and(|value| snapshot::fresh(value, now))
|| window
.resets_at
.reset_at
.as_deref()
.and_then(snapshot::timestamp)
.is_some_and(|reset| reset <= now);
@@ -106,27 +126,28 @@ impl PeerInfoService {
observed_at: window_observed,
stale: window_stale,
amount: Some(UpstreamQuotaAmount {
unit: usage.unit.clone(),
unit: unit.clone(),
used: window.used.clone(),
total: window.total.clone(),
remaining: window.remaining.clone(),
}),
}),
key: format!("peer:budget:{key}"),
key: format!("peer:budget:{}", window.key),
group: "general".to_owned(),
label: label.to_owned(),
label: window.label.as_deref().unwrap_or(&window.key).to_owned(),
limit_id: None,
limit_name: None,
role: Some(if key == "weekly" {
ProviderQuotaWindowRole::Secondary
} else {
ProviderQuotaWindowRole::Primary
}),
role: match window.key.as_str() {
"daily" => Some(ProviderQuotaWindowRole::Primary),
"weekly" => Some(ProviderQuotaWindowRole::Secondary),
"monthly" => Some(ProviderQuotaWindowRole::Monthly),
_ => None,
},
// Key 消费窗口覆盖此连接的全部请求,本机用量按同一时间边界聚合
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(seconds),
window_seconds: window.window_seconds,
used_percent,
reset_at: window.resets_at.as_deref().and_then(snapshot::datetime),
reset_at: window.reset_at.as_deref().and_then(snapshot::datetime),
limit_reached: !window_stale
&& quota::window_access(window, now) == QuotaAccessState::Exhausted,
local_usage: None,
@@ -135,8 +156,8 @@ impl PeerInfoService {
}
}
// 池内窗口各自保留账号归属,不能将不同套餐相加为统一消费额度
if peer.quota.disclosure == PeerQuotaDisclosure::Accounts {
for (index, account) in peer.quota.accounts.iter().enumerate() {
if let Some(quota) = &peer.quota {
for (index, account) in quota.accounts.iter().enumerate() {
let window_observed =
account.observed_at.as_deref().and_then(snapshot::datetime);
let window_stale = stale
@@ -146,6 +167,7 @@ impl PeerInfoService {
now,
);
for window in &account.windows {
let group = window.group.as_deref().unwrap_or("general");
let window_stale = window_stale
|| window
.reset_at
@@ -156,7 +178,7 @@ impl PeerInfoService {
"peer:{}:{}:{}",
account.id.len(),
account.id,
window.limit_id.as_deref().unwrap_or(&window.group)
window.limit_id.as_deref().unwrap_or(group)
);
windows.push(ProviderQuotaWindow {
upstream: Some(UpstreamQuotaWindow {
@@ -166,8 +188,8 @@ impl PeerInfoService {
amount: None,
}),
key: format!("{scope}:{}", window.key),
group: window.group.clone(),
label: window.label.clone(),
group: group.to_owned(),
label: window.label.as_deref().unwrap_or(&window.key).to_owned(),
limit_id: Some(scope),
limit_name: Some(match window.limit_name.as_deref() {
Some(name) => format!("账号 {} · {name}", index + 1),
@@ -13,24 +13,26 @@ pub(super) fn observe(
started_at: SystemTime,
now: SystemTime,
) -> Option<QuotaState> {
let usage = peer.usage.as_ref()?;
if !fresh(&peer.observed_at, now)
|| !component_fresh(Some(&peer.observed_at), Some(&peer.expires_at), now)
|| peer.usage.status != PeerComponentStatus::Available
|| !component_fresh(peer.usage.observed_at.as_deref(), None, now)
|| usage.status != PeerComponentStatus::Available
|| !component_fresh(usage.observed_at.as_deref(), None, now)
{
return None;
}
let observed_at = started_at
.min(timestamp(&peer.observed_at)?)
.min(timestamp(peer.usage.observed_at.as_deref()?)?);
let usage = peer.usage.data.as_ref()?;
.min(timestamp(usage.observed_at.as_deref()?)?);
let usage = usage.data.as_ref()?;
match access(usage, now) {
QuotaAccessState::Allowed => Some(QuotaState::allowed(observed_at)),
QuotaAccessState::Exhausted => {
let reset_at = [&usage.daily, &usage.weekly]
.into_iter()
let reset_at = usage
.windows
.iter()
.filter(|window| window_access(window, now) == QuotaAccessState::Exhausted)
.filter_map(|window| window.resets_at.as_deref().and_then(timestamp))
.filter_map(|window| window.reset_at.as_deref().and_then(timestamp))
.min();
Some(QuotaState::exhausted(
if usage.allowed == Some(false) {
@@ -51,8 +53,9 @@ pub(super) fn access(usage: &PeerKeyUsage, now: SystemTime) -> QuotaAccessState
match usage.allowed {
Some(true) => QuotaAccessState::Allowed,
Some(false) => QuotaAccessState::Exhausted,
None if [&usage.daily, &usage.weekly]
.into_iter()
None if usage
.windows
.iter()
.any(|window| window_access(window, now) == QuotaAccessState::Exhausted) =>
{
QuotaAccessState::Exhausted
@@ -64,7 +67,7 @@ pub(super) fn access(usage: &PeerKeyUsage, now: SystemTime) -> QuotaAccessState
/// 过期窗口保持未知,不从重置时间或金额推断恢复
pub(super) fn window_access(window: &PeerBudgetWindow, now: SystemTime) -> QuotaAccessState {
if window
.resets_at
.reset_at
.as_deref()
.and_then(timestamp)
.is_some_and(|reset| reset <= now)
@@ -43,6 +43,9 @@ impl PeerInfoService {
}
pub(super) fn refresh_due(&self, connection: &UpstreamConnection) -> bool {
if self.unsupported(connection) {
return false;
}
let state = self.refresh_state(connection);
state.try_lock().is_ok_and(|state| {
state.connection != *connection || Instant::now() >= state.next_attempt
@@ -71,6 +74,12 @@ impl PeerInfoService {
id: &ProviderAccountId,
manual: bool,
) -> Result<ProviderQuota, ProviderAdminError> {
// 首查、手动与周期刷新共享容量;排队后再读凭据,避免使用等待期间失效的连接
let _permit = self
.refresh_slots
.acquire()
.await
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
let loaded = self
.accounts
.load_current_credential(id)
@@ -85,18 +94,19 @@ impl PeerInfoService {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))?;
let slot = self.refresh_state(&connection);
let mut state = slot.lock().await;
if state.connection == connection
&& Instant::now()
< if manual {
state.manual_after
} else {
state.next_attempt
}
if !manual && self.unsupported(&connection)
|| state.connection == connection
&& Instant::now()
< if manual {
state.manual_after
} else {
state.next_attempt
}
{
let error = state.last_error;
drop(state);
if let Some(error) = error {
return Err(refresh_error(error));
if let Some(error) = error.and_then(refresh_error) {
return Err(error);
}
return self
.read_quota(id)
@@ -120,25 +130,38 @@ impl PeerInfoService {
let failure = result.as_ref().err().copied();
let delay = match failure {
None => Duration::from_secs(240 + stable_jitter(id.as_str())),
Some(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported) => {
Some(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::SharingDisabled) => {
Duration::from_secs(1800)
}
Some(_) => Duration::from_secs(30),
}
.max(retry_after.unwrap_or_default());
state.next_attempt = Instant::now() + delay;
state.manual_after = Instant::now()
+ if failure.is_some() {
delay
} else {
Duration::from_secs(15)
};
// 管理员修复授权后可主动复核,不继承自动重试的长退避,仍遵守上游限流
state.manual_after =
Instant::now() + Duration::from_secs(15).max(retry_after.unwrap_or_default());
state.last_error = failure;
let document = match result {
Ok(document) => Some(document),
// 空观测清除已撤销内容,并用共有观察时间阻止更早请求重新写回
Err(UpstreamInfoFailure::Unauthorized) => {
Some(OpaqueProviderData::new(Default::default()))
Err(UpstreamInfoFailure::Unsupported) => Some(snapshot::unsupported(&connection)),
Err(error @ UpstreamInfoFailure::SharingDisabled) => {
Some(snapshot::unavailable(&connection, error))
}
Err(error @ UpstreamInfoFailure::Unauthorized) => {
let discovered = self
.read_summary(id)
.await
.map_err(store_error)?
.and_then(|quota| quota.upstream)
.is_some_and(|info| {
info.connection_revision == connection.revision && info.discovered
});
// 首次探测失败不能证明对方是互联节点;已确认的连接只留下无共享内容的识别记录
Some(if discovered {
snapshot::unavailable(&connection, error)
} else {
OpaqueProviderData::new(Default::default())
})
}
Err(_) => None,
};
@@ -179,8 +202,9 @@ impl PeerInfoService {
);
}
drop(state);
if let Some(error) = failure {
return Err(refresh_error(error));
// 不支持协议是正常发现结果,管理端保留分类,后台不重复报警
if let Some(error) = failure.and_then(refresh_error) {
return Err(error);
}
self.read_quota(id)
.await
@@ -195,12 +219,13 @@ fn stable_jitter(id: &str) -> u64 {
}) % 60
}
fn refresh_error(error: UpstreamInfoFailure) -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(match error {
fn refresh_error(error: UpstreamInfoFailure) -> Option<ProviderAdminError> {
let message = match error {
UpstreamInfoFailure::Unauthorized => "上游未授权读取互联信息",
UpstreamInfoFailure::Unsupported => "上游不支持互联信息查询",
UpstreamInfoFailure::Unsupported | UpstreamInfoFailure::SharingDisabled => return None,
UpstreamInfoFailure::RateLimited => "上游互联查询已限流",
UpstreamInfoFailure::InvalidResponse => "上游互联响应无效",
UpstreamInfoFailure::Unavailable => "上游互联服务暂不可用",
})
};
Some(ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(message))
}
@@ -63,6 +63,7 @@ struct ModelObservation {
pub(super) struct SchedulingObservation {
validity: Validity,
concurrency_limit: Option<gateway_core::account::AccountConcurrencyLimit>,
exhausted_budgets: Vec<Validity>,
models: HashMap<String, Option<ModelObservation>>,
}
@@ -70,22 +71,23 @@ pub(super) struct SchedulingObservation {
impl SchedulingObservation {
pub(super) fn new(peer: &PeerLink, now: SystemTime) -> Option<Self> {
let validity = Validity::component(Some(&peer.observed_at), Some(&peer.expires_at))?;
let mut exhausted_budgets = Vec::with_capacity(2);
if peer.usage.status == PeerComponentStatus::Available
&& let Some(usage) = &peer.usage.data
let mut exhausted_budgets = Vec::new();
if let Some(component) = &peer.usage
&& component.status == PeerComponentStatus::Available
&& let Some(usage) = &component.data
&& let Some(usage_validity) =
Validity::component(peer.usage.observed_at.as_deref(), None)
Validity::component(component.observed_at.as_deref(), None)
&& quota::access(usage, now) == QuotaAccessState::Exhausted
{
if usage.allowed.is_some() {
exhausted_budgets.push(usage_validity);
} else {
// 窗口提供的补充证据只在各自重置边界之前影响调度评分
for window in [&usage.daily, &usage.weekly] {
for window in &usage.windows {
if quota::window_access(window, now) == QuotaAccessState::Exhausted {
let mut validity = usage_validity;
if let Some(reset) =
window.resets_at.as_deref().and_then(snapshot::timestamp)
window.reset_at.as_deref().and_then(snapshot::timestamp)
{
validity.expires_at = validity.expires_at.min(reset);
}
@@ -97,7 +99,11 @@ impl SchedulingObservation {
// 同一账号重复列出模型不增加候选数,多账号覆盖不计算池平均百分比
let mut model_accounts = HashMap::<&str, Option<usize>>::new();
for (index, account) in peer.quota.accounts.iter().enumerate() {
let quota_accounts = peer
.quota
.as_ref()
.map_or(&[][..], |quota| quota.accounts.as_slice());
for (index, account) in quota_accounts.iter().enumerate() {
for model in &account.models {
model_accounts
.entry(model)
@@ -128,7 +134,7 @@ impl SchedulingObservation {
accounts
.entry(index)
.or_insert_with(|| {
let account = &peer.quota.accounts[index];
let account = &quota_accounts[index];
let validity = Validity::component(
account.observed_at.as_deref(),
account.expires_at.as_deref(),
@@ -161,43 +167,52 @@ impl SchedulingObservation {
}
Some(Self {
validity,
concurrency_limit: peer
.key_limits
.and_then(|limits| limits.max_concurrency)
.and_then(gateway_core::account::AccountConcurrencyLimit::from_key_limit),
exhausted_budgets,
models,
})
}
pub(super) fn fresh(&self, now: SystemTime) -> bool {
self.validity.fresh(now)
&& now
.duration_since(self.validity.observed_at)
.unwrap_or_default()
<= snapshot::FRESH_FOR
self.validity.fresh(now) && snapshot::fresh_at(self.validity.observed_at, now)
}
pub(super) fn signal(&self, model: &str, now: SystemTime) -> Option<UpstreamSchedulingSignal> {
pub(super) fn concurrency_limit(
&self,
now: SystemTime,
) -> Option<gateway_core::account::AccountConcurrencyLimit> {
self.concurrency_limit.filter(|_| self.fresh(now))
}
pub(super) fn signal(
&self,
model: Option<&str>,
now: SystemTime,
) -> Option<UpstreamSchedulingSignal> {
if !self.fresh(now) {
return None;
}
if self
let budget_exhausted = self
.exhausted_budgets
.iter()
.any(|validity| validity.fresh(now))
{
return Some(UpstreamSchedulingSignal {
exhausted: true,
remaining_percent: None,
});
}
let model = self.models.get(model)?.as_ref()?;
if !model.validity.fresh(now) {
.any(|validity| validity.fresh(now));
let model = model
.and_then(|id| self.models.get(id)?.as_ref())
.filter(|model| model.validity.fresh(now));
if !budget_exhausted && model.is_none() && self.concurrency_limit.is_none() {
return None;
}
Some(UpstreamSchedulingSignal {
exhausted: model.exhausted,
remaining_percent: model
.account
.as_ref()
.and_then(|account| account.remaining_percent(now)),
concurrency_limit: self.concurrency_limit,
exhausted: budget_exhausted || model.is_some_and(|model| model.exhausted),
remaining_percent: if budget_exhausted {
None
} else {
model.and_then(|model| model.account.as_ref()?.remaining_percent(now))
},
})
}
}
@@ -12,7 +12,7 @@ use futures::{StreamExt as _, stream};
use gateway_core::{
account::{
LoadedCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
UpstreamConnection, UpstreamInfoSource, UpstreamSchedulingSignal,
UpstreamConnection, UpstreamInfoFailure, UpstreamInfoSource, UpstreamSchedulingSignal,
},
error::{StoreError, StoreErrorKind},
routing::ProviderKind,
@@ -24,6 +24,9 @@ use std::{
sync::{Arc, Mutex, RwLock},
time::SystemTime,
};
use tokio::sync::Semaphore;
const REFRESH_CONCURRENCY: usize = 4;
pub(super) struct CachedObservation {
pub connection: UpstreamConnection,
@@ -31,6 +34,7 @@ pub(super) struct CachedObservation {
pub checked_at: SystemTime,
pub scheduling: Option<Arc<SchedulingObservation>>,
pub summary: Option<Arc<PeerLink>>,
pub stored_failure: Option<UpstreamInfoFailure>,
}
pub(crate) struct PeerInfoService {
@@ -40,6 +44,7 @@ pub(crate) struct PeerInfoService {
pub(super) attempts: Mutex<HashMap<ProviderAccountId, Arc<tokio::sync::Mutex<RefreshState>>>>,
cursors: Mutex<BTreeMap<ProviderKind, usize>>,
pub(super) cache_refresh: tokio::sync::Mutex<()>,
pub(super) refresh_slots: Semaphore,
pub(super) observations: RwLock<HashMap<ProviderAccountId, CachedObservation>>,
}
@@ -56,12 +61,46 @@ impl PeerInfoService {
attempts: Mutex::default(),
cursors: Mutex::default(),
cache_refresh: tokio::sync::Mutex::new(()),
refresh_slots: Semaphore::new(REFRESH_CONCURRENCY),
observations: RwLock::default(),
}
}
pub(crate) async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
self.forget(id);
let has_observation = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
let has_attempt = self
.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
if !has_observation && !has_attempt {
return Ok(());
}
let Some(account) = self
.accounts
.get_account(id)
.await?
.filter(|account| self.supported(account))
else {
self.forget(id);
return Ok(());
};
let connection = UpstreamConnection::for_account(&account);
// 普通信息编辑保留观测与退避,连接变化由刷新状态中的身份比较处理
let mut observations = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if observations
.get(id)
.is_some_and(|cached| cached.connection != connection)
{
observations.remove(id);
}
Ok(())
}
@@ -133,12 +172,16 @@ impl PeerInfoService {
let peer = observation
.as_ref()
.and_then(|value| snapshot::decode(&value.quota));
let stored_failure = observation
.as_ref()
.and_then(|value| snapshot::stored_failure(&value.quota, &connection));
if self.current_connection(id).await?.as_ref() != Some(&connection) {
return Ok(None);
}
Ok(Some(Self::project_peer(
&connection,
peer.as_ref(),
stored_failure,
self.last_error(&connection),
SystemTime::now(),
)))
@@ -215,7 +258,7 @@ impl PeerInfoService {
if let Err(error) = self.refresh(&connection.account_id, false).await {
tracing::warn!(account_id = %connection.account_id, error = %error, "Upstream quota refresh failed");
}
}).buffer_unordered(4).collect::<Vec<_>>().await;
}).buffer_unordered(REFRESH_CONCURRENCY).collect::<Vec<_>>().await;
}
// 查询失败时保留该轮无法核对的账号,避免错误风暴清空所有节流记录
if failure.is_none() && !context.cancellation().is_cancelled() {
@@ -242,7 +285,7 @@ impl UpstreamInfoSource for PeerInfoService {
fn signal(
&self,
account: &ProviderAccount,
model: &str,
model: Option<&str>,
now: SystemTime,
) -> Option<UpstreamSchedulingSignal> {
if !account.enabled() {
@@ -1,18 +1,71 @@
//! 互联响应的有界校验、能力授权与时间有效性解释
use std::time::{Duration, SystemTime};
use std::{
collections::HashSet,
time::{Duration, SystemTime},
};
use chrono::{DateTime, Utc};
use gateway_core::account::{OpaqueProviderData, UpstreamInfoFailure};
use gateway_core::account::{OpaqueProviderData, UpstreamConnection, UpstreamInfoFailure};
use gateway_protocol::peer::{
MAX_ACCOUNTS, MAX_MODELS, MAX_QUOTA_WINDOWS, OBJECT, PeerLink, PeerPoolDisclosure,
SCHEMA_VERSION,
MAX_ACCOUNTS, MAX_MODELS, MAX_QUOTA_WINDOWS, OBJECT, PeerKeyUsage, PeerLink, PeerPool,
PeerQuota, SCHEMA_VERSION,
};
use serde::Deserialize as _;
pub(super) const MAX_AGE: Duration = Duration::from_secs(30 * 60);
pub(super) const FRESH_FOR: Duration = Duration::from_secs(5 * 60);
// 不支持协议是连接的识别结果,复用观测存储与版本保护,不伪造上游额度快照
pub(super) fn unsupported(connection: &UpstreamConnection) -> OpaqueProviderData {
OpaqueProviderData::new(serde_json::Map::from_iter([
("object".into(), "cpr.peer.unsupported".into()),
("schema_version".into(), 1.into()),
(
"connection_revision".into(),
connection.revision.clone().into(),
),
]))
}
// 授权撤销或共享关闭后只保存连接与失败原因,丢弃全部共享数据
pub(super) fn unavailable(
connection: &UpstreamConnection,
reason: UpstreamInfoFailure,
) -> OpaqueProviderData {
OpaqueProviderData::new(serde_json::Map::from_iter([
("object".into(), "cpr.peer.unavailable".into()),
("schema_version".into(), 1.into()),
(
"connection_revision".into(),
connection.revision.clone().into(),
),
("reason".into(), reason.as_str().into()),
]))
}
pub(super) fn stored_failure(
data: &OpaqueProviderData,
connection: &UpstreamConnection,
) -> Option<UpstreamInfoFailure> {
let fields = data.expose_to_provider();
if fields.get("schema_version")?.as_u64()? != 1
|| fields.get("connection_revision")?.as_str()? != connection.revision
{
return None;
}
match fields.get("object")?.as_str()? {
"cpr.peer.unsupported" => Some(UpstreamInfoFailure::Unsupported),
"cpr.peer.unavailable" => match fields.get("reason")?.as_str()? {
"unauthorized" => Some(UpstreamInfoFailure::Unauthorized),
"sharing_disabled" => Some(UpstreamInfoFailure::SharingDisabled),
"unsupported" => Some(UpstreamInfoFailure::Unsupported),
_ => None,
},
_ => None,
}
}
pub(super) fn timestamp(value: &str) -> Option<SystemTime> {
DateTime::parse_from_rfc3339(value)
.ok()
@@ -26,10 +79,12 @@ pub(super) fn datetime(value: &str) -> Option<DateTime<Utc>> {
}
pub(super) fn fresh(observed: &str, now: SystemTime) -> bool {
timestamp(observed).is_some_and(|observed| {
observed <= now + Duration::from_secs(60)
&& now.duration_since(observed).unwrap_or_default() <= FRESH_FOR
})
timestamp(observed).is_some_and(|observed| fresh_at(observed, now))
}
pub(super) fn fresh_at(observed: SystemTime, now: SystemTime) -> bool {
observed <= now + Duration::from_secs(60)
&& now.duration_since(observed).unwrap_or_default() <= FRESH_FOR
}
pub(super) fn component_fresh(
@@ -51,14 +106,15 @@ pub(super) fn component_fresh(
}
pub(super) fn decode(data: &OpaqueProviderData) -> Option<PeerLink> {
PeerLink::deserialize(
let peer = PeerLink::deserialize(
serde::de::value::MapDeserializer::<_, serde_json::Error>::new(
data.expose_to_provider()
.iter()
.map(|(key, value)| (key.as_str(), value)),
),
)
.ok()
.ok()?;
(peer.object == OBJECT && peer.schema_version == SCHEMA_VERSION).then_some(peer)
}
pub(super) fn parse(
@@ -67,11 +123,17 @@ pub(super) fn parse(
) -> Result<OpaqueProviderData, UpstreamInfoFailure> {
let mut peer: PeerLink =
serde_json::from_slice(bytes).map_err(|_| UpstreamInfoFailure::InvalidResponse)?;
let valid_text = |value: &str, max: usize| {
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
};
let observed = timestamp(&peer.observed_at).ok_or(UpstreamInfoFailure::InvalidResponse)?;
let expires = timestamp(&peer.expires_at).ok_or(UpstreamInfoFailure::InvalidResponse)?;
let accounts = peer
.quota
.as_ref()
.map_or(&[][..], |quota| quota.accounts.as_slice());
let budget_windows = peer
.usage
.as_ref()
.and_then(|usage| usage.data.as_ref())
.map_or(0, |usage| usage.windows.len());
if peer.object != OBJECT
|| peer.schema_version != SCHEMA_VERSION
|| observed > now + Duration::from_secs(60)
@@ -79,125 +141,42 @@ pub(super) fn parse(
|| expires <= observed
|| expires > observed + MAX_AGE
|| peer.capabilities.len() > 32
|| peer
.quota
.accounts
|| accounts
.iter()
.map(|account| account.windows.len())
.sum::<usize>()
> MAX_QUOTA_WINDOWS
|| peer.quota.accounts.len() > MAX_ACCOUNTS
|| budget_windows > MAX_QUOTA_WINDOWS
|| accounts.len() > MAX_ACCOUNTS
|| peer.models.len() > MAX_MODELS
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
// 未授权能力不能借同名数据块越过逐层公开边界
if !peer.capabilities.iter().any(|value| value == "key_usage") {
peer.usage.data = None;
let disclosed = |capability| peer.capabilities.iter().any(|value| value == capability);
if !disclosed("key_limits") {
peer.key_limits = None;
}
if !peer
.capabilities
.iter()
.any(|value| value == "pool_summary")
{
peer.pool = Default::default();
if !disclosed("key_usage") {
peer.usage = None;
}
if !peer
.capabilities
.iter()
.any(|value| value == "quota_windows")
{
peer.quota = Default::default();
if !disclosed("pool_summary") {
peer.pool = None;
}
if !peer
.capabilities
.iter()
.any(|value| value == "model_availability")
{
if !disclosed("quota_windows") {
peer.quota = None;
}
if !disclosed("model_availability") {
peer.models.clear();
}
if peer.pool.disclosure == PeerPoolDisclosure::Summary {
let count = peer
.pool
.account_count
.ok_or(UpstreamInfoFailure::InvalidResponse)?;
let states = peer
.pool
.states
.as_ref()
.ok_or(UpstreamInfoFailure::InvalidResponse)?;
let state_total = [
states.ready,
states.cooldown,
states.exhausted,
states.disabled,
states.error,
states.unknown,
]
.into_iter()
.map(u64::from)
.sum::<u64>();
if u64::from(count) > MAX_ACCOUNTS as u64 || state_total != u64::from(count) {
return Err(UpstreamInfoFailure::InvalidResponse);
}
if let Some(plans) = &peer.pool.plans
&& (plans.len() > MAX_ACCOUNTS
|| plans
.iter()
.any(|plan| !valid_text(&plan.name, 256) || plan.count > count)
|| plans.iter().map(|plan| u64::from(plan.count)).sum::<u64>() > u64::from(count))
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
if let Some(pool) = &peer.pool {
validate_pool(pool)?;
}
if let Some(usage) = &peer.usage.data {
if !valid_text(&usage.unit, 16) {
return Err(UpstreamInfoFailure::InvalidResponse);
}
for window in [&usage.daily, &usage.weekly] {
if [
Some(&window.used),
window.total.as_ref(),
window.remaining.as_ref(),
]
.into_iter()
.flatten()
.any(|value| decimal(value).is_none())
|| window
.resets_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
if let Some(usage) = peer.usage.as_ref().and_then(|usage| usage.data.as_ref()) {
validate_usage(usage)?;
}
for account in &peer.quota.accounts {
if !valid_text(&account.id, 256)
|| account.models.len() > MAX_MODELS
|| account.windows.iter().any(|window| {
!valid_text(&window.key, 256)
|| !valid_text(&window.label, 256)
|| !valid_text(&window.group, 128)
|| window
.limit_id
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.limit_name
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.used_percent
.is_some_and(|value| !value.is_finite() || !(0.0..=100.0).contains(&value))
|| window
.reset_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
})
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
if let Some(quota) = &peer.quota {
validate_quota(quota)?;
}
for model in &peer.models {
if !valid_text(&model.model, 256)
@@ -219,6 +198,113 @@ pub(super) fn parse(
}
}
fn valid_text(value: &str, max: usize) -> bool {
!value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
}
fn validate_pool(pool: &PeerPool) -> Result<(), UpstreamInfoFailure> {
let count = pool.account_count;
let states = &pool.states;
let state_total = [
states.ready,
states.cooldown,
states.exhausted,
states.disabled,
states.error,
states.unknown,
]
.into_iter()
.map(u64::from)
.sum::<u64>();
if u64::from(count) > MAX_ACCOUNTS as u64 || state_total != u64::from(count) {
return Err(UpstreamInfoFailure::InvalidResponse);
}
if let Some(plans) = &pool.plans
&& (plans.len() > MAX_ACCOUNTS
|| plans
.iter()
.any(|plan| !valid_text(&plan.name, 256) || plan.count > count)
|| plans.iter().map(|plan| u64::from(plan.count)).sum::<u64>() > u64::from(count))
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
Ok(())
}
fn validate_usage(usage: &PeerKeyUsage) -> Result<(), UpstreamInfoFailure> {
if usage
.unit
.as_deref()
.is_some_and(|unit| !valid_text(unit, 16))
|| !usage.windows.is_empty() && usage.unit.is_none()
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
let mut keys = HashSet::new();
for window in &usage.windows {
if !valid_text(&window.key, 256)
|| !keys.insert(&window.key)
|| window
.label
.as_deref()
.is_some_and(|label| !valid_text(label, 256))
|| window.window_seconds == Some(0)
|| [
Some(&window.used),
window.total.as_ref(),
window.remaining.as_ref(),
]
.into_iter()
.flatten()
.any(|value| decimal(value).is_none())
|| window
.reset_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
Ok(())
}
fn validate_quota(quota: &PeerQuota) -> Result<(), UpstreamInfoFailure> {
for account in &quota.accounts {
if !valid_text(&account.id, 256)
|| account.models.len() > MAX_MODELS
|| account.windows.iter().any(|window| {
!valid_text(&window.key, 256)
|| window
.label
.as_deref()
.is_some_and(|label| !valid_text(label, 256))
|| window
.group
.as_deref()
.is_some_and(|group| !valid_text(group, 128))
|| window
.limit_id
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.limit_name
.as_deref()
.is_some_and(|value| !valid_text(value, 256))
|| window
.used_percent
.is_some_and(|value| !value.is_finite() || !(0.0..=100.0).contains(&value))
|| window
.reset_at
.as_ref()
.is_some_and(|value| timestamp(value).is_none())
})
{
return Err(UpstreamInfoFailure::InvalidResponse);
}
}
Ok(())
}
pub(super) fn decimal(value: &str) -> Option<f64> {
if value.is_empty()
|| value.len() > 64
@@ -173,8 +173,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential
.credential_revision;
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&account_id),
@@ -217,8 +218,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential_revision
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -217,7 +217,7 @@ impl PluginsService {
artifact_sha256: metadata.sha256.clone(),
enabled: !configuration_required && compatible,
configuration: configuration.clone(),
secrets: None,
secrets: Default::default(),
bindings: bindings.clone(),
};
match self.configure_instance(None, input, context).await {
@@ -28,17 +28,16 @@ pub(super) fn same_creation_request(
existing: &PluginInstance,
input: &ConfigurePluginInstance,
) -> bool {
let same_secrets = input
let mut requested_secrets = input
.secrets
.as_ref()
.map_or(existing.secrets.is_empty(), |secrets| {
secrets.len() == existing.secrets.len()
&& secrets.iter().all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
})
.iter()
.filter_map(|(key, value)| value.as_ref().map(|value| (key, value)));
let same_secrets = requested_secrets.clone().count() == existing.secrets.len()
&& requested_secrets.all(|(key, value)| {
existing
.secrets
.get(key)
.is_some_and(|current| current.expose_secret() == value.expose_secret())
});
existing.name == input.name.trim()
&& existing.artifact_sha256 == input.artifact_sha256
@@ -158,7 +158,6 @@ impl PluginsService {
}
let saved = if let Some(previous) = &existing
&& previous.artifact_sha256 != input.artifact_sha256
&& input.secrets.is_none()
{
self.store
.load_version_configuration(&previous.id, &input.artifact_sha256)
@@ -167,6 +166,21 @@ impl PluginsService {
} else {
None
};
let mut secrets = saved.map(|saved| saved.secrets).unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
});
for (name, value) in input.secrets {
match value {
Some(value) => {
secrets.insert(name, value);
}
None => {
secrets.remove(&name);
}
}
}
let instance = PluginInstance {
id: existing.as_ref().map_or_else(
|| {
@@ -182,14 +196,7 @@ impl PluginsService {
enabled: input.enabled,
trusted_process: true,
configuration: input.configuration,
secrets: input
.secrets
.or_else(|| saved.map(|saved| saved.secrets))
.unwrap_or_else(|| {
existing
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
secrets,
bindings: input.bindings,
revision: candidate_revision,
};
@@ -366,6 +366,7 @@ fn target_environment_matches(target: &str) -> bool {
match target {
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
_ => false,
}
}
@@ -392,4 +393,18 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
runtime_os: "macos",
runtime_architecture: "aarch64",
},
Platform {
release_os: "windows",
release_architecture: "amd64",
target: "x86_64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "x86_64",
},
Platform {
release_os: "windows",
release_architecture: "arm64",
target: "aarch64-pc-windows-msvc",
runtime_os: "windows",
runtime_architecture: "aarch64",
},
];
@@ -69,7 +69,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: saved.configuration,
secrets: Some(saved.secrets),
secrets: Default::default(),
bindings: saved.bindings,
};
// 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置
@@ -202,7 +202,7 @@ impl PluginsService {
artifact_sha256: input.artifact_sha256,
enabled: existing.enabled,
configuration: configuration.configuration,
secrets: Some(configuration.secrets),
secrets: Default::default(),
bindings: configuration.bindings,
};
// 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置
@@ -361,9 +361,10 @@ impl ProxiesService for DefaultProxiesService {
proxy: Option<&OutboundProxy>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
let _permit = self
.test_slots
.try_acquire()
.map_err(|_| AdminError::new(AdminErrorKind::RateLimited, "代理测试已达并发上限"))?;
Ok(self.probe.test(proxy, detect_location).await)
}
@@ -374,9 +375,10 @@ impl ProxiesService for DefaultProxiesService {
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
let _permit = self
.test_slots
.try_acquire()
.map_err(|_| AdminError::new(AdminErrorKind::RateLimited, "代理测试已达并发上限"))?;
let record = self
.store
.get(id)
@@ -252,7 +252,7 @@ impl SettingsService for DefaultSettingsService {
.is_some_and(|models| models.contains_key(model))
})
{
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
return Err(AdminError::invalid("内置价目只能覆盖,不能删除"));
}
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
let defaults = defaults.get(&command.provider);
@@ -332,24 +332,35 @@ impl SettingsService for DefaultSettingsService {
configuration: Option<&gateway_core::account::OpaqueProviderData>,
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
let profile_provider = self.profile_provider(provider)?;
let global;
let (configuration, source) = if let Some(configuration) = configuration {
(configuration, "override")
let independent = configuration.filter(|key| {
gateway_core::settings::request_profiles::inherited_fields(key).is_none()
});
let preview = if let Some(key) = independent {
profile_provider.preview_key_client_profile(key, None)
} else {
global = self
let global = self
.load()
.await?
.client_profile(provider)
.cloned()
.or_else(|| profile_provider.default_client_profile())
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
(&global, "global")
};
let mut preview = profile_provider
.preview_client_profile(configuration)
.map_err(|error| super::map_provider_error(error, "client profile"))?
.into_inner();
preview.insert("source".to_owned(), serde_json::Value::from(source));
if let Some(key) = configuration {
profile_provider.preview_key_client_profile(key, Some(&global))
} else {
profile_provider.preview_client_profile(&global)
}
}
.map_err(|error| super::map_provider_error(error, "client profile"))?;
let mut preview = preview.into_inner();
preview.insert(
"source".to_owned(),
serde_json::Value::from(if independent.is_some() {
"override"
} else {
"global"
}),
);
Ok(gateway_core::account::OpaqueProviderData::new(preview))
}
@@ -419,7 +430,7 @@ impl SettingsService for DefaultSettingsService {
crate::ports::store::AdminStoreErrorKind::Conflict
| crate::ports::store::AdminStoreErrorKind::StaleRevision
) {
AdminError::conflict("运行设置已被其他调用修改,请重新读取并确认后提交")
AdminError::conflict("运行设置已变更,请刷新核对后提交")
} else {
map_store_error(error, "runtime settings")
}
@@ -2,7 +2,7 @@
use std::sync::Arc;
use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
use super::system_preflight::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
use crate::model::{MutationContext, system::SystemRestartPlan};
use async_trait::async_trait;
@@ -10,8 +10,8 @@ use crate::{
model::{
AdminError, AdminErrorKind,
system::{
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
SystemVersion,
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
SystemUpdateStatus, SystemVersion,
},
},
ports::system::{
@@ -25,7 +25,7 @@ pub trait SystemService: Send + Sync {
async fn version(&self) -> Result<SystemVersion, AdminError>;
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError>;
fn update_events(&self) -> SystemUpdateEventStream;
@@ -71,11 +71,11 @@ impl SystemService for DefaultSystemService {
async fn update_detail(
&self,
refresh: bool,
check: SystemUpdateCheck,
channel: Option<SystemUpdateChannel>,
) -> Result<SystemUpdateDetail, AdminError> {
self.operations
.update_detail(refresh, channel)
.update_detail(check, channel)
.await
.map_err(map_system_error)
}
@@ -15,6 +15,11 @@ use std::sync::Arc;
#[async_trait]
pub trait UsageDataService: Send + Sync {
async fn open_read(
&self,
query: UsageReadQuery,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> UsageDataResult<Box<dyn UsageReadSession>>;
async fn list_aggregates(
&self,
request: ListUsageAggregates,
@@ -58,6 +63,21 @@ impl DefaultUsageDataService {
#[async_trait]
impl UsageDataService for DefaultUsageDataService {
async fn open_read(
&self,
query: UsageReadQuery,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> UsageDataResult<Box<dyn UsageReadSession>> {
let filter = query.filter();
validation::query(&filter.records, None, None)?;
if let Some(group) = &filter.routing_group_ref {
validation::text(group, 128)?;
}
if !(1..=USAGE_READ_MAX_BATCH_ROWS).contains(&query.batch_size()) {
return Err(UsageDataError::invalid("读取批次条数应为 1 到 1024"));
}
self.store.open_read(query, cancellation).await
}
async fn list_aggregates(
&self,
request: ListUsageAggregates,
@@ -7,7 +7,6 @@ use gateway_core::account::OpaqueProviderData;
use gateway_admin::model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
@@ -146,57 +145,6 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
assert_eq!(quota.representative_used_percent(), Some(5.0));
}
#[test]
fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
success_count: 5,
input_tokens: Some(100),
output_tokens: Some(20),
cached_input_tokens: Some(80),
cache_write_input_tokens: Some(0),
reasoning_output_tokens: Some(0),
image_input_tokens: Some(0),
image_output_tokens: Some(0),
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: Some(120),
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: Some(Utc::now()),
request_buckets: Vec::new(),
models: Vec::new(),
};
let quota = ProviderQuota {
upstream: None,
credits: None,
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
upstream: None,
key: "free-rolling-24h".to_owned(),
group: "shortTerm".to_owned(),
label: "日限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(86_400),
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.usage_window(), None);
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
@@ -65,6 +65,36 @@ fn usage_window_should_not_fallback_to_short_daily_or_incomplete_windows() {
assert_eq!(quota.usage_window(), None);
}
#[test]
fn usage_window_should_preserve_provider_rolling_daily_statistics() {
let mut rolling = usage_window("rolling-day", "shortTerm", 86_400);
rolling.reset_at = None;
rolling.used_percent = None;
let mut quota = usage_quota(vec![rolling.clone()]);
assert_eq!(
selected_usage_window(&quota),
Some(("rolling-day", AccountUsagePeriod::Daily))
);
quota.limit_reached = true;
quota.apply_limit_reached_display();
assert_eq!(
selected_usage_window(&quota),
Some(("rolling-day", AccountUsagePeriod::Daily))
);
quota
.windows
.push(usage_window("week", "shortTerm", 7 * 86_400));
assert_eq!(
selected_usage_window(&quota),
Some(("week", AccountUsagePeriod::Weekly))
);
rolling.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
assert_eq!(usage_quota(vec![rolling]).usage_window(), None);
}
#[test]
fn usage_window_should_preserve_provider_order_for_equal_periods() {
let quota = usage_quota(vec![
@@ -162,7 +162,10 @@ fn source_selection_keeps_provider_order_with_the_maximum_window_count() {
fn short_or_model_specific_windows_are_not_account_capacity() {
let mut model = window("model", 7);
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
for item in forecast(&quota(vec![window("day", 1), model])) {
let mut rolling = window("rolling-day", 1);
rolling.reset_at = None;
rolling.used_percent = None;
for item in forecast(&quota(vec![window("day", 1), rolling, model])) {
assert!(item.source.is_none());
assert!(item.unavailable_reason.is_some());
assert!(item.estimated_tokens.is_none());
@@ -373,6 +373,7 @@ fn group_record() -> AccountGroupRecord {
fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
AccountGroupMemberFact {
inherited_version: None,
group_id: group_id(),
account_id: account_id.to_owned(),
status: AccountStatusFacts {
@@ -2,6 +2,7 @@
mod reset_credit_automation;
use gateway_core::engine::probe::AccountProbeTarget;
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
@@ -279,6 +280,10 @@ impl FakeProviderAdmin {
#[async_trait]
impl ProviderAdmin for FakeProviderAdmin {
fn peer_authentication_kinds(&self) -> &[&str] {
&["api_key"]
}
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
@@ -733,6 +738,20 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn load_account_record(
&self,
account_id: &ProviderAccountId,
) -> AdminStoreResult<Option<AccountRecord>> {
self.record("store.load_account_record");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.find(|account| account.id == account_id.as_str())
.cloned())
}
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
self.record("store.account_exists");
Ok(self
@@ -1147,7 +1166,10 @@ impl SettingsStore for StaticSettingsStore {
max_concurrent_per_account: 1,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -1316,7 +1338,9 @@ async fn connection_test_should_probe_unavailable_account() {
let events = services
.accounts()
.test_connection(
ProviderAccountId::new("acct_test").expect("account ID"),
AccountProbeTarget::ConnectionTest(
ProviderAccountId::new("acct_test").expect("account ID"),
),
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
)
.await
@@ -1353,7 +1377,9 @@ async fn connection_test_rate_limited_probe_returns_provider_failure() {
let events = services
.accounts()
.test_connection(
ProviderAccountId::new("acct_test").expect("account ID"),
AccountProbeTarget::ConnectionTest(
ProviderAccountId::new("acct_test").expect("account ID"),
),
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
)
.await
@@ -1386,7 +1412,9 @@ async fn connection_test_should_preserve_disabled_account_status() {
let events = services
.accounts()
.test_connection(
ProviderAccountId::new("acct_test").expect("account ID"),
AccountProbeTarget::ConnectionTest(
ProviderAccountId::new("acct_test").expect("account ID"),
),
gateway_core::routing::UpstreamModelId::new("grok-4.5").expect("model"),
)
.await
@@ -1880,6 +1908,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
auto_use_quota_credits: None,
model_access: Default::default(),
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
@@ -2667,12 +2696,15 @@ async fn peer_account_usage_should_follow_budget_windows_without_borrowing_pool_
for longest in [86_400, 604_800] {
let provider = FakeProviderAdmin::new("openai", events());
let mut account = account_record("openai");
account.concurrency_limit = None;
account.authentication_kind = "api_key".to_owned();
account.created_at = Utc::now() - TimeDelta::days(60);
let store = FakeAccountStore::with_account(account, events());
let reset_at = Utc::now() + TimeDelta::hours(1);
let mut quota = empty_quota();
quota.upstream = Some(ProviderUpstreamInfo {
max_concurrency: Some(7),
requests_per_minute: Some(60),
discovered: true,
observed_at: Some(Utc::now()),
stale: false,
@@ -2743,6 +2775,8 @@ async fn peer_account_usage_should_follow_budget_windows_without_borrowing_pool_
page.items[0].usage.as_ref().unwrap().total_tokens,
Some(1_234)
);
assert_eq!(page.items[0].capacity.total_slots, Some(7));
assert_eq!(page.items[0].account.concurrency_limit, None);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), window_usage.len());
for (query, expected) in queries.iter().zip(&window_usage) {
@@ -3365,6 +3399,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
pub(super) fn account_record(kind: &str) -> AccountRecord {
let now = Utc::now();
AccountRecord {
auto_use_quota_credits: false,
outbound_proxy_id: None,
outbound_proxy_enabled: true,
notes: None,
@@ -3724,6 +3759,7 @@ fn unsupported() -> ProviderAdminError {
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
name: None,
notes: Some("团队备用".to_owned()),
model_access: Default::default(),
enabled: false,
@@ -1,4 +1,5 @@
//! 按 Provider 组织凭据导入、授权与提交边界测试
mod openai;
mod peer;
mod xai;
@@ -0,0 +1,192 @@
//! 验证凭据提交后的互联首查、原生额度分派与异步失败边界
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use gateway_admin::{
model::provider_credentials::{ImportCredentials, ProviderQuota, ProviderQuotaReadRequest},
ports::{
peer::PeerUpstreamService,
provider::{ProviderAdminError, ProviderAdminErrorKind},
},
};
use gateway_core::{
account::{ProviderAccount, ProviderAccountId, UpstreamInfoSource, UpstreamSchedulingSignal},
error::StoreError,
routing::ProviderKind,
};
use super::super::{
AdminHarness,
accounts::{FakeAccountStore, FakeProviderAdmin, account_record, context, document, events},
};
#[tokio::test]
async fn imported_peer_accounts_should_start_observation_without_waiting_for_network() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_first", "acct_second"]);
let mut first = account_record("openai");
first.id = "acct_first".into();
first.authentication_kind = "api_key".into();
let mut second = first.clone();
second.id = "acct_second".into();
let store = FakeAccountStore::with_account(first.clone(), events);
store.set_accounts(vec![first, second]);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
let result = tokio::time::timeout(
Duration::from_secs(1),
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-peer"),
document: document(),
}),
)
.await
.expect("import must not wait for peer response")
.expect("import committed");
peer.wait_for_requests(1).await;
assert_eq!(peer.changed.lock().unwrap().len(), 2);
assert_eq!(result.credential_ids.len(), 2);
assert!(provider.quota_requests().is_empty());
peer.gate.add_permits(2);
peer.wait_for_requests(2).await;
assert_eq!(
*peer.requests.lock().unwrap(),
["acct_first", "acct_second"]
);
assert!(provider.quota_requests().is_empty());
}
#[tokio::test]
async fn native_import_should_use_provider_quota_when_peer_service_is_available() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_test"]);
let store = FakeAccountStore::new("openai", events);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-native"),
document: document(),
})
.await
.expect("native import");
provider.wait_for_quota_requests(1).await;
assert!(peer.requests.lock().unwrap().is_empty());
assert_eq!(
provider.quota_requests()[0].account_id.as_str(),
"acct_test"
);
}
struct PendingPeer {
changed: Mutex<Vec<String>>,
requests: Mutex<Vec<String>>,
started: tokio::sync::Notify,
gate: tokio::sync::Semaphore,
}
impl Default for PendingPeer {
fn default() -> Self {
Self {
changed: Mutex::default(),
requests: Mutex::default(),
started: tokio::sync::Notify::new(),
gate: tokio::sync::Semaphore::new(0),
}
}
}
impl PendingPeer {
async fn wait_for_requests(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.started.notified();
if self.requests.lock().unwrap().len() >= count {
return;
}
notified.await;
}
})
.await
.expect("initial peer observation started");
}
}
impl UpstreamInfoSource for PendingPeer {
fn signal(
&self,
_: &ProviderAccount,
_: Option<&str>,
_: std::time::SystemTime,
) -> Option<UpstreamSchedulingSignal> {
None
}
}
#[async_trait]
impl PeerUpstreamService for PendingPeer {
fn observed_concurrency_limit(
&self,
_: &gateway_core::account::QuotaObservationVersion,
_: std::time::SystemTime,
) -> Option<gateway_core::account::AccountConcurrencyLimit> {
None
}
async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
self.changed.lock().unwrap().push(id.as_str().into());
Ok(())
}
fn forget(&self, _: &ProviderAccountId) {}
async fn refresh_quota(
&self,
id: &ProviderAccountId,
) -> Result<ProviderQuota, ProviderAdminError> {
self.requests.lock().unwrap().push(id.as_str().into());
self.started.notify_one();
self.gate.acquire().await.unwrap().forget();
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))
}
async fn read_quota(&self, _: &ProviderAccountId) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected cached read")
}
async fn read_summary(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected summary read")
}
async fn read_quotas(
&self,
_: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
panic!("unexpected batch read")
}
}
@@ -1,5 +1,6 @@
//! 账号保护恢复编排:到期探测、快照参数与失败顺延
use gateway_core::engine::probe::AccountProbeTarget;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
@@ -40,7 +41,10 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
max_concurrent_per_account: 5,
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
key_queue_timeout_seconds: 30,
account_queue_timeout_seconds: 30,
switch_account_on_queue_failure: false,
max_retry_after_seconds: 240,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
@@ -241,6 +245,10 @@ impl AccountProbe for FailingProbe {
request: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
assert!(matches!(
request.target,
AccountProbeTarget::FreezeRecovery(_)
));
assert_eq!(request.upstream_model.as_str(), "snapshot-model");
Box::pin(async {
Err(AccountProbeError::new(

Some files were not shown because too many files have changed in this diff Show More