Compare commits

...
88 Commits
Author SHA1 Message Date
ZyyandCodex d26bc36db5 chore(release): v3.31.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:42 +08:00
ZyyandCodex 96d5ab674a feat(ui): refine forms, navigation and theme loading
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:34 +08:00
ZyyandCodex 84c0db2613 fix(ui): simplify token label and center reasoning effort
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:58:06 +08:00
ZyyandCodex d819315d8b fix(ci): allow independent UI dependency versions
Keep frozen installs, source compatibility builds, and lockfile checks
without requiring every consumer to pin the UI submodule revision.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:48:28 +08:00
ZyyandCodex 06e1d8f0ac chore(release): v3.31.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:40:32 +08:00
ZyyandCodex bdc8274b36 chore(ui): upgrade component library to v0.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:38:49 +08:00
ZyyandCodex 3f6607209b refactor(usage): remove reported model tracking and polish UI
Remove upstream response model tracking across providers, storage, APIs,
and plugin facts without migrating historical records.

Refine sidebar hints, locale loading indicators, model columns, and usage
page labels and filters.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:03:27 +08:00
ZyyandCodex 3c05bc8f07 refactor(plugins): remove provider-specific request parameters
Use request, attempt, and outbound middleware for plugin-owned identity
selection and complete request rewriting. Preserve native account identity
and partition WebSocket connections through existing outbound opening changes.

Remove the construction parameter channel and its obsolete tests and docs.
Keep middleware at v6 without a compatibility shim.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 03:30:52 +08:00
ZyyandCodex 9ccd84f1a0 test(store): fix pg_dump lifecycle probe on Ubuntu 26.04
Redirect the mock process stdout to /dev/null explicitly so the wait-stage probe works when sleep reopens closed standard descriptors. Preserve cancellation, process reaping, and partial archive cleanup assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:35:59 +08:00
ZyyandCodex a8f183707e chore(build): refresh base images and pin Ubuntu runners
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:02:46 +08:00
ZyyandCodex 3f20af61ff chore(plugins): update workbench to v0.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:23:55 +08:00
ZyyandCodex efaed4c3a6 fix(web): refine health loading and quota labels
Hide request health granularity until analytics are loaded and remove the placeholder daily granularity. Use compact uppercase quota badges and localized five-hour labels.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:08:42 +08:00
ZyyandCodex a36137bafb chore(release): v3.31.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 23:22:06 +08:00
2e7a39e1e8 feat(plugins): add provider request composition middleware (#511)
* feat(plugins): add provider request composition middleware

Expose per-attempt provider parameters and a final outbound preparation chain using middleware v6. Preserve native transport behavior when no outbound scope matches.

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

* fix(plugins): sync workbench with middleware v6

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 22:58:06 +08:00
ZyyandCodex 6648198df4 perf(responses): reduce WebSocket copies and trace completion latency
Move owned request bodies back into WebSocket requests and forward validated
UTF-8 frames without copying their payloads. Record quota, account feedback,
and cyber policy durations while preserving terminal delivery ordering.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 21:05:22 +08:00
ZyyandCodex 2eed9b20ed fix(web): persist table sorting preferences
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 20:54:37 +08:00
16aa08091c feat(plugins): select upstream adapters by account (#509)
* feat(plugins): select upstream adapters within account scopes

* fix(plugins): defer adapter decisions until account selection

Keep scoped adapters pending until an account is known so unavailable
fallbacks cannot mask a healthy earlier match. Filter native xAI accounts
when only an adapter can accept the request, preserving normal selection
and cold-stream behavior.

Cover fallback precedence and mixed-pool prewarm eligibility regressions.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 20:20:47 +08:00
ZyyandCodex abae4a631b fix(skills): enforce required domain guidance
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:53:37 +08:00
8983eb4ce9 fix(openai): preserve session affinity for managed upstream adapters (#508)
* fix(openai): preserve session affinity for managed upstream adapters

* test(openai): avoid coupling wire invariance to adapter lookup count

* fix(openai): preserve plugin control over adapter request identity

Keep affinity and turn aliases tied to the admitted request before attempt middleware. Pass plugin rewrites to the adapter without native identity revalidation.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:51:48 +08:00
ZyyandCodex cd605b850e chore(deps): pin UI dependency to v0.6.0 release tag
Reference the v0.6.0 release tag and synchronize the pnpm lockfile.
The verified remote tag resolves to the existing UI commit.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:02:45 +08:00
ZyyandCodex 7beebb2340 fix(deps): patch crypto-bigint and tokio security advisories
Update crypto-bigint to 0.7.6 for RUSTSEC-2026-0387 and Tokio to
1.53.3 for RUSTSEC-2026-0386. Preserve the exact Tokio constraint
and verify existing transport fingerprint and cancellation contracts.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 19:02:41 +08:00
ZyyandCodex 31de0bd83c perf(store): move client budget settlement off request path
Track pending charges in memory and settle them with a supervised worker.
Include pending costs in budget checks without retrying historical writes
at admission, and reject new admissions when the backlog reaches its limit.

Use idempotent per-key batches with write deadlines, adaptive batch sizes,
and bounded shutdown draining. Keep load tests outside the regular suite.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:12 +08:00
ZyyandCodex 5cfc6dee10 fix(openai): abort stalled websocket pumps during error cleanup
Stop the send pump before returning terminal errors or discarding an
exchange. Preserve socket cleanup ordering and cover stalled data and
control writes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:04 +08:00
ZyyandCodex ef5b4d7dbd fix(openai): reuse reasoning content registrations
Honor reasoning summary indexes and reuse registered content parts while
preserving upstream wire events and type conflict diagnostics.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 18:45:00 +08:00
ZyyandCodex 31974bc289 fix(diagnostics): align trace snapshot persistence limits
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 14:19:22 +08:00
ZyyandCodex e033e21994 fix(frontend): remove default concurrency input icon
Co-authored-by: Codex <noreply@openai.com>
2026-10-24 00:05:43 +08:00
ZyyandCodex 2818edb9fc perf(i18n): load language resources on demand
Load and cache the selected language with synchronized UI messages. Keep the latest selection when loads overlap and render missing messages as empty text without language fallback.

Co-authored-by: Codex <noreply@openai.com>
2026-10-24 00:05:38 +08:00
ZyyandCodex 1d1f34ba7e fix(frontend): refine admin copy and responsive layouts
Shorten contextual actions and loading messages while preserving explanatory text. Improve quota metadata, profile metrics, theme controls and usage layouts, and add collapsed navigation hints.

Use spaced lowercase time units and remove chart tooltip dividers.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 23:10:15 +08:00
ZyyandCodex 76291f448c chore(release): v3.30.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:47:55 +08:00
Zyy c10efea12c perf(dashboard): deduplicate histogram samples and trim unused trend fields
Group identical positive samples before numeric logarithm evaluation while retaining histogram boundaries and weights. Remove nine unused trend fields and synchronize frontend types, preview data, and API documentation.

Validate against the 30-million-row Docker dataset, matching retained statistics and reducing uncached today-summary median latency from 623 ms to 319 ms. Run 133 related tests, Clippy, formatting, frontend checks, and browser tab checks.
2026-10-23 21:06:12 +08:00
ZyyandCodex 806f7ad9ee fix(i18n): preserve server-authoritative relative times
Keep dashboard and quota relative-time thresholds and future timestamps owned by the backend deployment timezone. Localize only the structured presentation in the frontend.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:06:12 +08:00
ZyyandCodex 632edee7dc fix(frontend): localize account metrics and polish responsive layouts
Move account metric and relative-time presentation to the frontend using raw API values. Tighten latency unit spacing and unify edit actions and page header sizing.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:06:12 +08:00
ZyyandCodex 22518d064c chore(deps): sync UI and plugin release references
Track UI v0.6.0 and Plugins v0.6.1 release commits.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 21:06:12 +08:00
ZyyandCodex 64c1ec2afd chore(release): v3.30.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 19:05:30 +08:00
ZyyandCodex a3f0c1b028 fix: correct localized UI and update hyper-util
Restore quota labels, custom window durations, account usage panel width,
and consistent USD symbols. Return stable provider identity attribute keys
for frontend localization and refine login controls, copy, and alignment.

Pin hyper-util to the 0.1.21 registry release and remove the Git patch.

Tests were not rerun for this combined commit, as requested.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 19:02:48 +08:00
ZyyandCodex 605354c861 chore(release): v3.30.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 17:33:23 +08:00
ZyyandCodex 60e6bc0125 fix(deps): update audited cryptographic dependencies
Upgrade ctutils to 0.4.5 for RUSTSEC-2026-0385 and move chacha20 and wnaf off yanked releases.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 17:26:00 +08:00
ZyyandCodex bfea3e1d6f feat(frontend): unify API key import dialog
Route API key imports through a shared CC Switch dialog with a GitHub link. Simplify key creation actions and narrow the action menu.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 17:25:56 +08:00
ZyyandCodex 6b4b8faaf4 fix(openai): preserve client turn identifiers
Preserve turn IDs and parent references across request metadata, input history, headers, Images and Desktop context. Update contract coverage and API documentation for unchanged turn identity across accounts.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 15:59:19 +08:00
ZyyandCodex f195de38f5 fix(openai): isolate Live correlation and preserve business headers
Forward Live business headers through shared identity and transport filters,
preserving HTTP header bytes and repeated values. Scope tracking identifiers
to the selected or pinned account, remove client tracing and account-bound
metadata, and retain Live call ownership.

Preserve x-session-id and voice-session-id as business session references,
consistent with Codex 0.164.0. Validate standard HTTP header names in Core
and document the distinction between tracking and business identifiers.

Validation: 36 targeted nextest checks passed. Real-account checks through
the production Provider and LiveGateway completed SDP creation (201), WebRTC
connection and session.started, sideband events, and hangup (200), including
header-only business identifiers. Bidirectional RTP traffic was observed.
The deployed HTTP/UI path and speech recognition or spoken replies were not
validated; the earlier Voice session access denied error was not reproduced.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 15:59:19 +08:00
ZyyandCodex 07bdd425b6 fix(openai): preserve historical dates during location overrides
Keep client context dates stable across midnight, continuation and replay
while retaining timezone and web search location overrides. Remove wall-clock
dependence from both request encoding and selected-account adaptation.

Cover appended day contexts, replay, and HTTP/WebSocket upstream payloads;
update the request-location contract. The regression fails before the fix.
Provider tests (1028), architecture checks (14), workspace strict Clippy,
Rustfmt and diff checks pass.

Refs #503; the reported repeated zero-cache requests are not yet confirmed
to share this cause.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 12:27:02 +08:00
ZyyandCodex d48982eb04 fix(frontend): refine dashboard copy and summary value size
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 12:24:21 +08:00
39b35d46a0 feat(frontend): add reactive localization and refine admin layouts (#501)
* feat(frontend): add English and Traditional Chinese locales

* fix(frontend): capitalize the English login key label

* fix(frontend): capitalize standalone English labels

* feat(frontend): add reactive localization and refine admin layouts

Replace DOM text translation with domain-based Vue I18n resources for English, Simplified Chinese, and Traditional Chinese. Localize shared controls, notifications, charts, and key usage while refining table alignment, menus, and compact labels.

Expose semantic usage-window and trend-metric identifiers from the API. Pin the UI and workbench dependencies to published feature commits and preserve the original font defaults.

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

---------

Co-authored-by: Jimbb <14995924+jimbb@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 11:40:38 +08:00
8fc8c6228d fix(openai): avoid invalidating accounts on streaming 404 errors (#506)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-23 11:34:58 +08:00
Zyy 7da79b82fd fix(deps): align UI pin and update DOMPurify across frontend and workbench 2026-10-23 11:07:24 +08:00
ZyyandCodex a23afbf2f5 perf: streamline usage queries and batch account reads
Use literal contains filters for usage names and models, align Key Usage filtering, and remove unused usage query parameters. Optimize summary aggregation, record counting, quota reads, and credential cooldown lookups without adding indexes.

Co-authored-by: Codex <noreply@openai.com>
2026-10-23 01:08:57 +08:00
ZyyandCodex b4a5210e44 test: streamline behavior coverage and parallel execution
Remove redundant contract cases and express fixtures in terms of current behavior.
Reduce isolated database migration contention, run independent scenarios concurrently,
and replace fixed waits with clock and I/O synchronization. Check absolute Redis
session expiry and wait for PostgreSQL migration session locks to be released.
Document nextest for local test execution.

Validated 4060 tests, 70 Redis ACL tests, and five repeat runs of 26 focused tests.
The 10 plugin state tests also pass 50 repeat runs with four workers.
Rustfmt and strict Clippy pass. Local full-suite execution with 16 workers falls
from 162.760s to 89.072s, excluding compilation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-22 23:30:38 +08:00
Zyy f7f59103a3 fix: harden request lifecycles and align repository documentation
Preserve request evidence and cold middleware boundaries across cancellation. Make backup auditing atomic, retain committed account-group results, and serialize restart operations.

Unify frontend stream authentication and session recovery, clear stale secrets, and remove redundant runtime paths. Correct CI permissions and database test URL handling.

Align documentation with current module responsibilities, APIs, and validation workflows, including both submodules.
2026-10-22 22:06:01 +08:00
ZyyandCodex 5b77a6b8de docs: make GitHub replies more considerate
Explain how to address the reported scenario, offer practical next steps,
and clarify limits with a cooperative tone. Keep shared guidance centralized
in CONTRIBUTING.md and review each reply from the questioner's perspective.

Co-authored-by: Codex <noreply@openai.com>
2026-10-22 19:35:34 +08:00
ZyyandCodex 3f95f8b0c4 feat(diagnostics): preserve evidence and export ZIP bundles
Capture bounded request fingerprints and terminal usage without rewriting payloads.
Reserve critical trace events and report capture gaps centrally.
Export sanitized ZIP bundles with summaries through native browser downloads.
Simplify account export filenames.

Co-authored-by: Codex <noreply@openai.com>
2026-10-22 19:35:30 +08:00
ZyyandCodex 28fa655c26 fix(openai): scope input turns and preserve executable paths
Map input turn references with the same account identity as request metadata.
Limit the workspace preset to metadata usernames and remote namespaces;
preserve project names and executable paths to avoid path failures and
same-parent workspace collisions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-22 00:02:57 +08:00
ZyyandCodex 7a60bc6ded fix(openai): isolate account correlation identities
Scope outgoing session, turn, parent, window and cache identifiers to the selected account across HTTP, WebSocket, Desktop context and Search. Strip client trace propagation and forward account-scoped image turn metadata.

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 18:29:52 +08:00
ZyyandCodex a5b767e69b chore(deps): pin plugins v0.6.0
Sync the workbench release using host v3.29.7 protocol v5.

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 11:48:01 +08:00
ZyyandCodex 78bc57c5cf chore(release): v3.29.7
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 11:23:31 +08:00
ef11b72d8a fix(workers): recover daemon health after successful work (#499)
Report per-run daemon progress to reset health failures and restart backoff
after successful work, with stale-run isolation and recovery regression tests.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 11:08:15 +08:00
ZyyandCodex 52fb1e7180 fix(deps): update vulnerable redirect and TLS dependencies
Update follow-redirects, rustls-webpki, and webpki-roots to patched releases so frontend and backend security audits pass.

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:47:57 +08:00
ZyyandCodex 7a7a7ed6ad fix(openai): avoid full MSIX scans during version checks
Use the known MSIX archive offset to keep Windows Desktop version checks within the range download budget. Cover both architectures with a bounded-read regression test.

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:33:18 +08:00
b8479b59db fix(plugins): preserve request origin and raw upstream observations (#497)
* fix(plugins): preserve request origin and raw upstream observations

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

* perf(plugins): share immutable response observations

Avoid copying response metadata and header collections for every stream event. Update Workbench byte headers and virtual-time timeout coverage, and align the large-request fixture with RPC v5.

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

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:09:58 +08:00
2480f07f2d fix(plugins): honor configured model and HTTP request body limits (#496)
* fix(plugins): honor configured request body limits

* test(plugin-sdk): mirror upstream adapter module layout

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

* fix(plugins): preserve frozen request body budgets across callbacks

Carry the execution body budget through policy and attempt callbacks, and validate explicit-key model requests against their bound snapshot. Add regression coverage for configuration publication and document budget ownership and lifetime.

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-21 00:51:13 +08:00
niceSprite b2dc9f493d fix(plugins): retain request middleware resource streams (#492) 2026-10-20 23:09:06 +08:00
d92d310e45 fix(accounts): select the bound outbound proxy by default (#493)
Expose the stored proxy ID through account queries and use it to display
the bound proxy name without matching redacted endpoints. Preserve
unchanged bindings and keep the preserve option for batch editing.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 23:02:51 +08:00
ZyyandCodex 3caf83131a docs: refine GitHub reply tone
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 20:48:35 +08:00
ZyyandCodex af35cde374 chore(release): v3.29.6
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 18:44:26 +08:00
5c115fb38f fix(core): reserve rotation budget during connection recovery (#487)
* fix(retry): rotate accounts after connection failures

Reliable NotSent HTTP connection failures stay on the same account and
egress until the shared 30-second opening window is exhausted. When an
account's proxy does not respond, each opening waits for the full
15-second connect timeout on the same route, so requests that selected
that account fail after about 30 seconds while other accounts stay idle.

After a connection failure, exclude the account and reselect through
the existing rotation path, consuming routing and rotation budget and
sharing the same opening window. Rotation happens after the first
failure because a second same-account opening on an unresponsive
egress would leave too little of the window to rotate. If no other
account is eligible, restore the exclusion and continue the original
same-account HTTP recovery, so single-account deployments keep their
current behavior. Required accounts and continuation attempts are
unchanged.

Refs #485

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(retry): retain same-account connection recovery

Keep existing account rotation rules and cover rotation after a recovered
connection receives a replay-safe rate-limit rejection.

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

* fix(core): reserve connection budget for account rotation

Retry the original account before rotating on unsent connection failures.
Split the retry window and retain an opening for HTTP recovery on another
account, with bounded fallback when no alternative can be selected.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 17:42:42 +08:00
ed558e5396 fix(plugins): decouple upstream streams from initial deadlines (#489)
Transfer upstream stream lifetime to the model request after the initial RPC reply and give managed HTTP body reads an operation budget. Cover heartbeat continuity, cancellation cleanup, and per-read timeouts.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-20 17:17:28 +08:00
ZyyandCodex 929f781a63 perf(store): slim business backups and accelerate history cleanup
Export business data and published hourly usage from a shared snapshot as
bounded gzip SQL archives, excluding request history and diagnostic data.
Run bounded retention cycles every minute, deleting oldest unlocked rows
with per-batch lock and statement deadlines.

Add real PostgreSQL export/restore and lock-contention coverage, document
the backup scope, and move detailed UI guidance into a help popover.

Validation: 4011 backend tests passed in partitions; Rustfmt, Clippy,
frontend lint/type checks, and actionlint passed. Resource-limited load
checks covered current-adapter million-row backups and a read-only policy
check on the existing 31-million-row database. Final popover interaction
checks were stopped at the user's request. Online restore is deferred.

Co-authored-by: Codex <noreply@openai.com>
2026-10-20 14:42:29 +08:00
e0326ecba4 fix(openai): base pooled WS breaker on opening results (#484)
* fix(openai): base pooled WS breaker on opening results

Keep foreground fallback independent of pooled opening health and fence late results by circuit generation.

Refs #482

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

* fix(openai): preserve concurrent breaker results while closed

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-20 14:06:23 +08:00
ZyyandCodex 6057088300 chore(release): v3.29.5
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 17:42:17 +08:00
e3a49a6217 fix(openai): supplement declarations for hosted search history (#478)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 17:07:27 +08:00
b32f87fa8a fix(store): bound extrema rebuilds to their analytics bucket (#477)
Group extrema rebuilds by collection and analytics bucket, with explicit started_at bounds so large fact histories can use the time-range index. Preserve per-group build prefixes and aggregate results.

Keep the scan regression test on a single PostgreSQL connection so forced statistics flushes include the worker's reads. The updated test passes with the bounded query and rejects the previous query after reading 6,000,040 rows.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 16:50:01 +08:00
ZyyandCodex 91e8105e5d fix(openai): measure first-token latency from first response data
Record first-token timing from nonempty HTTP SSE data or WebSocket messages before JSON and event classification. Preserve semantic output, reasoning and text timing boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 13:59:05 +08:00
dependabot[bot]andCodex c4d9576639 chore(deps): update GitHub Actions and composite action pins (#472)
Align pinned action versions across CI, release workflows and composite actions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 13:52:12 +08:00
ZyyandCodex 015cd60be1 fix(ci): allow scoped Dependabot updates through rules guard
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 13:46:36 +08:00
ZyyandCodex bad2a245ab chore(release): v3.29.4
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 12:59:43 +08:00
ZyyandCodex b49b4528e7 fix(store): prevent analytics stalls during history cleanup
Batch empty-group deletion and extrema rebuilds, and reserve queue capacity
for live requests while historical contributions are removed.

Keep adaptive batch sizes across worker cycles so timed-out batches can
shrink and make progress without changing transaction or version ACK rules.

Add regression coverage for rollback, fair processing, and timeout recovery.
Validated with 452 Store tests, strict Clippy, and delayed PostgreSQL stress
with 30,000 historical requests plus 12,000 new requests at 200 per second.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 12:57:08 +08:00
dependabot[bot]andCodex d678f350c1 chore(deps): update frontend dependencies with compatible vue-tsc (#473)
Update nine frontend dependencies and pin vue-tsc to 3.3.11 to avoid the upstream imported-binding code generation regression.

Refs: https://github.com/vuejs/language-tools/issues/6237

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 12:14:01 +08:00
dependabot[bot]andCodex 17b05d4671 chore(deps): update backend dependencies and sha2 build profile (#471)
Update serde_json, sha2, syn, tokio-util, and uuid. Keep the sha2 dev/test optimization profile aligned with version 0.11.1.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 12:13:39 +08:00
ZyyandCodex 7148303ad6 chore(deps): update plugins submodule to v0.5.0
Pin modules/plugins to the published v0.5.0 release at 7500732.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 11:36:13 +08:00
ZyyandCodex f6538d433d test: align backend coverage with current behavior
Consolidate overlapping scenarios and remove obsolete assertions and
migration-specific SQL tests. Keep persistence fixtures on the current schema
and document the test maintenance rules in the development skill.

Validation: 3,997 tests passed across 12 crates, plus rustfmt, strict Clippy,
and skill validation. All 33 migration SQL files passed temporary isolated
execution, and the opt-in xAI official network test passed.

Co-authored-by: Codex <noreply@openai.com>
2026-10-19 11:36:06 +08:00
ZyyandCodex 406256bcc0 fix(ci): check frontend dependencies in the correct directory
Co-authored-by: Codex <noreply@openai.com>
2026-10-19 10:43:30 +08:00
ZyyandCodex d912fa0c59 fix(ui): auto-hide key usage log scrollbars
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 23:58:15 +08:00
ZyyandCodex a166580aed fix(ui): remove redundant failure messages and concurrency controls
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 23:31:12 +08:00
ZyyandCodex d1aec7311c fix(proxies): refresh regions and hide stale detection results
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 23:31:01 +08:00
ZyyandCodex 16d8a3f873 chore(release): v3.29.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 23:14:48 +08:00
ZyyandCodex ac6033f6fa docs: refine GitHub reply wording
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 23:10:58 +08:00
cef9f0b21e fix(openai): honor usable credits and recover quota after reset (#469)
Refs #465

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 22:45:15 +08:00
591f95cc18 fix(store): accept managed PostgreSQL and Redis passwords (#467)
* fix(store): accept managed PostgreSQL passwords

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

* fix(store): unify PostgreSQL and Redis password validation

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

* fix(store): preserve literal percent sequences in passwords

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-18 20:34:20 +08:00
ZyyandCodex 99ba5b1120 fix(openai): align request ordering and simplify timing metrics
Preserve response.create field order and align OAuth query ordering with
current official Codex sources.

Stop adding the timing-metrics request header by default and remove the
four unused engine TTFT and token interval fields throughout the backend,
plugin observation contracts, and frontend. Keep rate calculations unchanged.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 16:32:15 +08:00
835 changed files with 38856 additions and 11299 deletions
+25 -10
View File
@@ -1,27 +1,32 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
description: Codex Proxy RS 开发、修复、排障、代码审查和文档修改入口;页面、样式、文案、接口、调度、数据库及宿主 SDK、Runtime、插件接入均适用;PR、Issue、独立网关插件与发版叠加对应技能
---
# 开发指南
## 先匹配任务与文件
## 先确定领域与必读规范
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
| 命中条件,操作或文件满足其一即可 | 必须执行 |
职责边界决定问题由谁修复;即使代码位于账号、调度或 API 模块,只要影响插件接入或为插件增加处理,也命中插件边界。确定方案时说明相关业务事实的归属及已有实现的复用依据,不能以功能恢复或测试通过替代职责判断
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
| --- | --- |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
| 开发、排查或审查插件接入、宿主 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)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 |
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| 代码审查,包括 PR 审查 | [审查标准](../../../CONTRIBUTING.md#审查标准),叠加被审查行为所属领域的规范;审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
## 参考仓库的优先级
@@ -30,11 +35,21 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
## 执行与收尾
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 测试维护
- 测试随当前功能维护,名称、夹具和断言描述当前输入、行为与结果;功能变更时优先更新所属模块的现有用例,不为每次改动叠加一组记录变更经过的测试
- 清理只证明旧字段、旧路由或旧实现已经消失的用例与附带断言;未知字段校验使用合法请求基线加无业务含义的未知字段,避免因缺少必填项等其他错误而误通过
- 同一层对同一行为的重复覆盖合并为清晰的场景矩阵,复用相邻测试夹具;跨层测试确有不同职责或失败路径时保留,不为统一而扩大测试辅助抽象
- 断言可观察的行为与业务不变量,避免复述结构体赋值、罗列表列或字段清单、匹配 SQL 文本与源码布局;项目明确要求的架构边界检查继续维护
- 当前协议兼容、数据读取兼容、安全边界、并发和资源生命周期仍需覆盖,不按 `old`、`legacy` 等名称机械删除,也不能用改名代替无效用例清理
- 不在仓库中新增或保留迁移 SQL 专项测试,包括迁移文本、schema 演变与指定版本升级用例;需要验证迁移执行、数据保留或回滚时,在临时目录编写一次性验证并使用隔离数据库,测试通过即可,临时脚本不纳入维护测试集
- 持久化功能测试使用当前完整 schema,验证现行读写、事务与数据约束;插件运行时状态迁移等业务能力按当前功能测试,不归为迁移 SQL 专项测试
## 后端注释
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
short_description: "开发、排障与审查入口,按行为影响匹配领域边界并验证实现"
default_prompt: "使用 $cpr-dev-guide 按请求、行为影响和实际文件匹配领域必读规范,读完职责边界再确定方案,完成已授权的开发或审查、自审与验证。"
policy:
allow_implicit_invocation: true
@@ -1,10 +1,10 @@
# 文档检查
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
修改或审查文档时完整读取本流程,并读取 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件)的完整章节,覆盖内容归属与层次行文
## 写之前
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对。涉及合同或职责说明时,补读所属架构、API 或 SDK 的对应条款,核实事实;不因文档提及实现而执行无关的开发与构建流程
## 逐段验收
@@ -2,9 +2,11 @@
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
## 开始前
## 领域必读
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
## 按改动补齐规范
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
@@ -12,7 +14,7 @@
| 改动 | 规范入口 |
| --- | --- |
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
| Vue 组件、逻辑、请求或状态 | 使用 `$vue-best-practices`,读取[项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目;请求与接口合同同时执行开发入口的 API 分支 |
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
@@ -1,7 +1,7 @@
# 插件职责边界
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
开发、排查或审查插件接入、SDK、Runtime、宿主扩展接口及独立网关插件时完整读取,不以文件位于插件目录或正在选择能力为条件
确定方案前读取[职责与源码入口](../../../../docs/architecture.md#职责与源码入口)、[发布与执行边界](../../../../docs/architecture.md#发布与执行边界)及[信任与资源边界](../../../../docs/architecture.md#信任与资源边界),明确问题归属、必要宿主保护与插件责任
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
+8 -4
View File
@@ -13,13 +13,15 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
## 按需取材
## 按动作读取与核实
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
目标、授权、事实与脱敏要求适用于草拟、提交和回复;按下列动作读取必需规则与证据,再组织正文。症状反馈可保留未知根因,进入技术排查或方案判断时补齐对应规范
- 新建 Issue 时,读取本仓库的[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 需要技术排查、判断实现方案或修改时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读,不能仅凭症状建议跨职责补偿;提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
## 正文与证据
@@ -40,7 +42,9 @@ Issue 可记录复现、排查和变更经过;涉及常规文档修改时执
## 回复口吻
草拟和发布 Issue 回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
草拟、发布或修改 Issue 回复时,按 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)将核实结果写成面向提问者的交流答复,具体口吻规则集中维护在该节
发送或更新前从提问者视角通读:是否接住了他的目标和困难,建议是否便于尝试,补充材料的用途是否清楚,澄清或拒绝是否耐心解释了原因,能提供的替代办法是否说清。发现生硬时调整整句措辞与前后衔接,不能仅加缓和词;保留已核实的结论
## 发布与收尾
+2 -2
View File
@@ -19,7 +19,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
先完成上述范围判断,再按当前动作与差异涉及的领域叠加下表。命中的流程引用完整读取,长文档读取指定章节;只复用已实际读取且仍适用的内容,单纯整理正文或回复不扩展为完整实现审查
| 当前动作 | 读取与执行 |
| --- | --- |
@@ -27,7 +27,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
| 实现自审、审查或修改代码与文档 | 执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与对应检查,包括后端、插件、测试和 API 等命中项;只读审查同样执行,不能仅核对 PR 正文与 CI |
| 含前端改动 | [前端流程](../cpr-dev-guide/references/frontend.md),核对适用规范与当前实现的运行证据 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-github-pr"
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,实现自审与审查叠加开发指南的领域规范,核实问题、方案、验证、页面截图和 AI 模型披露。"
policy:
allow_implicit_invocation: true
@@ -4,6 +4,7 @@
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
按实际行为与文件执行 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)的领域必读,再判断方案与实现;只读审查也核对职责边界与生命周期。
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
+11 -7
View File
@@ -1,13 +1,17 @@
---
name: cpr-plugin-dev
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
description: 开发、修复、排查、审查或打包 Codex Proxy RS 独立网关插件,含处理器、清单与插件页面;仅改宿主能力时使用 cpr-dev-guide 的插件边界,不适用于 Codex 插件或 Vue 插件
---
# 网关插件开发
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
## 先读职责边界
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
插件在独立项目中通过公开 SDK 实现业务,插件业务错误在插件侧修复;宿主维护自身合同、资源与稳定性,不猜测插件意图、不补造业务数据,不在宿主、SDK 或 Runtime 中增加插件专属补偿
开发、排查和审查先完整读取[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md),再选择实现步骤;已有处理器修复和页面改动也执行,不以是否新增能力为条件。需要新增宿主能力时,先界定可独立使用的基础接口及其职责;必要的协议保护与资源回收仍由宿主负责
同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的领域必读与收尾要求;已实际读取且仍适用的内容复用,不递归重读入口。仅构建、打包或分发已有插件时按对应操作读取,若需诊断或修改行为,再补齐开发分支
## 范围与合同
@@ -18,13 +22,13 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
## 按当前步骤读取
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
完成上述适用边界后,按当前步骤读取下表;同时涉及多项时叠加。命中的实现或页面参考完整读取,长文档读取指定章节,示例、前端和打包资料在对应步骤加载
| 当前工作 | 入口 |
| --- | --- |
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
| 选择、使用或审查能力 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,不以已有调用能编译代替合同核对 |
| 开发、排查或审查清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;涉及清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
| 创建、修改或审查页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-plugin-dev"
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
short_description: "独立网关插件开发、排障与审查,先核对宿主边界再实现和验证"
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查、审查或打包独立网关插件,先读取适用职责边界,插件业务错误在插件侧修复,再按步骤读取实现与验证资料。"
policy:
allow_implicit_invocation: true
@@ -1,6 +1,6 @@
# 插件页面
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
创建、修改或审查插件页面时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断并执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
@@ -1,12 +1,12 @@
# 清单与处理器实现
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
开发、排查或审查插件清单、Rust 处理器时完整读取,先完成[插件入口](../SKILL.md#先读职责边界)的边界判断,涉及 Rust 时执行[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
使用能力前读取 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所用能力章节,已有实现的修复与审查也核对合同
## 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- `contributes` 每种能力最多一项,派生 ID 和固定阶段可省略;能力版本与中间件阶段按[扩展项简写](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)声明。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
+14 -14
View File
@@ -15,20 +15,6 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
## 按任务读取
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
| 任务 | 读取范围 |
| --- | --- |
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
@@ -37,3 +23,17 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
## 按任务读取
先落实上述范围与边界,再读取当前阶段的完整指定章节;实际发布包含准备与验收,不能只读执行命令。阶段变化时补齐读取,准备文案不加载执行流程,实际发布前须核对发布入口的完整副作用
| 任务 | 读取范围 |
| --- | --- |
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
| 实际发布 | 先完成[基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明)、[发行准备检查](references/preparation.md#发行准备检查),再执行[通道核对](references/operations.md#发行通道核对)、[执行发版](references/operations.md#执行发版)与[恢复与验收](references/operations.md#恢复与验收) |
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md);实际发布必须覆盖分支与版本、发布能力、文案和执行与验收 |
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
@@ -20,7 +20,7 @@
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
@@ -30,7 +30,7 @@
release/publish "$release_version"
```
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
@@ -19,7 +19,7 @@
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
实际发布由 `release/publish` 提交说明、版本文件及[插件接口弃用清单](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)的必要更新,准备说明时不要提前单独提交或把版本文件改成目标版本
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
## 发行准备检查
+2 -2
View File
@@ -18,7 +18,7 @@ runs:
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
@@ -38,7 +38,7 @@ runs:
- name: Upload frontend artifact
if: inputs.upload-artifact == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: frontend-dist
path: frontend/dist
+1 -1
View File
@@ -242,7 +242,7 @@ runs:
- name: Upload container diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
+1 -1
View File
@@ -52,7 +52,7 @@ updates:
- patch
- package-ecosystem: npm
directory: /
directory: /frontend
labels: []
schedule:
interval: weekly
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
container:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
+23 -6
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
backend:
if: inputs.run_backend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
@@ -34,7 +34,7 @@ jobs:
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
services:
postgres:
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
env:
POSTGRES_DB: codex_proxy
POSTGRES_USER: codex_proxy
@@ -48,7 +48,7 @@ jobs:
--health-timeout 5s
--health-retries 10
redis:
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
ports:
- 6379:6379
options: >-
@@ -122,7 +122,7 @@ jobs:
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.143
fallback: none
@@ -156,6 +156,20 @@ jobs:
echo "CPR_TEST_REDIS_URL=redis://:${redis_password}@127.0.0.1:6379"
} >> "$GITHUB_ENV"
- name: Expose matching PostgreSQL backup and restore tools
shell: bash
env:
CPR_TEST_POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
run: |
set -euo pipefail
client_dir="$RUNNER_TEMP/pg-client"
mkdir -p "$client_dir"
for tool in pg_dump psql; do
printf '#!/bin/sh\nexec docker exec -i --env PGPASSWORD "%s" %s "$@"\n' "$CPR_TEST_POSTGRES_CONTAINER" "$tool" > "$client_dir/$tool"
chmod +x "$client_dir/$tool"
done
echo "$client_dir" >> "$GITHUB_PATH"
- name: Rust tests
working-directory: backend
run: |
@@ -185,7 +199,7 @@ jobs:
frontend:
if: inputs.run_frontend
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -199,7 +213,7 @@ jobs:
uses: ./.github/actions/build-frontend
workflow-lint:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Checkout
@@ -209,6 +223,8 @@ jobs:
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
# https://github.com/rhysd/actionlint/issues/657
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
# https://github.com/actions/runner-images#available-images
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
@@ -217,6 +233,7 @@ jobs:
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
-ignore '^label "ubuntu-26\.04" is unknown\.'
.github/workflows/*.yml
- name: Check workflow script syntax
+6 -13
View File
@@ -20,8 +20,11 @@ concurrency:
jobs:
changes:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
@@ -102,7 +105,7 @@ jobs:
source-development:
needs: changes
if: needs.changes.outputs.source == 'true'
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout with development modules
@@ -119,23 +122,13 @@ jobs:
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ steps.toolchains.outputs.node }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
if [ "$locked_revision" != "$ui_revision" ]; then
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
exit 1
fi
done
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
+17 -17
View File
@@ -21,7 +21,7 @@ env:
jobs:
release-info:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 10
outputs:
tag: ${{ steps.version.outputs.tag }}
@@ -57,7 +57,7 @@ jobs:
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
@@ -73,7 +73,7 @@ jobs:
frontend:
needs: release-info
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
@@ -143,7 +143,7 @@ jobs:
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
@@ -196,7 +196,7 @@ jobs:
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Upload binary artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
@@ -205,7 +205,7 @@ jobs:
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: read
@@ -219,19 +219,19 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download frontend artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: frontend-dist
path: dist/frontend
- name: Download binary artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: codex-proxy-rs-*
path: dist/binaries
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
@@ -250,7 +250,7 @@ jobs:
subject-path: dist/release/*
- name: Upload release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: dist/release
@@ -283,19 +283,19 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download frontend artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: frontend-dist
path: dist/docker/web/dist
- name: Download Docker binary artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
@@ -362,7 +362,7 @@ jobs:
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
- name: Upload verified image digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
path: dist/digests/*.txt
@@ -375,7 +375,7 @@ jobs:
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
contents: write
@@ -390,13 +390,13 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-assets
path: dist/release
- name: Download verified image digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: image-digest-*
path: dist/digests
+13 -4
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
guard:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
@@ -33,14 +33,23 @@ jobs:
...params,
per_page: 100,
});
// 按 PR 作者识别官方机器人,维护者重开或编辑 PR 时仍使用同一判断
const isDependabot = pr.user.login === 'dependabot[bot]'
&& pr.user.type === 'Bot'
&& pr.head.repo?.full_name === `${owner}/${repo}`;
const isDependencyUpdate = file => isDependabot
&& file.status === 'modified'
&& (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)
|| /^\.github\/actions\/.+\/(?:action\.ya?ml|Dockerfile)$/.test(file.filename));
const isProtectedRule = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|| path === 'CONTRIBUTING.md'
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|| path === '.github' || path.startsWith('.github/'));
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
// 依赖更新只豁免已有文件的修改,重命名前后的受保护路径仍参与检查
if (!files.some(file => !isDependencyUpdate(file)
&& (isProtectedRule(file.filename) || isProtectedRule(file.previous_filename)))) {
core.info('No protected contribution rule changes detected.');
return;
}
@@ -56,7 +65,7 @@ jobs:
const marker = '<!-- agents-md-guard -->';
const body = [
marker,
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
'我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
'',
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
'',
+4 -4
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
backend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -28,7 +28,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-audit@0.22.2
fallback: none
@@ -38,7 +38,7 @@ jobs:
cargo-audit audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
- name: Checkout
@@ -56,7 +56,7 @@ jobs:
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
+5 -3
View File
@@ -5,14 +5,16 @@
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md),按实际操作和文件匹配规范,跨前后端同时执行 |
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);实现自审、审查或修改同时执行 cpr-dev-guide |
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
| 写、修或审查独立网关插件,排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide;仅改宿主能力时由 cpr-dev-guide 加载插件边界 |
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
+15 -5
View File
@@ -21,12 +21,17 @@
### GitHub 回复口吻
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
AI 草拟、发布或修改 Issue、PR 讨论及审查意见的回复时,以一起排查问题、讨论方案的口吻交流:
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 围绕问题、事实和结论展开,能自然省略主语时省略;需要说明个人判断或行动归属时用“我”,避免反复以“我”起句或强调自己,不用“我们”“本助手”代称自己
- 普通回复直接回应问题,不加问候语或称呼;向对方提问时称呼“佬”,同一条回复不重复称呼
- 先接住对方想完成的事和遇到的困难,再给出核实结果与建议;回复让对方感到问题被理解、有下一步可做,避免一上来归类、纠正或划定责任
- 建议用商量的口吻,说明尝试某个操作能帮助判断什么,给对方留出选择空间;“可以试试”“方便的话”等表达随语境使用,整句仍需自然,不靠堆叠缓和词修饰命令
- 回应已有能力、暂不支持或未采纳的建议时,结合场景说明可用入口或具体原因,有可行替代办法时一并说明,再交代必要边界;避免只回复“已有功能”“重复问题”“不在范围内”等结论
- 需要补充材料或配合操作时,先利用已有信息,只询问仍缺少的部分,说明用途和最小范围;例如“佬,方便再观察几次吗?如果还出现,可以附一下对应请求的诊断包,便于对照终态用量”
- 澄清误解或提出不同意见时,从观察和依据说起,照顾对方的使用环境与理解路径,避免责备、反问或居高临下的判断;已确认的事实保持明确,尚未确认的原因保留余地
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
- 排查、复现、修改和验证的完成状态与实际一致;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
## 问题与方案依据
@@ -84,7 +89,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
官方 `dependabot[bot]` 从本仓库分支发起的依赖更新可修改 `.github/workflows/` 中已有的 YAML 工作流,以及 `.github/actions/` 下已有的 `action.yml`、`action.yaml` 和 `Dockerfile`,继续执行正常审查与 CI;其他受保护文件以及新增、删除、重命名不在例外范围内
除此之外,调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
@@ -142,6 +149,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
| [共用组件](modules/ui/README.md)及其组件参考 | 如何安装、使用和开发独立 UI 库 | 组件公开 API、主题能力与库内开发;宿主页面约定归管理端主题,跨仓库联调归开发文档 |
| [官方插件示例](modules/plugins/README.md)及其示例说明 | 如何构建、使用和维护示例插件 | 示例自身的功能、配置与验证;通用协议归 SDK,宿主安装操作归插件使用文档 |
| [AGENTS.md](AGENTS.md) 与[项目技能](.agents/skills/cpr-dev-guide/SKILL.md) | AI 如何选择并执行任务流程 | 任务匹配、执行步骤和规范入口,不另建模块合同、版本表或协作标准 |
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
+4 -3
View File
@@ -80,7 +80,7 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CC Switch
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
@@ -93,7 +93,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,合并配置后重启 Codex;也可通过「导入到」把服务连接与密钥添加到 CC Switch
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
@@ -118,7 +118,8 @@ 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) |
| 开发插件 | [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) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
+42 -35
View File
@@ -198,7 +198,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -791,9 +791,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -856,7 +856,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -910,7 +910,7 @@ dependencies = [
"provider-xai",
"serde",
"serde_json",
"syn 3.0.6",
"syn 3.0.9",
"tempfile",
"thiserror",
"tokio",
@@ -1129,9 +1129,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-bigint"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
dependencies = [
"cpubits",
"ctutils",
@@ -1189,9 +1189,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
"subtle",
@@ -1657,7 +1657,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -1720,7 +1720,7 @@ dependencies = [
"serde_json",
"sha2",
"subtle",
"syn 3.0.6",
"syn 3.0.9",
"tempfile",
"thiserror",
"tokio",
@@ -1759,6 +1759,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"zip",
"zstd",
]
@@ -1903,6 +1904,7 @@ dependencies = [
name = "gateway-store"
version = "0.1.0"
dependencies = [
"async-compression",
"async-stream",
"async-trait",
"aws-credential-types",
@@ -1915,6 +1917,7 @@ dependencies = [
"gateway-admin",
"gateway-core",
"hex",
"percent-encoding",
"redis",
"secrecy",
"serde",
@@ -2250,8 +2253,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64 0.23.1",
"bytes",
@@ -2259,6 +2263,7 @@ dependencies = [
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"ipnet",
"libc",
@@ -3266,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -3626,7 +3631,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -3831,9 +3836,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -3999,7 +4004,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -4014,10 +4019,11 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"foldhash",
"indexmap",
"itoa",
"memchr",
@@ -4098,9 +4104,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -4480,9 +4486,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.6"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -4580,7 +4586,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn 3.0.9",
]
[[package]]
@@ -4649,9 +4655,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -4734,9 +4740,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.19"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
@@ -5051,9 +5057,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.27.0"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5220,9 +5226,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.5"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
@@ -5468,13 +5474,14 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wnaf"
version = "0.14.0"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
dependencies = [
"ff",
"group",
"hybrid-array",
"primefield",
]
[[package]]
+1 -3
View File
@@ -43,7 +43,7 @@ opt-level = 2
[profile.dev.package.blake2]
opt-level = 2
[profile.dev.package."sha2@0.11.0"]
[profile.dev.package."sha2@0.11.1"]
opt-level = 2
[profile.dev.package."miniz_oxide@0.9.1"]
@@ -57,7 +57,5 @@ codegen-units = 1
panic = "abort"
[patch.crates-io]
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
@@ -96,6 +96,10 @@ async fn execute(
Some(plugin_runtime.policy_registry()),
Some(plugin_runtime.execution_registry()),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_snapshots(&core.snapshots())
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let core = or_shutdown!(plugin_runtime, core.activate().await);
@@ -31,6 +31,10 @@ pub async fn run() -> Result<(), BootstrapError> {
Some(plugin_runtime.execution_registry()),
Some(plugin_runtime.frontend_authentication_registry()),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_snapshots(&core.snapshots())
);
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
+52 -29
View File
@@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
)
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
.env(
"CPR_DATABASE_PASSWORD",
"RdsTest9!abcdefGhRdsTest9!abcdefGh",
)
.env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh")
.status()
.expect("run isolated environment override test");
assert!(status.success());
@@ -152,14 +155,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
}
}
#[test]
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
let mut document = valid_config_document();
document["openai"]["tls"] = serde_json::json!({});
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
parse_config(&document.to_string()).expect("removed provider settings are ignored");
}
#[test]
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
@@ -189,7 +184,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
] {
let mut document = valid_config_document();
if case == "unused" {
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
document["openai"]["unused_setting"] = serde_json::json!(UNUSED_SECRET);
} else if case == "missing" {
document["host"]["listen"]
.as_object_mut()
@@ -229,7 +224,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
);
match case {
"unused" => assert!(
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
stderr.contains("openai.unused_setting") && stderr.contains("已忽略"),
"{stderr}"
),
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
@@ -253,19 +248,7 @@ fn config_loader_should_allow_missing_provider_configuration() {
let mut document = valid_config_document();
document.as_object_mut().unwrap().remove("openai");
document.as_object_mut().unwrap().remove("xai");
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
}
#[test]
fn config_loader_should_ignore_removed_provider_identity_sections() {
let mut document = valid_config_document();
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
let (config, _directory) =
parse_config(&document.to_string()).expect("removed identities ignored");
let debug = format!("{config:?}");
assert!(!debug.contains("legacy-invalid"));
assert!(!debug.contains("legacy-client"));
parse_config(&document.to_string()).expect("providers use defaults when omitted");
}
#[test]
@@ -282,13 +265,53 @@ fn config_loader_should_reject_embedded_database_password() {
}
#[test]
fn config_loader_should_reject_non_hex_postgres_password() {
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
fn config_loader_should_accept_managed_store_passwords() {
for password in [
"RdsTest9!abcdefGh".to_owned(),
"RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(),
POSTGRES_PASSWORD.to_owned(),
"x".repeat(128),
"Managed@:/?#%&+Password9!".to_owned(),
"数据库密码".repeat(4),
] {
for field in ["database", "redis"] {
let mut document = valid_config_document();
document["store"][field]["password"] = serde_json::json!(password);
let (config, _directory) =
parse_config(&document.to_string()).expect("managed store password");
assert!(!format!("{config:?}").contains(&password));
}
}
}
#[test]
fn config_loader_should_reject_wrong_length_redis_password() {
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
fn config_loader_should_reject_invalid_store_passwords() {
for password in [
String::new(),
"x".repeat(15),
"x".repeat(129),
" ".repeat(32),
"\u{3000}".repeat(6),
"数据库密码".repeat(9),
"RdsTest9!abcdefGh\u{0000}".to_owned(),
"RdsTest9!abcdefGh\n".to_owned(),
"RdsTest9!abcdefGh\t".to_owned(),
"RdsTest9!abcdefGh\u{007f}".to_owned(),
] {
for field in ["database", "redis"] {
let mut document = valid_config_document();
document["store"][field]["password"] = serde_json::json!(password);
assert_rejected(document.to_string());
}
}
}
#[test]
fn config_loader_should_reject_embedded_redis_password() {
assert_rejected(valid_config().replace(
"redis://127.0.0.1:6379/",
"redis://default:embedded@127.0.0.1:6379/",
));
}
#[test]
+8 -3
View File
@@ -26,10 +26,15 @@ cpr-plugin package \
--output-dir /path/to/output
```
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
`--resource-map web=web/dist` 将包内 `web/` 前缀映射到作者清单所在目录下的 `web/dist/`。
可重复传入不同前缀,源目录必须位于项目内;没有对应资源时省略该参数,未使用的映射会被拒绝。
未映射的资源按清单中的相对路径读取,只收集 `resources` 声明的文件
作者清单的省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口,归档只保留补全字段后的 `plugin.json`
输出为 `<插件 ID>-<版本>-<目标 triple>.tar.gz` 及同名 `.tar.gz.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和声明资源。
输入清单及生成后的清单均不得超过 64 KiB,压缩包上限为 32 MiB,展开归档上限为 128 MiB
作者清单不填写 `package`;字段省略规则、能力版本和挂载阶段见 [SDK 扩展项声明](../../crates/gateway-plugin/sdk/docs/manifest.md#扩展项简写)。
CLI 生成规范化后的 `plugin.json`,补入协议版本、目标平台和文件摘要。打包成功只证明输入符合包合同,实际启动与能力仍需在目标宿主验证
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
+1 -1
View File
@@ -250,7 +250,7 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"middleware":{"version":6,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{"version":2}
},
@@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use gateway_core::{
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
task::{DaemonTask, WorkerDaemonContext, WorkerTaskError},
};
use tracing::{info, warn};
@@ -75,18 +75,20 @@ impl BackupTask {
}
impl DaemonTask for BackupTask {
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move { self.run_daemon(cancellation).await })
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move { self.run_daemon(context).await })
}
}
impl BackupTask {
async fn run_daemon(&self, cancellation: CancellationToken) -> Result<(), WorkerTaskError> {
async fn run_daemon(&self, context: WorkerDaemonContext) -> Result<(), WorkerTaskError> {
let cancellation = context.cancellation();
loop {
if cancellation.is_cancelled() {
return Ok(());
}
self.run_cycle(&cancellation).await?;
self.run_cycle(cancellation).await?;
context.succeeded();
tokio::select! {
_ = cancellation.cancelled() => return Ok(()),
@@ -647,7 +649,7 @@ impl BackupTask {
for record in expired {
let Some(record) = self
.repository
.transition_to_deleting(&record.id, now)
.transition_to_deleting(&record.id, now, None)
.await
.map_err(repo_error)?
else {
@@ -671,7 +673,7 @@ impl BackupTask {
for decision in decisions.into_iter().take(RETENTION_BATCH_SIZE) {
let Some(record) = self
.repository
.transition_to_deleting(&decision.record_id, now)
.transition_to_deleting(&decision.record_id, now, None)
.await
.map_err(repo_error)?
else {
@@ -706,6 +708,10 @@ impl BackupTask {
fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
match error.code() {
code::CANCELLED => (code::CANCELLED, "备份已取消"),
code::ANALYTICS_NOT_READY => (
code::ANALYTICS_NOT_READY,
"用量统计正在重建,请在统计就绪后重试备份",
),
code::STAGING_SPACE_EXHAUSTED => (code::STAGING_SPACE_EXHAUSTED, "暂存磁盘空间不足"),
_ => (code::PG_DUMP_FAILED, "数据库导出失败"),
}
@@ -123,6 +123,7 @@ pub struct AccountRecord {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub model_access: gateway_core::account::AccountModelAccess,
pub outbound_proxy_id: Option<String>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub outbound_proxy_enabled: bool,
pub credential_state: CredentialState,
@@ -19,6 +19,10 @@ pub enum MutationAuditOperation {
ProviderAccountUpdate,
BackupStorageUpdate,
BackupScheduleUpdate,
BackupCreate,
BackupDelete,
BackupConnectionTest,
BackupDownloadUrl,
ProviderAccountAdaptConcurrency,
ProviderAccountRecover,
ProviderAccountBatchUpdate,
@@ -76,6 +80,10 @@ impl MutationAuditOperation {
Self::ProviderAccountUpdate => ("update", "provider_account"),
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
Self::BackupCreate => ("backup.created", "backup"),
Self::BackupDelete => ("backup.delete_requested", "backup"),
Self::BackupConnectionTest => ("backup.s3_connection_tested", "backup_settings"),
Self::BackupDownloadUrl => ("backup.download_url_created", "backup"),
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
Self::ProviderAccountRecover => ("recover", "provider_account"),
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
@@ -15,6 +15,8 @@ pub const STATE_CONFLICT: &str = "backup.state_conflict";
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
/// `pg_dump` 非零退出或失败
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
/// 用量汇总尚未完整发布
pub const ANALYTICS_NOT_READY: &str = "backup.analytics_not_ready";
/// 暂存磁盘空间不足
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
/// S3 认证失败
@@ -499,7 +499,7 @@ impl BackupError {
/// 构造稳定错误码常量
pub mod code;
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.sql.gz`
///
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
/// 输出统一使用 `/` 分隔且不含空段
@@ -510,7 +510,7 @@ pub fn build_object_key(
) -> Result<String, BackupError> {
let prefix = normalize_prefix(prefix)?;
Ok(format!(
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.dump",
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.sql.gz",
at.format("%Y"),
at.format("%m"),
at.format("%d"),
@@ -519,10 +519,10 @@ pub fn build_object_key(
))
}
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致
/// 下载文件名:`codex-proxy-rs_{短 id}.sql.gz`,与对象 key 的文件名一致
#[must_use]
pub fn build_download_file_name(backup_id: &str) -> String {
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
format!("codex-proxy-rs_{}.sql.gz", short_backup_id(backup_id))
}
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex
@@ -157,12 +157,15 @@ impl OtherRequestOutcome {
/// 用量记录过滤条件
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct UsageFilter {
pub client_api_key_name: Option<String>,
pub provider_account_name: Option<String>,
pub client_api_key_ref: Option<String>,
pub request_id: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub provider_kind: Option<String>,
pub model: Option<String>,
pub model_contains: Option<String>,
pub outcome: Option<RequestOutcome>,
pub status_code: Option<u16>,
pub transport: Option<String>,
@@ -506,6 +509,46 @@ pub struct RequestMetrics {
pub cache_hit_request_count: u64,
}
/// 摘要实际消费的请求计数与延迟样本,不承诺分位数或分布
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestSummaryMetrics {
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
pub cancelled_count: u64,
pub incomplete_count: u64,
pub caller_error_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub cache_write_input_tokens: u64,
pub reasoning_output_tokens: u64,
pub total_tokens: u64,
pub latency_sum_ms: u64,
pub latency_count: u64,
}
impl From<&RequestMetrics> for RequestSummaryMetrics {
fn from(metrics: &RequestMetrics) -> Self {
Self {
request_count: metrics.request_count,
success_count: metrics.success_count,
failure_count: metrics.failure_count,
cancelled_count: metrics.cancelled_count,
incomplete_count: metrics.incomplete_count,
caller_error_count: metrics.caller_error_count,
input_tokens: metrics.input_tokens,
output_tokens: metrics.output_tokens,
cached_input_tokens: metrics.cached_input_tokens,
cache_write_input_tokens: metrics.cache_write_input_tokens,
reasoning_output_tokens: metrics.reasoning_output_tokens,
total_tokens: metrics.total_tokens,
latency_sum_ms: metrics.latency_sum_ms,
latency_count: metrics.latency_count,
}
}
}
/// 上游 attempt 聚合指标
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttemptMetrics {
@@ -697,7 +740,6 @@ pub struct UsageListRecord {
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
@@ -722,10 +764,6 @@ pub struct UsageListRecord {
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -763,7 +801,6 @@ pub struct UsageRecord {
pub upstream_transport: Option<String>,
pub http_version: Option<String>,
pub websocket_pool: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
pub provider_metadata_json: Option<String>,
@@ -803,10 +840,6 @@ pub struct UsageRecord {
pub upstream_response_ms: Option<u64>,
pub upstream_api_overhead_ms: Option<f64>,
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
pub upstream_engine_iapi_tbt_ms: Option<f64>,
pub upstream_engine_service_tbt_ms: Option<f64>,
pub latency_ms: Option<u64>,
pub admission_decision_ms: Option<u64>,
pub account_selection_wait_ms: Option<u64>,
@@ -901,10 +934,19 @@ pub struct UsageOverview {
pub providers: Vec<ProviderObservation>,
}
/// 用量摘要所需的窄聚合事实
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageSummaryObservation {
pub analytics: AnalyticsMetadata,
pub range: TimeRange,
pub requests: RequestSummaryMetrics,
pub attempts: AttemptMetrics,
}
/// 用量摘要的用例结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageSummary {
pub overview: UsageOverview,
pub observation: UsageSummaryObservation,
pub average_latency_ms: Option<u64>,
}
@@ -1146,10 +1188,10 @@ pub struct DashboardWireProfile {
pub release: Option<DashboardDesktopRelease>,
}
/// Provider 画像的差异字段;展示层只显示标签和值,不解释 Provider 语义
/// Provider 画像的差异字段,以稳定标识传递语义,名称由前端本地化
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardWireAttribute {
pub label: String,
pub key: String,
pub value: String,
}
@@ -820,6 +820,13 @@ pub struct ProviderQuotaRequest {
pub rolling_usage: Option<AccountUsage>,
}
/// 当前目录页已读取的账号事实;批量投影不触发上游刷新
#[derive(Debug, Clone, Copy)]
pub struct ProviderQuotaReadRequest<'a> {
pub account: &'a AccountRecord,
pub rolling_usage: Option<&'a AccountUsage>,
}
/// Provider 已解析的 quota 结果及其不透明差异字段
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuota {
@@ -66,8 +66,12 @@ pub trait BackupRepository: Send + Sync {
at: DateTime<Utc>,
) -> AdminStoreResult<bool>;
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
/// 同事务插入人工 queued 任务与审计;存在活跃任务时返回 `backup.active_task_conflict`
async fn insert_backup_record(
&self,
seed: BackupRecordSeed,
context: &MutationContext,
) -> AdminStoreResult<BackupRecord>;
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`
@@ -111,11 +115,12 @@ pub trait BackupRepository: Send + Sync {
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用
/// 迁移到删除流程(completed/failed → deleting);人工操作同事务记录审计,保留策略传 None
async fn transition_to_deleting(
&self,
id: &str,
now: DateTime<Utc>,
context: Option<&MutationContext>,
) -> AdminStoreResult<Option<BackupRecord>>;
/// 远端删除成功后硬删除记录行
@@ -313,6 +318,7 @@ impl BackupRepository for UnavailableBackupRepository {
async fn insert_backup_record(
&self,
_seed: BackupRecordSeed,
_context: &MutationContext,
) -> AdminStoreResult<BackupRecord> {
Err(disabled_store())
}
@@ -363,6 +369,7 @@ impl BackupRepository for UnavailableBackupRepository {
&self,
_id: &str,
_now: DateTime<Utc>,
_context: Option<&MutationContext>,
) -> AdminStoreResult<Option<BackupRecord>> {
Err(disabled_store())
}
@@ -18,8 +18,9 @@ use crate::model::provider_credentials::{
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaReadRequest,
ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
explicit_plan_type,
};
use crate::model::{
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
@@ -289,6 +290,25 @@ pub trait ProviderAdmin: Send + Sync {
request: ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError>;
/// 只读投影当前页额度,返回值与输入逐项对应,单个失败不影响其他账号
/// 没有批量存储能力的 Provider 继续使用原有单账号语义
async fn read_quotas(
&self,
requests: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
futures::future::join_all(requests.iter().map(|request| async {
let account_id = ProviderAccountId::new(request.account.id.clone())
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Invalid))?;
self.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: request.rolling_usage.cloned(),
})
.await
}))
.await
}
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算
fn quota_forecast_observation(
&self,
@@ -30,7 +30,7 @@ use crate::model::{
DashboardObservation, DashboardQuery, DiagnosticDimension, DiagnosticsObservation,
Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageBillingProjection, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageInsightsObservation, UsageOverview, UsagePage, UsageQuery,
UsageInsightsObservation, UsagePage, UsageQuery, UsageSummaryObservation,
},
provider_credentials::{
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
@@ -485,7 +485,7 @@ pub trait ObservabilityStore: Send + Sync {
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview>;
) -> AdminStoreResult<UsageSummaryObservation>;
async fn usage_diagnostics(
&self,
@@ -114,10 +114,14 @@ impl DefaultAccountGroupService {
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
) -> Result<AccountGroupMutation, AdminError> {
let mut result = result.map_err(|error| map_store_error(error, "account group"))?;
if let Some(record) = result.record.as_mut() {
self.enrich_records(std::slice::from_mut(record)).await?;
}
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
if let Some(record) = result.record.as_mut()
&& let Err(error) = self.enrich_records(std::slice::from_mut(record)).await
{
tracing::warn!(group_id = %result.id.as_str(), error = %error, "committed account group runtime projection is unavailable");
// record 可缺省;不要用未完成的容量投影冒充有效展示数据。
result.record = None;
}
Ok(result)
}
}
@@ -30,8 +30,9 @@ use crate::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
PrepareCredentialRefresh, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
ProviderProfileAvatar, ProviderQuota, ProviderQuotaReadRequest, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
},
quota_forecast::{
AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window,
@@ -236,6 +237,78 @@ impl DefaultAccountsService {
)
}
async fn read_quotas(
providers: &ProviderAdminRegistry,
items: &[AccountPageItem],
rolling_usage: &BTreeMap<String, AccountUsage>,
) -> Vec<ProviderQuota> {
let mut groups = BTreeMap::<_, Vec<usize>>::new();
for (index, item) in items.iter().enumerate() {
groups
.entry(&item.account.provider_kind)
.or_default()
.push(index);
}
let batches = groups.into_iter().map(|(kind, indices)| async move {
let provider = match providers.require(kind) {
Ok(provider) => provider,
Err(error) => {
tracing::warn!(
provider = %kind,
error = %error,
"account directory provider is not registered; showing empty quota"
);
return Vec::new();
}
};
let requests = indices
.iter()
.map(|&index| {
let account = &items[index].account;
ProviderQuotaReadRequest {
account,
rolling_usage: rolling_usage.get(&account.id),
}
})
.collect::<Vec<_>>();
let results = provider.read_quotas(&requests).await;
if results.len() != indices.len() {
tracing::warn!(
provider = %kind,
expected = indices.len(),
actual = results.len(),
"account directory quota batch has invalid length; showing empty quota"
);
return Vec::new();
}
indices
.into_iter()
.zip(results)
.map(|(index, result)| {
let quota = result.unwrap_or_else(|error| {
tracing::warn!(
account_id = %items[index].account.id,
error = %error,
"account directory quota projection failed; showing empty quota"
);
empty_quota()
});
(index, quota)
})
.collect::<Vec<_>>()
});
// Provider 可独立完成,回填原页顺序并保留未注册或失败账号的位置
let mut quotas = vec![empty_quota(); items.len()];
for (index, quota) in futures::future::join_all(batches)
.await
.into_iter()
.flatten()
{
quotas[index] = quota;
}
quotas
}
async fn load_account(
&self,
account_id: &ProviderAccountId,
@@ -487,45 +560,7 @@ impl AccountsService for DefaultAccountsService {
.into_iter()
.map(|usage| (usage.account_id.clone(), usage))
.collect::<BTreeMap<_, _>>();
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
let account = &item.account;
let account_id = ProviderAccountId::new(account.id.clone())
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
// 该账号降级为空额度投影,其余账号与页面状态照常返回
let provider = match providers.require(&account.provider_kind) {
Ok(provider) => provider,
Err(error) => {
tracing::warn!(
account_id = %account.id,
error = %error,
"account directory provider is not registered; showing empty quota"
);
return Ok(empty_quota());
}
};
match provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: rolling_usage.get(&account.id).cloned(),
})
.await
{
Ok(quota) => Ok(quota),
Err(error) => {
tracing::warn!(
account_id = %account.id,
error = %error,
"account directory quota projection failed; showing empty quota"
);
Ok(empty_quota())
}
}
}))
.await
.into_iter()
.collect::<Result<Vec<_>, AdminError>>()?;
let mut quotas = Self::read_quotas(providers, &page.items, &rolling_usage).await;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
@@ -7,11 +7,10 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use gateway_core::runtime::SnapshotControl;
use secrecy::ExposeSecret as _;
use uuid::Uuid;
use crate::model::{
AdminError, AdminErrorKind, MutationContext,
auth::{AdminAuditEvent, AuditActorKind},
audit::{MutationAuditIntent, MutationAuditOperation},
backup::{
BackupError, BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupSettings,
BackupStatus, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
@@ -180,8 +179,8 @@ impl BackupService for DefaultBackupService {
}
self.append_audit(
context,
"backup.s3_connection_tested",
"backup_settings",
MutationAuditOperation::BackupConnectionTest,
"1",
vec![],
)
.await?;
@@ -219,11 +218,9 @@ impl BackupService for DefaultBackupService {
.map_err(map_backup_error)?;
let record = self
.repository
.insert_backup_record(seed)
.insert_backup_record(seed, context)
.await
.map_err(|error| map_store_error(error, "backup record"))?;
self.append_audit(context, "backup.created", "backup", vec![record.id.clone()])
.await?;
Ok(record)
}
@@ -264,9 +261,9 @@ impl BackupService for DefaultBackupService {
.map_err(map_backup_error)?;
self.append_audit(
context,
"backup.download_url_created",
"backup",
vec![backup_id.to_owned()],
MutationAuditOperation::BackupDownloadUrl,
backup_id,
vec![],
)
.await?;
Ok(DownloadUrlResult {
@@ -290,17 +287,10 @@ impl BackupService for DefaultBackupService {
let now = Utc::now();
let deleting = self
.repository
.transition_to_deleting(backup_id, now)
.transition_to_deleting(backup_id, now, Some(context))
.await
.map_err(|error| map_store_error(error, "backup record"))?
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
self.append_audit(
context,
"backup.delete_requested",
"backup",
vec![backup_id.to_owned()],
)
.await?;
Ok(deleting)
}
}
@@ -317,41 +307,15 @@ impl DefaultBackupService {
async fn append_audit(
&self,
context: &MutationContext,
action: &str,
operation: MutationAuditOperation,
entity_ref: &str,
changed_fields: Vec<String>,
) -> Result<(), AdminError> {
let actor_kind = match context.actor {
crate::model::MutationActor::AdminSession { .. } => AuditActorKind::AdminSession,
crate::model::MutationActor::AdminApiKey => AuditActorKind::AdminApiKey,
crate::model::MutationActor::System => AuditActorKind::System,
};
let actor_ref = match &context.actor {
crate::model::MutationActor::AdminSession { admin_user_id } => {
crate::model::auth::admin_session_actor_ref(admin_user_id)
}
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
crate::model::MutationActor::System => "system".to_owned(),
};
let actor_admin_user_id = match &context.actor {
crate::model::MutationActor::AdminSession { admin_user_id } => {
Some(admin_user_id.clone())
}
_ => None,
};
let event = AdminAuditEvent {
id: format!("audit_{}", Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: "backup".to_owned(),
entity_ref: entity_ref.to_owned(),
config_revision: None,
changed_fields,
occurred_at: Utc::now(),
};
let event = MutationAuditIntent {
operation,
entity_ref,
}
.event(context, changed_fields);
self.auth
.append_audit_event(event)
.await
@@ -9,10 +9,7 @@ use std::{
use chrono::Utc;
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::{
lifecycle::CancellationToken,
task::{DaemonTask, WorkerTaskError},
};
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
use tokio::{sync::Notify, time::Instant};
use uuid::Uuid;
@@ -332,8 +329,9 @@ impl ImportTasksService for DefaultImportTasksService {
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
impl DaemonTask for ImportTaskWorker {
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let cancellation = context.cancellation();
let mut active = FuturesUnordered::new();
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter
let cancelled = cancellation.cancelled();
@@ -354,6 +352,7 @@ impl DaemonTask for ImportTaskWorker {
};
active.push(self.0.execute(id, index, input));
}
context.succeeded();
tokio::select! {
() = &mut cancelled => {},
_ = active.next(), if !active.is_empty() => {},
@@ -106,7 +106,7 @@ impl DefaultKeyUsageService {
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
UsageFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model,
model_contains: model,
..UsageFilter::default()
}
}
@@ -0,0 +1,65 @@
//! 集中归纳已采集证据的缺口,不从缺失事件推断业务结果
use serde_json::Value;
use crate::model::diagnostics::DiagnosticBundle;
use super::gap;
pub(super) fn trace_gaps(bundle: &mut DiagnosticBundle, section: &str, trace: &Value) {
let trace_section = format!("{section}.trace");
for (field, reason) in [
("droppedEvents", "events_evicted"),
("droppedEvidenceEvents", "critical_evidence_evicted"),
("stageCountOverflow", "stage_coverage_incomplete"),
] {
if trace[field].as_u64().unwrap_or(0) > 0 {
gap(bundle, &trace_section, reason);
}
}
if !trace["stageCounts"].is_object() {
gap(bundle, &trace_section, "stage_coverage_not_recorded");
}
let events = trace["events"].as_array();
if !events.is_some_and(|events| {
events.iter().any(|event| {
event["stage"] == "request.routed" && event["data"]["accountPolicy"].is_object()
})
}) {
gap(bundle, section, "incident_policy_not_recorded");
}
for (index, event) in events.into_iter().flatten().enumerate() {
let event_section = format!("{trace_section}.events[{index}]");
let data = &event["data"];
for (missing, reason) in [
(
event["stage"] == "provider.response.parse_gap",
"response_parse_failed",
),
(data["jsonValid"] == false, "json_parse_failed"),
(data["truncated"] == true, "event_data_truncated"),
(
data["input"]["omittedItems"].as_u64().unwrap_or(0) > 0,
"input_prefix_sample_limited",
),
(
data["correlation"].as_object().is_some_and(|headers| {
headers
.values()
.any(|header| header["omittedValues"].as_u64().unwrap_or(0) > 0)
}),
"correlation_header_sample_limited",
),
] {
if missing {
gap(bundle, &event_section, reason);
}
}
if event["stage"] == "capture.gap" || data["truncated"] == true {
gap(bundle, &trace_section, "capture_gap_or_truncation");
}
if event["stage"] == "retry.decided" && data["phase"].is_null() {
gap(bundle, &trace_section, "legacy_retry_facts_not_recorded");
}
}
}
@@ -7,6 +7,8 @@ use std::{
use tokio::time::{Instant, timeout_at};
mod availability;
use super::{DefaultObservabilityService, map_store_error};
use crate::model::{
AdminError, AdminErrorKind,
@@ -150,38 +152,7 @@ impl DefaultObservabilityService {
&format!("{section}.trace"),
"not_recorded_or_retained",
),
Some(trace) => {
if trace["droppedEvents"].as_u64().unwrap_or(0) > 0 {
gap(&mut bundle, &format!("{section}.trace"), "events_evicted");
}
let events = trace["events"].as_array();
if !events.is_some_and(|events| {
events.iter().any(|event| {
event["stage"] == "request.routed"
&& event["data"]["accountPolicy"].is_object()
})
}) {
gap(&mut bundle, &section, "incident_policy_not_recorded");
}
for event in events.into_iter().flatten() {
if event["stage"] == "capture.gap" || event["data"]["truncated"] == true
{
gap(
&mut bundle,
&format!("{section}.trace"),
"capture_gap_or_truncation",
);
}
if event["stage"] == "retry.decided" && event["data"]["phase"].is_null()
{
gap(
&mut bundle,
&format!("{section}.trace"),
"legacy_retry_facts_not_recorded",
);
}
}
}
Some(trace) => availability::trace_gaps(&mut bundle, &section, trace),
}
gap(
&mut bundle,
@@ -267,17 +267,17 @@ impl ObservabilityService for DefaultObservabilityService {
range: TimeRange,
filter: UsageFilter,
) -> Result<UsageSummary, AdminError> {
let overview = self
let observation = self
.store
.usage_summary(range, filter)
.await
.map_err(|error| map_store_error(error, "usage summary"))?;
let average_latency_ms = average(
overview.requests.latency_sum_ms,
overview.requests.latency_count,
observation.requests.latency_sum_ms,
observation.requests.latency_count,
);
Ok(UsageSummary {
overview,
observation,
average_latency_ms,
})
}
@@ -41,7 +41,7 @@ impl AnalyticsValue for DashboardObservation {
&mut self.analytics
}
}
impl AnalyticsValue for UsageOverview {
impl AnalyticsValue for UsageSummaryObservation {
fn metadata(&mut self) -> &mut AnalyticsMetadata {
&mut self.analytics
}
@@ -177,7 +177,7 @@ type UsageKey = (TimeRange, UsageFilter);
pub(crate) struct CachedObservabilityStore {
inner: Arc<dyn ObservabilityStore>,
dashboard: Cache<DashboardQuery, DashboardObservation>,
summaries: Cache<UsageKey, UsageOverview>,
summaries: Cache<UsageKey, UsageSummaryObservation>,
insights: Cache<(UsageKey, Granularity), UsageInsightsObservation>,
diagnostics: Cache<(UsageKey, DiagnosticDimension, u16), DiagnosticsObservation>,
}
@@ -254,7 +254,7 @@ impl ObservabilityStore for CachedObservabilityStore {
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview> {
) -> AdminStoreResult<UsageSummaryObservation> {
self.summaries
.get((range, filter.clone()), self.inner.as_ref(), TTL, || {
self.inner.usage_summary(range, filter)
@@ -5,6 +5,7 @@
//! 供 backup::task 与 use_case::backup 的测试复用
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
@@ -91,7 +92,9 @@ pub(crate) fn queued_record(seed: &BackupRecordSeed) -> BackupRecord {
/// 内存版备份仓储
pub(crate) struct FakeBackupRepository {
audits: Mutex<Vec<AdminAuditEvent>>,
settings: Mutex<BackupSettings>,
settings_unavailable: AtomicBool,
records: Mutex<Vec<BackupRecord>>,
}
@@ -99,10 +102,21 @@ impl FakeBackupRepository {
pub(crate) fn new(settings: BackupSettings) -> Self {
Self {
settings: Mutex::new(settings),
audits: Mutex::new(Vec::new()),
settings_unavailable: AtomicBool::new(false),
records: Mutex::new(Vec::new()),
}
}
pub(crate) fn audit_events(&self) -> Vec<AdminAuditEvent> {
self.audits.lock().expect("audits").clone()
}
pub(crate) fn set_settings_unavailable(&self, unavailable: bool) {
self.settings_unavailable
.store(unavailable, Ordering::SeqCst);
}
/// 测试辅助:读取当前全部记录
pub(crate) fn all_records(&self) -> Vec<BackupRecord> {
self.records.lock().expect("records").clone()
@@ -134,6 +148,13 @@ impl FakeBackupRepository {
#[async_trait]
impl BackupRepository for FakeBackupRepository {
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
if self.settings_unavailable.load(Ordering::SeqCst) {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"backup",
"test store unavailable",
));
}
Ok(self.settings.lock().expect("settings").clone())
}
@@ -192,7 +213,11 @@ impl BackupRepository for FakeBackupRepository {
Ok(true)
}
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord> {
async fn insert_backup_record(
&self,
seed: BackupRecordSeed,
context: &MutationContext,
) -> AdminStoreResult<BackupRecord> {
let mut records = self.records.lock().expect("records");
if records.iter().any(|record| record.status.is_active()) {
return Err(AdminStoreError::new(
@@ -203,6 +228,13 @@ impl BackupRepository for FakeBackupRepository {
}
let record = queued_record(&seed);
records.push(record.clone());
self.audits.lock().expect("audits").push(
gateway_admin::model::audit::MutationAuditIntent {
operation: gateway_admin::model::audit::MutationAuditOperation::BackupCreate,
entity_ref: &record.id,
}
.event(context, vec!["status".to_owned()]),
);
Ok(record)
}
@@ -386,6 +418,7 @@ impl BackupRepository for FakeBackupRepository {
&self,
id: &str,
now: DateTime<Utc>,
context: Option<&MutationContext>,
) -> AdminStoreResult<Option<BackupRecord>> {
let mut records = self.records.lock().expect("records");
let Some(record) = records
@@ -396,6 +429,15 @@ impl BackupRepository for FakeBackupRepository {
};
record.status = BackupStatus::Deleting;
record.updated_at = now;
if let Some(context) = context {
self.audits.lock().expect("audits").push(
gateway_admin::model::audit::MutationAuditIntent {
operation: gateway_admin::model::audit::MutationAuditOperation::BackupDelete,
entity_ref: id,
}
.event(context, vec!["status".to_owned()]),
);
}
Ok(Some(record.clone()))
}
@@ -11,6 +11,7 @@ use gateway_admin::{
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
use super::support::{
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
@@ -31,7 +32,7 @@ async fn backup_task_runs_full_dump_upload_verify_pipeline() {
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.insert_backup_record(seed.clone(), &system_context())
.await
.expect("insert queued");
@@ -72,7 +73,7 @@ async fn backup_task_marks_failed_when_dump_port_fails() {
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.insert_backup_record(seed.clone(), &system_context())
.await
.expect("insert queued");
@@ -104,7 +105,7 @@ async fn backup_task_marks_failed_with_the_upload_error() {
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.insert_backup_record(seed.clone(), &system_context())
.await
.expect("insert queued");
@@ -137,7 +138,7 @@ async fn backup_task_recovery_marks_failed_with_the_upload_error() {
expires_at: None,
};
repository
.insert_backup_record(seed.clone())
.insert_backup_record(seed.clone(), &system_context())
.await
.expect("insert queued");
let cancellation = CancellationToken::new();
@@ -184,14 +185,14 @@ async fn retention_cleans_expired_scheduled_backups() {
expires_at: None,
};
repository
.insert_backup_record(old_seed.clone())
.insert_backup_record(old_seed.clone(), &system_context())
.await
.expect("insert old");
// 旧备份 40 天前完成(释放活跃名额),再插入新备份
let now = Utc::now();
repository.set_completed(&backup_id("old"), now - Duration::days(40));
repository
.insert_backup_record(new_seed.clone())
.insert_backup_record(new_seed.clone(), &system_context())
.await
.expect("insert new");
// 新备份 1 天前完成
@@ -278,7 +279,10 @@ async fn completed_recovery_removes_staging_for_dumping_and_uploading_tasks() {
object_key: "recovery.dump".to_owned(),
expires_at: None,
};
repository.insert_backup_record(seed.clone()).await.unwrap();
repository
.insert_backup_record(seed.clone(), &system_context())
.await
.unwrap();
let artifact = dump
.dump(DumpRequest {
backup_id: seed.id.clone(),
@@ -343,7 +347,10 @@ async fn manual_backup_preserves_primary_and_final_delete_failures() {
object_key: "codex/cleanup-failure.dump".into(),
expires_at: expiring.then(|| Utc::now() + Duration::hours(1)),
};
repository.insert_backup_record(seed.clone()).await.unwrap();
repository
.insert_backup_record(seed.clone(), &system_context())
.await
.unwrap();
let task = BackupTask::new(repository.clone(), dump.clone(), store.clone());
task.run_cycle(&CancellationToken::new()).await.unwrap();
let records = repository.all_records();
@@ -379,3 +386,40 @@ async fn manual_backup_preserves_primary_and_final_delete_failures() {
);
}
}
#[tokio::test(start_paused = true)]
async fn daemon_reports_success_only_after_a_complete_cycle_and_recovers_after_store_failure() {
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
let task = Arc::new(BackupTask::new(
repository.clone(),
Arc::new(FakeDumpPort::new()),
Arc::new(FakeObjectStore::new()),
));
let context = WorkerDaemonContext::new(CancellationToken::new());
repository.set_settings_unavailable(true);
let error = task.run(context.clone()).await.unwrap_err();
assert_eq!(error.as_safe_str(), "backup store failed");
assert_eq!(context.completed_cycles(), 0);
repository.set_settings_unavailable(false);
let recovered = WorkerDaemonContext::new(CancellationToken::new());
let worker = tokio::spawn({
let task = task.clone();
let context = recovered.clone();
async move { task.run(context).await }
});
recovered.progress_since(0).await;
assert_eq!(recovered.completed_cycles(), 1);
repository.set_settings_unavailable(true);
tokio::time::advance(std::time::Duration::from_secs(30)).await;
assert_eq!(
worker.await.unwrap().unwrap_err().as_safe_str(),
"backup store failed"
);
assert_eq!(recovered.completed_cycles(), 1);
let cancelled = WorkerDaemonContext::new(CancellationToken::new());
cancelled.cancellation().cancel();
task.run(cancelled.clone()).await.unwrap();
assert_eq!(cancelled.completed_cycles(), 0);
}
+9 -27
View File
@@ -5,20 +5,16 @@ use std::str::FromStr as _;
use chrono::{TimeDelta, Utc};
use gateway_core::account::OpaqueProviderData;
use gateway_admin::{
model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
provider_credentials::{
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
QuotaLocalUsageAttribution,
},
settings::AdminApiKey,
use gateway_admin::model::{
PageSize, Revision,
accounts::AccountUsage,
auth::LoginCommand,
client_keys::ClientKeyPageSize,
observability::{DecimalAmount, RequestOutcome, TimeRange},
provider_credentials::{
ProviderDocument, ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution,
},
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
settings::AdminApiKey,
};
mod backup;
@@ -99,20 +95,6 @@ fn login_command_debug_should_redact_password() {
assert!(!format!("{command:?}").contains("secret-password"));
}
#[test]
fn all_store_capability_traits_should_be_object_safe() {
fn assert_object_safe<T: ?Sized + Send + Sync>() {}
assert_object_safe::<dyn AccountStore>();
assert_object_safe::<dyn AuthStore>();
assert_object_safe::<dyn ClientKeyStore>();
assert_object_safe::<dyn ObservabilityStore>();
assert_object_safe::<dyn SettingsStore>();
fn assert_send_object_safe<T: ?Sized + Send>() {}
assert_send_object_safe::<dyn CredentialCommitGuard>();
}
#[test]
fn provider_document_debug_should_not_expose_opaque_material() {
let document = ProviderDocument::new(OpaqueProviderData::new(Default::default()));
@@ -58,7 +58,7 @@ fn object_key_shape() {
let key = build_object_key("production/backups", "backup_abc", at).unwrap();
assert_eq!(
key,
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.dump"
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.sql.gz"
);
}
@@ -149,6 +149,9 @@ async fn group_options_query_should_preserve_store_failures() {
#[derive(Default)]
struct FakeGroupStore {
projection_failure: bool,
missing_record: bool,
published_revision: Option<Arc<std::sync::atomic::AtomicU64>>,
requested_groups: Mutex<Vec<String>>,
members: Option<Vec<AccountGroupMemberFact>>,
}
@@ -223,6 +226,16 @@ impl AccountGroupStore for FakeGroupStore {
&self,
group_ids: &[AccountGroupId],
) -> AdminStoreResult<Vec<AccountGroupMemberFact>> {
if self.projection_failure {
assert_eq!(
self.published_revision
.as_ref()
.expect("publication counter")
.load(std::sync::atomic::Ordering::SeqCst),
2
);
return Err(unused());
}
*self.requested_groups.lock().expect("requested groups") = group_ids
.iter()
.map(|group_id| group_id.as_str().to_owned())
@@ -251,10 +264,16 @@ impl AccountGroupStore for FakeGroupStore {
async fn set_account_group_enabled(
&self,
_: SetAccountGroupEnabled,
command: SetAccountGroupEnabled,
_: &MutationContext,
) -> AdminStoreResult<AccountGroupMutation> {
Err(unused())
let mut record = group_record();
record.enabled = command.enabled;
Ok(AccountGroupMutation {
config_revision: Revision::new(2).expect("committed revision"),
id: record.id.clone(),
record: (!self.missing_record).then_some(record),
})
}
async fn delete_account_group(
@@ -431,3 +450,50 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
assert_eq!(created.kind(), AdminErrorKind::Invalid);
assert_eq!(updated.kind(), AdminErrorKind::Invalid);
}
struct RecordingPublication(Arc<std::sync::atomic::AtomicU64>);
impl gateway_core::runtime::SnapshotControl for RecordingPublication {
fn publish_committed(
&self,
revision: gateway_core::routing::ConfigRevision,
) -> futures::future::BoxFuture<'_, ()> {
Box::pin(async move {
self.0
.store(revision.get(), std::sync::atomic::Ordering::SeqCst);
})
}
}
#[tokio::test]
async fn committed_group_mutations_publish_even_when_the_display_projection_is_unavailable() {
for missing_record in [false, true] {
let published = Arc::new(std::sync::atomic::AtomicU64::new(0));
let groups = Arc::new(FakeGroupStore {
projection_failure: true,
missing_record,
published_revision: Some(published.clone()),
..Default::default()
});
let services = AdminHarness::new()
.account_groups(groups)
.snapshot(Arc::new(RecordingPublication(published.clone())))
.build()
.await;
let mutation = services
.account_groups()
.set_enabled(
&crate::backup::support::system_context(),
SetAccountGroupEnabled {
id: group_id(),
enabled: false,
},
)
.await
.expect("committed mutation remains successful");
assert_eq!(mutation.config_revision.get(), 2);
assert_eq!(mutation.id, group_id());
assert!(mutation.record.is_none());
assert_eq!(published.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}
@@ -2098,11 +2098,23 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
provider_data: None,
});
let failing = FakeProviderAdmin::new("xai", events.clone());
let mut xai_quota = failing.quota.lock().expect("provider quota").clone();
xai_quota.observed_at = Some(Utc::now());
failing.set_quota(xai_quota);
failing.fail_next_quota(ProviderAdminErrorKind::Invalid);
let store = FakeAccountStore::new("openai", events.clone());
let mut xai_account = account_record("xai");
xai_account.id = "acct_bad_xai".to_owned();
store.set_accounts(vec![account_record("openai"), xai_account]);
let mut healthy_xai = xai_account.clone();
healthy_xai.id = "acct_healthy_xai".to_owned();
let mut second_openai = account_record("openai");
second_openai.id = "acct_second_openai".to_owned();
store.set_accounts(vec![
account_record("openai"),
xai_account,
second_openai,
healthy_xai,
]);
let services = super::AdminHarness::new()
.accounts(store.clone())
.settings(Arc::new(StaticSettingsStore::default()))
@@ -2126,7 +2138,18 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
.await
.expect("one failing quota must not fail the directory page");
assert_eq!(page.items.len(), 2);
assert_eq!(
page.items
.iter()
.map(|item| item.account.id.as_str())
.collect::<Vec<_>>(),
[
"acct_test",
"acct_bad_xai",
"acct_second_openai",
"acct_healthy_xai"
]
);
let by_id = page
.items
.iter()
@@ -2139,7 +2162,9 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
assert!(degraded.quota.windows.is_empty());
assert!(degraded.quota.observed_at.is_none());
assert!(degraded.quota.provider_data.is_none());
assert_eq!(page.summary.total, 2);
assert!(by_id["acct_healthy_xai"].quota.observed_at.is_some());
assert_eq!(by_id["acct_second_openai"].quota.windows.len(), 1);
assert_eq!(page.summary.total, 4);
}
#[tokio::test]
@@ -3194,6 +3219,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 {
outbound_proxy_id: None,
outbound_proxy_enabled: true,
notes: None,
model_access: Default::default(),
@@ -38,9 +38,12 @@ async fn backup_service_creates_downloads_and_deletes_with_audit() {
assert_eq!(created.status, BackupStatus::Queued);
assert!(created.object_key.starts_with("codex/"));
assert!(
auth.audit_actions()
repository
.audit_events()
.iter()
.any(|action| action == "backup.created")
.any(|event| event.action == "backup.created"
&& event.entity_ref == created.id
&& event.request_id.as_deref() == Some(system_context().request_id.as_str()))
);
// 下载地址要求 completed 记录
@@ -65,9 +68,12 @@ async fn backup_service_creates_downloads_and_deletes_with_audit() {
.expect("delete backup");
assert_eq!(deleting.status, BackupStatus::Deleting);
assert!(
auth.audit_actions()
repository
.audit_events()
.iter()
.any(|action| action == "backup.delete_requested")
.any(|event| event.action == "backup.delete_requested"
&& event.entity_ref == created.id
&& event.changed_fields == ["status"])
);
}
@@ -14,7 +14,7 @@ use gateway_admin::{
use gateway_core::{
lifecycle::CancellationToken,
routing::ProviderKind,
task::{WorkerContribution, WorkerKind, WorkerRunnable},
task::{WorkerContribution, WorkerDaemonContext, WorkerKind, WorkerRunnable},
};
use uuid::Uuid;
@@ -75,7 +75,9 @@ fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandl
let cancellation = CancellationToken::new();
let token = cancellation.clone();
let handle = tokio::spawn(async move {
task.run(token).await.expect("import daemon exits cleanly");
task.run(WorkerDaemonContext::new(token))
.await
.expect("import daemon exits cleanly");
});
(cancellation, handle)
}
@@ -49,7 +49,7 @@ use gateway_admin::{
DashboardDesktopRelease, DashboardObservation, DashboardQuery, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticsObservation, Granularity, OpsErrorPage, OpsErrorQuery, RequestMetricPoint,
TimeRange, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
TimeRange, UsageDetail, UsageFilter, UsagePage, UsageQuery, UsageSummaryObservation,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
@@ -99,6 +99,7 @@ pub(super) struct AdminHarness {
proxy_probe: Arc<dyn gateway_admin::ports::proxy::ProxyProbe>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
snapshot: Arc<dyn SnapshotControl>,
auth: Arc<dyn AuthStore>,
client_keys: Arc<dyn ClientKeyStore>,
observability: Arc<dyn ObservabilityStore>,
@@ -125,6 +126,7 @@ impl AdminHarness {
proxy_probe: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
snapshot: Arc::new(NoopSnapshot),
auth: Arc::new(BootstrapAuthStore::default()),
client_keys: unavailable.clone(),
observability: unavailable.clone(),
@@ -168,6 +170,11 @@ impl AdminHarness {
self
}
pub(super) fn snapshot(mut self, snapshot: Arc<dyn SnapshotControl>) -> Self {
self.snapshot = snapshot;
self
}
pub(super) fn auth(mut self, store: Arc<dyn AuthStore>) -> Self {
self.auth = store;
self
@@ -291,7 +298,7 @@ impl AdminHarness {
self.providers,
)
.unwrap(),
snapshot: Arc::new(NoopSnapshot),
snapshot: self.snapshot,
account_probe: self.probe,
proxy_probe: self.proxy_probe,
client_distribution: Arc::new(NoopClientDistribution),
@@ -826,7 +833,11 @@ impl ObservabilityStore for UnavailableStore {
Err(unavailable("usage detail"))
}
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
async fn usage_summary(
&self,
_: TimeRange,
_: UsageFilter,
) -> AdminStoreResult<UsageSummaryObservation> {
Err(unavailable("usage summary"))
}
@@ -1033,7 +1044,7 @@ pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
},
user_agent: "gateway-admin-test".to_owned(),
attributes: vec![DashboardWireAttribute {
label: "Core".to_owned(),
key: "core".to_owned(),
value: "test".to_owned(),
}],
verified_at: Some(chrono::Utc::now()),
@@ -23,7 +23,7 @@ use gateway_admin::{
HealthStatus, LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
UsageBilling, UsageDetail, UsageFilter, UsageInsightsObservation, UsageListRecord,
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery,
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -1166,7 +1166,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview> {
) -> AdminStoreResult<UsageSummaryObservation> {
self.usage_summary_filters
.lock()
.expect("summary filters")
@@ -1178,9 +1178,12 @@ impl ObservabilityStore for FixtureObservabilityStore {
if self.summary_fail.load(Ordering::SeqCst) {
return Err(super::unavailable("projection summary failed"));
}
Ok(UsageOverview {
let overview = self.overview.lock().expect("overview").clone();
Ok(UsageSummaryObservation {
range,
..self.overview.lock().expect("overview").clone()
analytics: overview.analytics,
requests: (&overview.requests).into(),
attempts: overview.attempts,
})
}
@@ -1358,7 +1361,6 @@ fn total_record(
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
upstream_response_model: None,
service_tier: None,
input_tokens: Some(800),
output_tokens: Some(200),
@@ -1390,10 +1392,6 @@ fn total_record(
upstream_response_ms: None,
upstream_api_overhead_ms: None,
upstream_engine_ms: None,
upstream_engine_iapi_ttft_ms: None,
upstream_engine_service_ttft_ms: None,
upstream_engine_iapi_tbt_ms: None,
upstream_engine_service_tbt_ms: None,
latency_ms: Some(100),
admission_decision_ms: None,
account_selection_wait_ms: None,
@@ -1508,8 +1506,8 @@ async fn usage_summary_cache_should_coalesce_identical_queries_and_invalidate_de
.usage_summary(range, filter)
.await
.expect("generation refresh");
assert_eq!(refreshed.overview.analytics.generation, 1);
assert_eq!(refreshed.overview.requests.request_count, 7);
assert_eq!(refreshed.observation.analytics.generation, 1);
assert_eq!(refreshed.observation.requests.request_count, 7);
assert_eq!(store.usage_summary_filters.lock().unwrap().len(), 2);
}
@@ -1529,13 +1527,17 @@ async fn usage_summary_cache_should_coalesce_when_generation_advances_during_the
},
);
assert_eq!(
first.expect("first summary").overview.analytics.generation,
first
.expect("first summary")
.observation
.analytics
.generation,
1
);
assert_eq!(
second
.expect("coalesced summary")
.overview
.observation
.analytics
.generation,
1
@@ -21,7 +21,7 @@ fn counters_preserve_integer_precision_and_reject_json_numbers() {
}
}
#[tokio::test]
async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_absent() {
async fn usage_mutations_require_a_trusted_actor() {
let admin = super::AdminHarness::new().build().await;
let registry = admin.public_services();
let denied = registry
@@ -38,21 +38,6 @@ async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_a
.await
.is_err()
);
let ops = registry
.operations()
.filter(|op| op.starts_with("usage.") || *op == "pricing.evaluate")
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
ops,
std::collections::BTreeSet::from([
"usage.records.get",
"usage.records.list",
"usage.records.write",
"usage.aggregates.list",
"usage.aggregates.write",
"pricing.evaluate"
])
);
}
#[test]
fn sdk_wire_types_match_host_contracts() {
+1
View File
@@ -38,6 +38,7 @@ tracing = "0.1.44"
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v7"] }
zstd = "=0.13.3"
zip = { version = "9.0.4", default-features = false, features = ["deflate-flate2"] }
[dev-dependencies]
async-trait = "0.1.89"
@@ -84,7 +84,7 @@ pub(super) fn account_view(
let usage_period = quota.usage_window().map(|(_, period)| period);
let mut usage = account_usage_view(usage, usage_period, now, time);
if account.authentication_kind == "api_key" {
usage.window_label_display = "通用额度".to_owned();
usage.window_kind = AccountUsageWindowKind::General;
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
AccountView {
@@ -120,6 +120,7 @@ pub(super) fn account_view(
},
weight: account.weight.get(),
model_access: account.model_access,
outbound_proxy_id: account.outbound_proxy_id,
outbound_proxy_endpoint: account
.outbound_proxy
.as_ref()
@@ -240,6 +241,10 @@ pub(super) fn account_quota_view(
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
(
AccountQuotaView {
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
refreshed_at_relative: quota
.observed_at
.map(|value| time.relative_view(value, now)),
refreshed_at_display,
limit_reached: quota.limit_reached,
rate_limited_until,
@@ -343,12 +348,11 @@ pub(super) fn account_usage_view(
"known"
};
AccountUsageView {
window_label_display: match period {
Some(AccountUsagePeriod::Weekly) => "周额度窗口",
Some(AccountUsagePeriod::Monthly) => "月额度窗口",
None => "周/月额度窗口",
}
.to_owned(),
window_kind: match period {
Some(AccountUsagePeriod::Weekly) => AccountUsageWindowKind::Weekly,
Some(AccountUsagePeriod::Monthly) => AccountUsageWindowKind::Monthly,
None => AccountUsageWindowKind::Quota,
},
request_count: Some(usage.request_count),
request_count_display: format_number(usage.request_count),
input_tokens: usage.input_tokens,
@@ -469,7 +473,7 @@ pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
pub(super) fn empty_account_usage() -> AccountUsageView {
AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
window_kind: AccountUsageWindowKind::Quota,
request_count: None,
request_count_display: "—".to_owned(),
input_tokens: None,
@@ -267,6 +267,7 @@ pub struct AccountSummaryView {
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub capabilities: AccountCapabilitiesView,
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
@@ -384,6 +385,8 @@ pub use crate::admin::account_groups::AccountGroupRefView;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountQuotaView {
pub refreshed_at: Option<String>,
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
pub refreshed_at_display: String,
pub limit_reached: bool,
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`
@@ -424,11 +427,21 @@ pub struct AccountQuotaWindowView {
pub reset_at_display: String,
}
/// 账号用量窗口的语义标识,展示名称由管理端本地化
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountUsageWindowKind {
General,
Weekly,
Monthly,
Quota,
}
/// 账号观测用量
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageView {
pub window_label_display: String,
pub window_kind: AccountUsageWindowKind,
pub request_count: Option<u64>,
pub request_count_display: String,
pub input_tokens: Option<u64>,
@@ -1033,8 +1046,7 @@ impl AccountExportData {
time: crate::time::TimePresenter,
) -> Self {
let filename = format!(
"cpr-accounts-selected-{}-{}.json",
bundle.documents.len(),
"cpr-accounts-{}.json",
time.label(bundle.exported_at, "%Y-%m-%d")
);
let documents = bundle
@@ -0,0 +1,85 @@
//! 只打包已经脱敏的诊断文档,摘要与正文使用同一份证据
use std::io::{self, Cursor, Write};
use serde_json::Value;
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
pub(super) fn build(document: &Value) -> io::Result<Vec<u8>> {
let mut archive = ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.compression_level(Some(1))
.unix_permissions(0o600);
archive.start_file("diagnostics.json", options)?;
serde_json::to_writer_pretty(&mut archive, document)?;
archive.start_file("summary.txt", options)?;
write!(
archive,
"CPR 诊断摘要\n导出时间:{}\n目标:{}\n导出截断:{}\n环境:{}\n",
document["collectedAt"],
document["target"],
document["exportTruncated"],
document["environment"]
)?;
for request in document["requests"].as_array().into_iter().flatten() {
let evidence = &request["evidence"];
let trace = &evidence["trace"];
let detail = &evidence["detail"]["request"];
writeln!(
archive,
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={}\n传输:{} → {}\n时延:{}",
detail["logicalOutcome"],
detail["statusCode"],
detail["attemptCount"],
detail["requestedModel"],
detail["upstreamModel"],
detail["clientTransport"],
detail["upstreamTransport"],
detail["latencyDetails"]
)?;
writeln!(
archive,
"\n请求:{}\n总事件:{};淘汰事件:{};关键证据淘汰:{}\n采集阶段:{}",
request["requestId"],
trace["totalEvents"],
trace["droppedEvents"],
trace["droppedEvidenceEvents"],
trace["stageCounts"]
)?;
for event in trace["events"].as_array().into_iter().flatten() {
if matches!(
event["stage"].as_str(),
Some(
"request.finished"
| "attempt.failed"
| "retry.decided"
| "provider.response.usage"
| "request.usage"
| "capture.gap"
| "provider.response.parse_gap"
)
) {
writeln!(
archive,
"attempt={} t={}ms {}:{}",
event["attemptIndex"], event["elapsedMs"], event["stage"], event["data"]
)?;
}
}
}
if !document["error"].is_null() {
writeln!(archive, "\n目标错误:{}", document["error"])?;
}
writeln!(archive, "\n证据缺口:")?;
for gap in document["availability"]["gaps"]
.as_array()
.into_iter()
.flatten()
{
writeln!(archive, "{}:{}", gap["section"], gap["reason"])?;
}
archive.start_file("README.txt", options)?;
archive.write_all("diagnostics.json 为完整结构化证据,summary.txt 为同一证据的摘要\n先检查 availability.gaps、exportTruncated 与 trace.stageCounts\n缺失、字段无效、上游明确返回 0、采集截断和证据淘汰含义不同\nprovider.response.usage 为上游终态观测及共享解析结果,request.usage 为网关最终归集结果\n输入指纹来自已解析对象;outbound 为准备发送的对象,成功发送需结合发送事实与响应判断\n指纹只能比较内容与顺序,不能证明上游缓存节点或缓存是否仍存在\nenvironment 是导出时环境;事发配置见 incidentConfiguration 与 request.routed\n历史记录不会补生成当时未采集的信息,未知业务内容不属于自动脱敏保证范围\n".as_bytes())?;
Ok(archive.finish()?.into_inner())
}
@@ -13,6 +13,7 @@ use super::{
};
use crate::{auth::SessionState, time::TimePresenter};
mod archive;
mod privacy;
#[derive(Deserialize)]
@@ -46,26 +47,43 @@ where
.await
.map_err(map_service_error)?;
let time = TimePresenter::new(state.admin_services().timezone());
let id = match &bundle.target {
DiagnosticTarget::Request(id) | DiagnosticTarget::Operation(id) => id,
};
let suffix = id
.chars()
.take(128)
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>();
let disposition = format!("attachment; filename=cpr-diagnostics-{suffix}.zip");
let document = privacy::redact(document(bundle, time));
let body = serde_json::to_vec_pretty(&document).map_err(|_| {
map_service_error(gateway_admin::model::AdminError::internal(
"诊断文件生成失败",
))
})?;
let body = tokio::task::spawn_blocking(move || archive::build(&document))
.await
.map_err(archive_error)?
.map_err(archive_error)?;
Ok((
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=diagnostics.json",
),
(header::CACHE_CONTROL, "private, no-store"),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
(header::CONTENT_TYPE, "application/zip".to_owned()),
(header::CONTENT_DISPOSITION, disposition),
(header::CACHE_CONTROL, "private, no-store".to_owned()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()),
],
body,
))
}
fn archive_error(source: impl Into<gateway_core::error::ErrorSource>) -> AdminError {
map_service_error(
gateway_admin::model::AdminError::internal("诊断文件生成失败").with_source(source),
)
}
fn document(bundle: DiagnosticBundle, time: TimePresenter) -> Value {
let target = match &bundle.target {
DiagnosticTarget::Request(id) => json!({"requestId": id}),
@@ -101,6 +119,7 @@ fn document(bundle: DiagnosticBundle, time: TimePresenter) -> Value {
}).collect::<Vec<_>>();
json!({
"schemaVersion": 5,
"exportTruncated": false,
"source": "gateway.diagnostics",
"exportPolicy": "support_credentials_redacted",
"target": target,
@@ -49,7 +49,7 @@ pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
outcome.as_str()
}
pub(crate) fn request_metrics_view(metrics: &domain::RequestMetrics) -> RequestMetricsView {
fn request_summary_metrics_view(metrics: &domain::RequestSummaryMetrics) -> RequestMetricsView {
RequestMetricsView {
request_count: metrics.request_count,
success_count: metrics.success_count,
@@ -279,7 +279,6 @@ pub(crate) fn usage_list_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
client_transport: record.client_transport,
upstream_transport: record.upstream_transport,
@@ -304,10 +303,6 @@ pub(crate) fn usage_list_record_view(
upstream_response_ms: record.upstream_response_ms,
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
upstream_engine_ms: record.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
},
first_token_latency_ms: record.first_token_ms,
latency_ms: record.latency_ms,
@@ -381,7 +376,6 @@ pub(crate) fn usage_record_view(
model,
requested_model: record.requested_model_id,
upstream_model: record.upstream_model_id,
upstream_response_model: record.upstream_response_model,
service_tier: record.service_tier,
status_code,
client_transport: record.client_transport,
@@ -411,10 +405,6 @@ pub(crate) fn usage_record_view(
upstream_response_ms: record.upstream_response_ms,
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
upstream_engine_ms: record.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
},
attempt_index: None,
attempt_count: u64::from(record.attempt_count),
@@ -694,20 +684,10 @@ pub(crate) fn trend_point_view(
tokens_value: point.metrics.total_tokens,
errors: format_compact_number(point.service_failure_count),
errors_value: point.service_failure_count,
latency: display_duration(point.average_latency_ms),
latency_value: point.average_latency_ms,
max_latency: display_duration(point.metrics.max_latency_ms),
max_latency_value: point.metrics.max_latency_ms,
min_latency: display_duration(point.metrics.min_latency_ms),
min_latency_value: point.metrics.min_latency_ms,
success_rate: success_rate_value
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
success_rate_value,
first_token_p50_ms: point
.metrics
.first_token_latency_percentiles
.p50_ms
.map(|value| value.as_f64()),
first_token_p95_ms: point
.metrics
.first_token_latency_percentiles
@@ -719,20 +699,6 @@ pub(crate) fn trend_point_view(
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p50: point.metrics.output_throughput_p50,
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
capacity_utilization: point
.metrics
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
}
}
@@ -743,34 +709,34 @@ pub(crate) fn trend_summary_view(
match kind {
TrendKind::Usage => vec![
TrendSummaryView {
label: "输入".to_owned(),
metric: TrendMetric::InputTokens,
value: format_compact_number(summary.input_tokens),
ratio: None,
},
TrendSummaryView {
label: "输出".to_owned(),
metric: TrendMetric::OutputTokens,
value: format_compact_number(summary.output_tokens),
ratio: None,
},
TrendSummaryView {
label: "缓存".to_owned(),
metric: TrendMetric::CachedInputTokens,
value: format_compact_number(summary.cached_input_tokens),
ratio: None,
},
],
TrendKind::Latency => vec![
TrendSummaryView {
label: "首字 P95".to_owned(),
metric: TrendMetric::FirstTokenP95Ms,
value: display_duration_f64(summary.peak_first_token_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "总耗时 P95".to_owned(),
metric: TrendMetric::LatencyP95Ms,
value: display_duration_f64(summary.peak_latency_p95_ms),
ratio: None,
},
TrendSummaryView {
label: "速率 P50".to_owned(),
metric: TrendMetric::OutputThroughputP50,
value: summary
.minimum_output_throughput_p50
.map_or_else(|| "—".to_owned(), |value| format!("{value} tok/s")),
@@ -779,19 +745,19 @@ pub(crate) fn trend_summary_view(
],
TrendKind::Errors => vec![
TrendSummaryView {
label: "错误数".to_owned(),
metric: TrendMetric::Errors,
value: format_compact_number(summary.service_failure_count),
ratio: None,
},
TrendSummaryView {
label: "成功率".to_owned(),
metric: TrendMetric::SuccessRate,
value: "—".to_owned(),
ratio: summary
.success_rate
.map(|value| format!("{:.1}%", value * 100.0)),
},
TrendSummaryView {
label: "总请求".to_owned(),
metric: TrendMetric::Requests,
value: format_compact_number(summary.request_count),
ratio: None,
},
@@ -883,7 +849,7 @@ pub(crate) fn wire_profile_view(
.attributes
.into_iter()
.map(|attribute| DashboardWireAttributeView {
label: attribute.label,
key: attribute.key,
value: attribute.value,
})
.collect(),
@@ -936,8 +902,10 @@ pub(crate) fn dashboard_view(
.filter(|window| window.used_percent.is_none())
.and_then(|window| window.local_usage.as_ref())
.map(|usage| usage.request_count);
let (metric_label, metric_value) = match local_requests {
let (metric, metric_value_raw, metric_label, metric_value) = match local_requests {
Some(count) => (
DashboardAccountMetric::Requests,
Some(count),
"次数".to_owned(),
if count > 0 {
format_compact_number(count)
@@ -946,6 +914,8 @@ pub(crate) fn dashboard_view(
},
),
None => (
DashboardAccountMetric::Tokens,
credential.total_tokens,
"今日 Token".to_owned(),
credential
.total_tokens
@@ -979,8 +949,14 @@ pub(crate) fn dashboard_view(
usage_window: credential
.quota_window
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
metric,
metric_value_raw,
metric_label,
metric_value,
last_used_at: credential.last_used_at,
last_used_relative: credential
.last_used_at
.map(|value| time.relative_view(value, range.end)),
last_used: time.relative_optional(credential.last_used_at, range.end),
});
}
@@ -1058,7 +1034,7 @@ pub(crate) fn usage_summary_view(
summary: domain::UsageSummary,
time: crate::time::TimePresenter,
) -> UsageSummaryView {
let overview = summary.overview;
let overview = summary.observation;
UsageSummaryView {
analytics: analytics_view(overview.analytics, time),
total_requests: format_compact_number(overview.requests.request_count),
@@ -1068,7 +1044,8 @@ pub(crate) fn usage_summary_view(
cache_write_input_tokens: format_compact_number(overview.requests.cache_write_input_tokens),
total_tokens: format_compact_number(overview.requests.total_tokens),
average_latency_ms: display_duration(summary.average_latency_ms),
logical_requests: request_metrics_view(&overview.requests),
average_latency_ms_value: summary.average_latency_ms,
logical_requests: request_summary_metrics_view(&overview.requests),
attempts: attempt_metrics_view(&overview.attempts),
}
}
@@ -25,27 +25,28 @@ impl DashboardQuery {
}
}
/// 逻辑请求列表、汇总和洞察查询参数
/// 请求明细的字段筛选、时间范围与分页参数
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageQuery {
pub client_api_key_name: Option<String>,
pub account_name: Option<String>,
pub status: Option<String>,
pub current_page: Option<u32>,
pub page_size: Option<u16>,
pub kind: Option<String>,
pub outcome: Option<String>,
pub client_api_key_id: Option<String>,
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
pub end_time: Option<String>,
}
/// 用量汇总与洞察只按时间和 Provider 查询
#[derive(Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsageAnalyticsQuery {
pub provider: Option<String>,
pub period: Option<String>,
pub as_of: Option<i64>,
pub start_time: Option<String>,
@@ -258,16 +259,6 @@ pub(crate) fn domain_diagnostic_dimension(
}
}
pub(crate) fn request_outcome(
value: Option<String>,
) -> Result<Option<domain::RequestOutcome>, WireValidationError> {
non_empty(value)
.map(|value| {
domain::RequestOutcome::new(value).map_err(|_| WireValidationError::new("outcome"))
})
.transpose()
}
pub(crate) fn usage_range(
start: Option<&str>,
end: Option<&str>,
@@ -288,39 +279,14 @@ pub(crate) fn dashboard_today_range(
crate::time::query_range(start, end, period, as_of, timezone, "today")
}
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
let outcome = query.outcome.clone().or_else(|| {
non_empty(query.kind.clone()).filter(|value| {
matches!(
value.as_str(),
"running" | "succeeded" | "failed" | "cancelled" | "incomplete"
)
})
});
Ok(domain::UsageFilter {
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
request_id: non_empty(query.request_id.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.route.clone()),
pub(crate) fn usage_filter(query: &UsageQuery) -> domain::UsageFilter {
domain::UsageFilter {
client_api_key_name: non_empty(query.client_api_key_name.clone()),
provider_account_name: non_empty(query.account_name.clone()),
provider_kind: non_empty(query.provider.clone()),
model: non_empty(query.model.clone()),
outcome: request_outcome(outcome)?,
status_code: parse_status(query.status_code)?,
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
search: non_empty(query.search.clone()),
})
}
pub(crate) fn analytics_filter(
query: &UsageQuery,
) -> Result<domain::UsageFilter, WireValidationError> {
if query.search.is_some() {
return Err(WireValidationError::new("search"));
model_contains: non_empty(query.model.clone()),
..Default::default()
}
usage_filter(query)
}
pub(crate) fn usage_command(
@@ -344,7 +310,7 @@ pub(crate) fn usage_command(
query.as_of,
timezone,
)?,
filter: usage_filter(query)?,
filter: usage_filter(query),
current_page,
page_size: page_size_value,
})
@@ -142,7 +142,7 @@ where
pub(crate) async fn usage_records_summary<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<UsageQuery>,
AdminQuery(query): AdminQuery<UsageAnalyticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
@@ -155,7 +155,10 @@ where
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let filter = analytics_filter(&query).map_err(map_wire_error)?;
let filter = domain::UsageFilter {
provider_kind: non_empty(query.provider),
..Default::default()
};
let result = state
.admin_services()
.observability()
@@ -174,7 +177,7 @@ where
pub(crate) async fn usage_insights_overview<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<UsageQuery>,
AdminQuery(query): AdminQuery<UsageAnalyticsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
@@ -188,7 +191,10 @@ where
state.admin_services().timezone(),
)
.map_err(map_wire_error)?;
let filter = analytics_filter(&query).map_err(map_wire_error)?;
let filter = domain::UsageFilter {
provider_kind: non_empty(query.provider),
..Default::default()
};
let result = state
.admin_services()
.observability()
@@ -103,7 +103,6 @@ pub struct UsageListRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub client_transport: String,
pub upstream_transport: Option<String>,
@@ -142,7 +141,6 @@ pub struct UsageRecordView {
pub model: Option<String>,
pub requested_model: Option<String>,
pub upstream_model: Option<String>,
pub upstream_response_model: Option<String>,
pub service_tier: Option<String>,
pub status_code: Option<i64>,
pub client_transport: String,
@@ -230,14 +228,6 @@ pub struct UsageLatencyDetailsView {
pub upstream_api_overhead_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_iapi_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_service_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_iapi_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_engine_service_tbt_ms: Option<f64>,
}
/// 单次上游尝试展示
@@ -314,27 +304,33 @@ pub struct TrendPointView {
pub tokens_value: u64,
pub errors: String,
pub errors_value: u64,
pub latency: String,
pub latency_value: Option<u64>,
pub max_latency: String,
pub max_latency_value: Option<u64>,
pub min_latency: String,
pub min_latency_value: Option<u64>,
pub success_rate: String,
pub success_rate_value: Option<f64>,
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub output_throughput_p50: Option<u64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub capacity_utilization: Option<f64>,
}
/// 趋势指标的稳定身份,展示名称由管理端本地化
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TrendMetric {
InputTokens,
OutputTokens,
CachedInputTokens,
FirstTokenP95Ms,
LatencyP95Ms,
OutputThroughputP50,
Errors,
SuccessRate,
Requests,
}
/// Dashboard 趋势摘要
#[derive(Debug, Serialize)]
pub struct TrendSummaryView {
pub label: String,
pub metric: TrendMetric,
pub value: String,
pub ratio: Option<String>,
}
@@ -394,7 +390,15 @@ pub struct DashboardCacheCardView {
pub average_first_token_latency_ms: String,
}
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影
/// Dashboard 账号概览指标的稳定标识
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DashboardAccountMetric {
Requests,
Tokens,
}
/// Dashboard 账号概览卡片所需的 Provider 安全投影
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardAccountUsageView {
@@ -409,8 +413,12 @@ pub struct DashboardAccountUsageView {
pub request_buckets: Vec<crate::time::RequestBucketView>,
pub quota_used_percent: Option<f64>,
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
pub metric: DashboardAccountMetric,
pub metric_value_raw: Option<u64>,
pub metric_label: String,
pub metric_value: String,
pub last_used_at: Option<DateTime<Utc>>,
pub last_used_relative: Option<crate::time::RelativeTimeView>,
pub last_used: String,
}
@@ -523,7 +531,7 @@ pub struct DashboardWireProfileView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DashboardWireAttributeView {
pub label: String,
pub key: String,
pub value: String,
}
@@ -603,6 +611,7 @@ pub struct UsageSummaryView {
pub cache_write_input_tokens: String,
pub total_tokens: String,
pub average_latency_ms: String,
pub average_latency_ms_value: Option<u64>,
pub logical_requests: RequestMetricsView,
pub attempts: AttemptMetricsView,
}
@@ -44,6 +44,8 @@ use crate::{
const MAXIMUM_BODY_BYTES: usize = 1024 * 1024;
const MAXIMUM_MODEL_BODY_BYTES: usize = 8 * 1024 * 1024;
// 传输归属由宿主写入,不能用插件可覆盖的安全响应头识别管理错误
const PLUGIN_RESPONSE_HEADER: &str = "x-cpr-plugin-response";
// HTML/SVG 即使被直接导航打开也不能获得管理端 origin;执行入口由隔离 iframe 的消息桥承载
const RESOURCE_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
@@ -124,17 +126,17 @@ async fn model_responses(
match decode_request_with_body(&body, &headers, MAXIMUM_MODEL_BODY_BYTES) {
Ok(decoded) => decoded,
Err(error) => {
return Ok(protocol_error_response(
return Ok(plugin_response(protocol_error_response(
StatusCode::BAD_REQUEST,
error.protocol_body(),
));
)));
}
};
let service = state.openai().clone();
let execution = service.execution();
let prepared = match execution.prepare_plugin_execution(&client_key_id).await {
Ok(prepared) => prepared,
Err(error) => return Ok(gateway_error_response(&error)),
Err(error) => return Ok(plugin_response(gateway_error_response(&error))),
};
let authorization: Arc<dyn ResponseAuthorization> =
Arc::new(PluginModelsAuthorization { state, target });
@@ -158,7 +160,7 @@ async fn model_responses(
Some(authorization),
)
.await;
Ok(response)
Ok(plugin_response(response))
}
struct PluginModelsAuthorization {
@@ -431,5 +433,12 @@ fn raw_response(result: PluginManagementResponse) -> Result<Response, AdminError
);
// 宿主提供默认值,插件显式字段随后覆盖;保留同名多值,只验证 HTTP 语法
headers.extend(decode_headers(result.headers).map_err(|_| AdminError::bad_gateway())?);
Ok(response)
Ok(plugin_response(response))
}
fn plugin_response(mut response: Response) -> Response {
response
.headers_mut()
.insert(PLUGIN_RESPONSE_HEADER, HeaderValue::from_static("1"));
response
}
@@ -213,14 +213,6 @@ struct OutputTimingView {
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_iapi_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_service_ttft_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_iapi_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_engine_service_tbt_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_event_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
first_reasoning_ms: Option<u64>,
@@ -260,10 +252,6 @@ fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> Rec
upstream_response_ms: value.upstream_response_ms,
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
upstream_engine_ms: value.upstream_engine_ms,
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
first_event_ms: value.first_event_ms,
first_reasoning_ms: value.first_reasoning_ms,
first_text_ms: value.first_text_ms,
@@ -126,7 +126,7 @@ pub(crate) async fn hangup(
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let protocol_headers = protocol_header_pairs(&headers);
let protocol_headers = filter_protocol_headers(&headers);
let outcome = gateway
.hangup(LiveHangupRequest {
call_id: &call_id,
@@ -141,13 +141,8 @@ pub(crate) async fn hangup(
let status = StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::OK);
let mut response = Response::new(axum::body::Body::from(outcome.body));
*response.status_mut() = status;
for (name, value) in outcome.headers {
if let (Ok(name), Ok(value)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_bytes(value.as_bytes()),
) {
response.headers_mut().insert(name, value);
}
for (name, value) in live_forwarded_response_headers(&outcome.headers) {
response.headers_mut().append(name, value);
}
response
}
@@ -262,20 +257,7 @@ async fn read_call_body(
}
}
/// realtime 协议头的 `(name, value)` 形态;名称小写化以稳定上游顺序。
pub(crate) fn protocol_header_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
super::filter_protocol_headers(headers)
.into_iter()
.map(|header| {
(
header.name().to_ascii_lowercase(),
String::from_utf8_lossy(header.value()).into_owned(),
)
})
.collect()
}
/// 汇集引导响应;只放行白名单响应头,正文按字节透传。
/// 汇集引导响应;普通响应头与正文按字节透传
async fn collect_live_call_response(started: StartedExecution, path: String) -> Response {
let request_id = started.request_id;
let mut execution = PendingExecution::new(started.session);
@@ -34,14 +34,6 @@ pub(crate) const MAX_LIVE_BODY_BYTES: usize = 16 * 1024 * 1024;
pub(crate) const LIVE_CALLS_ENDPOINT: &str = "realtime-calls";
/// realtime calls 的官方查询参数;上游按 `architecture=avas` 返回 WebRTC answer。
pub(crate) const LIVE_CALLS_QUERY: &str = "intent=quicksilver&architecture=avas";
/// 客户端协议头允许清单;语音会话语义字段,转发给上游引导请求。
pub(crate) const LIVE_PROTOCOL_HEADERS: [&str; 5] = [
"openai-alpha",
"x-session-id",
"session-id",
"thread-id",
"openai-safety-identifier",
];
pub(crate) fn router() -> Router<crate::ApiState> {
Router::new()
@@ -136,22 +128,12 @@ pub(crate) fn realtime_unsupported_response(capability: &str) -> Response {
.into_response()
}
/// 请求侧协议头过滤;语音语义字段按允许清单转发,其余一律不透传。
/// 共用 OpenAI 入站头边界;Content-Type 由正文适配结果单独提供
pub(crate) fn filter_protocol_headers(headers: &HeaderMap) -> Vec<ProviderHttpHeader> {
LIVE_PROTOCOL_HEADERS
.iter()
.flat_map(|name| {
headers
.get_all(*name)
.into_iter()
.map(|value| (*name, value))
})
.filter_map(|(name, value)| {
let value = value.to_str().ok()?;
Some(ProviderHttpHeader::new(
name,
Bytes::copy_from_slice(value.as_bytes()),
))
super::responses::passthrough_request_headers(headers)
.filter(|(name, _)| *name != header::CONTENT_TYPE)
.map(|(name, value)| {
ProviderHttpHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes()))
})
.collect()
}
@@ -281,20 +263,20 @@ pub(crate) fn encode_call_request(
.map_err(|_| invalid("Codex live call request is invalid"))
}
/// 响应头白名单;`Location` 是客户端获知 call id 的合同。
/// 共用响应身份与传输边界;Live 原始正文另保留上游 Content-Type
pub(crate) fn live_forwarded_response_headers(
headers: &[ProviderResponseHeader],
) -> Vec<(HeaderName, HeaderValue)> {
const FORWARDABLE: [&str; 5] = [
"location",
"content-type",
"retry-after",
"x-request-id",
"openai-request-id",
];
let connection_options = super::responses::response_connection_options(headers);
headers
.iter()
.filter(|header| FORWARDABLE.contains(&header.name()))
.filter(|header| {
header.name().eq_ignore_ascii_case("content-type")
|| super::responses::response_header_is_forwardable(
header.name(),
&connection_options,
)
})
.filter_map(|header| {
Some((
HeaderName::from_bytes(header.name().as_bytes()).ok()?,
@@ -128,15 +128,7 @@ async fn open_sideband(
client_api_key_id: client.policy().key_id(),
account_scope: client.policy().account_scope(),
style,
protocol_headers: filter_protocol_headers(&headers)
.into_iter()
.map(|header| {
(
header.name().to_ascii_lowercase(),
String::from_utf8_lossy(header.value()).into_owned(),
)
})
.collect(),
protocol_headers: filter_protocol_headers(&headers),
subprotocols: offered_subprotocols(&headers),
});
let result = tokio::select! {
@@ -321,24 +321,36 @@ impl AuthorizedResponseBody {
impl MiddlewareBody for AuthorizedResponseBody {
fn next_frame(&mut self) -> BoxFuture<'_, Result<Option<MiddlewareFrame>, MiddlewareError>> {
Box::pin(async move {
loop {
let elapsed = self.last_authorized_at.elapsed();
if elapsed >= AUTHORIZATION_RECHECK_INTERVAL {
if let Err(error) = self.authorization.authorize().await {
return Err(self.revoke(error).await);
}
self.last_authorized_at = Instant::now();
continue;
if self.last_authorized_at.elapsed() >= AUTHORIZATION_RECHECK_INTERVAL {
if let Err(error) = self.authorization.authorize().await {
return Err(self.revoke(error).await);
}
self.last_authorized_at = Instant::now();
}
let error = {
let Some(inner) = self.inner.as_mut() else {
return Ok(None);
};
let wait = AUTHORIZATION_RECHECK_INTERVAL - elapsed;
tokio::select! {
frame = inner.next_frame() => return frame,
() = tokio::time::sleep(wait) => {}
// 复核身份只暂停同一次读取,不能丢弃已经消费上游事件的 future
let frame = inner.next_frame();
tokio::pin!(frame);
loop {
let elapsed = self.last_authorized_at.elapsed();
if elapsed >= AUTHORIZATION_RECHECK_INTERVAL {
if let Err(error) = self.authorization.authorize().await {
break error;
}
self.last_authorized_at = Instant::now();
continue;
}
let wait = AUTHORIZATION_RECHECK_INTERVAL - elapsed;
tokio::select! {
result = &mut frame => return result,
() = tokio::time::sleep(wait) => {}
}
}
}
};
Err(self.revoke(error).await)
})
}
@@ -879,7 +891,7 @@ impl StreamingExecutionBody {
}
continue;
}
// 已提交的流在确认 Core 终结后才交付成功终态,不能先输出 completed 再报结算失败
// 已提交的流先确认 Core 终结,避免后续 EOF 验证推翻已交付的成功终态
if self.awaiting_terminal_eof && self.committed {
self.verify_terminal_eof().await?;
encoded.push_back(done_frame());
@@ -14,11 +14,11 @@ pub(crate) use http::{
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use request::decode_request_with_body;
pub use request::{
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
decode_request_with_headers,
};
pub(crate) use request::{decode_request_with_body, passthrough_request_headers};
pub use response::OpenAiResponsesEncoder;
pub(crate) use websocket::responses_websocket;
pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context};
@@ -2,7 +2,7 @@
use std::{borrow::Cow, fmt, net::IpAddr};
use axum::http::HeaderMap;
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bytes::Bytes;
use gateway_core::operation::{
@@ -567,6 +567,20 @@ fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
}
fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
passthrough_request_headers(headers)
.map(|(name, value)| {
Value::Array(vec![
Value::String(name.as_str().to_owned()),
Value::String(STANDARD.encode(value.as_bytes())),
])
})
.collect()
}
/// HTTP 与 Live 入站共用的身份、传输边界;保留业务头原始多值字节
pub(crate) fn passthrough_request_headers(
headers: &HeaderMap,
) -> impl Iterator<Item = (&HeaderName, &HeaderValue)> {
let connection_headers = headers
.get_all("connection")
.iter()
@@ -579,14 +593,7 @@ fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
headers
.iter()
.filter(|(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
.map(|(name, value)| {
Value::Array(vec![
Value::String(name.as_str().to_owned()),
Value::String(STANDARD.encode(value.as_bytes())),
])
})
.collect()
.filter(move |(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
}
fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
@@ -5,7 +5,7 @@ use super::{
pump::run_pump,
state::{ConnectionCommand, ConnectionStats},
};
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
use axum::extract::ws::{CloseFrame, Message, Utf8Bytes, WebSocket, close_code};
use futures::{Sink, Stream};
use gateway_core::{
engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader},
@@ -142,7 +142,7 @@ impl ResponsesWebSocketConnection {
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误
pub async fn send_text(
&mut self,
payload: String,
payload: impl Into<Utf8Bytes>,
context: WriteContext,
) -> Result<WriteOutcome, ConnectionWriteError> {
self.send(Message::Text(payload.into()), context).await
@@ -3,7 +3,7 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use axum::http::StatusCode;
use axum::{extract::ws::Utf8Bytes, http::StatusCode};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::CommitRequirement;
@@ -268,7 +268,7 @@ pub(super) async fn forward_response(
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
}
let terminal = frame.terminal();
let Ok(message) = String::from_utf8(frame.into_bytes().to_vec()) else {
let Ok(message) = Utf8Bytes::try_from(frame.into_bytes()) else {
return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await;
};
let phase = match (first_frame_written, terminal) {
@@ -58,7 +58,8 @@ fn decode_response_create_inner(
else {
return Err(ResponseCreateFrameError::ExpectedObject);
};
match body.remove("type") {
// 保持剩余字段顺序,避免末尾元数据交换到模型等路由字段之前
match body.shift_remove("type") {
Some(Value::String(message_type)) if message_type == "response.create" => {}
_ => return Err(ResponseCreateFrameError::UnsupportedType),
}
+54 -6
View File
@@ -101,21 +101,51 @@ impl TimePresenter {
self.timezone.local(*value).to_rfc3339()
}
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
pub(crate) fn relative_view(
self,
value: DateTime<Utc>,
now: DateTime<Utc>,
) -> RelativeTimeView {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
return self.datetime(&value);
return RelativeTimeView::Absolute {
value: self.datetime(&value),
};
}
if elapsed.num_seconds() < 60 {
return "刚刚".to_owned();
return RelativeTimeView::JustNow;
}
if elapsed.num_minutes() < 60 {
return format!("{} 分钟前", elapsed.num_minutes());
return RelativeTimeView::Elapsed {
value: elapsed.num_minutes(),
unit: RelativeTimeUnit::Minute,
};
}
if elapsed.num_hours() < 24 {
return format!("{} 小时前", elapsed.num_hours());
return RelativeTimeView::Elapsed {
value: elapsed.num_hours(),
unit: RelativeTimeUnit::Hour,
};
}
RelativeTimeView::Elapsed {
value: elapsed.num_days(),
unit: RelativeTimeUnit::Day,
}
}
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
match self.relative_view(value, now) {
RelativeTimeView::JustNow => "刚刚".to_owned(),
RelativeTimeView::Elapsed { value, unit } => {
let unit = match unit {
RelativeTimeUnit::Minute => "分钟",
RelativeTimeUnit::Hour => "小时",
RelativeTimeUnit::Day => "天",
};
format!("{value} {unit}前")
}
RelativeTimeView::Absolute { value } => value,
}
format!("{} 天前", elapsed.num_days())
}
pub(crate) fn relative_optional(
@@ -127,6 +157,24 @@ impl TimePresenter {
}
}
/// 后端按查询锚点与部署时区决定展示方式,前端只本地化分类和单位
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RelativeTimeView {
JustNow,
Elapsed { value: i64, unit: RelativeTimeUnit },
Absolute { value: String },
}
/// 相对时间保持分钟、小时、天的固定精度,不引入月或年的日历推算
#[derive(Debug, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RelativeTimeUnit {
Minute,
Hour,
Day,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestBucketView {
@@ -46,7 +46,6 @@ async fn list_route_should_keep_camel_case_group_and_page_wire() {
value["data"]["items"][0]["usage"]["retainedTotalUsd"],
"5.5"
);
assert!(value["data"]["items"][0].get("member_count").is_none());
}
#[tokio::test]
@@ -9,6 +9,67 @@ use tower::ServiceExt as _;
use super::super::{AdminTestFixture, AdminTestState};
#[tokio::test]
async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() {
use chrono::Utc;
use gateway_core::account::{AccountStatusFacts, CredentialState, OutboundProxy, QuotaState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (proxy_id, proxy_url, enabled) in [
(
Some("proxy-a"),
Some("socks5h://alice:secret@127.0.0.1:50101"),
true,
),
(
Some("proxy-b"),
Some("socks5h://bob:secret@127.0.0.1:50101"),
false,
),
(None, Some("socks5h://alice:secret@127.0.0.1:50101"), true),
(None, None, true),
] {
let now = Utc::now();
let facts = AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
};
let mut item = account_page_item(facts, now, "oauth");
item.account.outbound_proxy_id = proxy_id.map(str::to_owned);
item.account.outbound_proxy = proxy_url.map(|url| OutboundProxy::parse(url).unwrap());
item.account.outbound_proxy_enabled = enabled;
*fixture.account.lock().unwrap() = Some(item);
let response = admin::accounts::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/accounts")
.header("x-request-id", "req_proxy_binding")
.header(header::COOKIE, "cpr_session=valid-session")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 32768).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let account = &value["data"]["items"][0];
assert_eq!(account["outboundProxyId"], serde_json::json!(proxy_id));
assert_eq!(
account["outboundProxyEndpoint"],
serde_json::json!(proxy_url.map(|_| "socks5h://127.0.0.1:50101"))
);
assert!(!String::from_utf8(body.to_vec()).unwrap().contains("secret"));
}
}
#[tokio::test]
async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
use chrono::{DateTime, Duration, Utc};
@@ -99,6 +160,90 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
}
}
#[tokio::test]
async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone() {
use chrono::{DateTime, Duration, Utc};
use gateway_admin::model::provider_credentials::ProviderQuota;
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
use serde_json::json;
for zone in ["UTC", "Asia/Shanghai", "Asia/Kathmandu", "America/New_York"] {
let zone = zone
.parse::<gateway_core::time::DeploymentTimeZone>()
.unwrap();
let fixture = AdminTestFixture::with_timezone(zone).await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
*fixture.account.lock().unwrap() = Some(account_page_item(
AccountStatusFacts {
enabled: true,
credential_state: CredentialState::Ready,
access_token_expires_at: None,
quota: QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
now,
"oauth",
));
let future = now + Duration::days(1);
let future_display = zone.local(future).format("%Y-%m-%d %H:%M:%S").to_string();
for (observed_at, expected, legacy) in [
(None, json!(null), "—"),
(Some(now), json!({ "kind": "just_now" }), "刚刚"),
(
Some(now - Duration::seconds(90)),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(now - Duration::hours(25)),
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
"1 天前",
),
(
Some(future),
json!({ "kind": "absolute", "value": future_display }),
future_display.as_str(),
),
] {
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
plan_type: None,
observed_at,
refresh_token_expires_at: None,
windows: Vec::new(),
credits: None,
limit_reached: false,
provider_data: None,
});
let response = admin::accounts::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/accounts")
.header("x-request-id", "req_quota_relative_time")
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::ACCEPT_LANGUAGE, "en")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 32768).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
let quota = &value["data"]["items"][0]["quota"];
assert_eq!(quota["refreshedAtRelative"], expected);
assert_eq!(quota["refreshedAtDisplay"], legacy);
let refreshed_at = quota["refreshedAt"]
.as_str()
.map(|value| DateTime::parse_from_rfc3339(value).unwrap().to_utc());
assert_eq!(refreshed_at, observed_at);
}
}
}
fn account_page_item(
facts: gateway_core::account::AccountStatusFacts,
now: chrono::DateTime<chrono::Utc>,
@@ -112,6 +257,7 @@ fn account_page_item(
AccountPageItem {
account: AccountRecord {
outbound_proxy_id: None,
outbound_proxy_enabled: true,
id: "acct_cooldown".to_owned(),
provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(),
@@ -149,7 +295,7 @@ fn account_page_item(
}
#[tokio::test]
async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_aliases() {
async fn account_usage_preserves_cache_read_and_write_counts_and_missing_values() {
use chrono::Utc;
use gateway_admin::model::accounts::AccountUsage;
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
@@ -215,37 +361,9 @@ async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_alia
usage["cacheWriteInputTokensDisplay"],
if write.is_some() { "7" } else { "—" }
);
for alias in [
"createdTokens",
"createdTokensDisplay",
"readTokens",
"readTokensDisplay",
] {
assert!(usage.get(alias).is_none(), "obsolete token alias: {alias}");
}
}
}
#[tokio::test]
async fn standalone_credential_rotation_route_is_not_exposed() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = admin::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/rotate")
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn connection_update_requires_admin_and_validates_before_calling_the_service() {
let fixture = AdminTestFixture::new().await;
@@ -181,7 +181,13 @@ mod query {
#[test]
fn account_query_should_reject_unknown_fields() {
assert!(serde_json::from_value::<ListQuery>(json!({"id": "cred_1"})).is_err());
let mut query = json!({"page": 1, "pageSize": 20});
serde_json::from_value::<ListQuery>(query.clone())
.unwrap()
.validate()
.expect("valid account query");
query["unknownField"] = json!(true);
assert!(serde_json::from_value::<ListQuery>(query).is_err());
}
}
@@ -248,9 +254,8 @@ mod profile_statistics {
value["activityInsights"]["invocations"][0]["usageCount"],
29
);
assert!(value.get("cycle").is_none());
assert!(value.get("models").is_none());
assert!(value.get("estimatedCost").is_none());
assert_eq!(value["activityInsights"]["fastModePercent"], 0.0);
assert!(value["activityInsights"]["invocations"][0]["skillId"].is_null());
}
#[test]
@@ -375,7 +380,7 @@ mod batch_update {
assert!(request.concurrency_limit.is_none());
assert!(
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
"accountIds": ["acct_test"], "enabled": true, "legacy": true
"accountIds": ["acct_test"], "enabled": true, "unknownField": true
}))
.is_err()
);
@@ -471,13 +476,13 @@ fn single_update_should_reject_oversized_notes_and_control_characters() {
}
mod response {
use gateway_api::admin::accounts::AccountUsageView;
use gateway_api::admin::accounts::{AccountUsageView, AccountUsageWindowKind};
use gateway_api::admin::presenter::format_decimal_currency;
#[test]
fn account_usage_view_should_keep_unobserved_numbers_null() {
let view = AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
window_kind: AccountUsageWindowKind::Quota,
request_count: None,
request_count_display: "-".to_owned(),
input_tokens: None,
@@ -511,7 +516,7 @@ mod response {
models: Vec::new(),
};
let value = serde_json::to_value(view).expect("serialize account usage");
assert_eq!(value["windowLabelDisplay"], "周/月额度窗口");
assert_eq!(value["windowKind"], "quota");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert!(value["reasoningOutputTokens"].is_null());
@@ -583,12 +588,12 @@ mod actions {
}
#[test]
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
fn account_actions_should_validate_account_ids_and_reject_unknown_fields() {
let id: AccountIdQuery =
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
assert!(id.validate().is_ok());
let action: AccountActionRequest =
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
serde_json::from_value(json!({ "accountId": "invalid-id" })).expect("decode action");
assert_eq!(action.validate().unwrap_err().field(), "accountId");
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
"accountId": "acct_1"
@@ -598,7 +603,7 @@ mod actions {
assert!(
serde_json::from_value::<AccountRefreshRequest>(json!({
"accountId": "acct_1",
"expectedConfigRevision": 0
"unknownField": true
}))
.is_err()
);
@@ -772,15 +777,14 @@ mod actions {
assert_eq!(invalid.validate().unwrap_err().field(), "data");
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"document": {}
"provider": "openai"
}))
.is_err()
);
assert!(
serde_json::from_value::<AccountImportRequest>(json!({
"provider": "openai",
"expectedConfigRevision": 7,
"unknownField": true,
"data": {}
}))
.is_err()
@@ -11,7 +11,7 @@ use gateway_api::admin::accounts::{
use gateway_core::operation::RawJsonPayload;
#[test]
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
fn quota_forecast_projection_formats_capacity_and_preserves_null_zero() {
let now = "2026-09-12T00:00:00Z".parse().unwrap();
let forecast = AccountQuotaForecast {
period: AccountUsagePeriod::Weekly,
@@ -54,16 +54,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert_eq!(week["estimatedTokensDisplay"], "1M");
assert!(week["estimatedUsd"].is_null());
assert_eq!(week["estimatedUsdDisplay"], "—");
for forecast in value["forecasts"].as_array().unwrap() {
for field in [
"remainingTokens",
"remainingTokensDisplay",
"remainingUsd",
"remainingUsdDisplay",
] {
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
}
}
assert_eq!(
week["source"],
serde_json::json!({
@@ -77,9 +67,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
"usdDisplay": "$0.1234"
})
);
assert!(week.get("method").is_none());
assert!(week.get("methodDisplay").is_none());
assert!(value.get("generatedAtDisplay").is_none());
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
@@ -87,7 +74,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert_eq!(value["forecasts"][1]["period"], "monthly");
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
assert_eq!(value["forecasts"][1]["extrapolated"], true);
assert!(value.get("account").is_none());
}
#[test]
@@ -375,18 +375,17 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
assert!(
serde_json::from_value::<UpdateClientKeyRequest>(json!({
"id": "key_1",
"expectedConfigRevision": 1,
"name": "key",
"groupIds": [],
"maxConcurrency": 0,
"requestsPerMinute": 0,
"tokensPerMinute": 0
"unknownField": true
}))
.is_err()
);
assert!(
serde_json::from_value::<CreateClientKeyRequest>(json!({
"expectedConfigRevision": 7,
"unknownField": true,
"name": "terminal key",
"groupIds": [],
"maxConcurrency": 2,
@@ -458,7 +457,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
));
let list =
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
assert!(list.get("configRevision").is_none());
assert_eq!(list["total"], 1);
assert_eq!(list["items"][0]["id"], "key_visible");
assert_eq!(
@@ -468,12 +466,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
assert_eq!(list["items"][0]["routingScope"], "all");
assert_eq!(list["items"][0]["maxConcurrency"], 2);
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
assert!(list["items"][0].get("tokensPerMinute").is_none());
assert!(list["items"][0].get("policyJson").is_none());
assert_eq!(list["items"][0]["dailyLimitUsd"], "0");
assert_eq!(list["items"][0]["weeklyLimitUsd"], "0");
assert_eq!(list["items"][0]["dailyUsedUsd"], "0");
assert!(list["items"][0].get("unresolvedRequests").is_none());
assert_eq!(
DateTime::parse_from_rfc3339(
list["items"][0]["lastUsedAt"]
@@ -511,6 +511,7 @@ mod provider {
total_slots: None,
},
account: AccountRecord {
outbound_proxy_id: None,
outbound_proxy_enabled: true,
notes: None,
model_access: Default::default(),
+31 -6
View File
@@ -41,7 +41,7 @@ use gateway_admin::{
DashboardObservation, DashboardQuery, DecimalAmount, DiagnosticDimension,
DiagnosticsObservation, Granularity, OpsError, OpsErrorPage, OpsErrorQuery,
RequestMetricPoint, TimeRange, UsageDetail, UsageFilter, UsageListRecord,
UsageOverview, UsagePage, UsageQuery,
UsageOverview, UsagePage, UsageQuery, UsageSummaryObservation,
},
provider_credentials::{
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
@@ -112,6 +112,7 @@ pub(super) struct AdminTestFixture {
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
pub account: Arc<Mutex<Option<AccountPageItem>>>,
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
}
@@ -187,9 +188,18 @@ impl AdminTestFixture {
plugin_ports.clone(),
plugin_ports.clone(),
);
let provider_quota = Arc::new(Mutex::new(None));
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))),
Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))),
Arc::new(UnusedProvider::new(
"openai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
)),
Arc::new(UnusedProvider::new(
"xai",
Arc::clone(&provider_error),
Arc::clone(&provider_quota),
)),
];
let bundle = gateway_admin::initialize(
AdminConfig {
@@ -236,6 +246,7 @@ impl AdminTestFixture {
dashboard_observation,
dashboard_summary_range,
provider_error,
provider_quota,
account,
account_usage,
}
@@ -1547,12 +1558,17 @@ impl ObservabilityStore for UnusedStore {
&self,
range: TimeRange,
filter: UsageFilter,
) -> AdminStoreResult<UsageOverview> {
) -> AdminStoreResult<UsageSummaryObservation> {
let mut data = self.observations.lock().expect("observations");
data.summaries.push((range, filter));
data.summary
.clone()
.map(|summary| UsageOverview { range, ..summary })
.map(|summary| UsageSummaryObservation {
range,
analytics: summary.analytics,
requests: (&summary.requests).into(),
attempts: summary.attempts,
})
.ok_or_else(|| unavailable("usage summary"))
}
@@ -1585,13 +1601,19 @@ impl ObservabilityStore for UnusedStore {
struct UnusedProvider {
kind: ProviderKind,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
}
impl UnusedProvider {
fn new(kind: &str, error: Arc<Mutex<Option<ProviderAdminError>>>) -> Self {
fn new(
kind: &str,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
error,
quota,
}
}
}
@@ -1686,6 +1708,9 @@ impl ProviderAdmin for UnusedProvider {
&self,
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
) -> Result<ProviderQuota, ProviderAdminError> {
if let Some(quota) = self.quota.lock().expect("provider quota").clone() {
return Ok(quota);
}
Err(self
.error
.lock()
@@ -34,7 +34,35 @@ async fn export(
let body = to_bytes(response.into_body(), 20 * 1024 * 1024)
.await
.unwrap();
(status, headers, serde_json::from_slice(&body).unwrap())
let file = if status == StatusCode::OK {
assert_eq!(headers[header::CONTENT_TYPE], "application/zip");
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&body)).unwrap();
assert_eq!(archive.len(), 3);
let file: Value =
serde_json::from_reader(archive.by_name("diagnostics.json").unwrap()).unwrap();
for name in ["summary.txt", "README.txt"] {
use std::io::Read as _;
let mut text = String::new();
archive
.by_name(name)
.unwrap()
.read_to_string(&mut text)
.unwrap();
assert!(!text.is_empty());
for marker in [
"fixture-authorization",
"fixture-refresh",
"fixture-password",
"fixture@example.invalid",
] {
assert!(!text.contains(marker), "archive leaked {marker} in {name}");
}
}
file
} else {
serde_json::from_slice(&body).unwrap()
};
(status, headers, file)
}
#[tokio::test]
@@ -85,11 +113,15 @@ async fn diagnostic_export_keeps_retry_evidence_and_original_errors_but_not_cred
.lock()
.unwrap()
.extend([error, unrelated]);
let trace = json!({"droppedEvents": 0, "events": [
let trace = json!({"droppedEvents": 5, "droppedEvidenceEvents": 1, "stageCountOverflow": 2,
"stageCounts": {"provider.response.parse_gap": 1}, "events": [
{"stage": "request.routed", "data": {"accountPolicy": {"maxAccountRotations": 3}}},
{"stage": "account.selection", "data": {"candidates": [{"id": "acct_high", "weight": 2}, {"id": "acct_low", "weight": 1}]}},
{"stage": "attempt.failed", "data": {"kind": "unavailable", "recovery": {"replaySafe": true}}},
{"stage": "retry.decided", "data": {"phase": "execute", "retryable": false, "clientRetrySuppressed": true, "replayBarrierReached": false}},
{"stage": "provider.response.parse_gap", "data": {"reason": "invalid_json"}},
{"stage": "provider.request.input", "data": {"input": {"omittedItems": 10}}},
{"stage": "upstream.event", "data": {"truncated": true, "jsonValid": false}},
]});
fixture.usage_detail.lock().unwrap().replace(UsageDetail {
request: usage_record_with_account(
@@ -108,10 +140,32 @@ async fn diagnostic_export_keeps_retry_evidence_and_original_errors_but_not_cred
assert_eq!(status, StatusCode::OK, "{file}");
assert_eq!(
headers[header::CONTENT_DISPOSITION],
"attachment; filename=diagnostics.json"
"attachment; filename=cpr-diagnostics-req_err.zip"
);
assert_eq!(headers[header::CACHE_CONTROL], "private, no-store");
assert_eq!(file["schemaVersion"], 5);
assert_eq!(
file["exportTruncated"], false,
"capture loss is distinct from export truncation"
);
for reason in [
"events_evicted",
"critical_evidence_evicted",
"stage_coverage_incomplete",
"response_parse_failed",
"input_prefix_sample_limited",
"event_data_truncated",
"json_parse_failed",
] {
assert!(
file["availability"]["gaps"]
.as_array()
.unwrap()
.iter()
.any(|gap| gap["reason"] == reason),
"missing gap {reason}"
);
}
assert_eq!(file["requests"][0]["evidence"]["trace"], trace);
assert_eq!(
file["requests"][0]["evidence"]["incidentConfiguration"]["revision"],
@@ -1,13 +1,13 @@
//! 查询 wire 校验的固定合同测试
use gateway_api::admin::observability::{
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind, UsageQuery,
parse_attempt_index, parse_datetime, parse_status,
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind,
UsageAnalyticsQuery, UsageQuery, parse_attempt_index, parse_datetime, parse_status,
};
use serde_json::json;
#[test]
fn dashboard_query_should_parse_terminal_trend_kinds() {
fn dashboard_query_should_parse_error_trends() {
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
}
@@ -19,7 +19,7 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
}
#[test]
fn usage_query_should_accept_element_plus_pagination() {
fn usage_query_should_accept_page_number_and_size() {
let query: UsageQuery = serde_json::from_value(json!({
"currentPage": 129,
"pageSize": 10
@@ -29,32 +29,21 @@ fn usage_query_should_accept_element_plus_pagination() {
}
#[test]
fn usage_query_should_reject_removed_total_toggle() {
assert!(serde_json::from_value::<UsageQuery>(json!({"includeTotal": true})).is_err());
fn observability_queries_should_reject_unknown_fields() {
let query = json!({"unknownFilter": true});
assert!(serde_json::from_value::<DashboardQuery>(query.clone()).is_err());
assert!(serde_json::from_value::<UsageQuery>(query.clone()).is_err());
assert!(serde_json::from_value::<UsageAnalyticsQuery>(query.clone()).is_err());
assert!(serde_json::from_value::<OpsQuery>(query.clone()).is_err());
assert!(serde_json::from_value::<DiagnosticsQuery>(query).is_err());
}
#[test]
fn usage_query_should_reject_removed_cursor_contract() {
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
}
#[test]
fn usage_query_should_reject_nonstandard_page_name() {
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
}
#[test]
fn ops_query_should_reject_page_size_above_terminal_limit() {
fn ops_query_should_reject_page_size_above_maximum() {
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
fn ops_query_should_reject_removed_filters() {
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
@@ -1,8 +1,107 @@
//! 响应 DTO 与固定 wire 形状的合同测试
//! 验证观测响应的指标、展示投影与查询时间边界
use chrono::Utc;
use serde_json::json;
#[tokio::test]
async fn usage_summary_keeps_request_attempt_cost_and_average_latency_wire_fields() {
use crate::admin::{AdminTestFixture, AdminTestState};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Duration;
use gateway_admin::model::observability::{
AttemptMetrics, CostCoverage, CurrencyCost, RequestMetrics, TimeRange, UsageOverview,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
for (sum, count, display, raw) in [
(404, 3, "134 ms", Some(134)),
(0, 1, "0 ms", Some(0)),
(0, 0, "—", None),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
fixture.observations.lock().unwrap().summary = Some(UsageOverview {
analytics: gateway_admin::model::observability::AnalyticsMetadata {
generated_at: now,
..Default::default()
},
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
requests: RequestMetrics {
request_count: 5,
success_count: 4,
cancelled_count: 1,
caller_error_count: 1,
input_tokens: 400,
output_tokens: 80,
cached_input_tokens: 30,
cache_write_input_tokens: 7,
reasoning_output_tokens: 9,
total_tokens: 480,
latency_sum_ms: sum,
latency_count: count,
..Default::default()
},
attempts: AttemptMetrics {
attempt_count: 6,
success_count: 4,
failure_count: 1,
cancelled_count: 1,
rate_limited_count: 1,
cost_coverage: CostCoverage {
provider_reported_count: 3,
unavailable_count: 1,
..Default::default()
},
costs: vec![CurrencyCost {
currency: "USD".into(),
amount: "1.25".parse().unwrap(),
}],
..Default::default()
},
providers: Vec::new(),
});
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records/summary?provider=openai")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_summary_projection")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
assert_eq!(
value["data"],
json!({
"analytics": value["data"]["analytics"],
"totalRequests": "5", "inputTokens": "400", "outputTokens": "80",
"cachedInputTokens": "30", "cacheWriteInputTokens": "7", "totalTokens": "480",
"averageLatencyMs": display,
"averageLatencyMsValue": raw,
"logicalRequests": {"requestCount":5,"successCount":4,"failureCount":0,"cancelledCount":1,"incompleteCount":0,"callerErrorCount":1,"inputTokens":400,"outputTokens":80,"cachedInputTokens":30,"cacheWriteInputTokens":7,"reasoningOutputTokens":9,"totalTokens":480},
"attempts": {"attemptCount":6,"successCount":4,"failureCount":1,"cancelledCount":1,"incompleteCount":0,"rateLimitedCount":1,"authFailureCount":0,"provider5xxCount":0,"costCoverage":{"known":3,"partial":0,"unknown":1,"notBillable":0},"costs":[{"currency":"USD","estimatedAmount":"1.25"}]}
})
);
let observations = fixture.observations.lock().unwrap();
assert_eq!(observations.summaries.len(), 1);
assert_eq!(
observations.summaries[0].1.provider_kind.as_deref(),
Some("openai")
);
}
}
#[tokio::test]
async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headline_count() {
use axum::{
@@ -288,10 +387,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
record.upstream_response_ms = Some(1_000);
record.upstream_api_overhead_ms = Some(120.25);
record.upstream_engine_ms = Some(6400.0);
record.upstream_engine_iapi_ttft_ms = Some(650.5);
record.upstream_engine_service_ttft_ms = Some(720.25);
record.upstream_engine_iapi_tbt_ms = Some(18.45);
record.upstream_engine_service_tbt_ms = Some(20.12);
fixture
.usage_detail
.lock()
@@ -362,29 +457,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
value["data"]["latencyDetails"]["upstreamEngineMs"],
json!(6400.0)
);
assert_eq!(
value["data"]["latencyDetails"]["upstreamEngineIapiTtftMs"],
json!(650.5)
);
assert_eq!(
value["data"]["latencyDetails"]["upstreamEngineServiceTtftMs"],
json!(720.25)
);
assert_eq!(
value["data"]["latencyDetails"]["upstreamEngineIapiTbtMs"],
json!(18.45)
);
assert_eq!(
value["data"]["latencyDetails"]["upstreamEngineServiceTbtMs"],
json!(20.12)
);
assert!(value["data"].get("firstTokenMs").is_none());
assert!(value["data"].get("firstTokenLatencyMsDisplay").is_none());
assert!(
value["data"]["latencyDetails"]
.get("firstTokenMs")
.is_none()
);
assert_eq!(
serde_json::json!({
"accountId": value["data"]["accountId"],
@@ -490,8 +562,6 @@ async fn zero_attempt_failure_detail_omits_trace_and_keeps_missing_upstream_fact
assert_eq!(data["clientStatusCode"], 503);
assert_eq!(data["message"], "no upstream account is available");
assert_eq!(data["model"], "coding");
assert!(data.get("upstreamStatusCode").is_none());
assert!(data.get("httpVersion").is_none());
for field in [
"provider",
"accountId",
@@ -642,7 +712,6 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert!(value["data"]["items"][0].get("impactScore").is_none());
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
@@ -695,7 +764,6 @@ pub(super) fn usage_record_with_account(
upstream_transport: Some("http_sse".to_owned()),
http_version: Some("h2".to_owned()),
websocket_pool: None,
upstream_response_model: None,
service_tier: None,
provider_metadata_json: None,
attempt_count: 1,
@@ -734,10 +802,6 @@ pub(super) fn usage_record_with_account(
upstream_response_ms: None,
upstream_api_overhead_ms: None,
upstream_engine_ms: None,
upstream_engine_iapi_ttft_ms: None,
upstream_engine_service_ttft_ms: None,
upstream_engine_iapi_tbt_ms: None,
upstream_engine_service_tbt_ms: None,
latency_ms: None,
admission_decision_ms: None,
account_selection_wait_ms: None,
@@ -759,7 +823,7 @@ pub(super) fn usage_record_with_account(
}
#[tokio::test]
async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
async fn usage_route_should_forward_name_and_model_contains_filters() {
use axum::{
body::Body,
http::{Request, StatusCode, header},
@@ -775,7 +839,7 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records?outcome=provider_future_state")
.uri("/api/admin/usage/records?model=%20coding%20&clientApiKeyName=%20Key%20%25%20&accountName=%20Account%20Name%20")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_other_outcome")
.body(Body::empty())
@@ -785,6 +849,16 @@ async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
.expect("usage response");
assert_eq!(response.status(), StatusCode::OK);
let observations = fixture.observations.lock().unwrap();
let filter = &observations.records.last().expect("usage query").filter;
assert_eq!(filter.client_api_key_name.as_deref(), Some("Key %"));
assert_eq!(filter.model_contains.as_deref(), Some("coding"));
assert_eq!(
filter.provider_account_name.as_deref(),
Some("Account Name")
);
assert!(filter.client_api_key_ref.is_none());
assert!(filter.provider_account_ref.is_none());
}
#[tokio::test]
@@ -834,7 +908,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
provider_account_authentication_kind: Some("oauth".to_owned()),
upstream_model_id: Some("grok-4.5".to_owned()),
upstream_transport: Some("http_sse".to_owned()),
upstream_response_model: Some("grok-4.6-build".to_owned()),
service_tier: Some("default".to_owned()),
input_tokens: Some(1),
output_tokens: Some(1),
@@ -877,10 +950,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
upstream_response_ms: Some(2_000),
upstream_api_overhead_ms: None,
upstream_engine_ms: None,
upstream_engine_iapi_ttft_ms: None,
upstream_engine_service_ttft_ms: None,
upstream_engine_iapi_tbt_ms: None,
upstream_engine_service_tbt_ms: None,
latency_ms: Some(31),
admission_decision_ms: Some(1),
account_selection_wait_ms: Some(2),
@@ -988,7 +1057,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
"requestedModel": value["data"]["items"][0]["requestedModel"],
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
"subagentKind": value["data"]["items"][0]["subagentKind"],
@@ -1012,7 +1080,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
"imageOutputTokens": 9,
"requestedModel": "grok-4.5",
"upstreamModel": "grok-4.5",
"upstreamResponseModel": "grok-4.6-build",
"reasoningEffort": "max",
"reasoningPreset": "ultra",
"subagentKind": "worker",
@@ -1398,3 +1465,301 @@ async fn usage_status_query_accepts_three_modes_and_rejects_unknown_values() {
}
}
}
#[tokio::test]
async fn dashboard_summary_should_identify_metrics_without_localized_labels() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Duration;
use gateway_admin::model::{
accounts::AccountUsage,
observability::{DashboardAccountUsage, DashboardObservation, TimeRange},
provider_credentials::{ProviderQuotaWindow, QuotaLocalUsageAttribution},
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use crate::admin::{AdminTestFixture, AdminTestState};
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
yesterday: Default::default(),
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
totals: Default::default(),
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: [
(None, Some(2_000), Some(now)),
(None, Some(0), None),
(None, None, None),
(Some(9), Some(2_000), Some(now)),
(Some(0), Some(2_000), None),
]
.into_iter()
.enumerate()
.map(|(index, (requests, tokens, last_used_at))| {
let account_id = format!("acct_metric_{index}");
DashboardAccountUsage {
account_id: account_id.clone(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
name: account_id.clone(),
email: None,
plan_type: None,
plan_type_display: None,
request_count: 10,
success_count: 10,
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_write_input_tokens: None,
reasoning_output_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: tokens,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at,
request_buckets: Vec::new(),
quota_used_percent: None,
quota_window: requests.map(|request_count| ProviderQuotaWindow {
key: "rolling".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(AccountUsage {
account_id,
request_count,
success_count: request_count,
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_write_input_tokens: None,
reasoning_output_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: None,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at,
request_buckets: Vec::new(),
models: Vec::new(),
}),
provider_data: None,
}),
models: Vec::new(),
}
})
.collect(),
recent_requests: Vec::new(),
});
for (kind, metrics) in [
(
"usage",
["inputTokens", "outputTokens", "cachedInputTokens"],
),
(
"latency",
["firstTokenP95Ms", "latencyP95Ms", "outputThroughputP50"],
),
("errors", ["errors", "successRate", "requests"]),
] {
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri(format!("/api/admin/dashboard/summary?kind={kind}"))
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::ACCEPT_LANGUAGE, "en")
.header("x-request-id", format!("req_trend_metrics_{kind}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::OK, "{kind}: {value}");
let accounts = value["data"]["accountUsage"].as_array().unwrap();
assert_eq!(accounts.len(), 5);
for (account, (metric, raw, display, last_used_at)) in accounts.iter().zip([
("tokens", Some(2_000), "2K", Some(now)),
("tokens", Some(0), "0", None),
("tokens", None, "—", None),
("requests", Some(9), "9", Some(now)),
("requests", Some(0), "—", None),
]) {
assert_eq!(account["metric"], metric);
assert_eq!(account["metricValueRaw"], json!(raw));
assert_eq!(account["metricValue"], display);
assert_eq!(account["lastUsedAt"], json!(last_used_at));
}
let summary = value["data"]["trend"]["summary"].as_array().unwrap();
assert_eq!(summary.len(), metrics.len());
for (item, metric) in summary.iter().zip(metrics) {
assert_eq!(item["metric"], metric);
assert!(item.get("label").is_none());
assert!(item["value"].is_string());
}
}
}
#[tokio::test]
async fn dashboard_relative_time_preserves_server_anchor_boundaries_and_deployment_timezone() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::{DateTime, Duration};
use gateway_admin::model::observability::{
DashboardAccountUsage, DashboardObservation, TimeRange,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
use crate::admin::{AdminTestFixture, AdminTestState};
let now = "2026-06-15T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
for (zone, future_display) in [
("UTC", "2026-06-15 00:00:01"),
("Asia/Shanghai", "2026-06-15 08:00:01"),
("Asia/Kathmandu", "2026-06-15 05:45:01"),
("America/New_York", "2026-06-14 20:00:01"),
] {
for (elapsed_ms, expected, legacy) in [
(None, json!(null), "—"),
(
Some(-1_000),
json!({ "kind": "absolute", "value": future_display }),
future_display,
),
(Some(-1), json!({ "kind": "just_now" }), "刚刚"),
(Some(0), json!({ "kind": "just_now" }), "刚刚"),
(Some(59_999), json!({ "kind": "just_now" }), "刚刚"),
(
Some(60_000),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(119_999),
json!({ "kind": "elapsed", "value": 1, "unit": "minute" }),
"1 分钟前",
),
(
Some(3_599_999),
json!({ "kind": "elapsed", "value": 59, "unit": "minute" }),
"59 分钟前",
),
(
Some(3_600_000),
json!({ "kind": "elapsed", "value": 1, "unit": "hour" }),
"1 小时前",
),
(
Some(86_399_999),
json!({ "kind": "elapsed", "value": 23, "unit": "hour" }),
"23 小时前",
),
(
Some(86_400_000),
json!({ "kind": "elapsed", "value": 1, "unit": "day" }),
"1 天前",
),
(
Some(34_560_000_000),
json!({ "kind": "elapsed", "value": 400, "unit": "day" }),
"400 天前",
),
] {
// 每个边界使用独立缓存,避免相同锚点复用上一轮观测数据
let fixture = AdminTestFixture::with_timezone(zone.parse().unwrap()).await;
fixture.auth.insert_session("valid-session");
let last_used_at = elapsed_ms.map(|value| now - Duration::milliseconds(value));
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
yesterday: Default::default(),
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).unwrap(),
totals: Default::default(),
provider_accounts: Default::default(),
trend: Vec::new(),
account_usage: vec![DashboardAccountUsage {
account_id: "acct_relative_time".to_owned(),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
name: "Relative time fixture".to_owned(),
email: None,
plan_type: None,
plan_type_display: None,
request_count: 1,
success_count: 1,
input_tokens: None,
output_tokens: None,
cached_input_tokens: None,
cache_write_input_tokens: None,
reasoning_output_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: None,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at,
request_buckets: Vec::new(),
quota_used_percent: None,
quota_window: None,
models: Vec::new(),
}],
recent_requests: Vec::new(),
});
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri(format!(
"/api/admin/dashboard/summary?asOf={}",
now.timestamp_millis()
))
.header(header::COOKIE, "cpr_session=valid-session")
.header(header::ACCEPT_LANGUAGE, "en")
.header("x-request-id", "req_relative_time")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::OK, "{zone}: {value}");
let account = &value["data"]["accountUsage"][0];
assert_eq!(
account["lastUsedRelative"], expected,
"{zone}: {elapsed_ms:?}"
);
assert_eq!(account["lastUsed"], legacy, "{zone}: {elapsed_ms:?}");
assert_eq!(account["lastUsedAt"], json!(last_used_at));
}
}
}
@@ -32,7 +32,7 @@ use gateway_core::routing::extensions::ExtensionSetLease;
use gateway_core::routing::extensions::ExtensionSetReference;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use tower::ServiceExt as _;
@@ -41,6 +41,7 @@ use super::super::AdminTestFixture;
#[derive(Debug, Clone, Copy)]
enum ModelReply {
Complete,
SessionError,
PendingResponse,
PendingBody,
}
@@ -50,6 +51,7 @@ struct ModelExecution {
cancellation: Mutex<Option<CancellationToken>>,
entered: Arc<tokio::sync::Notify>,
closed: Arc<AtomicBool>,
body_control: Arc<ModelBodyControl>,
}
impl ModelExecution {
@@ -59,6 +61,7 @@ impl ModelExecution {
cancellation: Mutex::new(None),
entered: Arc::default(),
closed: Arc::default(),
body_control: Arc::default(),
})
}
}
@@ -92,6 +95,7 @@ impl ExecutionService for ModelExecution {
reply: self.reply,
entered: self.entered.clone(),
closed: self.closed.clone(),
body_control: self.body_control.clone(),
}),
ExtensionSetReference::new(
ExtensionSetId::new("model-bridge-test".into()).unwrap(),
@@ -122,6 +126,7 @@ struct ModelMiddleware {
reply: ModelReply,
entered: Arc<tokio::sync::Notify>,
closed: Arc<AtomicBool>,
body_control: Arc<ModelBodyControl>,
}
impl MiddlewarePlan for ModelMiddleware {
@@ -152,12 +157,19 @@ impl MiddlewarePlan for ModelMiddleware {
let reply = self.reply;
let closed = self.closed.clone();
let entered = self.entered.clone();
let body_control = self.body_control.clone();
Box::pin(async move {
entered.notify_one();
if matches!(reply, ModelReply::PendingResponse) {
return futures::future::pending().await;
}
let (bytes, framing, terminal) = if matches!(reply, ModelReply::PendingBody) {
let (bytes, framing, terminal) = if matches!(reply, ModelReply::SessionError) {
(
"{\"code\":40101,\"message\":\"plugin error\",\"data\":null}",
MiddlewareFraming::JsonDocument,
true,
)
} else if matches!(reply, ModelReply::PendingBody) {
(
"event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_bridge\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}\n\n",
MiddlewareFraming::SseEvent,
@@ -172,32 +184,67 @@ impl MiddlewarePlan for ModelMiddleware {
};
Ok(MiddlewareResponse::new(
"openai".into(),
200,
if matches!(reply, ModelReply::SessionError) {
401
} else {
200
},
vec![
MiddlewareHeader::new("set-cookie", "fixture=value".into()),
MiddlewareHeader::new("www-authenticate", "Bearer".into()),
MiddlewareHeader::new("x-plugin-response", "complete".into()),
MiddlewareHeader::new("x-cpr-plugin-response", "untrusted".into()),
],
Box::new(ModelBody {
frame: Some(MiddlewareFrame::new(bytes.into(), framing, terminal)),
pending: !terminal,
closed,
control: body_control,
}),
))
})
}
}
#[derive(Debug, Default)]
struct ModelBodyControl {
delayed_frame: AtomicBool,
pending_reads: AtomicUsize,
waiting: tokio::sync::Notify,
release: tokio::sync::Notify,
}
struct ModelBody {
frame: Option<MiddlewareFrame>,
pending: bool,
closed: Arc<AtomicBool>,
control: Arc<ModelBodyControl>,
}
impl MiddlewareBody for ModelBody {
fn next_frame(&mut self) -> BoxFuture<'_, Result<Option<MiddlewareFrame>, MiddlewareError>> {
let frame = self.frame.take();
let delayed_frame = if frame.is_none() && self.pending {
self.control.pending_reads.fetch_add(1, Ordering::SeqCst);
self.control.waiting.notify_one();
// 模拟读取在 future 创建时已经消费事件,取消后不能重新取得原帧
self.control.delayed_frame.swap(false, Ordering::SeqCst).then(|| {
MiddlewareFrame::new(
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_bridge\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}}\n\n".into(),
MiddlewareFraming::SseEvent,
true,
)
})
} else {
None
};
Box::pin(async move {
if let Some(frame) = self.frame.take() {
if let Some(frame) = frame {
return Ok(Some(frame));
}
if let Some(frame) = delayed_frame {
self.control.release.notified().await;
self.pending = false;
return Ok(Some(frame));
}
if self.pending {
@@ -245,6 +292,7 @@ async fn management_calls_and_callbacks_preserve_headers_and_override_defaults()
.header("set-cookie", "first=1")
.header("set-cookie", "second=2")
.header("content-security-policy", "default-src 'self'")
.header("x-cpr-plugin-response", "untrusted")
.body(Body::empty())
.unwrap(),
)
@@ -273,6 +321,15 @@ async fn management_calls_and_callbacks_preserve_headers_and_override_defaults()
response.headers()["content-security-policy"],
"default-src 'self'"
);
assert_eq!(response.headers()["x-cpr-plugin-response"], "1");
assert_eq!(
response
.headers()
.get_all("x-cpr-plugin-response")
.iter()
.count(),
1
);
}
}
@@ -288,6 +345,7 @@ async fn model_bridge_preserves_complete_request_and_response_headers() {
assert_eq!(response.headers()["set-cookie"], "fixture=value");
assert_eq!(response.headers()["www-authenticate"], "Bearer");
assert_eq!(response.headers()["x-plugin-response"], "complete");
assert_eq!(response.headers()["x-cpr-plugin-response"], "1");
assert_eq!(response.headers()["cache-control"], "no-store");
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
@@ -298,6 +356,40 @@ async fn model_bridge_preserves_complete_request_and_response_headers() {
);
}
#[tokio::test]
async fn model_bridge_distinguishes_host_authentication_from_plugin_session_errors() {
let fixture = published_fixture().await;
let router = crate::openai::api_router_with_admin_and_execution(
fixture.services,
ModelExecution::new(ModelReply::SessionError),
);
let mut unauthenticated = model_request(false);
unauthenticated.headers_mut().remove("cookie");
unauthenticated.headers_mut().remove("authorization");
unauthenticated.headers_mut().remove("x-api-key");
let response = router.clone().oneshot(unauthenticated).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(!response.headers().contains_key("x-cpr-plugin-response"));
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["code"],
40101
);
let response = router.oneshot(model_request(false)).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.headers()["x-cpr-plugin-response"], "1");
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["code"],
40101
);
}
#[tokio::test(start_paused = true)]
async fn model_bridge_revokes_while_waiting_for_the_first_response() {
let fixture = published_fixture().await;
@@ -358,6 +450,52 @@ async fn model_bridge_revokes_and_closes_a_pending_stream() {
assert!(execution.closed.load(Ordering::SeqCst));
}
#[tokio::test(start_paused = true)]
async fn model_bridge_authorization_rechecks_preserve_the_pending_frame_read() {
let fixture = published_fixture().await;
let execution = ModelExecution::new(ModelReply::PendingBody);
execution
.body_control
.delayed_frame
.store(true, Ordering::SeqCst);
let response =
crate::openai::api_router_with_admin_and_execution(fixture.services, execution.clone())
.oneshot(model_request(true))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = tokio::spawn(axum::body::to_bytes(response.into_body(), 4096));
tokio::time::timeout(
std::time::Duration::from_secs(5),
execution.body_control.waiting.notified(),
)
.await
.expect("第二帧读取已开始");
for _ in 0..3 {
tokio::time::advance(std::time::Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert_eq!(
execution.body_control.pending_reads.load(Ordering::SeqCst),
1
);
assert!(!body.is_finished());
}
execution.body_control.release.notify_one();
let body = tokio::time::timeout(std::time::Duration::from_secs(5), body)
.await
.expect("原先等待的终态帧应完成交付")
.unwrap()
.unwrap();
let body = std::str::from_utf8(&body).unwrap();
assert_eq!(body.matches("event: response.completed").count(), 1);
assert!(!body.contains("response.failed"));
assert_eq!(
execution.body_control.pending_reads.load(Ordering::SeqCst),
1
);
assert!(execution.closed.load(Ordering::SeqCst));
}
pub(super) fn resource_fixture(
path: &str,
) -> gateway_admin::model::plugins::management::PluginManagementResponse {
@@ -35,7 +35,7 @@ use std::{collections::BTreeMap, sync::Arc};
mod management;
#[test]
fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
fn artifact_metadata_wire_preserves_identity_icons_and_contributions() {
use gateway_admin::model::plugins::{
PluginArtifactIcon, PluginArtifactIconVariants, PluginArtifactMetadata, PluginContribution,
};
@@ -70,7 +70,7 @@ fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
secret_fields: vec![],
state_namespaces: vec![],
};
let value = serde_json::to_value(metadata).expect("v2 artifact metadata JSON");
let value = serde_json::to_value(metadata).expect("artifact metadata JSON");
assert_eq!(value["displayName"], "Example");
assert_eq!(value["publisher"], "test");
assert!(value["author"].is_null());
@@ -80,33 +80,29 @@ fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
value["contributes"]["middleware"]["id"],
"test.example.middleware"
);
assert!(value.get("capabilities").is_none());
assert!(value.get("permissionDescriptions").is_none());
}
#[test]
fn instance_binding_wire_uses_contribution_without_capability_alias() {
fn instance_binding_requires_a_contribution_and_rejects_unknown_fields() {
use gateway_admin::model::plugins::instances::PluginCapabilityBinding;
let binding = serde_json::from_value::<PluginCapabilityBinding>(serde_json::json!({
let input = serde_json::json!({
"contribution": "test.example.usage",
"stage": "observation",
"order": 0,
"failurePolicy": "observe"
}))
.expect("v2 contribution binding");
});
let binding = serde_json::from_value::<PluginCapabilityBinding>(input.clone())
.expect("contribution binding");
assert_eq!(binding.contribution, "test.example.usage");
assert!(
serde_json::from_value::<PluginCapabilityBinding>(serde_json::json!({
"capability": "usage",
"stage": "observation",
"order": 0,
"failurePolicy": "observe"
}))
.is_err(),
"legacy capability binding must be rejected"
);
let mut missing = input.clone();
missing.as_object_mut().unwrap().remove("contribution");
assert!(serde_json::from_value::<PluginCapabilityBinding>(missing).is_err());
let mut unknown = input;
unknown["unknownField"] = serde_json::json!(true);
assert!(serde_json::from_value::<PluginCapabilityBinding>(unknown).is_err());
}
#[tokio::test]
@@ -381,7 +377,7 @@ async fn plugin_upload_endpoints_require_admin_and_bound_raw_bodies() {
}
#[tokio::test]
async fn artifact_acceptance_returns_the_flat_install_contract() {
async fn artifact_acceptance_returns_installed_artifact_and_configuration_status() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
@@ -418,22 +414,11 @@ async fn artifact_acceptance_returns_the_flat_install_contract() {
assert_eq!(result["defaultInstanceId"], serde_json::Value::Null);
assert_eq!(result["configurationRequired"], false);
assert_eq!(result["artifact"]["metadata"]["sha256"], "a".repeat(64));
assert!(
result["artifact"]["metadata"]
.get("permissionDescriptions")
.is_none()
);
assert!(
result["artifact"]["metadata"]
.get("requestedPermissions")
.is_none()
);
assert!(result["artifact"]["acceptedAt"].is_string());
assert!(result.get("mutation").is_none());
}
#[tokio::test]
async fn removed_instance_policy_fields_and_extra_accept_fields_are_rejected() {
async fn plugin_mutations_reject_unknown_fields() {
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -442,24 +427,25 @@ async fn removed_instance_policy_fields_and_extra_accept_fields_are_rejected() {
let fixture = super::AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (path, body) in [
(
"/api/admin/plugins/instances",
serde_json::json!({
"name":"Example",
"artifactSha256":"a".repeat(64),
"enabled":false,
"trustedProcess":true,
"configuration":{},
"grants":[],
"bindings":[]
}),
),
let instance = serde_json::json!({
"name":"Example",
"artifactSha256":"a".repeat(64),
"enabled":false,
"configuration":{},
"bindings":[]
});
serde_json::from_value::<gateway_admin::model::plugins::instances::ConfigurePluginInstance>(
instance.clone(),
)
.expect("valid instance request");
for (path, mut body) in [
("/api/admin/plugins/instances", instance),
(
"/api/admin/plugins/artifacts/accept",
serde_json::json!({"sha256":"a".repeat(64),"trusted":true}),
serde_json::json!({"sha256":"a".repeat(64)}),
),
] {
body["unknownField"] = serde_json::json!(true);
let response = gateway_api::admin::router::<super::AdminTestState>()
.with_state(fixture.state())
.oneshot(
@@ -181,7 +181,7 @@ fn privacy_policy_is_required_and_rejects_unknown_contract_fields() {
.remove("codexPrivacyPolicy");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
let mut unknown = update_body();
unknown["codexPrivacyPolicy"]["protectedFields"] = json!([]);
unknown["codexPrivacyPolicy"]["unknownField"] = json!(true);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(unknown).is_err());
}
@@ -472,120 +472,15 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
}
#[test]
fn settings_request_and_response_fields_should_stay_in_lockstep() {
use std::collections::{BTreeMap, BTreeSet};
use gateway_admin::model::Revision;
use gateway_admin::model::settings::RuntimeSettings;
use gateway_api::admin::settings::RuntimeSettingsView;
use gateway_core::account::RotationStrategy;
use gateway_core::routing::{PublicModelId, UpstreamModelId};
let request: UpdateRuntimeSettingsRequest =
serde_json::from_value(update_body()).expect("decode settings");
request.validate().expect("fixture settings must validate");
let request_fields: BTreeSet<String> = update_body()
.as_object()
.expect("request body object")
.keys()
.cloned()
.collect();
let settings = RuntimeSettings {
request_profiles: Default::default(),
config_revision: Revision::new(7).expect("revision"),
model_mappings: request
.model_mappings
.iter()
.map(|(public, upstream)| {
Ok((
PublicModelId::new(public.clone())?,
UpstreamModelId::new(upstream.clone())?,
))
})
.collect::<Result<BTreeMap<_, _>, gateway_core::error::IdentifierError>>()
.expect("valid model mappings"),
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
updated_at: chrono::Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: request.values.refresh_margin_seconds,
refresh_concurrency: u32::try_from(request.values.refresh_concurrency).expect("u32"),
max_concurrent_per_account: u32::try_from(request.values.max_concurrent_per_account)
.expect("u32"),
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
max_account_rotations: 3,
openai_session_affinity_ttl_hours: 24,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.values.smart_scheduling,
min_codex_desktop_version: request.values.min_codex_desktop_version,
min_codex_cli_version: request.values.min_codex_cli_version,
usage_retention_days: u32::try_from(request.values.usage_retention_days).expect("u32"),
ops_event_retention_days: u32::try_from(request.values.ops_event_retention_days)
.expect("u32"),
audit_retention_days: u32::try_from(request.values.audit_retention_days).expect("u32"),
account_auto_freeze_enabled: true,
account_auto_freeze_threshold: 12,
account_auto_freeze_window_seconds: 600,
account_auto_freeze_duration_seconds: 7_200,
account_auto_freeze_probe_enabled: true,
account_auto_freeze_probe_model: None,
account_auto_freeze_adaptive_concurrency: true,
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
};
let response_fields: BTreeSet<String> = serde_json::to_value(RuntimeSettingsView::from((
settings,
gateway_api::TimePresenter::new(Default::default()),
)))
.expect("serialize view")
.as_object()
.expect("view object")
.keys()
.cloned()
.collect();
let mut expected_fields = request_fields;
expected_fields.insert("providerRequestProfiles".to_owned());
expected_fields.insert("openaiClientProfile".to_owned());
expected_fields.insert("xaiClientProfile".to_owned());
expected_fields.insert("updatedAt".to_owned());
expected_fields.insert("updatedAtDisplay".to_owned());
expected_fields.insert("smartSchedulingDefaults".to_owned());
assert_eq!(response_fields, expected_fields);
}
#[test]
fn settings_request_accepts_json_bytes_with_flattened_fields() {
serde_json::from_slice::<UpdateRuntimeSettingsRequest>(
&serde_json::to_vec(&update_body()).unwrap(),
)
.expect("settings JSON bytes must decode like the HTTP request body");
}
#[test]
fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
fn settings_service_contract_flattens_values_and_rejects_unknown_fields() {
use gateway_admin::model::settings::{ReplaceRuntimeSettings, RuntimeSettings};
let current = super::test_runtime_settings();
assert_eq!(
serde_json::from_slice::<RuntimeSettings>(&serde_json::to_vec(&current).unwrap()).unwrap(),
current
);
let mut record = serde_json::to_value(&current).unwrap();
assert!(record.get("values").is_none());
assert_eq!(
record["refresh_margin_seconds"],
current.values.refresh_margin_seconds
);
assert_eq!(
serde_json::from_value::<RuntimeSettings>(record.clone()).unwrap(),
current
@@ -594,13 +489,11 @@ fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
assert!(serde_json::from_value::<RuntimeSettings>(record).is_err());
let command = ReplaceRuntimeSettings::from(current);
assert_eq!(
serde_json::from_slice::<ReplaceRuntimeSettings>(&serde_json::to_vec(&command).unwrap())
.unwrap(),
command
);
let mut wire = serde_json::to_value(&command).unwrap();
assert!(wire.get("values").is_none());
assert_eq!(
wire["refresh_margin_seconds"],
command.values.refresh_margin_seconds
);
assert_eq!(
serde_json::from_value::<ReplaceRuntimeSettings>(wire.clone()).unwrap(),
command
@@ -610,17 +503,9 @@ fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
}
#[test]
fn settings_request_should_reject_unknown_revision_field() {
fn settings_request_should_reject_unknown_fields() {
let mut body = update_body();
body["expectedConfigRevision"] = json!(7);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
#[test]
fn settings_request_should_reject_removed_bucket_retention() {
let mut body = update_body();
body["bucketRetentionDays"] = json!(365);
body["unknownSetting"] = json!(true);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
@@ -960,36 +845,23 @@ fn decompression_setting_should_reject_invalid_values() {
}
#[tokio::test]
async fn settings_reject_removed_global_fast_policy() {
async fn settings_update_rejects_unknown_fields_without_writing() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let app = app(fixture.state());
let initial = fixture.settings.settings.lock().unwrap().clone();
for value in [json!(true), json!(false), Value::Null] {
let mut body = update_body();
body["disableFast"] = value;
let response = app
.clone()
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
}
let mut body = update_body();
body["unknownSetting"] = json!(true);
let response = app
.oneshot(request(Method::GET, "/api/admin/settings", None))
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response_json(response).await["data"]
.get("disableFast")
.is_none()
);
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
}
#[test]
@@ -1555,10 +1427,10 @@ async fn guardian_reservation_round_trips_and_rejects_invalid_values() {
#[tokio::test]
async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_values() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
for (mode, rotations, ttl) in [("relaxed", 0, 1), ("preferred", 3, 24), ("strict", 31, 720)] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
body["openaiAccountAffinity"] = json!(mode);
body["maxAccountRotations"] = json!(rotations);
body["openaiSessionAffinityTtlHours"] = json!(ttl);
@@ -1566,7 +1438,7 @@ async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_valu
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
Some(body.clone()),
))
.await
.unwrap();
@@ -1575,7 +1447,9 @@ async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_valu
assert_eq!(response["data"]["openaiAccountAffinity"], mode);
assert_eq!(response["data"]["maxAccountRotations"], rotations);
assert_eq!(response["data"]["openaiSessionAffinityTtlHours"], ttl);
body["configRevision"] = response["data"]["configRevision"].clone();
}
let saved = fixture.settings.settings.lock().unwrap().clone();
// 类型错误沿用 AdminJson 的 422,合法类型越界由字段校验返回 400
for (field, value, expected_status) in [
(
@@ -1610,20 +1484,19 @@ async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_valu
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
body[field] = value;
let mut invalid = body.clone();
invalid[field] = value;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
Some(invalid),
))
.await
.unwrap();
assert_eq!(response.status(), expected_status, "{field}");
}
assert_eq!(*fixture.settings.settings.lock().unwrap(), saved);
}
#[test]
@@ -86,7 +86,7 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
}
#[tokio::test]
async fn update_should_return_accepted_and_report_restart_only_through_status() {
async fn update_should_return_accepted_and_expose_restart_status() {
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem::default())).await;
fixture.auth.insert_session("valid-session");
let router = app(fixture.state());
@@ -110,7 +110,6 @@ async fn update_should_return_accepted_and_report_restart_only_through_status()
.expect("JSON");
assert_eq!(body["data"]["operationId"], "update-1");
assert_eq!(body["data"]["targetVersion"], "0.2.0");
assert!(body["data"].get("needRestart").is_none());
let response = router
.oneshot(
Request::builder()

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