Compare commits

...
47 Commits
Author SHA1 Message Date
ZyyandCodex 68f05a4481 chore: bump version to 3.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:59 +08:00
ZyyandCodex bb4d178b5d docs(release): prepare v3.4.0 release notes
Document managed proxies, connection tests before saving, credential and
certificate fixes, account and interface improvements, and migration
behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:26 +08:00
ZyyandCodex 73487fd98b fix(api-keys): trim trailing zeros from budget amounts
Display budget amounts with up to two decimal places without padding
trailing zeros. Keep exact amounts in the details popover and preserve
accounting precision.

Validation: frontend lint/type checks/build and browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:20 +08:00
ZyyandCodex 66fd09eba6 feat(proxies): test connections before saving and refine controls
Separate connection testing from saving in proxy forms. Probe unsaved URLs
with the shared certificate configuration and test concurrency limit,
without changing saved proxy settings or linked accounts.

Keep saved authentication when testing an unchanged connection, use
clearer placeholders, fix action column widths, and remove the redundant
list refresh button.

Validation: 5 proxy API tests, scoped strict Clippy, rustfmt, frontend
lint/type checks/build, and browser checks passed. Codex CLI requests
through real HTTP and SOCKS5 proxies completed with matching upstream
text while Clash TUN remained enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:14 +08:00
ZyyandCodex bd152dec00 feat: integrate managed proxies and WebSocket fixes (#60)
Integrate managed account proxies and the non-streaming WebSocket fix.
Include shared CA trust, credential import protection, paginated linked
account management, and the reviewed interface refinements.

Preserve the original contribution and the separate proxy and UI commits.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:54:24 +08:00
ZyyandCodex 7090dca502 fix(ui): correct modal dragging and sidebar scrolling
Use positional offsets for modal dragging to prevent seams between table
cells. Scroll sidebar navigation independently while keeping the brand
and footer fixed, with an auto-hiding scrollbar at the outer edge.

Validation: frontend lint/type checks/build and browser checks for
modal dragging, sidebar overflow, alignment, and auto-hide passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:45 +08:00
ZyyandCodex 029f3124bf feat(proxies): paginate linked accounts and support removal
Load linked accounts on demand with shared plan metadata and guarded
removal to direct connections. Preserve credentials, scheduling settings,
and groups when clearing a proxy binding.

Align proxy forms and tables with shared components, integrate endpoint
details into outbound selectors, and consolidate probe results into the
latency column.

Validation: 11 proxy regressions, strict workspace Clippy, rustfmt,
frontend lint/type checks/build, and local browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:31 +08:00
ZyyandCodex 840707e5a1 fix(proxies): share CA trust and preserve credentials during import
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 13:07:31 +08:00
Yanshan Chen 704b652f86 feat: add managed account proxies and fix non-streaming WebSocket requests 2026-09-11 11:55:09 +08:00
ZyyandCodex 2b98839c29 fix(admin): unify plan labels and refine budget popover
Normalize provider plan display names to PascalCase while preserving raw
plan values. Replace native budget tooltips with the shared borderless
popover, retaining exact amounts and daily/weekly reset information.
Reuse the dayjs date helper for explicit Beijing time formatting.

Validation: 371 Admin/API tests, strict Clippy, rustfmt, frontend lint and
type checks passed. Local frontend and backend health checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:47:34 +08:00
ZyyandCodex 90dd5b5a57 chore: bump version to 3.3.32
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:12 +08:00
ZyyandCodex 5ad9378b33 docs(release): prepare 3.3.32 release notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:08 +08:00
ZyyandCodex 4a413f5bb7 fix(budgets): remove manual charge reconciliation
Settle only observed USD costs and treat missing costs as zero without
blocking client keys. Preserve known retry costs and idempotent settlement.
Remove reconciliation controls, endpoints, pending records, and obsolete
schema fields; migrate legacy unknown charges to zero without changing
recorded usage.

Display daily and weekly amounts with two decimal places while retaining
full precision for accounting and tooltips. Distinguish missing WebSocket
closing handshakes from TCP resets in connection diagnostics.

Validation: workspace tests with PostgreSQL/Redis, targeted regressions,
strict Clippy, rustfmt, frontend lint/build, and migration checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:13:06 +08:00
ZyyandCodex 0bab566d04 fix(xai): correct subscription plans and Codex tool handling
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 04:49:22 +08:00
ZyyandCodex 9b19cf083a feat(release): publish release notes from Markdown
Require committed, non-empty notes matching the target release version before
creating a tag. Read notes from the tagged source and place them before the
installation guide and generated release changelog.

Add the 3.3.31 feature summary and keep maintainer instructions in the release
script help and repository skill instead of the deployment README.

Validation: shell syntax and workflow lint passed. Nine isolated checks cover
local publication, stale or missing notes, and release-body rendering.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:40:00 +08:00
ZyyandCodex 8b22788746 chore: bump version to 3.3.31
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:34:04 +08:00
ZyyandCodex e973fc516e feat(accounts): separate setup from account import
Share account settings across import, single-account editing, and batch editing.
Commit credentials and import settings in the same transaction. Improve modal
actions and fix the flash at the end of light/dark theme transitions.

Reauthenticate each execution against the current key policy so existing
WebSocket connections apply updated RPM, concurrency limits, and enabled state.
Add regression coverage for policy updates and unavailable snapshots.

Validation: all 1,950 backend tests, workspace Clippy, frontend lint/typecheck/build,
migration checks, Compose validation, and workflow lint passed. Completed 24 real
requests with a free account using gpt-5.6-luna to verify daily/weekly budgets,
RPM, concurrency, reconciliation, WebSocket policy updates, and budget persistence
across restarts and usage-log cleanup.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:26:48 +08:00
ZyyandCodex 12ecbf574a fix(accounts): fill missing plans from provider quota snapshots
Use explicit plan types from OpenAI quota snapshots to fill missing or
unknown account plans before building account and dashboard display names.
Preserve known plan subtypes and reuse cached quota without extra requests
or changes to persisted credential facts.

Cover quota projection and account list/detail fallback behavior, and
document the backend display contract.

Validation: gateway-admin, OpenAI, and xAI tests (1049 passed), strict
Clippy for affected backend crates, gateway build, and diff whitespace
checks passed. A real account JSON import and upstream quota refresh
confirmed free/Free in list, detail, and cached quota responses.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:27:45 +08:00
ZyyandCodex 443bd6ad17 feat: integrate account proxies and daily key budgets (#58)
Enforce daily and seven-day client key budgets with a durable charge ledger,
and route provider requests through each account's configured proxy.

Preserve backend plan display names and Codex personal access token imports.
Keep PAT validation on the selected proxy, preserve credential refreshes
when account egress changes, and include the upstream SOCKS IPv6 fix.

Use the PR migration layout, keep the account proxy and key forms aligned
with existing controls, and document the ownership and settlement rules
in the architecture guide.

Validation: 1942 workspace tests passed with isolated PostgreSQL/Redis;
strict Clippy, rustfmt, frontend lint/build, and migration checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:07:28 +08:00
ZyyandCodex c795098a87 feat(openai): support Codex personal access token imports
Accept at- personal access tokens through existing AT and JSON imports,
including the official personal_access_token field. Verify identity with
auth whoami instead of trusting document metadata, preserve optional email,
and exclude PAT credentials from OAuth refresh scheduling.

Expose explicit static PAT validation errors without leaking credentials
or raw upstream responses. Cover rejection, invalid identity, timeouts,
redirect protection, export round trips, and unchanged JWT import behavior.

Validation: OpenAI provider tests (562 passed), gateway-admin tests
(100 passed), strict Clippy for both crates with all targets and features,
workspace rustfmt, and diff whitespace checks passed.
Upstream behavior was tested with mocks; no live PAT was used.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:02:51 +08:00
ZyyandCodex 1f0009341d fix(accounts): resolve plan display names in the backend
Map OpenAI plan identifiers to official desktop labels in the provider.
Expose planTypeDisplay in account and dashboard responses while preserving
raw planType values, and render backend labels directly in the frontend.
Show missing plans as unknown and retain raw identifiers in badge tooltips.

Validation: Admin/API tests (368 passed), OpenAI admin tests (29 passed),
strict Clippy for affected backend crates with all targets and features,
frontend production build, targeted ESLint, and diff whitespace checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:02:51 +08:00
Codex 1359eaa894 feat: add account egress and client key USD budgets
Import sub2api proxy bindings, enforce durable daily and seven-day budgets alongside existing key concurrency, and integrate matching admin controls and audited reconciliation.
2026-09-10 23:41:35 +08:00
ZyyandCodex 74318af570 fix(routing): report missing models with explicit errors
Return model_not_found with HTTP 404 when all scoped provider catalogs
confirm the requested or mapped model is absent. Explain missing mapping
targets separately and preserve unavailable-provider errors for circuit
blocks, empty account scopes, and unsupported operations.

Cover missing models, aliases, empty and unknown catalogs, provider
fallback, and streaming and buffered HTTP error responses.

Validation: gateway-core and gateway-api tests (541 passed), strict Clippy
for both crates with all targets and features, workspace rustfmt, and diff
whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-10 22:23:42 +08:00
Zyy ddc10d7c1a docs: add Apache 2.0 license and community link 2026-09-10 16:14:44 +08:00
ZyyandCodex 6bc2189e6f refactor(frontend): streamline quota reset dialog and spacing
Show remaining usage limits and an always-visible reset credit list with per-credit reset actions. Use backend-provided credit titles and quota window labels while preserving confirmation and ambiguous-result recovery.

Display credit expiration in UTC+8 without a timezone suffix. Balance modal title and reset section spacing, and remove inline baseline whitespace from account quota summary cells without changing row heights or the borderless design.

Validation: browser layout and quota popover interaction checks, expiration timezone checks, frontend typecheck, targeted ESLint, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-10 15:33:43 +08:00
ZyyandCodex b206ee8271 chore: bump version to 3.3.30
Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:28:01 +08:00
ZyyandCodex 70ff923033 fix(frontend): prevent pagination overflow on narrow screens
Adapt visible page buttons to the pagination container width while preserving the existing button styles. Hide the total count on narrow containers and keep the page-size selector on the left with navigation on the right. Retain the total count and full pager on wider layouts.

Validation: browser layout checks across 320–1280px widths and up to 1380 pages, pagination interactions and disabled states, frontend typecheck, targeted ESLint, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:26:55 +08:00
ZyyandCodex a2fbc073a4 refactor(store): consolidate database migrations into initial baseline
Fold the final columns, constraints, and indexes into the initial schema and remove superseded migrations and historical backfills. Refresh the frozen checksum manifest, simplify migration test setup, and retain canonical audit identity coverage. Keep migration documentation focused on general rules.

Validation: PostgreSQL schema dumps match before and after consolidation, all 125 PostgreSQL tests passed, and frozen checksum, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 19:11:49 +08:00
ZyyandCodex 9944041dc8 fix(openai): allow more time for WebSocket heartbeat responses
Increase the default heartbeat timeout from 5 to 30 seconds so brief upstream stalls have more time to recover during response generation. Document that heartbeat checks also apply to active pooled connections and accept any inbound frame as liveness.

Validation: WebSocket pool tests (26 passed), strict OpenAI Clippy with all targets and features, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 17:46:45 +08:00
ZyyandCodex 18a04f1213 fix(openai): restore periodic quota checks and timely reset recovery
Recheck exhausted accounts every 30 minutes even before their recorded reset, so early provider resets can be discovered. Allow each pending reset boundary to trigger an earlier check after a two-minute grace period, then return to periodic retries if exhaustion persists.

Separate quota checks from model catalog refreshes. Scan quota eligibility every 30 seconds while retaining the configured catalog interval and independent per-window recovery rules. Keep quota timing regressions in the mirrored integration-test directory and exercise the public service.

Validation: quota integration tests (46 passed), gateway application and architecture tests (36 passed), strict OpenAI Clippy with all targets and features, workspace rustfmt, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 15:39:41 +08:00
ZyyandCodex 35988d0eb7 fix(billing): correct provider pricing and service tier accounting
Use the actual response tier for OpenAI costs and persisted observations.
Replace broad model-prefix pricing with explicit variants and documented
aliases and snapshots. Retain regular Sol prices, excluding temporary
promotions, and add GPT Image 2.5 Sunburst and Flare token pricing.

Include File Search call fees and leave incomplete tool totals unpriced.
Add Grok 4.6 and confirmed Priority rates, persist the served xAI tier,
and preserve authoritative zero-cost responses. Reject inconsistent cache
counts, invalid totals and token-count overflow during local estimation.

Validation: OpenAI, xAI and gateway-protocol tests, additional billing and
compaction regressions, workspace rustfmt, strict workspace Clippy with
all targets and features, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-09 13:37:12 +08:00
ZyyandCodex 6673b6bd1e chore: bump version to 3.3.29
Co-authored-by: Codex <noreply@openai.com>
2026-09-08 17:55:36 +08:00
ZyyandCodex 2a7fd08e9e perf(ci): reuse release images and persist Cargo build caches
Share container verification between CI and release image jobs. Validate both native release architectures and publish manifests from the verified digests, eliminating the duplicate release source build.

Persist BuildKit Cargo cache mounts across CI runs, refresh release caches per commit, and generate SARIF and vulnerability gate results from one Trivy scan.

Validation: actionlint, composite action ShellCheck, embedded shell syntax, Trivy severity and malformed-report checks, and manifest digest validation passed locally. Full GitHub workflow timing remains to be measured.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 17:50:17 +08:00
ZyyandCodex 7f69e81176 docs(readme): streamline project overview and client setup
Condense the feature overview and client setup guidance, and link to detailed operational documentation for troubleshooting and maintenance.

Validation: checked local Markdown links, anchors, shell syntax and diff whitespace.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 17:36:49 +08:00
ZyyandCodex 93dc417b79 fix(observability): preserve failure causes and complete log retention
Preserve safe failure stages and causes through OpenAI and xAI transport
mapping, Core traces and persisted errors without changing replay safety.
Distinguish missing WebSocket close handshakes from TCP resets and surface
connection facts in the borderless diagnostics panel.

Retain complete UTC date groups instead of limiting segment counts. Default
application and OAuth recovery logs to seven days and full payload logs to
one day. Compress closed segments, drain bounded file queues on shutdown
and report write or maintenance failures through the logging health probe.
Normalize file names and expose an independent OAuth recovery switch that
defaults to off. Remove max_files; leave old file names for manual cleanup.

Validation: workspace backend tests with isolated PostgreSQL, final Host
and application tests (104 passed), OpenAI tests (542 passed), xAI tests
(371 passed), rustfmt and strict Clippy. Frontend lint/build, light/dark
and mobile browser checks, and Compose validation passed. Redis integration
tests were not configured and followed the existing skip convention.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 17:15:42 +08:00
ZyyandCodex f905e4b3fb chore: bump version to 3.3.28
Co-authored-by: Codex <noreply@openai.com>
2026-09-08 02:42:09 +08:00
ZyyandCodex ed3e23887b fix(openai): calculate standalone image generation costs
Estimate gpt-image-2 generation and edit costs from the official standard
API rates for text input, image input, cached input and image output.
Emit calculated costs before image delivery while preserving raw responses.
Leave unsupported models, invalid usage and ambiguous mixed cache totals
unpriced. Mark calculated total-only amounts with an approximation sign.

Validation: 1,107 related backend tests, workspace rustfmt and strict
OpenAI/gateway-api Clippy. Replaying the previously verified live usage
of 18 text input and 229 image output tokens yields exactly USD 0.00696.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 02:41:14 +08:00
ZyyandCodex 91f35d88cf fix(openai): record image usage and endpoint request models
Persist models declared by standalone image and search requests without
routing them through the text model catalog. Extract image token usage
before delivering the unchanged JSON response, preserving unknown counts
and upstream totals without double-counting image token details.

Validation: 1,474 related backend tests, workspace rustfmt and strict
Clippy passed. One authorized live gpt-image-2 generation reported 18 input,
229 output and 247 total tokens, matching the canonical usage exactly.
Search responses inspected in official source and production logs did not
report usage, so their token counts remain unknown.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 02:23:00 +08:00
ZyyandCodex f465a337e8 refactor(openai): simplify connection and endpoint module files
Restore connection.rs for production and integration tests by folding the
heartbeat tests into the existing connection test module. Keep all heartbeat
coverage while satisfying the workspace layout rules without a subdirectory.

Rename provider_endpoint.rs to endpoint.rs in production and tests. Update
module declarations, imports and frozen file manifests to match.

Validation: gateway application tests (36 passed), all gateway-api tests
(263 passed), workspace rustfmt, and strict gateway-api Clippy with all
targets and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 01:51:00 +08:00
ZyyandCodex 37465e2872 fix(tests): align WebSocket connection module layout
Move production and test connection modules to connection/mod.rs so the
heartbeat submodule follows the workspace directory module convention.
Keep the production and integration test layouts mirrored, and update the
frozen file manifests to include the heartbeat regression tests.

Validation: gateway application tests (36 passed), all gateway-api tests
(263 passed), workspace rustfmt, and strict gateway-api Clippy with all
targets and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 01:46:08 +08:00
ZyyandCodex 70c18dadd6 chore: bump version to 3.3.27
Co-authored-by: Codex <noreply@openai.com>
2026-09-08 01:37:11 +08:00
ZyyandCodex 881a0eaef6 fix(openai): keep downstream WebSocket connections alive
Send a downstream Ping every 25 seconds while responses are pending or the
connection is idle. Match the official Codex client's Pong handling without
adding a Pong deadline or extending business request timeouts. Skip missed
ticks and keep heartbeat writes bounded and cancellable.

Record Ping/Pong counts and read/write idle durations in disconnect logs.
Serialize correlation IDs as borrowed strings so gateway-api checks do not
require the serde/rc feature.

Cover silent active responses, delayed ticks, missing Pongs, blocked writes
and lifecycle cancellation. Adapt connection expiry coverage for heartbeats.

Validation: gateway-api WebSocket tests (38 passed), workspace rustfmt,
and strict gateway-api Clippy with all targets and features, without serde/rc.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 01:36:22 +08:00
ZyyandCodex 95f7a5a636 fix(openai): isolate response metadata across WebSocket reuse
Restore opening response metadata before returning a completed WebSocket to
its pool. Prevent per-response headers, model names and ETags from leaking
into subsequent exchanges and growing downstream response headers.

Match the official Codex response-scoped metadata handling while preserving
opening headers, legitimate multi-value headers and turn-state updates.
Cover 40 consecutive exchanges on one socket with a regression test that
fails on the second exchange before the fix.

Validation: provider-openai transport tests (292 passed), workspace rustfmt,
and strict provider-openai Clippy with all targets and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-08 01:10:44 +08:00
dependabot[bot] 871406ab24 chore(deps): bump the minor-and-patch group in /backend with 9 updates (#54)
Bumps the minor-and-patch group in /backend with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [tokio](https://github.com/tokio-rs/tokio) | `1.52.3` | `1.53.1` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.7.0` | `0.7.1` |
| [flate2](https://github.com/rust-lang/flate2-rs) | `1.1.9` | `1.1.10` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.15.0` | `1.16.0` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.2` | `1.6.3` |
| [aws-lc-rs](https://github.com/aws/aws-lc-rs) | `1.16.2` | `1.18.1` |
| [hyper](https://github.com/hyperium/hyper) | `1.8.1` | `1.11.1` |
| [rustls-native-certs](https://github.com/rustls/rustls-native-certs) | `0.8.3` | `0.8.4` |
| [rustls-pki-types](https://github.com/rustls/pki-types) | `1.14.0` | `1.15.1` |


Updates `tokio` from 1.52.3 to 1.53.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.53.1)

Updates `tower-http` from 0.7.0 to 0.7.1
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.7.0...tower-http-0.7.1)

Updates `flate2` from 1.1.9 to 1.1.10
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.9...1.1.10)

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

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

Updates `aws-lc-rs` from 1.16.2 to 1.18.1
- [Release notes](https://github.com/aws/aws-lc-rs/releases)
- [Commits](https://github.com/aws/aws-lc-rs/compare/v1.16.2...v1.18.1)

Updates `hyper` from 1.8.1 to 1.11.1
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.8.1...v1.11.1)

Updates `rustls-native-certs` from 0.8.3 to 0.8.4
- [Release notes](https://github.com/rustls/rustls-native-certs/releases)
- [Commits](https://github.com/rustls/rustls-native-certs/compare/v/0.8.3...v/0.8.4)

Updates `rustls-pki-types` from 1.14.0 to 1.15.1
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.14.0...v/1.15.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: tower-http
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: flate2
  dependency-version: 1.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-lc-rs
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: hyper
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: rustls-native-certs
  dependency-version: 0.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: rustls-pki-types
  dependency-version: 1.15.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 00:49:34 +08:00
dependabot[bot] 38a1cc6933 chore(deps): bump the minor-and-patch group in /frontend with 5 updates (#55)
Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue) | `1.37.0` | `1.41.0` |
| [vue-router](https://github.com/vuejs/router) | `5.3.0` | `5.3.1` |
| [@antfu/eslint-config](https://github.com/antfu/eslint-config) | `9.3.0` | `9.5.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.0` | `26.4.1` |
| [eslint](https://github.com/eslint/eslint) | `10.9.1` | `10.10.0` |


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

Updates `vue-router` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/vuejs/router/releases)
- [Commits](https://github.com/vuejs/router/compare/v5.3.0...v5.3.1)

Updates `@antfu/eslint-config` from 9.3.0 to 9.5.1
- [Release notes](https://github.com/antfu/eslint-config/releases)
- [Commits](https://github.com/antfu/eslint-config/compare/v9.3.0...v9.5.1)

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

Updates `eslint` from 10.9.1 to 10.10.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.1...v10.10.0)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-router
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@antfu/eslint-config"
  dependency-version: 9.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 10.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 00:42:23 +08:00
dependabot[bot] d38e0d4296 chore(deps): bump softprops/action-gh-release (#53)
Bumps the minor-and-patch group with 1 update: [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `softprops/action-gh-release` from 3.0.2 to 3.0.3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 00:42:11 +08:00
ZyyandCodex d12d75a037 fix(openai): preserve WebSocket continuation and surface streaming errors
Keep non-persistent downstream WebSocket requests on pooled upstream sockets,
even when opening exceeds the fast-path budget or HTTP fallback was selected.
This preserves the response state needed by subsequent previous_response_id
requests.

Select troubleshooting records by non-cancellation error_kind instead of the
request outcome. Share the predicate between the list and count queries, and
remove missing-error fallbacks so incomplete streaming errors remain visible.

Validation: provider-openai tests (532 passed), PostgreSQL observability tests
(19 passed), workspace rustfmt, and strict workspace Clippy with all targets
and features.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 12:24:28 +08:00
296 changed files with 18332 additions and 4062 deletions
+242
View File
@@ -0,0 +1,242 @@
name: Verify container
description: Scan and validate a built image with the deployment Compose configuration
inputs:
image:
description: Local image tag or immutable registry digest to validate
required: true
platform:
description: Native runner image platform
required: true
artifact-name:
description: Unique diagnostics artifact name within the workflow run
required: true
runs:
using: composite
steps:
- name: Define isolated runtime values
shell: bash
env:
IMAGE: ${{ inputs.image }}
run: |
set -euo pipefail
admin_password="$(openssl rand -hex 24)"
postgres_password="$(openssl rand -hex 24)"
redis_password="$(openssl rand -hex 24)"
echo "::add-mask::$admin_password"
echo "::add-mask::$postgres_password"
echo "::add-mask::$redis_password"
{
echo "CPR_IMAGE=$IMAGE"
echo "COMPOSE_PROJECT_NAME=cpr-check-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${RUNNER_ARCH,,}"
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
echo "CI_ADMIN_PASSWORD=$admin_password"
echo "CI_POSTGRES_PASSWORD=$postgres_password"
echo "CI_REDIS_PASSWORD=$redis_password"
} >> "$GITHUB_ENV"
- name: Scan container image
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_PLATFORM: ${{ inputs.platform }}
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
with:
image-ref: ${{ inputs.image }}
version: v0.72.0
format: json
output: trivy-results.json
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '0'
- name: Generate Trivy SARIF
shell: bash
run: trivy convert --format sarif --output trivy-results.sarif trivy-results.json
- name: Upload Trivy SARIF
if: >-
${{ always() && hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: runtime-container-${{ inputs.platform }}
- name: Enforce Trivy vulnerability gate
shell: bash
run: trivy convert --format table --severity HIGH,CRITICAL --exit-code 1 trivy-results.json
- name: Prepare isolated runtime
shell: bash
run: |
set -euo pipefail
umask 0077
# Bind roots hand off ownership to the image-specific database users.
# Sticky directories keep that initialization compatible with user-namespaced runners.
install -d -m 1777 .runtime/postgres .runtime/redis
mkdir -p "$DIAGNOSTICS_DIR"
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
.runtime/data .runtime/logs
ruby <<'RUBY'
path = "deploy/config.example.yaml"
content = File.read(path)
replacements = {
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
}
replacements.each do |placeholder, configured|
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
end
File.write("deploy/config.yaml", content)
RUBY
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
- name: Validate Docker Compose
shell: bash
run: |
set -euo pipefail
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml config --quiet
- name: Configure Redis host memory overcommit
shell: bash
run: |
set -euo pipefail
sudo sysctl -w vm.overcommit_memory=1
test "$(sysctl -n vm.overcommit_memory)" = 1
- name: Verify PostgreSQL and Redis volume persistence
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait postgres redis
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli SET cpr:ci:persistence-probe persisted
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c \
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"DROP TABLE cpr_ci_persistence_probe;"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli DEL cpr:ci:persistence-probe
- name: Start and verify application
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:8080/healthz")"
test "$status" = 204
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
- name: Verify clean logs and graceful shutdown
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" stop --timeout 30 codex-proxy-rs
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
test -n "$container_id"
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
> "$DIAGNOSTICS_DIR/application.log"
"${compose[@]}" logs --no-color --timestamps postgres \
> "$DIAGNOSTICS_DIR/postgres.log"
"${compose[@]}" logs --no-color --timestamps redis \
> "$DIAGNOSTICS_DIR/redis.log"
if grep --extended-regexp --ignore-case \
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
"$DIAGNOSTICS_DIR/application.log" \
|| grep --extended-regexp --ignore-case \
'(^|[[:space:]])(warning|error|fatal|panic):' \
"$DIAGNOSTICS_DIR/postgres.log" \
|| grep --extended-regexp --ignore-case \
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
"$DIAGNOSTICS_DIR/redis.log"; then
echo "container logs contain a warning or error severity" >&2
exit 1
fi
- name: Collect container diagnostics
if: always()
shell: bash
run: |
set +e
mkdir -p "$DIAGNOSTICS_DIR"
raw_dir="$(mktemp -d)"
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
if [ "${#container_ids[@]}" -gt 0 ]; then
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
fi
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
raw_dir = ENV.fetch("RAW_DIR")
output_dir = ENV.fetch("OUTPUT_DIR")
secrets = [
ENV.fetch("CI_ADMIN_PASSWORD"),
ENV.fetch("CI_POSTGRES_PASSWORD"),
ENV.fetch("CI_REDIS_PASSWORD"),
]
encoded_secrets = secrets.map do |secret|
secret.encode("UTF-8").bytes.map do |byte|
character = byte.chr
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
end.join
end
Dir.children(raw_dir).each do |name|
content = File.binread(File.join(raw_dir, name))
(secrets + encoded_secrets).each do |secret|
content = content.gsub(secret, "<redacted>")
end
File.binwrite(File.join(output_dir, name), content)
end
RUBY
rm -rf "$raw_dir"
cp trivy-results.json trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
- name: Upload container diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
if-no-files-found: warn
retention-days: 7
- name: Remove isolated runtime
if: always()
shell: bash
run: |
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml down --remove-orphans || true
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
+31 -223
View File
@@ -17,36 +17,44 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }}
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
- name: Define isolated runtime values
shell: bash
run: |
set -euo pipefail
admin_password="$(openssl rand -hex 24)"
postgres_password="$(openssl rand -hex 24)"
redis_password="$(openssl rand -hex 24)"
echo "::add-mask::$admin_password"
echo "::add-mask::$postgres_password"
echo "::add-mask::$redis_password"
{
echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics"
echo "CI_ADMIN_PASSWORD=$admin_password"
echo "CI_POSTGRES_PASSWORD=$postgres_password"
echo "CI_REDIS_PASSWORD=$redis_password"
} >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Cache Cargo mounts
id: cargo-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
restore-keys: |
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
# Extraction runs before actions/cache saves its directories in post steps.
- name: Restore Cargo mounts to BuildKit
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
with:
builder: ${{ steps.buildx.outputs.name }}
scratch-dir: ${{ runner.temp }}/container-cache-dance
cache-map: |
{
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
}
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
- name: Build runtime image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
@@ -61,209 +69,9 @@ jobs:
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
- name: Generate Trivy SARIF
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
- name: Verify runtime image
uses: ./.github/actions/verify-container
with:
image-ref: ${{ env.CPR_IMAGE }}
version: v0.72.0
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '0'
- name: Upload Trivy SARIF
if: >-
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: runtime-container
- name: Enforce Trivy vulnerability gate
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.CPR_IMAGE }}
version: v0.72.0
format: table
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '1'
- name: Prepare isolated runtime
shell: bash
run: |
set -euo pipefail
umask 0077
# Bind roots hand off ownership to the image-specific database users.
# Sticky directories keep that initialization compatible with user-namespaced runners.
install -d -m 1777 .runtime/postgres .runtime/redis
mkdir -p "$DIAGNOSTICS_DIR"
sudo install -d -m 0770 -o "$(id -u)" -g 10001 \
.runtime/data .runtime/logs
ruby <<'RUBY'
path = "deploy/config.example.yaml"
content = File.read(path)
replacements = {
"password: &postgres_password ''" => "password: &postgres_password '#{ENV.fetch("CI_POSTGRES_PASSWORD")}'",
"password: &redis_password ''" => "password: &redis_password '#{ENV.fetch("CI_REDIS_PASSWORD")}'",
"default_password: ''" => "default_password: '#{ENV.fetch("CI_ADMIN_PASSWORD")}'",
}
replacements.each do |placeholder, configured|
raise "missing config placeholder: #{placeholder}" unless content.sub!(placeholder, configured)
end
File.write("deploy/config.yaml", content)
RUBY
sudo chown "$(id -u):10001" deploy/config.yaml
chmod 0640 deploy/config.yaml
- name: Validate Docker Compose
shell: bash
run: |
set -euo pipefail
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml config --quiet
- name: Configure Redis host memory overcommit
shell: bash
run: |
set -euo pipefail
sudo sysctl -w vm.overcommit_memory=1
test "$(sysctl -n vm.overcommit_memory)" = 1
- name: Verify PostgreSQL and Redis volume persistence
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait postgres redis
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli SET cpr:ci:persistence-probe persisted
"${compose[@]}" up -d --no-build --wait --force-recreate postgres redis
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -At -U codex_proxy -d codex_proxy -c \
"SELECT value FROM cpr_ci_persistence_probe;")" = persisted
test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
"DROP TABLE cpr_ci_persistence_probe;"
"${compose[@]}" exec -T -e REDISCLI_AUTH="$CI_REDIS_PASSWORD" redis \
redis-cli DEL cpr:ci:persistence-probe
- name: Start and verify application
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:8080/healthz")"
test "$status" = 204
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
- name: Verify clean logs and graceful shutdown
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" stop --timeout 30 codex-proxy-rs
container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)"
test -n "$container_id"
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0
"${compose[@]}" logs --no-color --timestamps codex-proxy-rs \
> "$DIAGNOSTICS_DIR/application.log"
"${compose[@]}" logs --no-color --timestamps postgres \
> "$DIAGNOSTICS_DIR/postgres.log"
"${compose[@]}" logs --no-color --timestamps redis \
> "$DIAGNOSTICS_DIR/redis.log"
if grep --extended-regexp --ignore-case \
'"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \
"$DIAGNOSTICS_DIR/application.log" \
|| grep --extended-regexp --ignore-case \
'(^|[[:space:]])(warning|error|fatal|panic):' \
"$DIAGNOSTICS_DIR/postgres.log" \
|| grep --extended-regexp --ignore-case \
'[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \
"$DIAGNOSTICS_DIR/redis.log"; then
echo "container logs contain a warning or error severity" >&2
exit 1
fi
- name: Collect container diagnostics
if: always()
shell: bash
run: |
set +e
mkdir -p "$DIAGNOSTICS_DIR"
raw_dir="$(mktemp -d)"
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1
"${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1
"${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1
mapfile -t container_ids < <("${compose[@]}" ps -a -q)
if [ "${#container_ids[@]}" -gt 0 ]; then
docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1
fi
RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY'
raw_dir = ENV.fetch("RAW_DIR")
output_dir = ENV.fetch("OUTPUT_DIR")
secrets = [
ENV.fetch("CI_ADMIN_PASSWORD"),
ENV.fetch("CI_POSTGRES_PASSWORD"),
ENV.fetch("CI_REDIS_PASSWORD"),
]
encoded_secrets = secrets.map do |secret|
secret.encode("UTF-8").bytes.map do |byte|
character = byte.chr
character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte)
end.join
end
Dir.children(raw_dir).each do |name|
content = File.binread(File.join(raw_dir, name))
(secrets + encoded_secrets).each do |secret|
content = content.gsub(secret, "<redacted>")
end
File.binwrite(File.join(output_dir, name), content)
end
RUBY
rm -rf "$raw_dir"
cp trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true
- name: Upload container diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
if-no-files-found: warn
retention-days: 7
- name: Remove isolated runtime
if: always()
shell: bash
run: |
docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml down --remove-orphans || true
sudo rm -rf "$GITHUB_WORKSPACE/.runtime" "$GITHUB_WORKSPACE/deploy/config.yaml"
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
+1
View File
@@ -50,6 +50,7 @@ jobs:
- 'deploy/**'
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
+74 -34
View File
@@ -88,6 +88,13 @@ jobs:
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
@@ -163,15 +170,6 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
container:
needs: [release-info]
permissions:
contents: read
security-events: write
uses: ./.github/workflows/_container.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
needs: release-info
@@ -194,8 +192,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
@@ -259,8 +258,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
@@ -408,6 +408,7 @@ jobs:
contents: read
packages: write
id-token: write
security-events: write
env:
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
@@ -491,22 +492,34 @@ jobs:
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
- name: Scan exact app image digest
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_PLATFORM: ${{ matrix.dockerPlatform }}
- name: Verify exact app image digest
uses: ./.github/actions/verify-container
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
version: v0.72.0
format: table
severity: HIGH,CRITICAL
vuln-type: os,library
scanners: vuln
ignore-unfixed: true
exit-code: '1'
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
platform: ${{ matrix.dockerPlatform }}
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}
- name: Record verified image digest
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build-and-push.outputs.digest }}
IMAGE_ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
mkdir -p dist/digests
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
- name: Upload verified image digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
path: dist/digests/*.txt
if-no-files-found: error
retention-days: 1
publish:
needs: [release-info, package-assets, build-docker-image, container]
needs: [release-info, package-assets, build-docker-image]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -527,6 +540,13 @@ jobs:
name: release-assets
path: dist/release
- name: Download verified image digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: image-digest-*
path: dist/digests
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
@@ -558,7 +578,12 @@ jobs:
platform="${platform//[[:space:]]/}"
[ -n "${platform}" ] || continue
arch="${platform##*/}"
sources+=("${image}:sha-${GIT_SHA}-${arch}")
digest="$(cat "dist/digests/linux-${arch}.txt")"
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "invalid verified image digest for ${platform}" >&2
exit 1
fi
sources+=("${image}@${digest}")
done
if [ "${#sources[@]}" -eq 0 ]; then
@@ -625,15 +650,10 @@ jobs:
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
- name: Prepare release notes
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
@@ -654,6 +674,26 @@ jobs:
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+33 -65
View File
@@ -5,27 +5,32 @@
# Codex Proxy RS
集中管理 OpenAI、xAI 账号,为 Codex 和兼容客户端提供统一的访问地址与密钥。
面向 Codex 的自托管多账号 AI 网关。
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/license/mit)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [常见问题](#常见问题) · [部署说明](deploy/README.md)
[功能](#功能) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
将 OpenAI、xAI 账号接入同一个服务,通过网页管理账号、分配客户端密钥和查看使用情况。
客户端使用统一的访问地址与代理密钥,上游账号凭据由服务端管理。
## 功能
- 在网页中添加账号,查看额度、请求记录和费用,刷新或重新授权过期账号。
- 用账号分组控制不同密钥可使用的账号,设置并发和请求频率。
- 接入 Codex CLI、桌面端和支持 Responses API 的客户端。
- 支持流式对话、WebSocket、图片生成与编辑、Codex 独立搜索。
- 支持 S3/R2 数据库备份、同大版本在线更新和回滚。
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
- **部署运维**:Docker Compose 部署、S3/R2 数据库备份、同大版本在线更新与回滚。
支持 OpenAI、xAI 账号,但图片与独立搜索目前仅通过 OpenAI 提供。模型和功能是否可用取决于上游账号权限。
**不支持 `/v1/chat/completions`**;仅支持该接口的客户端不能直接接入。
图片生成、编辑和 Codex 独立搜索由 OpenAI 账号提供;模型与功能的可用性取决于上游账号权限。
> [!NOTE]
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
## 快速开始
@@ -85,76 +90,31 @@ curl -i http://127.0.0.1:8080/healthz
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)。
### 3. 添加账号和密钥
### 3. 添加账号与客户端密钥
1. 在「账号」中添加 OpenAI 或 xAI 账号。OpenAI 支持 OAuth 授权、AT/RT 和账号 JSON;xAI 支持 OAuth 授权和账号 JSON。
2. 按需创建账号分组,并把账号加入分组。
3. 创建客户端密钥,选择可用分组。**不选分组表示可使用全部账号**。
4. 点击该密钥的「使用密钥」,复制客户端配置。
只导入 AT 的账号不能自动续期。xAI 暂不支持用 API Key 导入上游账号。
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
3. 打开密钥的「使用密钥」,复制客户端配置。
## 客户端接入
### Codex CLI 和桌面端
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)。
在「使用密钥」中按系统复制 `config.toml` 和 `auth.json`,也可以通过 CCSwitch 导入。
已有文件先备份,再合并配置并重启 Codex。客户端使用代理密钥,OpenAI 账号登录状态由服务端管理。
新配置已启用原生生图。可以直接提出需求,例如:
> 帮我做一个咖啡店首页,生成一张咖啡主题配图并用到页面中。
是否调用生图由 Codex 根据任务决定,也可以明确要求生成图片。需要支持生图的客户端、模型和 OpenAI 账号;
普通对话可用不代表账号一定有生图额度。
完整配置和旧版配置迁移见 [客户端配置](deploy/README.md#客户端配置)。
### 其他客户端
选择支持 **Responses API** 的客户端,填写:
**其他 Responses API 客户端**:填写以下信息。
| 配置 | 值 |
| --- | --- |
| Base URL | `http://127.0.0.1:8080/v1`,远程使用时换成服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 密钥 |
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
可先检查密钥是否能读取模型列表:
可用模型以该密钥查询到的模型列表为准:
```bash
curl http://127.0.0.1:8080/v1/models \
-H 'Authorization: Bearer <client-api-key>'
```
接口列表与请求示例见 [接口文档](docs/api.md)。
## 常见问题
### 仍然要求登录,或无法生成图片
重新从管理端复制配置,确认修改的是客户端实际读取的用户配置目录,然后完全退出并重启客户端。
保留仅含代理密钥的 `auth.json` 不会使新配置失效。详细检查步骤见
[登录与生图排查](deploy/README.md#登录与生图排查)。
### 没有可用账号或模型
检查账号是否启用、凭据是否过期、额度是否耗尽,以及密钥绑定的分组是否包含可用账号。
「恢复账号」只清除本地错误状态,不能恢复上游权限或增加额度。
### 如何更新或查看日志
同一大版本内拉取发布镜像并重建应用容器:
```bash
docker compose -f deploy/compose.yaml pull codex-proxy-rs
docker compose -f deploy/compose.yaml up -d --no-build codex-proxy-rs
docker compose -f deploy/compose.yaml logs --tail=100 -f codex-proxy-rs
```
> [!WARNING]
> 升级前先备份。跨大版本需使用新的数据目录重新部署,不支持直接沿用旧库在线升级。
> `.runtime/` 包含数据库和运行数据,请勿删除;账号文件、密钥和日志也不要公开分享。
## 文档
- [客户端接入与生图](deploy/README.md#客户端配置)
@@ -163,3 +123,11 @@ docker compose -f deploy/compose.yaml logs --tail=100 -f codex-proxy-rs
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
## 社区
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
+36 -32
View File
@@ -154,9 +154,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -165,14 +165,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.39.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -400,9 +401,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -440,9 +441,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.2"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9"
dependencies = [
"base64-simd",
"bytes",
@@ -743,6 +744,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",
]
[[package]]
@@ -1190,7 +1192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1257,9 +1259,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -1432,6 +1434,7 @@ dependencies = [
"cron",
"futures",
"gateway-core",
"heck",
"hex",
"rand_core 0.6.4",
"secrecy",
@@ -1466,7 +1469,7 @@ dependencies = [
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"tower-http 0.7.0",
"tower-http 0.7.1",
"tracing",
"url",
"uuid",
@@ -1487,6 +1490,7 @@ dependencies = [
"sha2 0.11.0",
"thiserror",
"tracing",
"url",
"uuid",
]
@@ -1835,9 +1839,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.8.1"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
@@ -1850,7 +1854,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -1877,10 +1880,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2213,9 +2215,9 @@ dependencies = [
[[package]]
name = "miniz_oxide"
version = "0.8.9"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
@@ -2610,6 +2612,7 @@ dependencies = [
"hex",
"hyper",
"hyper-rustls",
"hyper-util",
"reqwest",
"roxmltree",
"rustls",
@@ -2627,6 +2630,7 @@ dependencies = [
"tokio-rustls",
"tokio-tungstenite 0.28.0",
"tokio-util",
"tower-service",
"tracing",
"tracing-subscriber",
"tungstenite 0.27.0",
@@ -2735,7 +2739,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3005,7 +3009,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3026,9 +3030,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -3038,9 +3042,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
@@ -3704,10 +3708,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3805,9 +3809,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -3928,9 +3932,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.7.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"bitflags",
"bytes",
+5 -1
View File
@@ -15,7 +15,7 @@ resolver = "3"
[workspace.package]
version = "0.0.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
@@ -38,3 +38,7 @@ strip = "symbols"
lto = "thin"
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" }
+1
View File
@@ -28,6 +28,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] }
serde_json = "1"
syn = { version = "3.0.3", features = ["full", "visit"] }
tempfile = "3.27.0"
tokio-rustls = { version = "=0.26.4", default-features = false }
[lints]
workspace = true
+5 -1
View File
@@ -78,7 +78,10 @@ pub async fn run() -> Result<(), BootstrapError> {
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
(
core.account_probe(),
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
),
host.client_distribution_resolver(),
host.system_operations(),
)
@@ -87,6 +90,7 @@ pub async fn run() -> Result<(), BootstrapError> {
let mut probes = store.health_probes();
probes.extend(core.health_probes());
probes.push(host.logging_health_probe());
let api = gateway_api::initialize(
api,
core.execution_service(),
@@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"proxy_probe",
"serve",
"system_update",
"workers",
+145
View File
@@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
+1
View File
@@ -19,6 +19,7 @@ bytes = "1.11.1"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
heck = "0.5.0"
hex = "0.4.3"
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
+17 -2
View File
@@ -25,7 +25,7 @@ pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -149,6 +149,7 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
@@ -163,6 +164,11 @@ pub struct AdminServices {
}
impl AdminServices {
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
}
#[must_use]
pub fn auth(&self) -> &dyn AuthService {
self.auth.as_ref()
@@ -247,10 +253,11 @@ pub async fn initialize(
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
let (probe, proxy_probe) = probes;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
@@ -290,6 +297,12 @@ pub async fn initialize(
backup_ports.object_store(),
);
let services = AdminServices {
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
snapshot.clone(),
registry.clone(),
)),
auth,
accounts,
account_groups: Arc::new(DefaultAccountGroupService::new(
@@ -316,11 +329,13 @@ pub async fn initialize(
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
backups,
@@ -18,6 +18,15 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 账号列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSortField {
@@ -89,6 +98,7 @@ pub struct AccountRecord {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
pub quota: QuotaState,
@@ -211,6 +221,7 @@ pub struct UpdateAccount {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 账号更新结果。
@@ -228,6 +239,7 @@ pub struct BatchUpdateAccounts {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 批量账号更新结果。
@@ -5,6 +5,7 @@ use std::{fmt, num::NonZeroU16};
use chrono::{DateTime, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
routing::{AccountGroupId, ProviderKind},
};
@@ -93,6 +94,7 @@ pub struct ClientKeyRecord {
pub prefix: String,
pub enabled: bool,
pub limits: RateLimits,
pub budget: ClientBudgetStatus,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -146,6 +148,7 @@ pub struct CreateClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub budget: ClientBudgetLimits,
}
/// 管理用例生成 ID 与明文后的持久化命令。
@@ -156,6 +159,7 @@ pub struct NewClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub budget: ClientBudgetLimits,
pub plaintext: String,
}
@@ -179,6 +183,8 @@ pub struct UpdateClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 修改 Client Key 启用状态。
@@ -10,6 +10,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
pub mod proxies;
pub mod settings;
pub mod system;
@@ -471,6 +471,8 @@ pub struct DashboardAccountUsage {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Admin 按 Provider 补齐的套餐展示名称。
pub plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
@@ -16,7 +16,9 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
accounts::{
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -106,6 +108,8 @@ pub struct CredentialDetails {
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub document: ProviderDocument,
}
@@ -129,6 +133,7 @@ pub struct CredentialImportResult {
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
pub struct PrepareCredentialImport {
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub document: ProviderDocument,
}
@@ -144,6 +149,7 @@ impl fmt::Debug for PrepareCredentialImport {
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
pub name: String,
@@ -171,6 +177,8 @@ pub struct PreparedCredentialImport {
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
pub settings: Option<AccountImportSettings>,
pub prepared: PreparedCredentialImport,
}
@@ -249,6 +257,8 @@ pub struct PendingAuthorizationMutation {
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
outbound_proxy_id: Option<String>,
}
impl PendingAuthorizationMutation {
@@ -262,6 +272,8 @@ impl PendingAuthorizationMutation {
provider_kind,
target,
owner_binding,
outbound_proxy: None,
outbound_proxy_id: None,
}
}
@@ -279,6 +291,29 @@ impl PendingAuthorizationMutation {
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
&self.owner_binding
}
#[must_use]
pub fn with_outbound_proxy(
mut self,
proxy: Option<gateway_core::account::OutboundProxy>,
) -> Self {
self.outbound_proxy = proxy;
self
}
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy.as_ref()
}
#[must_use]
pub fn with_outbound_proxy_id(mut self, id: Option<String>) -> Self {
self.outbound_proxy_id = id;
self
}
pub fn outbound_proxy_id(&self) -> Option<&str> {
self.outbound_proxy_id.as_deref()
}
}
impl PendingAuthorizationMutation {
@@ -300,7 +335,7 @@ impl PendingAuthorizationMutation {
AuthorizationOwner::AdminApiKey => serde_json::json!({"kind": "admin_api_key"}),
AuthorizationOwner::System => serde_json::json!({"kind": "system"}),
};
Map::from_iter([
let mut document = Map::from_iter([
(
"provider_kind".to_owned(),
Value::String(self.provider_kind.as_str().to_owned()),
@@ -311,7 +346,17 @@ impl PendingAuthorizationMutation {
"started_request_id".to_owned(),
Value::String(self.owner_binding.started_request_id().to_owned()),
),
])
]);
if let Some(proxy) = &self.outbound_proxy {
document.insert(
"outbound_proxy_url".to_owned(),
Value::String(proxy.expose_url().to_owned()),
);
}
if let Some(id) = &self.outbound_proxy_id {
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
}
document
}
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
@@ -341,6 +386,12 @@ impl PendingAuthorizationMutation {
StoredAuthorizationOwnerV1::AdminApiKey => AuthorizationOwner::AdminApiKey,
StoredAuthorizationOwnerV1::System => AuthorizationOwner::System,
};
let proxy = document
.outbound_proxy_url
.as_deref()
.map(gateway_core::account::OutboundProxy::parse)
.transpose()
.map_err(|_| invalid())?;
Ok(Self::new(
provider_kind,
target,
@@ -348,7 +399,9 @@ impl PendingAuthorizationMutation {
owner,
started_request_id: document.started_request_id,
},
))
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(document.outbound_proxy_id))
}
}
@@ -359,6 +412,10 @@ struct StoredAuthorizationMutationV1 {
target: StoredAuthorizationTargetV1,
owner: StoredAuthorizationOwnerV1,
started_request_id: String,
#[serde(default)]
outbound_proxy_url: Option<String>,
#[serde(default)]
outbound_proxy_id: Option<String>,
}
#[derive(Deserialize)]
@@ -382,6 +439,7 @@ pub struct StartAuthorization {
pub context: MutationContext,
pub name: String,
pub reauthorization: Option<ProviderAccountId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// OAuth 流程启动结果。
@@ -447,6 +505,7 @@ pub enum AuthorizationCredentialCommit {
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub settings: Option<AccountImportSettings>,
pub pending: PendingAuthorizationMutation,
pub credential: AuthorizationCredentialCommit,
}
@@ -481,7 +540,10 @@ impl PreparedAuthorizationCommit {
self
}
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
pub(crate) fn into_commit(
self,
settings: Option<AccountImportSettings>,
) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
(AuthorizationCredentialCommit::Create(credential), None)
@@ -496,6 +558,7 @@ impl PreparedAuthorizationCommit {
};
AuthorizationCommitSettlement {
command: AuthorizationCommit {
settings,
pending: self.pending,
credential,
},
@@ -509,7 +572,7 @@ impl PreparedAuthorizationCommit {
credential_guard,
authorization_guard,
..
} = self.into_commit();
} = self.into_commit(None);
drop(credential_guard);
if let Some(guard) = authorization_guard {
guard.abort().await?;
@@ -521,6 +584,7 @@ impl PreparedAuthorizationCommit {
/// 完成 Provider OAuth 流程。
#[derive(Clone, PartialEq, Eq)]
pub struct CompleteAuthorization {
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub flow_id: String,
pub callback_url: String,
@@ -718,6 +782,8 @@ pub struct ProviderQuotaRequest {
/// Provider 已解析的 quota 结果及其不透明差异字段。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuota {
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
pub plan_type: Option<String>,
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
@@ -726,6 +792,14 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
let value = value.trim();
!value.is_empty() && !value.eq_ignore_ascii_case("unknown")
})
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
@@ -840,6 +914,13 @@ pub struct ProviderResetCreditResult {
}
impl ProviderQuota {
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
}
}
/// 返回账号展开面板使用的当前额度窗口用量。
///
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
@@ -1019,6 +1100,8 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
pub plan_type_display: Option<String>,
pub projection: gateway_core::account::AccountStatusProjection,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
@@ -0,0 +1,105 @@
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use super::{PageSize, Revision, account_groups::AccountGroupRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountProxySelection {
Direct,
Url(OutboundProxy),
Saved(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportProxyBinding {
pub id: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct ProxyListQuery {
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyAccountRef {
pub id: String,
pub name: String,
pub email: Option<String>,
pub provider_kind: String,
pub authentication_kind: String,
pub plan_type: Option<String>,
pub plan_type_display: Option<String>,
pub groups: Vec<AccountGroupRef>,
pub enabled: bool,
}
/// 按代理查询关联账号,分页与搜索均在存储层执行。
#[derive(Debug, Clone)]
pub struct ProxyAccountListQuery {
pub proxy_id: String,
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone)]
pub struct ProxyAccountPage {
pub items: Vec<ProxyAccountRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub id: String,
pub name: String,
pub proxy: OutboundProxy,
pub revision: Revision,
pub account_count: u64,
pub last_test_at: Option<DateTime<Utc>>,
pub last_test: Option<ProxyTestResult>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ProxyPage {
pub items: Vec<ProxyRecord>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone)]
pub struct NewProxy {
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
pub id: String,
pub revision: Revision,
pub name: String,
pub proxy: Option<OutboundProxy>,
}
#[derive(Debug, Clone)]
pub struct ProxyMutation {
pub config_revision: Revision,
pub record: ProxyRecord,
}
@@ -3,5 +3,6 @@
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod proxy;
pub mod store;
pub mod system;
@@ -8,6 +8,7 @@ use gateway_core::{
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
use heck::ToUpperCamelCase;
use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
@@ -18,7 +19,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits,
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
};
/// Provider 管理失败的稳定分类。
@@ -40,11 +41,13 @@ pub enum ProviderAdminErrorKind {
///
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
public_message: Option<&'static str>,
}
impl std::fmt::Debug for ProviderAdminError {
@@ -53,6 +56,7 @@ impl std::fmt::Debug for ProviderAdminError {
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.field("public_message", &self.public_message)
.finish()
}
}
@@ -63,6 +67,7 @@ impl ProviderAdminError {
Self {
kind,
message: None,
public_message: None,
}
}
@@ -72,6 +77,18 @@ impl ProviderAdminError {
self
}
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
#[must_use]
pub const fn with_public_message(mut self, message: &'static str) -> Self {
self.public_message = Some(message);
self
}
#[must_use]
pub const fn public_message(&self) -> Option<&'static str> {
self.public_message
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
@@ -91,6 +108,11 @@ impl ProviderAdminError {
pub trait ProviderAdmin: Send + Sync {
fn provider_kind(&self) -> &ProviderKind;
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
}
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
///
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
@@ -230,6 +252,37 @@ impl ProviderAdminRegistry {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
pub(crate) fn resolve_account_plan(
&self,
provider_kind: &str,
plan_type: &mut Option<String>,
quota: Option<&ProviderQuota>,
) -> Option<String> {
if let Some(quota) = quota {
quota.fill_missing_plan_type(plan_type);
}
self.plan_type_display(provider_kind, plan_type.as_deref())
}
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
pub(crate) fn plan_type_display(
&self,
provider_kind: &str,
plan_type: Option<&str>,
) -> Option<String> {
let plan_type = explicit_plan_type(plan_type)?.trim();
let provider = ProviderKind::new(provider_kind.to_owned())
.ok()
.and_then(|kind| self.providers.get(&kind));
let display = provider.map_or_else(
|| plan_type.to_owned(),
|provider| provider.plan_type_display(plan_type),
);
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
Some(display.replace('+', " Plus ").to_upper_camel_case())
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
self.providers
@@ -0,0 +1,66 @@
use async_trait::async_trait;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage>;
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn record_test(
&self,
id: &str,
revision: Revision,
result: ProxyTestResult,
context: &MutationContext,
) -> AdminStoreResult<ProxyRecord>;
}
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
pub trait ProxyImportGuard: Send + Sync {}
pub struct ProxyImportReservation {
pub binding: ImportProxyBinding,
pub guard: Box<dyn ProxyImportGuard>,
}
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
}
@@ -382,6 +382,7 @@ pub struct AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
}
impl AdminAccountStorePorts {
@@ -390,11 +391,13 @@ impl AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
) -> Self {
Self {
accounts,
runtime,
groups,
proxies,
}
}
}
@@ -447,6 +450,11 @@ impl AdminStorePorts {
self.accounts.groups.clone()
}
#[must_use]
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
self.accounts.proxies.clone()
}
#[must_use]
pub fn auth(&self) -> Arc<dyn AuthStore> {
self.auth.clone()
@@ -269,7 +269,7 @@ impl DefaultAccountsService {
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
let stored = if refresh_quota {
let mut stored = if refresh_quota {
self.load_account(account_id).await?
} else {
stored
@@ -281,6 +281,11 @@ impl DefaultAccountsService {
.await?;
let usage = quota.representative_window_usage().cloned();
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
&mut stored.account.plan_type,
Some(&quota),
),
projection: stored.projection,
usage,
account: stored.account,
@@ -366,9 +371,14 @@ impl AccountsService for DefaultAccountsService {
.items
.into_iter()
.zip(quotas)
.map(|(item, quota)| {
.map(|(mut item, quota)| {
let usage = quota.representative_window_usage().cloned();
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
&mut item.account.plan_type,
Some(&quota),
),
usage,
account: item.account,
projection: item.projection,
@@ -735,6 +745,7 @@ fn map_reset_credits_error_after_refresh(
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -99,6 +99,7 @@ impl ClientKeyService for DefaultClientKeyService {
label: command.label,
group_ids: command.group_ids,
limits: command.limits,
budget: command.budget,
plaintext: plaintext.clone(),
},
context,
@@ -8,6 +8,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod xai;
@@ -83,7 +84,7 @@ fn map_provider_error(
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, message)
AdminError::new(kind, error.public_message().unwrap_or(message))
}
async fn publish_committed(
@@ -109,22 +110,58 @@ async fn required_credential(
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
required_credential(accounts, provider_kind, account_id, resource).await?;
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => AuthorizationMutationTarget::Create {
name: command.name.clone(),
},
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
@@ -132,7 +169,9 @@ async fn pending_authorization(
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
))
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
fn validate_prepared_import(
@@ -217,14 +256,24 @@ async fn validate_authorization_commit(
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit();
} = prepared.into_commit(settings);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard {
@@ -164,6 +164,11 @@ impl DefaultObservabilityService {
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
account.plan_type_display = self
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = china_day_start(observation.range.end);
let yesterday_start = today_start - Duration::days(1);
let today =
@@ -770,6 +775,7 @@ impl DefaultObservabilityService {
let quotas = futures::future::join_all(quota_reads).await;
for (account, quota) in accounts.iter_mut().zip(quotas) {
if let Some(mut quota) = quota {
quota.fill_missing_plan_type(&mut account.plan_type);
quota.apply_limit_reached_display();
account.quota_used_percent = quota.representative_used_percent();
account.quota_window = quota.representative_window().cloned();
@@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync {
pub(crate) struct DefaultOpenAiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -66,11 +67,13 @@ impl DefaultOpenAiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -142,9 +156,17 @@ impl OpenAiService for DefaultOpenAiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
drop(proxy_reservation);
self.provider
.account_facts_changed(&result.credential_ids)
.await;
@@ -160,6 +182,7 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"OpenAI credential",
@@ -173,9 +196,10 @@ impl OpenAiService for DefaultOpenAiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -191,6 +215,7 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"OpenAI authorization",
)
@@ -0,0 +1,261 @@
use std::sync::Arc;
use async_trait::async_trait;
use futures::{StreamExt, stream};
use gateway_core::{
account::{OutboundProxy, ProviderAccountId},
routing::ProviderKind,
runtime::SnapshotControl,
};
use tokio::sync::Semaphore;
use super::{map_store_error, publish_committed};
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
proxies::*,
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError>;
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
}
pub(crate) struct DefaultProxiesService {
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
test_slots: Semaphore,
}
impl DefaultProxiesService {
pub(crate) fn new(
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
probe,
snapshot,
providers,
test_slots: Semaphore::new(4),
}
}
}
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let account_id = ProviderAccountId::new(account_id.to_owned())
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
let revision = self
.store
.remove_account(proxy_id, &account_id, context)
.await
.map_err(|error| {
if error.kind() == AdminStoreErrorKind::Conflict {
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
} else {
map_store_error(error, "proxy account")
}
})?;
publish_committed(self.snapshot.as_ref(), revision).await?;
Ok(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError> {
if query.proxy_id.is_empty()
|| query.proxy_id.len() > 128
|| query.proxy_id.chars().any(char::is_control)
|| query.page == 0
|| query.search.len() > 256
|| query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("关联账号查询参数不合法"));
}
let mut page = self
.store
.list_accounts(query)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
page.items = stream::iter(page.items)
.map(|mut account| async {
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
let mut cached_quota = None;
if explicit_plan_type(account.plan_type.as_deref()).is_none()
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
&& let Ok(provider) = self.providers.require(&kind)
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
&& let Ok(quota) = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
{
cached_quota = Some(quota);
}
account.plan_type_display = self.providers.resolve_account_plan(
&account.provider_kind,
&mut account.plan_type,
cached_quota.as_ref(),
);
account
})
.buffered(8)
.collect()
.await;
Ok(page)
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
async fn create(
&self,
mut command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.create(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn update(
&self,
mut command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.update(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let result = self
.store
.delete(id, revision, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result).await?;
Ok(result)
}
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
Ok(self.probe.test(proxy).await)
}
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if record.revision != revision {
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
}
let result = self.probe.test(&record.proxy).await;
self.store
.record_test(id, revision, result, context)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
}
fn validate_name(value: &str) -> Result<String, AdminError> {
let value = value.trim();
if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) {
return Err(AdminError::invalid("代理名称需要 1 至 100 个字符"));
}
Ok(value.to_owned())
}
@@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync {
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -57,11 +58,13 @@ impl DefaultXaiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -95,9 +109,17 @@ impl XaiService for DefaultXaiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
drop(proxy_reservation);
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
for account_id in &result.credential_ids {
@@ -119,6 +141,7 @@ impl XaiService for DefaultXaiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"xAI credential",
@@ -132,9 +155,10 @@ impl XaiService for DefaultXaiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -150,6 +174,7 @@ impl XaiService for DefaultXaiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"xAI authorization",
)
@@ -121,6 +121,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -141,6 +142,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -177,6 +179,7 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
models: Vec::new(),
};
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -204,6 +207,7 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -231,6 +235,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
@@ -10,6 +10,7 @@ use serde_json::{Value, json};
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -96,7 +97,21 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
PendingAuthorizationMutation::from_storage_v1(Value::Object(
expected.to_storage_v1()
)),
Ok(expected),
Ok(expected.clone()),
);
let with_proxy = expected
.with_outbound_proxy(Some(
gateway_core::account::OutboundProxy::parse(
"http://user:secret@proxy.example:8080",
)
.unwrap(),
))
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
with_proxy.to_storage_v1()
)),
Ok(with_proxy)
);
}
}
@@ -105,6 +105,18 @@ impl FakeProviderAdmin {
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_with_public_message(
&self,
kind: ProviderAdminErrorKind,
message: &'static str,
) {
*self.failure.lock().expect("provider failure") = Some(
ProviderAdminError::new(kind)
.with_message("upstream body containing a secret token")
.with_public_message(message),
);
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
*self.quota_failure.lock().expect("provider quota failure") = Some(kind);
}
@@ -201,6 +213,10 @@ impl ProviderAdmin for FakeProviderAdmin {
&self.kind
}
fn plan_type_display(&self, plan_type: &str) -> String {
format!("{} display: {plan_type}", self.kind)
}
async fn account_unavailable(&self, _: &ProviderAccountId) {
self.record("provider.account_unavailable");
}
@@ -425,6 +441,7 @@ pub(super) struct FakeAccountStore {
account_after_probe: Mutex<Option<AccountRecord>>,
fail_commit: Mutex<bool>,
audit_requests: Mutex<Vec<String>>,
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
}
@@ -441,6 +458,7 @@ impl FakeAccountStore {
account_after_probe: Mutex::new(None),
fail_commit: Mutex::new(false),
audit_requests: Mutex::new(Vec::new()),
import_settings: Mutex::new(Vec::new()),
quota_window_usage: Mutex::new(Vec::new()),
quota_window_queries: Mutex::new(Vec::new()),
})
@@ -450,6 +468,15 @@ impl FakeAccountStore {
*self.fail_commit.lock().expect("store failure") = true;
}
pub(super) fn import_settings(
&self,
) -> Vec<Option<gateway_admin::model::accounts::AccountImportSettings>> {
self.import_settings
.lock()
.expect("import settings")
.clone()
}
pub(super) fn audit_requests(&self) -> Vec<String> {
self.audit_requests.lock().expect("audit requests").clone()
}
@@ -656,6 +683,10 @@ impl AccountStore for FakeAccountStore {
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_import");
self.record_context(context);
self.require_commit()?;
@@ -675,6 +706,10 @@ impl AccountStore for FakeAccountStore {
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_authorization");
self.record_context(context);
self.require_commit()?;
@@ -1213,6 +1248,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
@@ -1249,6 +1285,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
@@ -1290,6 +1327,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
@@ -1320,9 +1358,11 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
#[tokio::test]
async fn accounts_list_should_return_complete_directory_semantics() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
let page = accounts_service(provider, store)
.await
let mut stored = account_record("openai");
stored.plan_type = Some(" self_serve_business_prolite ".to_owned());
let store = FakeAccountStore::with_account(stored, events());
let services = accounts_service(provider, store).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
@@ -1344,6 +1384,91 @@ async fn accounts_list_should_return_complete_directory_semantics() {
account.projection.status,
gateway_admin::model::accounts::AccountStatus::Normal
);
assert_eq!(
(
account.account.plan_type.as_deref(),
account.plan_type_display.as_deref(),
),
(
Some(" self_serve_business_prolite "),
Some("OpenaiDisplaySelfServeBusinessProlite"),
)
);
let detail = services
.accounts()
.quota(
&ProviderAccountId::new("acct_test").expect("account ID"),
false,
)
.await
.expect("account detail");
assert_eq!(detail.plan_type_display, account.plan_type_display);
assert_eq!(detail.account.plan_type, account.account.plan_type);
}
#[tokio::test]
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
for (stored_plan, quota_plan, expected, expected_display) in [
(None, Some("free"), Some("free"), Some("OpenaiDisplayFree")),
(
Some(" "),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("unknown"),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("self_serve_business_prolite"),
Some("team"),
Some("self_serve_business_prolite"),
Some("OpenaiDisplaySelfServeBusinessProlite"),
),
(None, None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: quota_plan.map(str::to_owned),
..empty_quota()
});
let mut stored = account_record("openai");
stored.plan_type = stored_plan.map(str::to_owned);
let store = FakeAccountStore::with_account(stored, events());
let services = accounts_service(provider, store).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.expect("account list");
let account = page.items.first().expect("account item");
assert_eq!(account.account.plan_type.as_deref(), expected);
assert_eq!(account.plan_type_display.as_deref(), expected_display);
for refresh in [false, true] {
let detail = services
.accounts()
.quota(
&ProviderAccountId::new("acct_test").expect("account ID"),
refresh,
)
.await
.expect("account quota detail");
assert_eq!(detail.account.plan_type.as_deref(), expected);
assert_eq!(detail.plan_type_display, account.plan_type_display);
}
}
}
#[tokio::test]
@@ -1351,6 +1476,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1482,6 +1608,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1529,6 +1656,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1577,6 +1705,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1643,6 +1772,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![
@@ -1897,6 +2027,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -1931,6 +2062,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: None,
id: "acct_test".to_owned(),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
groups: Vec::new(),
@@ -1972,6 +2104,7 @@ fn prepared_create_with_id(
) -> PreparedCredentialCreate {
let now = Utc::now();
PreparedCredentialCreate {
outbound_proxy: None,
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
provider_kind,
name: name.to_owned(),
@@ -2098,3 +2231,17 @@ fn store_unavailable() -> AdminStoreError {
fn unsupported() -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
}
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
enabled: false,
concurrency_limit: Some(
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
),
weight: gateway_core::account::AccountWeight::new(7).expect("weight"),
group_ids: vec![
gateway_core::routing::AccountGroupId::new("grp_00000000000000000000000000000091")
.expect("group ID"),
],
}
}
@@ -5,6 +5,7 @@ mod backup;
mod client_keys;
mod observability;
mod openai;
mod proxies;
mod settings;
mod system;
mod xai;
@@ -82,6 +83,7 @@ pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
@@ -101,6 +103,7 @@ impl AdminHarness {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
@@ -179,6 +182,14 @@ impl AdminHarness {
self
}
pub(super) fn proxies(
mut self,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
) -> Self {
self.proxies = proxies;
self
}
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
self.system = system;
self
@@ -196,6 +207,7 @@ impl AdminHarness {
self.accounts,
self.account_runtime,
self.account_groups,
self.proxies,
),
self.auth,
self.client_keys,
@@ -205,7 +217,7 @@ impl AdminHarness {
),
self.providers,
Arc::new(NoopSnapshot),
self.probe,
(self.probe, Arc::new(proxies::TestProxies::default())),
Arc::new(NoopClientDistribution),
self.system,
)
@@ -50,6 +50,8 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-openai"),
document: document(),
})
@@ -75,9 +77,52 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-openai"]);
}
#[tokio::test]
async fn openai_import_should_expose_only_explicit_public_errors_without_committing() {
use gateway_admin::model::AdminErrorKind;
for (provider_kind, admin_kind) in [
(ProviderAdminErrorKind::Invalid, AdminErrorKind::Invalid),
(
ProviderAdminErrorKind::Unavailable,
AdminErrorKind::Unavailable,
),
(
ProviderAdminErrorKind::BadGateway,
AdminErrorKind::BadGateway,
),
] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider
.fail_next_with_public_message(provider_kind, "Codex PAT 验证失败,请检查令牌后重试");
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
let error = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-pat-failure"),
document: document(),
})
.await
.expect_err("validation must fail before committing accounts");
assert_eq!(error.kind(), admin_kind);
assert_eq!(error.to_string(), "Codex PAT 验证失败,请检查令牌后重试");
assert!(!format!("{error:?}").contains("secret token"));
assert_eq!(recorded(&events), ["provider.prepare_import"]);
assert!(store.audit_requests().is_empty());
}
}
#[tokio::test]
async fn openai_import_should_refresh_quota_for_every_imported_account() {
let events = events();
@@ -89,6 +134,8 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-batch"),
document: document(),
})
@@ -132,6 +179,8 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-quota-failure"),
document: document(),
})
@@ -157,6 +206,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-create"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -166,6 +216,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -193,6 +244,10 @@ async fn openai_authorization_create_should_observe_initial_quota() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
}
@@ -207,6 +262,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-quota-failure"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -216,6 +272,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -238,6 +295,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
store.fail_next_commit();
let services = service(provider.clone(), store).await;
let command = || CompleteAuthorization {
settings: None,
context: context("oauth-store-retry"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -246,6 +304,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-store-retry"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -293,6 +352,8 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-error"),
document: document(),
})
@@ -311,6 +372,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai"),
name: "reauthorize".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
@@ -336,6 +398,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -387,3 +450,34 @@ fn deletion(account_id: &str) -> CredentialDeletion {
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
}
}
#[tokio::test]
async fn reauthorization_with_import_settings_releases_claim_without_committing() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("reauthorization-settings"),
name: "existing account".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
})
.await
.expect("start reauthorization");
let error = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("reauthorization-settings"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect_err("reauthorization must preserve settings");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
assert!(!recorded(&events).contains(&"store.commit_authorization"));
assert!(store.import_settings().is_empty());
}
@@ -0,0 +1,227 @@
use async_trait::async_trait;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreResult,
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
#[derive(Default)]
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
}
struct ImportGuard(super::accounts::EventLog);
impl gateway_admin::ports::proxy::ProxyImportGuard for ImportGuard {}
impl Drop for ImportGuard {
fn drop(&mut self) {
self.0.lock().unwrap().push("proxy.release");
}
}
#[async_trait]
impl ProxyStore for TestProxies {
async fn remove_account(
&self,
_: &str,
_: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn reserve_import(
&self,
id: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
let events = self
.events
.as_ref()
.ok_or_else(|| super::unavailable("proxy"))?;
events.lock().unwrap().push("proxy.reserve");
Ok(gateway_admin::ports::proxy::ProxyImportReservation {
binding: ImportProxyBinding {
id: id.to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
},
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
let items = self
.accounts
.clone()
.ok_or_else(|| super::unavailable("proxy"))?;
Ok(ProxyAccountPage {
total: items.len() as u64,
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn record_test(
&self,
_: &str,
_: Revision,
_: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: &OutboundProxy) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota};
use std::sync::Arc;
for (stored, cached, expected) in [
(None, Some("free"), Some("free")),
(Some("unknown"), Some("free"), Some("free")),
(Some("plus"), Some("free"), Some("plus")),
(None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
windows: vec![],
limit_reached: false,
provider_data: None,
});
let services = super::AdminHarness::new()
.provider(provider.clone())
.proxies(Arc::new(TestProxies {
accounts: Some(vec![ProxyAccountRef {
id: "acct_plan".to_owned(),
name: "套餐测试".to_owned(),
email: None,
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: stored.map(str::to_owned),
plan_type_display: None,
groups: vec![],
enabled: true,
}]),
..Default::default()
}))
.build()
.await;
let result = services
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: "proxy_plan".to_owned(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap();
assert_eq!(result.items[0].plan_type.as_deref(), expected);
assert_eq!(
result.items[0].plan_type_display.as_deref(),
match expected {
Some("free") => Some("OpenaiDisplayFree"),
Some("plus") => Some("OpenaiDisplayPlus"),
_ => None,
}
);
let requests = provider.quota_requests();
assert_eq!(requests.len(), usize::from(stored != Some("plus")));
assert!(requests.iter().all(|request| !request.refresh));
}
}
#[tokio::test]
async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() {
use super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
};
use gateway_admin::model::provider_credentials::ImportCredentials;
use gateway_admin::ports::provider::ProviderAdminErrorKind;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for failure in [None, Some("prepare"), Some("commit")] {
let events = events();
let provider = FakeProviderAdmin::new(kind, events.clone());
let store = FakeAccountStore::new(kind, events.clone());
if failure == Some("prepare") {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
if failure == Some("commit") {
store.fail_next_commit();
}
let services = super::AdminHarness::new()
.provider(provider)
.accounts(store)
.proxies(Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
let command = ImportCredentials {
outbound_proxy_id: Some("proxy_import".to_owned()),
settings: None,
context: context("reserved-import"),
document: document(),
};
let result = if kind == "openai" {
services.openai().import_document(command).await
} else {
services.xai().import_document(command).await
};
assert_eq!(result.is_err(), failure.is_some());
let events = recorded(&events);
let expected = if failure == Some("prepare") {
vec!["proxy.reserve", "provider.prepare_import", "proxy.release"]
} else {
vec![
"proxy.reserve",
"provider.prepare_import",
"store.commit_import",
"proxy.release",
]
};
assert_eq!(&events[..expected.len()], expected);
}
}
}
@@ -20,6 +20,8 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-xai"),
document: document(),
})
@@ -42,6 +44,10 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-xai"]);
}
@@ -56,6 +62,8 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-batch"),
document: document(),
})
@@ -90,6 +98,8 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-quota-failure"),
document: document(),
})
+1 -1
View File
@@ -2,7 +2,7 @@
name = "gateway-api"
version = "0.1.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
autotests = false
@@ -18,16 +18,55 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
Ok(())
}
fn into_settings(
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(id) = &self.outbound_proxy_id {
require_wire_id(id, "outboundProxyId")?;
}
if let Some(settings) = &self.settings {
settings.validate()?;
}
AccountProvider::parse(&self.provider)?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
@@ -47,6 +86,11 @@ impl AccountImportRequest {
Ok((
provider,
ImportCredentials {
outbound_proxy_id: self.outbound_proxy_id,
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
document: provider_document(self.data, "data")?,
},
@@ -57,9 +101,11 @@ impl AccountImportRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub outbound_proxy_id: Option<String>,
pub provider: String,
pub name: String,
pub account_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
}
impl StartAccountAuthorizationRequest {
@@ -89,6 +135,10 @@ impl StartAccountAuthorizationRequest {
context,
name: self.name,
reauthorization,
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
},
))
}
@@ -97,6 +147,7 @@ impl StartAccountAuthorizationRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub flow_id: String,
pub callback_url: String,
@@ -104,6 +155,9 @@ pub struct CompleteAccountAuthorizationRequest {
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(settings) = &self.settings {
settings.validate()?;
}
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
@@ -128,6 +182,10 @@ impl CompleteAccountAuthorizationRequest {
Ok((
provider,
CompleteAuthorization {
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
@@ -139,6 +197,8 @@ impl CompleteAccountAuthorizationRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -159,6 +219,10 @@ impl UpdateAccountRequest {
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
self.validate()?;
Ok(UpdateAccount {
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
account_id: self.account_id,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -59,6 +59,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
plan_type_display,
projection,
usage,
quota,
@@ -89,6 +90,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
user_id: account.upstream_user_id,
label: None,
plan_type: account.plan_type,
plan_type_display: plan_type_display.unwrap_or_else(|| "未知套餐".to_owned()),
authentication_kind: account.authentication_kind,
has_refresh_token: account.has_refresh_token,
status,
@@ -99,6 +101,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
outbound_proxy_endpoint: account
.outbound_proxy
.as_ref()
.map(|proxy| proxy.endpoint()),
access_token_expires_at: expires_at,
access_token_expires_at_display: account
.access_token_expires_at
@@ -2,6 +2,48 @@
use super::*;
#[derive(Debug, Clone)]
pub struct AccountProxyUpdate(pub Option<gateway_core::account::OutboundProxy>);
impl<'de> Deserialize<'de> for AccountProxyUpdate {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value.is_empty() {
return Ok(Self(None));
}
gateway_core::account::OutboundProxy::parse(&value)
.map(|proxy| Self(Some(proxy)))
.map_err(serde::de::Error::custom)
}
}
pub(super) fn proxy_selection(
id: Option<String>,
url: Option<AccountProxyUpdate>,
) -> Result<Option<gateway_admin::model::proxies::AccountProxySelection>, WireValidationError> {
use gateway_admin::model::proxies::AccountProxySelection;
match (id, url) {
(Some(_), Some(_)) => Err(WireValidationError::new("outboundProxyId")),
(Some(id), None) if id.is_empty() => Ok(Some(AccountProxySelection::Direct)),
(Some(id), None) => {
if id.len() > 128
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(WireValidationError::new("outboundProxyId"));
}
Ok(Some(AccountProxySelection::Saved(id)))
}
(None, Some(value)) => Ok(Some(
value
.0
.map_or(AccountProxySelection::Direct, AccountProxySelection::Url),
)),
(None, None) => Ok(None),
}
}
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -19,6 +61,8 @@ pub struct ListQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -68,6 +112,7 @@ impl BatchUpdateAccountsRequest {
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -192,6 +237,7 @@ pub struct AccountSummaryView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
pub provider: String,
@@ -202,6 +248,8 @@ pub struct AccountView {
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
/// 后端生成的套餐展示名称;缺失套餐时为“未知套餐”。
pub plan_type_display: String,
pub authentication_kind: String,
pub has_refresh_token: bool,
pub status: String,
@@ -11,6 +11,8 @@ use gateway_admin::model::client_keys::{
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
routing::AccountGroupId,
};
@@ -33,6 +35,15 @@ const MAX_CURSOR_BYTES: usize = 512;
const MAX_SEARCH_BYTES: usize = 256;
const DEFAULT_PAGE_SIZE: u16 = 50;
fn parse_budget(
value: Option<String>,
field: &'static str,
) -> Result<Option<Decimal>, WireValidationError> {
value
.map(|value| value.parse().map_err(|_| WireValidationError::new(field)))
.transpose()
}
/// Client Key 列表查询。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -140,6 +151,8 @@ pub struct CreateClientKeyRequest {
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: Option<String>,
weekly_limit_usd: Option<String>,
}
impl CreateClientKeyRequest {
@@ -154,6 +167,11 @@ impl CreateClientKeyRequest {
name: self.name,
label: self.label,
group_ids,
budget: ClientBudgetLimits {
daily_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?.unwrap_or_default(),
weekly_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?
.unwrap_or_default(),
},
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -172,6 +190,8 @@ pub struct UpdateClientKeyRequest {
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: Option<String>,
weekly_limit_usd: Option<String>,
}
impl UpdateClientKeyRequest {
@@ -188,6 +208,8 @@ impl UpdateClientKeyRequest {
name: self.name,
label: self.label,
group_ids,
daily_limit_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?,
weekly_limit_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -247,6 +269,12 @@ pub struct ClientKeyView {
enabled: bool,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: String,
weekly_limit_usd: String,
daily_used_usd: String,
weekly_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
weekly_resets_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
@@ -292,6 +320,12 @@ impl From<ClientKeyRecord> for ClientKeyView {
enabled: record.enabled,
max_concurrency: record.limits.max_concurrency,
requests_per_minute: record.limits.requests_per_minute,
daily_limit_usd: record.budget.limits.daily_usd.canonical(),
weekly_limit_usd: record.budget.limits.weekly_usd.canonical(),
daily_used_usd: record.budget.daily_used_usd.canonical(),
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
created_at: record.created_at,
updated_at: record.updated_at,
last_used_at: record.last_used_at,
@@ -16,6 +16,7 @@ pub mod client_keys;
mod extract;
pub mod observability;
pub mod presenter;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod wire;
@@ -34,6 +35,7 @@ where
{
Router::new()
.merge(account_groups::router::<S>())
.merge(proxies::router::<S>())
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(backups::router::<S>())
@@ -213,13 +213,17 @@ fn capitalize_first(value: &str) -> String {
pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<BillingView> {
match billing? {
domain::UsageBilling::Total { total, .. } => Some(BillingView {
domain::UsageBilling::Total { source, total } => Some(BillingView {
input_amount_display: "—".to_owned(),
output_amount_display: "—".to_owned(),
cache_read_amount_display: "—".to_owned(),
cache_write_amount_display: "—".to_owned(),
standard_amount_display: "—".to_owned(),
total_amount_display: format_money(total),
total_amount_display: if source == "calculated" {
format!("≈ {}", format_money(total))
} else {
format_money(total)
},
input_price_display: "—".to_owned(),
output_price_display: "—".to_owned(),
cache_read_price_display: "—".to_owned(),
@@ -896,6 +900,10 @@ pub(crate) fn dashboard_view(
.clone()
.unwrap_or_else(|| credential.name.clone()),
plan_type: credential.plan_type.clone(),
plan_type_display: credential
.plan_type_display
.clone()
.unwrap_or_else(|| "未知套餐".to_owned()),
tokens: credential
.total_tokens
.map_or_else(|| "—".to_owned(), format_compact_number),
@@ -373,6 +373,7 @@ pub struct DashboardAccountUsageView {
pub authentication_kind: String,
pub email: String,
pub plan_type: Option<String>,
pub plan_type_display: String,
pub tokens: String,
pub request_count: u64,
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
@@ -0,0 +1,452 @@
use axum::{
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use gateway_admin::model::{
PageSize, Revision,
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
ProxyTestResult, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ListQuery {
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AccountsQuery {
proxy_id: String,
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RemoveAccountRequest {
proxy_id: String,
account_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
name: String,
proxy_url: AccountProxyUpdate,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct IdRequest {
id: String,
revision: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProbeRequest {
proxy_url: AccountProxyUpdate,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
message: String,
}
impl From<ProxyTestResult> for ProxyTestView {
fn from(result: ProxyTestResult) -> Self {
Self {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
message: result.message,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
id: String,
name: String,
email: Option<String>,
provider: String,
authentication_kind: String,
plan_type: Option<String>,
plan_type_display: Option<String>,
groups: Vec<AccountGroupRefView>,
enabled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
id: String,
name: String,
endpoint: String,
has_authentication: bool,
revision: u64,
account_count: u64,
last_test_at: Option<String>,
last_test: Option<ProxyTestView>,
created_at: String,
updated_at: String,
}
impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
let endpoint = record.proxy.endpoint();
Self {
id: record.id,
name: record.name,
has_authentication: record.proxy.expose_url() != endpoint,
endpoint,
revision: record.revision.get(),
account_count: record.account_count,
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
last_test: record.last_test.map(Into::into),
created_at: record.created_at.to_rfc3339(),
updated_at: record.updated_at.to_rfc3339(),
}
}
}
#[derive(Serialize)]
struct ProxyPageView {
items: Vec<ProxyView>,
page: PageMeta,
}
#[derive(Serialize)]
struct ProxyAccountPageView {
items: Vec<ProxyAccountView>,
page: PageMeta,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MutationView {
record: ProxyView,
config_revision: u64,
}
impl From<ProxyMutation> for MutationView {
fn from(value: ProxyMutation) -> Self {
Self {
record: value.record.into(),
config_revision: value.config_revision.get(),
}
}
}
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/proxies", get(list::<S>))
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
.route(
"/api/admin/proxies/accounts/remove",
post(remove_account::<S>),
)
.route("/api/admin/proxies/create", post(create::<S>))
.route("/api/admin/proxies/update", post(update::<S>))
.route("/api/admin/proxies/delete", post(delete::<S>))
.route("/api/admin/proxies/test", post(test::<S>))
.route("/api/admin/proxies/probe", post(probe::<S>))
}
fn revision(value: u64) -> Result<Revision, AdminError> {
if value > i64::MAX as u64 {
return Err(AdminError::bad_request("代理版本不合法"));
}
Revision::new(value).map_err(|_| AdminError::bad_request("代理版本不合法"))
}
fn map_error(error: gateway_admin::model::AdminError) -> AdminError {
super::wire::map_admin_service_error(error)
}
async fn list<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list(ProxyListQuery {
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyPageView {
items: result.items.into_iter().map(Into::into).collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn list_accounts<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: query.proxy_id,
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyAccountPageView {
items: result
.items
.into_iter()
.map(|account| ProxyAccountView {
id: account.id,
name: account.name,
email: account.email,
provider: account.provider_kind,
authentication_kind: account.authentication_kind,
plan_type: account.plan_type,
plan_type_display: account.plan_type_display,
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
enabled: account.enabled,
})
.collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.create(
NewProxy {
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn remove_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RemoveAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let revision = state
.admin_services()
.proxies()
.remove_account(
&request.proxy_id,
&request.account_id,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})),
))
}
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.map(|value| {
value
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))
})
.transpose()?;
let result = state
.admin_services()
.proxies()
.update(
UpdateProxy {
id: request.id,
revision: revision(request.revision)?,
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.delete(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": result.get()})),
))
}
async fn probe<S>(
_: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<ProbeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.probe(&proxy)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyTestView::from(result)),
))
}
async fn test<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.test(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from(result)),
))
}
+16 -2
View File
@@ -143,13 +143,27 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
/// Gateway 错误的 OpenAI HTTP 表达。
pub fn gateway_error_response(error: &GatewayError) -> Response {
let (status, default_type, default_code) = gateway_error_contract(error.kind());
openai_error_response(
let mut response = openai_error_response(
status,
error.client_message(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
)
.into_response()
.into_response();
if let Some(delay) = error.retry_after()
&& let Ok(value) = HeaderValue::from_str(
&delay
.as_secs()
.saturating_add(u64::from(delay.subsec_nanos() > 0))
.max(1)
.to_string(),
)
{
response
.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
@@ -15,8 +15,8 @@ use serde_json::Value;
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
endpoint::collect_raw_json_response,
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::{OpenAiRequestHeaders, request_client_context},
};
+1 -1
View File
@@ -1,10 +1,10 @@
//! OpenAI 客户端协议 adapter。
pub mod auth;
mod endpoint;
pub mod error;
pub mod images;
pub mod models;
mod provider_endpoint;
pub mod responses;
pub mod router;
pub mod search;
@@ -23,6 +23,7 @@ const OUTBOUND_COMMAND_BUFFER: usize = 32;
const INBOUND_EVENT_BUFFER: usize = 32;
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
const DOWNSTREAM_PING_INTERVAL: Duration = Duration::from_secs(25);
/// WebSocket pump 的写入与生命周期预算。
#[derive(Clone, Copy)]
@@ -171,9 +172,27 @@ struct ConnectionStats {
command_backpressure_count: AtomicU64,
ping_received_count: AtomicU64,
pong_written_count: AtomicU64,
ping_written_count: AtomicU64,
pong_received_count: AtomicU64,
last_read_ms: AtomicU64,
last_write_ms: AtomicU64,
}
impl ConnectionStats {
fn record_read(&self, opened_at: Instant) {
self.last_read_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
fn record_write(&self, opened_at: Instant) {
self.last_write_ms.store(
u64::try_from(opened_at.elapsed().as_millis()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
fn observe_command_queue(&self, sender: &mpsc::Sender<ConnectionCommand>) {
let queued = OUTBOUND_COMMAND_BUFFER
.saturating_sub(sender.capacity())
@@ -400,10 +419,15 @@ impl ResponsesWebSocketConnection {
}
pub(super) fn log_summary(&self, request_count: u64) {
let connection_age_ms = self.age().as_millis();
let read_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_read_ms.load(Ordering::Relaxed)));
let write_idle_ms = connection_age_ms
.saturating_sub(u128::from(self.stats.last_write_ms.load(Ordering::Relaxed)));
tracing::info!(
websocket_connection_id = %self.connection_id,
request_count,
connection_age_ms = self.age().as_millis(),
connection_age_ms,
pump_exit_reason = self
.exit_reason
.map_or("unknown", PumpExitReason::as_str),
@@ -417,6 +441,10 @@ impl ResponsesWebSocketConnection {
.load(Ordering::Relaxed),
ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed),
pong_written_count = self.stats.pong_written_count.load(Ordering::Relaxed),
ping_written_count = self.stats.ping_written_count.load(Ordering::Relaxed),
pong_received_count = self.stats.pong_received_count.load(Ordering::Relaxed),
read_idle_ms,
write_idle_ms,
"Responses WebSocket disconnected"
);
}
@@ -493,6 +521,12 @@ async fn run_pump<S, E>(
let deadline = tokio::time::sleep_until(opened_at + config.max_age);
tokio::pin!(deadline);
let mut deadline_elapsed = false;
let mut heartbeat = tokio::time::interval_at(
opened_at + DOWNSTREAM_PING_INTERVAL,
DOWNSTREAM_PING_INTERVAL,
);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ping_sequence = 0_u64;
let reason = loop {
tokio::select! {
biased;
@@ -531,6 +565,9 @@ async fn run_pump<S, E>(
ConnectionWriteError::Transport { message }
});
let failed = result.is_err();
if !failed {
stats.record_write(opened_at);
}
let _ = acknowledged.send(result);
if failed {
break PumpExitReason::WriteError;
@@ -539,45 +576,79 @@ async fn run_pump<S, E>(
break PumpExitReason::ServerClose;
}
}
message = socket.next() => match message {
Some(Ok(Message::Text(payload))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
break reason;
// 官方 Codex WsStream 在独立 pump 中回复 Pong,且不将控制帧交给业务流。
// 这里只为下游链路保活,不设置 Pong deadline,也不延长业务请求预算。
_ = heartbeat.tick() => {
ping_sequence = ping_sequence.wrapping_add(1);
let ping = Message::Ping(ping_sequence.to_be_bytes().to_vec().into());
let result = tokio::select! {
biased;
() = cancellation.cancelled() => break PumpExitReason::LifecycleShutdown,
result = timeout(config.write_timeout, socket.send(ping)) => result,
};
match result {
Ok(Ok(())) => {
stats.ping_written_count.fetch_add(1, Ordering::Relaxed);
stats.record_write(opened_at);
}
}
Some(Ok(Message::Binary(_))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
break reason;
Ok(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket Ping write failed"
);
break PumpExitReason::WriteError;
}
Err(_) => break PumpExitReason::WriteTimeout,
}
Some(Ok(Message::Ping(payload))) => {
stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await {
Ok(Ok(())) => {
stats.pong_written_count.fetch_add(1, Ordering::Relaxed);
}
message = socket.next() => {
if matches!(&message, Some(Ok(_))) {
stats.record_read(opened_at);
}
match message {
Some(Ok(Message::Text(payload))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Text(payload.to_string())) {
break reason;
}
Ok(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket Pong write failed"
);
break PumpExitReason::WriteError;
}
Err(_) => break PumpExitReason::WriteTimeout,
}
Some(Ok(Message::Binary(_))) => {
if let Err(reason) = emit_incoming(&incoming, ConnectionEvent::Binary) {
break reason;
}
}
Some(Ok(Message::Ping(payload))) => {
stats.ping_received_count.fetch_add(1, Ordering::Relaxed);
match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await {
Ok(Ok(())) => {
stats.pong_written_count.fetch_add(1, Ordering::Relaxed);
stats.record_write(opened_at);
}
Ok(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket Pong write failed"
);
break PumpExitReason::WriteError;
}
Err(_) => break PumpExitReason::WriteTimeout,
}
}
Some(Ok(Message::Pong(_))) => {
stats.pong_received_count.fetch_add(1, Ordering::Relaxed);
}
Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose,
Some(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket receive failed"
);
break PumpExitReason::ReadError;
}
None => break PumpExitReason::PeerEof,
}
Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose,
Some(Err(error)) => {
tracing::info!(
websocket_connection_id = %connection_id,
error = %error,
"Responses WebSocket receive failed"
);
break PumpExitReason::ReadError;
}
None => break PumpExitReason::PeerEof,
},
}
};
@@ -247,7 +247,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
}
};
started.session.trace().record("client.connection", serde_json::json!({
"transport": "websocket", "connectionId": connection.id(), "correlationId": correlation_id,
"transport": "websocket", "connectionId": connection.id(), "correlationId": correlation_id.as_ref(),
"requestIndex": request_count,
}));
started
@@ -14,8 +14,8 @@ use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest
use crate::ApiState;
use crate::openai::{
auth::{authenticate_client, client_access_error_response},
endpoint::collect_raw_json_response,
error::gateway_error_response,
provider_endpoint::collect_raw_json_response,
responses::{OpenAiRequestHeaders, request_client_context},
};
@@ -756,3 +756,60 @@ mod actions {
);
}
}
mod import_settings {
use gateway_api::admin::accounts::{AccountImportRequest, CompleteAccountAuthorizationRequest};
use serde_json::json;
#[test]
fn import_and_oauth_apply_the_same_settings_validation() {
for (field, value) in [
("concurrencyLimit", json!(0)),
("concurrencyLimit", json!(4_294_967_296_u64)),
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
] {
let mut settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
settings[field] = value;
let import: AccountImportRequest = serde_json::from_value(
json!({"provider": "openai", "data": {}, "settings": settings}),
)
.expect("import request");
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).expect("OAuth request");
assert_eq!(
import.validate().expect_err("invalid settings").field(),
field
);
assert_eq!(
oauth.validate().expect_err("invalid settings").field(),
field
);
}
}
#[test]
fn import_settings_require_a_complete_explicit_configuration() {
let settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 100, "groupIds": []});
let request: AccountImportRequest =
serde_json::from_value(json!({"provider": "openai", "data": {}, "settings": settings}))
.expect("settings");
assert!(request.validate().is_ok());
for field in ["enabled", "concurrencyLimit", "weight", "groupIds"] {
let mut incomplete = settings.clone();
incomplete
.as_object_mut()
.expect("settings object")
.remove(field);
assert!(
serde_json::from_value::<AccountImportRequest>(
json!({"provider": "openai", "data": {}, "settings": incomplete})
)
.is_err(),
"missing {field}"
);
}
}
}
@@ -10,6 +10,46 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
"requestsPerMinute": 0, "dailyLimitUsd": "0.1234567891", "weeklyLimitUsd": "15"});
let command = serde_json::from_value::<CreateClientKeyRequest>(payload.clone())
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.budget.daily_usd.canonical(), "0.1234567891");
assert_eq!(command.budget.weekly_usd.canonical(), "15");
assert_eq!(command.limits.max_concurrency, 3);
for field in ["dailyLimitUsd", "weeklyLimitUsd"] {
for invalid in ["-1", "NaN", "1e3", "10000000000", "0.00000000001", ""] {
let mut payload = payload.clone();
payload[field] = json!(invalid);
assert_eq!(
serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap_err()
.field(),
field
);
}
}
let mut update = payload;
update["id"] = json!("key_budget");
update.as_object_mut().unwrap().remove("dailyLimitUsd");
update["weeklyLimitUsd"] = json!("0");
let command = serde_json::from_value::<UpdateClientKeyRequest>(update)
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.daily_limit_usd, None);
assert_eq!(
command.weekly_limit_usd,
Some(gateway_core::metering::Decimal::ZERO)
);
}
#[test]
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
@@ -207,6 +247,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
label: None,
@@ -238,6 +279,10 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
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"]
@@ -82,6 +82,7 @@ mod auth;
mod client_keys;
mod errors;
mod observability;
mod proxies;
mod settings;
mod system;
mod wire;
@@ -124,7 +125,12 @@ impl AdminTestFixture {
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
});
let stores = AdminStorePorts::new(
AdminAccountStorePorts::new(unused.clone(), unused.clone(), account_groups.clone()),
AdminAccountStorePorts::new(
unused.clone(),
unused.clone(),
account_groups.clone(),
Arc::new(proxies::MemoryProxies::default()),
),
auth.clone(),
client_keys.clone(),
unused,
@@ -144,7 +150,7 @@ impl AdminTestFixture {
stores,
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
Arc::new(StaticClientDistribution),
system,
)
@@ -640,6 +646,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
budget: Default::default(),
id: id.clone(),
name: "revealed".to_owned(),
label: None,
@@ -64,6 +64,7 @@ fn dashboard_account_usage_should_keep_daily_request_timeline() {
authentication_kind: "oauth".to_owned(),
email: "account@example.com".to_owned(),
plan_type: Some("free".to_owned()),
plan_type_display: "Free".to_owned(),
tokens: "—".to_owned(),
request_count: 3,
request_buckets: vec![DashboardAccountRequestBucketView {
@@ -919,6 +920,22 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
compact: true,
started_at: Utc::now(),
});
{
let mut records = fixture.usage_records.lock().expect("usage records");
for source in ["calculated", "provider_reported"] {
let mut image = records[0].clone();
image.id = format!("image_{source}");
image.endpoint = "/v1/images/generations".to_owned();
image.provider_kind = Some("openai".to_owned());
image.requested_model_id = Some("gpt-image-2".to_owned());
image.upstream_model_id = None;
image.billing = Some(UsageBilling::Total {
source: source.to_owned(),
total: usd("0.00696"),
});
records.push(image);
}
}
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
@@ -1016,4 +1033,12 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
})
);
assert!(value["data"]["items"][0].get("metadata").is_none());
assert_eq!(
value["data"]["items"][1]["billing"]["totalAmountDisplay"],
"≈ $0.007"
);
assert_eq!(
value["data"]["items"][2]["billing"]["totalAmountDisplay"],
"$0.007"
);
}
@@ -0,0 +1,520 @@
use std::sync::Mutex;
use async_trait::async_trait;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
#[derive(Default)]
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
fn missing() -> AdminStoreError {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "missing proxy")
}
#[async_trait]
impl ProxyStore for MemoryProxies {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy account",
"changed binding",
));
}
Ok(Revision::new(4).unwrap())
}
async fn reserve_import(
&self,
_: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
Err(missing())
}
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect();
Ok(ProxyPage {
total: u64::try_from(items.len()).unwrap(),
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
self.0.lock().unwrap().clone().ok_or_else(missing)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if !self
.0
.lock()
.unwrap()
.as_ref()
.is_some_and(|record| record.id == query.proxy_id)
{
return Err(missing());
}
Ok(ProxyAccountPage {
items: vec![ProxyAccountRef {
id: "acct_linked".to_owned(),
name: "工作账号".to_owned(),
email: Some("work@example.invalid".to_owned()),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: Some("plus".to_owned()),
plan_type_display: None,
groups: vec![],
enabled: true,
}],
total: 21,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn create(
&self,
command: NewProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let record = ProxyRecord {
id: "proxy_test".to_owned(),
name: command.name,
proxy: command.proxy,
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test: None,
last_test_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
*self.0.lock().unwrap() = Some(record.clone());
Ok(ProxyMutation {
config_revision: record.revision,
record,
})
}
async fn update(
&self,
command: UpdateProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.name = command.name;
if let Some(proxy) = command.proxy {
record.proxy = proxy;
}
record.revision = Revision::new(record.revision.get() + 1).unwrap();
Ok(ProxyMutation {
config_revision: record.revision,
record: record.clone(),
})
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
self.0.lock().unwrap().take().ok_or_else(missing)?;
Ok(Revision::new(3).unwrap())
}
async fn record_test(
&self,
_: &str,
_: Revision,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
Ok(record.clone())
}
}
pub(super) struct SuccessfulProbe;
#[async_trait]
impl ProxyProbe for SuccessfulProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
ProxyTestResult {
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
message: "Connected".to_owned(),
}
}
}
async fn request(
fixture: &AdminTestFixture,
path: &str,
body: Option<Value>,
authenticated: bool,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.header("x-request-id", "req_proxy_tests")
.uri(path)
.method(if body.is_some() { "POST" } else { "GET" });
if authenticated {
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
}
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
let response = gateway_api::admin::proxies::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
builder
.header(header::CONTENT_TYPE, "application/json")
.body(body)
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(!text.contains("private-password"));
assert!(!text.contains("test-user"));
(status, serde_json::from_slice(&body).unwrap())
}
#[tokio::test]
async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_credentials() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
"name": " Office ", "proxyUrl": "http://test-user:private-password@proxy.example:8080"
})),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["name"], "Office");
assert_eq!(created["data"]["record"]["hasAuthentication"], true);
assert_eq!(
created["data"]["record"]["endpoint"],
"http://proxy.example:8080/"
);
assert!(created["data"]["record"].get("proxyUrl").is_none());
assert_eq!(created["data"]["record"]["accountCount"], 0);
assert!(created["data"]["record"].get("accounts").is_none());
let (status, linked) = request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work",
None,
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
linked["data"]["page"],
json!({"page":2,"pageSize":20,"total":21,"totalPages":2})
);
assert_eq!(
linked["data"]["items"],
json!([{
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
}])
);
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2");
let (_, listed) = request(
&fixture,
"/api/admin/proxies?page=1&pageSize=20",
None,
true,
)
.await;
assert_eq!(listed["data"]["items"][0], tested["data"]);
let (status, _) = request(
&fixture,
"/api/admin/proxies/update",
Some(json!({"id": "proxy_test", "revision": 1, "name": "Renamed"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/delete",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
}
#[tokio::test]
async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let draft = json!({
"proxyUrl": "http://test-user:private-password@proxy.example:8080"
});
let (status, probed) = request(
&fixture,
"/api/admin/proxies/probe",
Some(draft.clone()),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
probed["data"],
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
let (_, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
true,
)
.await;
let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
}
#[tokio::test]
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/probe", Some(body), true)
.await
.0,
expected
);
}
}
#[tokio::test]
async fn proxy_routes_require_auth_and_reject_invalid_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
assert_eq!(
request(&fixture, "/api/admin/proxies", None, false).await.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test",
None,
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
for query in [
"",
"proxyId=",
"proxyId=proxy_test&page=0",
"proxyId=proxy_test&pageSize=0",
"proxyId=proxy_test&pageSize=201",
"proxyId=proxy_test&search=%00",
"proxyId=proxy_test&unknown=1",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/accounts?{query}"),
None,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=missing",
None,
true
)
.await
.0,
StatusCode::NOT_FOUND
);
for action in [
"create",
"update",
"delete",
"test",
"probe",
"accounts/remove",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/{action}"),
Some(json!({})),
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
}
for query in ["page=0", "pageSize=0", "pageSize=201", "unknown=1"] {
assert_eq!(
request(&fixture, &format!("/api/admin/proxies?{query}"), None, true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
for body in [
json!({"name":" ","proxyUrl":"http://proxy.example:8080"}),
json!({"name":"Invalid","proxyUrl":""}),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/create", Some(body), true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
let (status, _) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})),
true,
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn proxy_account_removal_validates_binding_and_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(
json!({"proxyId":"", "accountId":"acct_linked"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":""}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"invalid"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(body),
true
)
.await
.0,
expected
);
}
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"], json!({"configRevision":4}));
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试");
}
@@ -50,6 +50,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/observability/routes.rs",
"src/admin/observability/wire.rs",
"src/admin/presenter.rs",
"src/admin/proxies.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
@@ -61,7 +62,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/openai/images/mod.rs",
"src/openai/mod.rs",
"src/openai/models.rs",
"src/openai/provider_endpoint.rs",
"src/openai/endpoint.rs",
"src/openai/responses/error.rs",
"src/openai/responses/http.rs",
"src/openai/responses/mod.rs",
@@ -96,6 +97,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/observability/mod.rs",
"tests/admin/observability/query.rs",
"tests/admin/observability/response.rs",
"tests/admin/proxies.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
@@ -107,7 +109,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/images/mod.rs",
"tests/openai/mod.rs",
"tests/openai/models.rs",
"tests/openai/provider_endpoint.rs",
"tests/openai/endpoint.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/websocket/connection.rs",
@@ -11,6 +11,21 @@ use gateway_api::openai::error::{
openai_error_response,
};
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
let error = GatewayError::new(GatewayErrorKind::RateLimited, "key budget exhausted")
.with_client_code(code)
.with_retry_after(std::time::Duration::from_millis(1501));
let response = gateway_error_response(&error);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(response.headers()["retry-after"], "2");
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], code);
}
}
#[test]
fn invalid_request_error_should_map_to_openai_bad_request() {
assert_eq!(
@@ -433,3 +448,151 @@ async fn continuation_recovery_should_preserve_the_official_retry_signal() {
})
);
}
mod model_routing {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
DefaultExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::routing::{ProviderKind, RuntimeSnapshot};
use gateway_core::runtime::RuntimeSnapshotHandle;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::openai::{
IgnoredClientApiKeyUsage, UnusedAdmissions, UnusedContinuation, UnusedExecutionStore,
};
struct TestCircuits {
blocked: bool,
}
impl ProviderCircuitPort for TestCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async move {
Ok(if self.blocked {
ProviderCircuitDecision::BlockedUntil(
SystemTime::now() + Duration::from_secs(30),
)
} else {
ProviderCircuitDecision::Allow
})
})
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
}
async fn request_model(
snapshot: RuntimeSnapshot,
model: &str,
stream: bool,
blocked: bool,
) -> (StatusCode, Value) {
let execution = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(TestCircuits { blocked }),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
let response = crate::openai::api_router(Arc::new(execution))
.await
.oneshot(
Request::post("/v1/responses")
.header("authorization", "Bearer sk_model_routing")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": model, "input": "hello", "stream": stream}).to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("error body");
(status, serde_json::from_slice(&body).expect("error JSON"))
}
#[tokio::test]
async fn missing_models_should_return_404_with_a_specific_explanation() {
for (model, mappings, message) in [
(
"missing-model",
BTreeMap::new(),
"the requested model was not found in the provider catalogs available to this API key; check the model name",
),
(
"model-a",
BTreeMap::from([("model-a".to_owned(), "missing-target".to_owned())]),
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping",
),
] {
for stream in [false, true] {
let snapshot = crate::openai::snapshot("sk_model_routing", "openai")
.with_model_mappings(mappings.clone());
let response = request_model(snapshot, model, stream, false).await;
assert_eq!(
response,
(
StatusCode::NOT_FOUND,
json!({"error": {
"type": "invalid_request_error",
"code": "model_not_found",
"message": message,
}}),
),
"model={model}, stream={stream}",
);
}
}
}
#[tokio::test]
async fn an_existing_model_with_a_blocked_provider_should_remain_service_unavailable() {
let response = request_model(
crate::openai::snapshot("sk_model_routing", "openai"),
"model-a",
true,
true,
)
.await;
assert_eq!(
(response.0, response.1["error"]["code"].as_str()),
(
StatusCode::SERVICE_UNAVAILABLE,
Some("no_available_provider")
),
);
}
}
@@ -1,8 +1,8 @@
mod auth;
mod endpoint;
mod error;
mod images;
mod models;
mod provider_endpoint;
mod responses;
mod router;
mod search;
@@ -444,21 +444,6 @@ async fn inbound_overload_should_close_without_executing_queued_frames() {
));
}
#[tokio::test(start_paused = true)]
async fn pump_does_not_emit_an_active_ping() {
let PumpHarness {
connection,
incoming: _incoming,
mut written,
..
} = test_connection(false);
tokio::time::advance(Duration::from_secs(5 * 60)).await;
tokio::task::yield_now().await;
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
drop(connection);
}
#[tokio::test]
async fn outbound_commands_are_written_in_acknowledged_order() {
let PumpHarness {
@@ -582,13 +567,17 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
tokio::time::advance(Duration::from_secs(60 * 60)).await;
tokio::task::yield_now().await;
let message = tokio::time::timeout(Duration::from_secs(1), socket.next())
.await
.expect("connection limit response timeout")
.expect("connection remains available for the limit error")
.expect("valid WebSocket frame");
let ClientMessage::Text(text) = message else {
panic!("connection limit must be a text error event");
let text = loop {
let message = tokio::time::timeout(Duration::from_secs(1), socket.next())
.await
.expect("connection limit response timeout")
.expect("connection remains available for the limit error")
.expect("valid WebSocket frame");
match message {
ClientMessage::Text(text) => break text,
ClientMessage::Ping(_) => {}
other => panic!("unexpected frame before connection limit: {other:?}"),
}
};
let value = serde_json::from_str::<Value>(&text).expect("connection limit JSON");
@@ -606,7 +595,8 @@ async fn idle_connection_reaches_the_official_limit_without_starting_an_executio
);
assert_eq!(trace.starts.load(Ordering::Acquire), 0);
socket.close(None).await.expect("close WebSocket");
// 服务端已因生命周期到期关闭;不再向关闭中的连接写入。
drop(socket);
server.abort();
}
@@ -949,3 +939,134 @@ async fn client_close_should_cancel_active_execution_without_starting_queued_req
assert!(trace.cancelled.load(Ordering::Acquire));
server.abort();
}
#[tokio::test(start_paused = true)]
async fn idle_pump_sends_heartbeat_without_bursting_after_a_delay() {
let PumpHarness {
connection,
incoming: _incoming,
mut written,
..
} = test_connection(false);
tokio::task::yield_now().await;
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
tokio::time::advance(Duration::from_secs(25)).await;
let first = written.recv().await.expect("first heartbeat");
assert!(matches!(first, Message::Ping(_)));
tokio::time::advance(Duration::from_secs(120)).await;
let second = written.recv().await.expect("heartbeat after delay");
assert!(matches!(&second, Message::Ping(_)));
assert_ne!(first, second);
tokio::task::yield_now().await;
assert!(matches!(written.try_recv(), Err(TryRecvError::Empty)));
drop(connection);
}
#[tokio::test(start_paused = true)]
async fn missing_pongs_do_not_cancel_the_connection_or_block_business_frames() {
let PumpHarness {
mut connection,
incoming,
mut written,
..
} = test_connection(false);
for _ in 0..13 {
tokio::time::advance(Duration::from_secs(25)).await;
assert!(matches!(written.recv().await, Some(Message::Ping(_))));
}
incoming
.send(Ok(Message::Text("still connected".into())))
.expect("send business frame after 325 seconds without Pong");
assert!(matches!(
connection.next_event().await,
Some(ConnectionEvent::Text(text)) if text == "still connected"
));
incoming.send(Ok(Message::Close(None))).expect("close peer");
assert_eq!(
connection.wait_for_exit().await,
PumpExitReason::ClientClose
);
tokio::time::advance(Duration::from_secs(25)).await;
assert!(written.recv().await.is_none());
}
#[tokio::test(start_paused = true)]
async fn stalled_heartbeat_can_be_cancelled_without_waiting_for_write_timeout() {
let PumpHarness {
mut connection,
incoming: _incoming,
written: _written,
dropped,
cancellation,
} = test_connection(true);
tokio::time::advance(Duration::from_secs(25)).await;
tokio::task::yield_now().await;
cancellation.cancel();
let reason = tokio::time::timeout(Duration::from_secs(1), connection.wait_for_exit())
.await
.expect("cancel a blocked heartbeat promptly");
assert_eq!(reason, PumpExitReason::LifecycleShutdown);
assert!(dropped.load(Ordering::Acquire));
}
#[tokio::test(start_paused = true)]
async fn stalled_heartbeat_obeys_the_existing_write_timeout() {
let PumpHarness {
mut connection,
incoming: _incoming,
written: _written,
dropped,
..
} = test_connection(true);
let started = tokio::time::Instant::now();
assert_eq!(
connection.wait_for_exit().await,
PumpExitReason::WriteTimeout
);
assert_eq!(started.elapsed(), Duration::from_secs(25 + 300));
assert!(dropped.load(Ordering::Acquire));
}
#[tokio::test]
async fn active_response_receives_heartbeats_during_two_hundred_seconds_of_silence() {
let (trace, mut socket, server) = start_active_response().await;
for _ in 0..8 {
tokio::time::pause();
tokio::time::advance(Duration::from_secs(25)).await;
tokio::task::yield_now().await;
tokio::time::resume();
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("heartbeat while the upstream response is pending")
.expect("connection remains open")
.expect("valid heartbeat frame");
let ClientMessage::Ping(payload) = message else {
panic!("expected heartbeat while business stream is silent: {message:?}");
};
// 与官方 WsStream 一样,在业务响应未完成时回复同 payload 的 Pong。
socket
.send(ClientMessage::Pong(payload))
.await
.expect("reply to heartbeat");
}
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
assert!(!trace.cancelled.load(Ordering::Acquire));
trace.release_terminal.notify_one();
let message = tokio::time::timeout(Duration::from_secs(2), socket.next())
.await
.expect("terminal response after heartbeats")
.expect("connection remains open")
.expect("valid terminal frame");
let ClientMessage::Text(text) = message else {
panic!("expected completed response: {message:?}");
};
let value: Value = serde_json::from_str(&text).expect("terminal JSON");
assert_eq!(value["type"], "response.completed");
assert_eq!(trace.starts.load(Ordering::Acquire), 1);
socket.close(None).await.expect("close WebSocket");
server.abort();
}
@@ -22,7 +22,7 @@ use gateway_core::upstream::UpstreamSendState;
use serde_json::{Value, json};
use tower::ServiceExt;
use super::provider_endpoint::BufferedJsonSession;
use super::endpoint::BufferedJsonSession;
use super::{api_router, authenticated_client};
const SEARCH_RESPONSE: &[u8] = br#"{ "encrypted_output":"ciphertext", "output":"search result", "results":[{"type":"text_result","ref_id":"turn0search0","future":9007199254740993}] }"#;
+2 -1
View File
@@ -2,7 +2,7 @@
name = "gateway-core"
version = "0.1.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
autotests = false
@@ -24,6 +24,7 @@ semver = "1.0.27"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
url = "2.5.8"
[lints]
workspace = true
@@ -2,6 +2,8 @@
mod error;
mod model;
mod proxy;
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
pub mod scope;
mod selection;
mod store;
@@ -654,7 +654,7 @@ const fn status_projection(status: AccountStatus) -> AccountStatusProjection {
}
}
/// 不含 secret 的账号持久事实。
/// 账号持久事实;代理认证信息只通过显式 secret accessor 读取。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccount {
id: ProviderAccountId,
@@ -676,6 +676,7 @@ pub struct ProviderAccount {
access_token_expires_at: Option<SystemTime>,
next_refresh_at: Option<SystemTime>,
has_refresh_token: bool,
outbound_proxy: Option<super::OutboundProxy>,
}
impl ProviderAccount {
@@ -710,6 +711,7 @@ impl ProviderAccount {
access_token_expires_at,
next_refresh_at: None,
has_refresh_token: false,
outbound_proxy: None,
}
}
@@ -726,6 +728,17 @@ impl ProviderAccount {
self
}
#[must_use]
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
self.outbound_proxy = proxy;
self
}
#[must_use]
pub const fn outbound_proxy(&self) -> Option<&super::OutboundProxy> {
self.outbound_proxy.as_ref()
}
#[must_use]
pub fn with_account_facts(
mut self,
@@ -0,0 +1,49 @@
//! Explicit account egress. Credentials never appear in Debug or ordinary admin projections.
use std::fmt;
use url::Url;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct OutboundProxy(Url);
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid outbound proxy; expected http, https, socks5 or socks5h URL with a host and port")]
pub struct InvalidOutboundProxy;
impl OutboundProxy {
pub fn parse(value: &str) -> Result<Self, InvalidOutboundProxy> {
if value.len() > 4096 || value.chars().any(char::is_control) {
return Err(InvalidOutboundProxy);
}
let url = Url::parse(value).map_err(|_| InvalidOutboundProxy)?;
if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h")
|| url.host_str().is_none()
|| url.port_or_known_default().is_none_or(|port| port == 0)
|| !matches!(url.path(), "" | "/")
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(InvalidOutboundProxy);
}
Ok(Self(url))
}
#[must_use]
pub fn expose_url(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn endpoint(&self) -> String {
let mut url = self.0.clone();
let _ = url.set_username("");
let _ = url.set_password(None);
url.to_string()
}
}
impl fmt::Debug for OutboundProxy {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("OutboundProxy(<redacted>)")
}
}
@@ -0,0 +1,52 @@
//! 按 Key 持久化 USD 限额与已取得费用,不依赖尽力写入的请求观测。
use std::time::SystemTime;
use futures::future::BoxFuture;
use crate::{error::GatewayError, metering::Decimal, policy::ClientApiKeyId};
use super::ModelRequestId;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ClientBudgetLimits {
pub daily_usd: Decimal,
pub weekly_usd: Decimal,
}
impl ClientBudgetLimits {
#[must_use]
pub fn is_limited(self) -> bool {
self.daily_usd != Decimal::ZERO || self.weekly_usd != Decimal::ZERO
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientBudgetStatus {
pub limits: ClientBudgetLimits,
pub daily_used_usd: Decimal,
pub weekly_used_usd: Decimal,
pub daily_resets_at: Option<SystemTime>,
pub weekly_resets_at: Option<SystemTime>,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetCharge {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
/// 已取得的 USD 费用,包含重试;缺少费用的尝试按零累计。
pub amount_usd: Decimal,
pub completed_at: SystemTime,
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("client budget store is unavailable")]
pub struct ClientBudgetError;
pub trait ClientBudgetPort: Send + Sync {
/// 原子检查当前限额与已用金额,不创建预扣费或待结算记录。
fn admit(&self, key_id: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>>;
/// 按网关请求 ID 幂等累计已取得费用;写入失败由 Store 重试。
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>>;
}
@@ -30,6 +30,7 @@ use crate::event::{
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
};
use crate::lifecycle::CancellationToken;
use crate::metering::Decimal;
use crate::operation::{Operation, ProviderSessionState};
use crate::routing::RoutingPlan;
use futures::future::Fuse;
@@ -166,6 +167,8 @@ where
request_id,
client_api_key_ref,
observation: ResponseObservation::new(timing_started_at),
budget_prior_attempts_usd: Decimal::ZERO,
budget_attempt_already_counted: false,
trace,
deadline,
deadline_timer: Delay::new(
@@ -197,6 +200,7 @@ where
delivery_pending: false,
upstream_complete: false,
finalized: false,
finalized_at: None,
image_generation_requested,
last_retryable_failure: None,
last_retryable_failure_events: Vec::new(),
@@ -253,6 +257,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
request_id: ModelRequestId,
client_api_key_ref: crate::policy::ClientApiKeyId,
observation: ResponseObservation,
budget_prior_attempts_usd: Decimal,
budget_attempt_already_counted: bool,
trace: TraceContext,
deadline: SystemTime,
/// 会话级 deadline 计时器;deadline 固定,帧循环内复用而非逐事件新建。
@@ -288,6 +294,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
delivery_pending: bool,
upstream_complete: bool,
finalized: bool,
finalized_at: Option<SystemTime>,
image_generation_requested: bool,
/// 最近一次为无感恢复而被丢弃的原始上游错误;只在后续空选路时成为终态。
last_retryable_failure: Option<ProviderError>,
@@ -475,6 +482,35 @@ where
self.finalized
}
#[must_use]
pub fn budget_charge(&self) -> super::budget::ClientBudgetCharge {
// 超出数据库可表示范围时保留最大金额,避免溢出后误记为零。
let amount_usd = self
.budget_prior_attempts_usd
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
super::budget::ClientBudgetCharge {
key_id: self.client_api_key_ref.clone(),
request_id: self.request_id.clone(),
amount_usd,
completed_at: self.finalized_at.unwrap_or_else(SystemTime::now),
}
}
fn budget_attempt_usd(&self) -> Decimal {
if self.budget_attempt_already_counted {
return Decimal::ZERO;
}
// Key 只累计已取得的 USD 费用;缺少费用的请求按零结算。
// 发送状态仍用于判断重放是否安全并保留在诊断中,不能据此推断存在欠费。
self.observation
.cost
.total()
.filter(|money| money.currency().as_str() == "USD")
.map(crate::metering::Money::amount)
.unwrap_or(Decimal::ZERO)
}
/// 返回截至当前已完成的实际上游调用结果。
///
/// 调用方可保存已消费下标;该切片在会话生命周期内只追加、不改写。
@@ -800,6 +836,7 @@ where
let mut events = std::mem::take(&mut self.last_retryable_failure_events);
if !events.is_empty() {
self.observe_atomic_terminal_events(&mut events);
self.budget_attempt_already_counted = true;
return Ok(Some(PullOutcome::TerminalFailure {
events,
error: last_failure,
@@ -1143,6 +1180,11 @@ where
"sendState": format!("{attempt_send_state:?}"),
}));
if retryable {
for event in &atomic_client_events {
for fact in event.canonical_facts() {
self.observation.observe_event(fact);
}
}
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
// 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。
let persistence_error = self.request_persisted.then(|| error.clone());
@@ -1317,6 +1359,12 @@ where
}
fn reset_uncommitted_observations(&mut self) {
// 响应观测按尝试隔离;被丢弃尝试中已取得的费用仍计入 Key。
self.budget_prior_attempts_usd = self
.budget_prior_attempts_usd
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
self.budget_attempt_already_counted = false;
self.observation.reset_for_attempt();
self.upstream_complete = false;
}
@@ -1326,6 +1374,7 @@ where
return Ok(());
}
let completed_at = SystemTime::now();
self.finalized_at = Some(completed_at);
self.observation.finish();
let upstream_request_id = self.current.as_ref().and_then(|current| {
current
@@ -1505,11 +1554,12 @@ where
"sendState": format!("{:?}", finalization.send_state), "attempts": self.attempts,
}),
);
let completed_at = SystemTime::now();
self.finalized_at = Some(completed_at);
if !self.request_persisted {
self.finalized = true;
return Ok(());
}
let completed_at = SystemTime::now();
self.observation.finish();
let upstream_request_id = self.current.as_ref().and_then(|current| {
current
@@ -1854,6 +1904,9 @@ fn duration_ms(duration: Duration) -> u64 {
fn record_trace_error(trace: &TraceContext, error: &ProviderError) {
trace.record("attempt.failed", json!({
"kind": error.kind().as_str(), "sendState": format!("{:?}", error.send_state()),
"diagnostic": error.diagnostic().map(|diagnostic| json!({
"stage": diagnostic.stage(), "code": diagnostic.code(), "message": diagnostic.as_str(),
})),
"upstreamStatus": error.upstream_status(),
"upstreamCode": error.upstream_code().map(|code| code.as_str()),
"rawError": error.raw_upstream_error().map(|raw| {
@@ -14,6 +14,7 @@ use uuid::Uuid;
use crate::engine::admission::{
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
};
use crate::engine::budget::{ClientBudgetCharge, ClientBudgetPort};
use crate::engine::continuation::{
ContinuationBinding, NativeContinuationPin, NativeContinuationPort,
NativeContinuationStoreErrorKind, PreviousResponseId,
@@ -256,6 +257,7 @@ pub struct DefaultExecutionService {
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
budget: Option<Arc<dyn ClientBudgetPort>>,
}
impl DefaultExecutionService {
@@ -283,9 +285,16 @@ impl DefaultExecutionService {
circuits,
continuation,
client_api_key_usage,
budget: None,
}
}
#[must_use]
pub fn with_budget(mut self, budget: Arc<dyn ClientBudgetPort>) -> Self {
self.budget = Some(budget);
self
}
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
let StartExecution {
client,
@@ -325,9 +334,18 @@ impl DefaultExecutionService {
&self,
mut request: PendingStartExecution,
) -> Result<StartedExecution, GatewayError> {
request.client.policy.authorize().map_err(|_| {
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
})?;
// 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。
request.client = self
.authenticate(request.client.policy.plaintext_key().expose_for_auth())
.map_err(|error| match error {
ClientAuthenticationError::InvalidKey => {
GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid")
}
ClientAuthenticationError::SnapshotUnavailable => GatewayError::new(
GatewayErrorKind::Internal,
"runtime snapshot is unavailable",
),
})?;
let started_at = SystemTime::now();
let deadline_at = started_at
.checked_add(MODEL_REQUEST_DEADLINE)
@@ -504,6 +522,12 @@ impl DefaultExecutionService {
client_api_key_id: client.policy.key_id().clone(),
model_request_id: request_id.clone(),
};
if let Some(budget) = &self.budget
&& let Err(error) = budget.admit(client.policy.key_id().clone()).await
{
admission.release().await;
return Err(error);
}
let observation = plan
.candidates()
.first()
@@ -524,7 +548,7 @@ impl DefaultExecutionService {
operation: operation.kind(),
endpoint: metadata.endpoint,
client_transport: metadata.transport.as_str().to_owned(),
requested_model: target.into_public_model(),
requested_model: target.into_public_model().or(observation.requested_model),
client_ip: metadata.client_ip,
user_agent: metadata.user_agent,
reasoning_effort: observation.reasoning_effort,
@@ -552,6 +576,18 @@ impl DefaultExecutionService {
{
Ok(core) => core,
Err(error) => {
if let Some(budget) = &self.budget {
settle_budget(
budget.as_ref(),
ClientBudgetCharge {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
amount_usd: crate::metering::Decimal::ZERO,
completed_at: SystemTime::now(),
},
)
.await;
}
admission.release().await;
return Err(gateway_error_from_engine(&error));
}
@@ -565,6 +601,7 @@ impl DefaultExecutionService {
admission,
Arc::clone(&self.circuits),
Arc::clone(&self.continuation),
self.budget.clone(),
)),
})
}
@@ -922,6 +959,12 @@ struct AdmissionLease {
model_request_id: ModelRequestId,
}
async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge) {
if let Err(error) = port.settle(charge).await {
tracing::error!(%error, "Client budget settlement failed; storage will retry on the next request");
}
}
impl AdmissionLease {
async fn release(self) {
if let Err(error) = self
@@ -941,6 +984,7 @@ struct DefaultExecutionSession {
continuation: Arc<dyn NativeContinuationPort>,
observed_provider_outcomes: usize,
continuation_recorded: bool,
budget: Option<Arc<dyn ClientBudgetPort>>,
}
impl DefaultExecutionSession {
@@ -949,6 +993,7 @@ impl DefaultExecutionSession {
admission: AdmissionLease,
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
budget: Option<Arc<dyn ClientBudgetPort>>,
) -> Self {
Self {
core: Some(core),
@@ -957,6 +1002,7 @@ impl DefaultExecutionSession {
continuation,
observed_provider_outcomes: 0,
continuation_recorded: false,
budget,
}
}
@@ -973,6 +1019,11 @@ impl DefaultExecutionSession {
.is_some_and(ResponseExecutionSession::is_finalized)
&& let Some(admission) = self.admission.take()
{
if let Some(budget) = self.budget.take()
&& let Some(core) = &self.core
{
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
admission.release().await;
}
}
@@ -1023,6 +1074,9 @@ impl DefaultExecutionSession {
.get(self.observed_provider_outcomes..)
.unwrap_or_default();
publish_provider_attempt_outcomes(self.circuits.as_ref(), pending).await;
if let Some(budget) = self.budget.take() {
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
if let Some(admission) = self.admission.take() {
admission.release().await;
}
@@ -1213,6 +1267,17 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError {
match error {
crate::validation::RoutingError::ModelNotFound {
model,
mapped_model,
} => GatewayError::new(
GatewayErrorKind::ModelNotFound,
if model == mapped_model {
"the requested model was not found in the provider catalogs available to this API key; check the model name"
} else {
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping"
},
),
crate::validation::RoutingError::NoCapableProvider { .. }
| crate::validation::RoutingError::NoCapableProviderEndpoint { .. }
| crate::validation::RoutingError::EmptyAccountScope => GatewayError::new(
@@ -1,6 +1,7 @@
//! 模型请求生命周期、单行持久化 port 与 commit/send/cancellation 边界。
pub mod admission;
pub mod budget;
pub mod continuation;
pub mod coordinator;
pub mod execution;
@@ -20,7 +20,9 @@ use crate::event::{EventSequenceValidator, ProviderEvent};
use crate::identity::ProviderKind;
use crate::operation::Operation;
use crate::policy::ClientApiKeyId;
use crate::routing::{ModelCapabilities, ModelPresentation, ProviderCandidate, UpstreamModelId};
use crate::routing::{
ModelCapabilities, ModelPresentation, ProviderCandidate, PublicModelId, UpstreamModelId,
};
use crate::upstream::OpaqueUpstreamValue;
use crate::upstream::{UpstreamSendState, UpstreamTransport};
@@ -416,6 +418,8 @@ pub struct ProviderRequest {
/// Provider 对公共观测表可解释的请求语义;未知字段保持空值。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderRequestObservation {
/// 独立 Provider 端点声明的请求模型,仅用于观测,不参与文本模型目录路由。
pub requested_model: Option<PublicModelId>,
/// 客户端原始请求中的推理强度。
pub reasoning_effort: Option<String>,
pub reasoning_preset: Option<String>,
+105 -8
View File
@@ -172,17 +172,72 @@ impl fmt::Debug for ClientVisibleUpstreamError {
/// Adapter 只能放入不含凭据、cookie、原始请求正文和 opaque response ID 的有界摘要;
/// 原始上游错误返回由 [`RawUpstreamError`] 单独承载。
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderDiagnostic(String);
pub struct ProviderDiagnostic {
message: String,
stage: Option<&'static str>,
code: Option<&'static str>,
}
impl ProviderDiagnostic {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
Self {
message: message.into(),
stage: None,
code: None,
}
}
/// 附着本地定义的阶段与原因码;独立于发送状态和重试策略。
#[must_use]
pub const fn with_classification(mut self, stage: &'static str, code: &'static str) -> Self {
self.stage = Some(stage);
self.code = Some(code);
self
}
/// 从错误链补充可证明的 I/O 原因;只保留本地原因码,不格式化原始错误。
#[must_use]
pub fn with_io_cause(mut self, error: &(dyn std::error::Error + 'static)) -> Self {
let mut source = Some(error);
while let Some(error) = source {
if let Some(error) = error.downcast_ref::<std::io::Error>() {
let code = match error.kind() {
std::io::ErrorKind::ConnectionRefused => Some("connection_refused"),
std::io::ErrorKind::ConnectionReset => Some("tcp_reset"),
std::io::ErrorKind::ConnectionAborted => Some("connection_aborted"),
std::io::ErrorKind::BrokenPipe => Some("broken_pipe"),
std::io::ErrorKind::UnexpectedEof => Some("unexpected_eof"),
std::io::ErrorKind::TimedOut => Some("io_timeout"),
std::io::ErrorKind::NetworkUnreachable => Some("network_unreachable"),
std::io::ErrorKind::HostUnreachable => Some("host_unreachable"),
_ => None,
};
if let Some(code) = code {
self.message.push_str("; I/O cause: ");
self.message.push_str(code);
self.code = Some(code);
break;
}
}
source = error.source();
}
self
}
#[must_use]
pub const fn stage(&self) -> Option<&'static str> {
self.stage
}
#[must_use]
pub const fn code(&self) -> Option<&'static str> {
self.code
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
&self.message
}
}
@@ -817,7 +872,10 @@ impl fmt::Debug for ProviderError {
"sensitive_context",
&self.sensitive_context_redacted.then_some("<redacted>"),
)
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
.field(
"diagnostic",
&self.diagnostic.as_deref().map(|_| "<present>"),
)
.field(
"raw_upstream_error",
&self.raw_upstream_error.as_ref().map(|_| "<present>"),
@@ -915,8 +973,15 @@ impl GatewayErrorKind {
pub struct GatewayError {
kind: GatewayErrorKind,
message: &'static str,
diagnostic: Option<ProviderDiagnostic>,
diagnostic: Option<Box<ProviderDiagnostic>>,
client_visible_upstream_error: Option<ClientVisibleUpstreamError>,
client_details: Option<Box<GatewayClientErrorDetails>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct GatewayClientErrorDetails {
code: Option<&'static str>,
retry_after: Option<Duration>,
}
impl GatewayError {
@@ -928,9 +993,33 @@ impl GatewayError {
message,
diagnostic: None,
client_visible_upstream_error: None,
client_details: None,
}
}
#[must_use]
pub fn with_client_code(mut self, code: &'static str) -> Self {
self.client_details
.get_or_insert_with(Default::default)
.code = Some(code);
self
}
#[must_use]
pub fn with_retry_after(mut self, delay: Duration) -> Self {
self.client_details
.get_or_insert_with(Default::default)
.retry_after = Some(delay);
self
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
self.client_details
.as_ref()
.and_then(|details| details.retry_after)
}
/// 将 Provider 错误归一为客户端无关错误。
#[must_use]
pub fn from_provider(error: &ProviderError) -> Self {
@@ -993,7 +1082,7 @@ impl GatewayError {
/// 附加只供运维观测使用的脱敏 Provider 摘要。
#[must_use]
pub fn with_diagnostic(mut self, diagnostic: ProviderDiagnostic) -> Self {
self.diagnostic = Some(diagnostic);
self.diagnostic = Some(Box::new(diagnostic));
self
}
@@ -1019,7 +1108,7 @@ impl GatewayError {
/// 返回可安全持久化和展示的 Provider 诊断摘要。
#[must_use]
pub fn diagnostic(&self) -> Option<&ProviderDiagnostic> {
self.diagnostic.as_ref()
self.diagnostic.as_deref()
}
/// 返回优先用于原客户端协议响应的 message;持久化和日志必须继续使用
@@ -1045,6 +1134,11 @@ impl GatewayError {
self.client_visible_upstream_error
.as_ref()
.and_then(ClientVisibleUpstreamError::code)
.or_else(|| {
self.client_details
.as_ref()
.and_then(|details| details.code)
})
}
}
@@ -1054,7 +1148,10 @@ impl fmt::Debug for GatewayError {
.debug_struct("GatewayError")
.field("kind", &self.kind)
.field("message", &self.message)
.field("diagnostic", &self.diagnostic.as_ref().map(|_| "<present>"))
.field(
"diagnostic",
&self.diagnostic.as_deref().map(|_| "<present>"),
)
.field(
"client_visible_upstream_error",
&self
+14 -2
View File
@@ -52,6 +52,7 @@ pub struct CoreStorePorts {
snapshots: Arc<dyn SnapshotStorePort>,
snapshot_subscriptions: Arc<dyn SnapshotSubscriptionPort>,
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
budget: Option<Arc<dyn engine::budget::ClientBudgetPort>>,
}
impl CoreStorePorts {
@@ -79,8 +80,15 @@ impl CoreStorePorts {
snapshots,
snapshot_subscriptions,
client_api_key_usage,
budget: None,
}
}
#[must_use]
pub fn with_budget(mut self, budget: Arc<dyn engine::budget::ClientBudgetPort>) -> Self {
self.budget = Some(budget);
self
}
}
pub struct CoreBundle {
@@ -145,7 +153,7 @@ pub async fn initialize(
Arc::clone(&ports.snapshot_subscriptions),
));
let worker_contributions = publisher.worker_contributions()?;
let service = Arc::new(DefaultExecutionService::new(
let mut service = DefaultExecutionService::new(
snapshots.clone(),
ports.execution,
providers,
@@ -153,7 +161,11 @@ pub async fn initialize(
ports.circuits,
ports.continuation,
ports.client_api_key_usage,
));
);
if let Some(budget) = ports.budget {
service = service.with_budget(budget);
}
let service = Arc::new(service);
let execution: Arc<dyn ExecutionService> = service.clone();
let account_probe: Arc<dyn AccountProbe> = service;
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
@@ -14,6 +14,8 @@ pub struct Decimal(u128);
impl Decimal {
pub const ZERO: Self = Self(0);
/// 数据库可表示的最大非负金额。
pub const MAX: Self = Self(MAX_SCALED_DECIMAL);
/// 从按十位小数缩放的整数创建。
///
@@ -778,6 +778,19 @@ impl RuntimeSnapshot {
}
if candidates.is_empty() {
// 模型存在性必须包含被熔断的 Provider;目录未知时不能断言模型不存在。
let mut scoped_providers = providers.intersection(&self.providers).peekable();
if scoped_providers.peek().is_some()
&& scoped_providers.all(|provider| {
self.known_provider_catalogs.contains(provider)
&& !self.contains_public_model_for_provider(public_model, provider)
})
{
return Err(RoutingError::ModelNotFound {
model: public_model.as_str().to_owned(),
mapped_model: self.mapped_model(public_model.as_str()),
});
}
return Err(RoutingError::NoCapableProvider {
model: public_model.as_str().to_owned(),
});
@@ -85,6 +85,16 @@ pub enum RoutingError {
/// 实体 ID。
id: String,
},
/// 账号范围内的 Provider 目录均已知,且均没有请求或映射后的模型。
#[error(
"model `{model}` (mapped to `{mapped_model}`) was not found in the scoped provider catalogs"
)]
ModelNotFound {
/// 客户端提交的模型名称。
model: String,
/// 应用模型映射后的名称。
mapped_model: String,
},
/// 固定平台内没有可执行本次请求的 Provider。
#[error("no provider can execute model `{model}`")]
NoCapableProvider {
@@ -1,3 +1,4 @@
mod proxy;
mod selection;
mod selection_diagnostics;
@@ -0,0 +1,33 @@
use gateway_core::account::OutboundProxy;
#[test]
fn proxy_endpoints_support_explicit_schemes_and_redact_credentials() {
for scheme in ["http", "https", "socks5", "socks5h"] {
let proxy =
OutboundProxy::parse(&format!("{scheme}://user:p%40ss%3Aword@[::1]:1080")).unwrap();
assert!(proxy.expose_url().contains("p%40ss%3Aword"));
assert!(!proxy.endpoint().contains("user"));
assert!(!proxy.endpoint().contains("p%40ss"));
assert!(!format!("{proxy:?}").contains("user"));
assert!(proxy.endpoint().contains("[::1]:1080"));
}
}
#[test]
fn invalid_proxy_never_silently_becomes_direct() {
for url in [
"",
"localhost:1080",
"file:///etc/passwd",
"ftp://host:21",
"http://host:0",
"socks5://host",
"http://host:80/path",
"http://host:80?secret=1",
"http://host:80#fragment",
"http://user:secret@host:80\n",
] {
let error = OutboundProxy::parse(url).unwrap_err();
assert!(!error.to_string().contains("secret"));
}
}
@@ -1292,7 +1292,13 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
Ok(ProviderEvent::observation(first_observation)),
Err(
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
.with_replay_safe(),
.with_replay_safe()
.with_diagnostic(
gateway_core::error::ProviderDiagnostic::new(
"response event idle timeout after 300s",
)
.with_classification("receive", "receive_idle_timeout"),
),
),
],
},
@@ -1329,6 +1335,16 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
.iter()
.any(|event| event["stage"] == "attempt.started" && event["attemptIndex"] == 2)
);
let failed = events
.iter()
.find(|event| event["stage"] == "attempt.failed")
.unwrap();
assert_eq!(
failed["data"]["diagnostic"],
serde_json::json!({
"stage": "receive", "code": "receive_idle_timeout", "message": "response event idle timeout after 300s"
})
);
assert_eq!(events.last().unwrap()["stage"], "request.finished");
assert_eq!(finalization.upstream_transport.as_deref(), Some("http_sse"));
@@ -2142,6 +2158,146 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
let state = store.state.lock().expect("store lock");
assert_eq!(state.finalizations[0].cost_source, CostSource::Unavailable);
assert_eq!(state.finalizations[0].cost_ticks, None);
assert_eq!(
session.budget_charge().amount_usd.scaled(),
999,
"missing final usage cannot erase a charged retry"
);
}
#[test]
fn key_budget_accumulates_only_observed_retry_costs() {
for (first_cost, first_send, expected) in [
(Some(999), UpstreamSendState::Sent, 1122),
(
Some(gateway_core::metering::Decimal::MAX.scaled()),
UpstreamSendState::Sent,
gateway_core::metering::Decimal::MAX.scaled(),
),
(None, UpstreamSendState::Sent, 123),
(None, UpstreamSendState::NotSent, 123),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut first = Vec::new();
if first_send != UpstreamSendState::NotSent {
first.push(Ok(GatewayEvent::Started(ResponseMeta::new(
"resp_first",
"gpt-5",
))));
}
if let Some(cost) = first_cost {
first.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
first.push(Err(ProviderError::new(
ProviderErrorKind::Unavailable,
first_send,
)
.with_replay_safe()));
let (coordinator, _, _) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: first,
},
Script::Stream {
account_id: "acct_second",
items: vec![
Ok(GatewayEvent::Started(ResponseMeta::new(
"resp_done",
"gpt-5",
))),
Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(123).unwrap(),
)),
Ok(GatewayEvent::Completed(ResponseMeta::new(
"resp_done",
"gpt-5",
))),
],
},
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
block_on(session.collect_uncommitted()).unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
let charge = session.budget_charge();
assert_eq!(charge.amount_usd.scaled(), expected);
assert_eq!(
session.budget_charge().completed_at,
charge.completed_at,
"final settlement time stays stable"
);
}
}
#[test]
fn key_budget_settles_post_send_failures_from_observed_cost_without_replaying() {
for send_state in [UpstreamSendState::Sent, UpstreamSendState::Ambiguous] {
for cost in [None, Some(123)] {
for committed in [false, true] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut items = vec![Ok(GatewayEvent::Started(ResponseMeta::new(
"response-interrupted",
"gpt-5",
)))];
if let Some(cost) = cost {
items.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
items.push(Err(ProviderError::new(
ProviderErrorKind::Transport,
send_state,
)));
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items,
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
if committed {
block_on(session.next_event()).unwrap().unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
loop {
match block_on(session.next_event()) {
Ok(Some(_)) => {}
Err(EngineError::Provider(_)) => break,
other => panic!("expected terminal transport error, got {other:?}"),
}
}
} else {
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
}
assert!(session.is_finalized());
assert_eq!(
session.budget_charge().amount_usd.scaled(),
cost.unwrap_or(0),
"send={send_state:?}, committed={committed}, cost={cost:?}"
);
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
}
}
}
}
#[test]
@@ -2672,10 +2828,16 @@ fn exhausted_atomic_response_failed_should_deliver_only_the_last_failure_once()
fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire() {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut failure = bare_atomic_response_failed("response-only-failed");
let mut events = failure.take_atomic_client_events();
events.push(ProviderEvent::canonical(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(500).unwrap(),
)));
let failure = failure.with_atomic_client_events(events);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_only",
items: vec![Err(bare_atomic_response_failed("response-only-failed"))],
items: vec![Err(failure)],
},
Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
@@ -2715,6 +2877,11 @@ fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire()
));
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
assert_eq!(
session.budget_charge().amount_usd.scaled(),
500,
"restoring a discarded failure must not charge its usage twice"
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.intermediate_failures, 1);
@@ -2852,6 +3019,7 @@ fn ambiguous_send_state_stops_retry() {
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.send_state, UpstreamSendState::Ambiguous);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
assert_eq!(finalization.attempt_count, 1);
assert!(!finalization.committed);
assert!(finalization.latency_ms.is_some());
@@ -3326,6 +3494,7 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
assert_eq!(state.commits, 0);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Cancelled);
assert!(!state.finalizations[0].committed);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
}
#[test]
@@ -7,6 +7,231 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
#[derive(Default)]
struct Admissions {
active: Arc<AtomicBool>,
limits: Mutex<Vec<RateLimits>>,
}
impl ClientAdmissionPort for Admissions {
fn admit(
&self,
request: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async move {
self.limits.lock().unwrap().push(request.limits);
assert!(!self.active.swap(true, Ordering::SeqCst));
Ok(ClientAdmissionDecision::Granted)
})
}
fn release<'a>(
&'a self,
_: &'a ClientApiKeyId,
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async { Ok(self.active.swap(false, Ordering::SeqCst)) })
}
fn restore(
&self,
_: ClientAdmissionRecovery,
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
Box::pin(async { Ok(ClientAdmissionRestoreResult::default()) })
}
}
struct Budget {
reject: bool,
active: Arc<AtomicBool>,
charges: Mutex<Vec<ClientBudgetCharge>>,
}
impl ClientBudgetPort for Budget {
fn admit(&self, _: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async {
assert!(self.active.load(Ordering::SeqCst));
if self.reject {
Err(
GatewayError::new(GatewayErrorKind::RateLimited, "budget exhausted")
.with_client_code("key_daily_budget_exceeded"),
)
} else {
Ok(())
}
})
}
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>> {
Box::pin(async move {
assert!(
self.active.load(Ordering::SeqCst),
"settle before releasing the concurrent request slot"
);
self.charges.lock().unwrap().push(charge);
Ok(())
})
}
}
fn service(admissions: Arc<Admissions>, budget: Arc<Budget>) -> DefaultExecutionService {
DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions,
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
)
.with_budget(budget)
}
fn request(service: &DefaultExecutionService, transport: ClientTransport) -> StartExecution {
StartExecution {
client: service.authenticate("sk_start_test").unwrap(),
public_model: PublicModelId::new("gpt-start").unwrap(),
operation: start_operation(),
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: "/v1/responses".to_owned(),
transport,
stream: transport != ClientTransport::HttpJson,
client_ip: None,
user_agent: None,
previous_response_id: None,
},
}
}
#[test]
fn budget_rejection_releases_client_concurrency_without_creating_a_charge() {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
reject: true,
active: admissions.active.clone(),
charges: Mutex::default(),
});
let service = service(admissions.clone(), budget.clone());
for transport in [
ClientTransport::HttpJson,
ClientTransport::HttpSse,
ClientTransport::WebSocket,
] {
let result = block_on(service.start(request(&service, transport)));
assert!(
matches!(result, Err(error) if error.client_error_code() == Some("key_daily_budget_exceeded"))
);
assert!(!admissions.active.load(Ordering::SeqCst));
assert!(budget.charges.lock().unwrap().is_empty());
}
}
#[test]
fn reused_client_uses_updated_limits_for_each_execution() {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").unwrap();
let limited = RateLimits {
max_concurrency: 1,
requests_per_minute: 1,
};
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let started = block_on(service.start(next)).expect("new execution");
block_on(started.session.detach_finalize());
}
assert_eq!(
*admissions.limits.lock().unwrap(),
vec![limited, RateLimits::unlimited()]
);
}
#[test]
fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
for suspend in [false, true] {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let next = request(&service, ClientTransport::WebSocket);
if suspend {
snapshots.suspend();
} else {
snapshots.publish(start_snapshot_with_policy(
2,
false,
RateLimits::unlimited(),
));
}
let result = block_on(service.start(next));
let expected = if suspend {
GatewayErrorKind::Internal
} else {
GatewayErrorKind::Unauthorized
};
assert!(matches!(result, Err(error) if error.kind() == expected));
assert!(admissions.limits.lock().unwrap().is_empty());
}
}
#[test]
fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all_transports() {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
reject: false,
active: admissions.active.clone(),
charges: Mutex::default(),
});
let service = service(admissions.clone(), budget.clone());
for transport in [
ClientTransport::HttpJson,
ClientTransport::HttpSse,
ClientTransport::WebSocket,
] {
for detach in [true, false] {
let mut started = block_on(service.start(request(&service, transport))).unwrap();
assert!(admissions.active.load(Ordering::SeqCst));
if detach {
block_on(started.session.detach_finalize());
} else {
assert!(block_on(started.session.next_event()).is_err());
block_on(started.session.detach_finalize());
}
assert!(!admissions.active.load(Ordering::SeqCst));
}
}
let charges = budget.charges.lock().unwrap();
assert_eq!(
charges.len(),
6,
"one settlement per request, including detached finalized sessions"
);
assert!(
charges
.iter()
.all(|charge| charge.amount_usd == gateway_core::metering::Decimal::ZERO)
);
}
use async_trait::async_trait;
use bytes::Bytes;
use futures::{executor::block_on, future::BoxFuture};
@@ -26,7 +251,7 @@ use gateway_core::engine::execution::{
use gateway_core::engine::probe::{AccountProbe, AccountProbeErrorSource, AccountProbeRequest};
use gateway_core::engine::provider::{
Provider, ProviderCallMetadata, ProviderCatalogGeneration, ProviderModelCapabilities,
ProviderRegistry, ProviderRequest, ProviderStream,
ProviderRegistry, ProviderRequest, ProviderRequestObservation, ProviderStream,
};
use gateway_core::engine::{
AttemptContext, AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization,
@@ -260,7 +485,9 @@ impl Provider for LocalFailingProvider {
}
}
struct ColdFailingProvider;
struct ColdFailingProvider {
requested_model: Option<PublicModelId>,
}
#[async_trait]
impl Provider for ColdFailingProvider {
@@ -272,6 +499,13 @@ impl Provider for ColdFailingProvider {
ProviderCatalogGeneration::default()
}
fn request_observation(&self, _: &Operation, _: &ClientApiKeyId) -> ProviderRequestObservation {
ProviderRequestObservation {
requested_model: self.requested_model.clone(),
..Default::default()
}
}
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
@@ -323,12 +557,19 @@ fn successful_authentication_should_record_client_key_usage() {
#[test]
fn provider_endpoint_should_persist_its_real_v1_endpoint() {
assert_provider_endpoint_observation(None);
assert_provider_endpoint_observation(Some("gpt-image-2"));
}
fn assert_provider_endpoint_observation(model: Option<&str>) {
let store = Arc::new(TrackingExecutionStore::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(client_snapshot()),
store.clone(),
ProviderRegistry::new([Arc::new(ColdFailingProvider) as Arc<dyn Provider>])
.expect("provider registry"),
ProviderRegistry::new([Arc::new(ColdFailingProvider {
requested_model: model.map(|model| PublicModelId::new(model).expect("model")),
}) as Arc<dyn Provider>])
.expect("provider registry"),
Arc::new(UnusedAdmissions),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
@@ -369,7 +610,7 @@ fn provider_endpoint_should_persist_its_real_v1_endpoint() {
vec!["/v1/images/generations".to_owned()],
"execution error: {error:?}"
);
assert_eq!(store.requested_models(), vec![None]);
assert_eq!(store.requested_models(), vec![model.map(str::to_owned)]);
let upstream_models = store.upstream_models();
assert!(!upstream_models.is_empty());
assert!(upstream_models.iter().all(Option::is_none));
@@ -479,7 +720,13 @@ fn known_catalog_should_reject_a_model_that_the_provider_did_not_publish() {
panic!("a known provider catalog must be authoritative for model availability");
};
assert_eq!(error.kind(), GatewayErrorKind::NoAvailableProvider);
assert_eq!(
(error.kind(), error.client_message()),
(
GatewayErrorKind::ModelNotFound,
"the requested model was not found in the provider catalogs available to this API key; check the model name",
)
);
}
#[test]
@@ -901,11 +1148,15 @@ fn client_snapshot() -> RuntimeSnapshot {
}
fn start_snapshot() -> RuntimeSnapshot {
start_snapshot_with_policy(1, true, RateLimits::unlimited())
}
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities =
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("config revision"),
ConfigRevision::new(revision).expect("config revision"),
AccountSelectionPolicy::new(
RotationStrategy::Smart,
std::num::NonZeroU32::new(1).expect("concurrency"),
@@ -921,8 +1172,8 @@ fn start_snapshot() -> RuntimeSnapshot {
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
account_scope(&provider, "acct_start"),
true,
RateLimits::unlimited(),
enabled,
limits,
)],
)
.expect("start snapshot")
+12 -1
View File
@@ -30,7 +30,10 @@ fn provider_error_debug_should_not_print_classified_upstream_values() {
fn classified_provider_diagnostic_survives_clone_without_entering_debug() {
let message = "OpenAI WebSocket closed before terminal response (close code 1000)";
let error = ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_diagnostic(ProviderDiagnostic::new(message));
.with_diagnostic(
ProviderDiagnostic::new(message)
.with_classification("receive", "closed_before_terminal"),
);
let cloned = error.clone();
let gateway = GatewayError::from_provider(&cloned);
@@ -38,6 +41,14 @@ fn classified_provider_diagnostic_survives_clone_without_entering_debug() {
gateway.diagnostic().map(ProviderDiagnostic::as_str),
Some(message)
);
assert_eq!(
gateway.diagnostic().and_then(ProviderDiagnostic::stage),
Some("receive")
);
assert_eq!(
gateway.diagnostic().and_then(ProviderDiagnostic::code),
Some("closed_before_terminal")
);
assert!(!format!("{error:?}").contains(message));
assert!(!format!("{gateway:?}").contains(message));
}
+127 -13
View File
@@ -326,12 +326,123 @@ fn known_provider_catalog_missing_mapped_model_should_be_filtered() {
)
.expect_err("known xAI catalog does not contain the mapped OpenAI model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "gpt-5.4".to_owned(),
mapped_model: "gpt-5.5".to_owned(),
}
);
}
#[test]
fn missing_model_should_be_reported_when_all_scoped_catalogs_are_known() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect_err("neither provider publishes the requested model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "missing-model".to_owned(),
mapped_model: "missing-model".to_owned(),
}
);
}
#[test]
fn missing_model_in_one_provider_should_still_route_to_another() {
let snapshot = snapshot();
let plan = snapshot
.plan(
&PublicModelId::new("grok-4.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect("xAI publishes the model missing from OpenAI");
assert_eq!(plan.candidates()[0].provider().as_str(), "xai");
}
#[test]
fn blocked_model_provider_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("gpt-5.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("OpenAI is blocked and xAI does not publish this model");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn blocked_unknown_catalog_should_not_prove_model_absence() {
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![
ProviderKind::new("openai").expect("provider"),
ProviderKind::new("xai").expect("provider"),
],
vec![model("xai", "grok-4.5", capabilities())],
Vec::new(),
)
.expect("snapshot")
.with_account_directory(account_directory());
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("the blocked provider's catalog cannot establish model absence");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn empty_account_scope_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let empty_scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::default()),
ClientRoutingScope::all_accounts(),
));
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
empty_scope,
&RoutingContext::default(),
)
.expect_err("the API key has no accounts");
assert_eq!(error, gateway_core::error::RoutingError::EmptyAccountScope);
}
#[test]
fn provider_endpoint_plan_should_not_consult_or_publish_the_text_model_catalog() {
let snapshot = snapshot();
@@ -463,19 +574,22 @@ fn known_unsupported_operation_should_not_be_bypassed() {
)
.expect("snapshot");
assert!(
snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.is_err()
);
let error = snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.expect_err("the model exists but cannot perform this operation");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
@@ -56,10 +56,13 @@ struct PublishingCatalogProvider {
queries: AtomicUsize,
}
struct UnavailableCatalogProvider;
enum TestCatalogProvider {
Unavailable,
Empty,
}
#[async_trait]
impl Provider for UnavailableCatalogProvider {
impl Provider for TestCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
@@ -71,10 +74,13 @@ impl Provider for UnavailableCatalogProvider {
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
match self {
Self::Unavailable => Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Self::Empty => Ok(Vec::new()),
}
}
async fn execute(
@@ -145,7 +151,7 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let providers =
ProviderRegistry::new([Arc::new(UnavailableCatalogProvider) as Arc<dyn Provider>])
ProviderRegistry::new([Arc::new(TestCatalogProvider::Unavailable) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
@@ -162,6 +168,35 @@ fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
assert_eq!(snapshot.client_policies().count(), 1);
}
#[test]
fn known_empty_catalog_should_report_model_not_found() {
let providers =
ProviderRegistry::new([Arc::new(TestCatalogProvider::Empty) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let snapshot = block_on(compiler.compile()).expect("compile empty catalog");
let error = snapshot
.plan(
&PublicModelId::new("public-model").expect("model"),
&super::operation(),
snapshot.all_account_scope(),
&gateway_core::routing::RoutingContext {
required_provider: Some(ProviderKind::new("alpha").expect("provider")),
..gateway_core::routing::RoutingContext::default()
},
)
.expect_err("a successfully published empty catalog proves model absence");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "public-model".to_owned(),
mapped_model: "upstream-model".to_owned(),
},
);
}
#[test]
fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
let provider = Arc::new(PublishingCatalogProvider {
+1 -1
View File
@@ -17,7 +17,7 @@ futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
scraper = "0.27.0"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
+22 -4
View File
@@ -118,16 +118,31 @@ pub struct LoggingConfig {
pub level: String,
pub stdout: bool,
pub file: FileLoggingConfig,
/// 将 OAuth 原始 AT/RT 写入独立恢复日志;默认关闭,沿用 file 的目录与保留期。
#[serde(default)]
pub oauth_recovery: bool,
/// 将完整请求原文写入独立诊断日志;默认关闭,因为内容包含凭据。
/// 请求报文的留存窗口由 request_dump_retention_days 独立控制。
#[serde(default)]
pub request_dump: bool,
/// 完整报文的最少保留天数;按 UTC 日期整组清理,默认 1 天。
#[serde(default = "default_request_dump_retention_days")]
pub request_dump_retention_days: usize,
}
fn default_file_retention_days() -> usize {
7
}
fn default_request_dump_retention_days() -> usize {
1
}
impl LoggingConfig {
fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> {
EnvFilter::try_new(&self.level)
.map_err(|_| ConfigError::InvalidField("host.logging.level"))?;
if !self.stdout && !self.file.enabled {
if !self.stdout && !self.file.enabled && !self.oauth_recovery && !self.request_dump {
return Err(ConfigError::InvalidField("host.logging"));
}
if self.file.directory.as_os_str().is_empty() {
@@ -143,8 +158,10 @@ impl LoggingConfig {
"host.logging.file.max_file_size_mb",
));
}
if self.file.max_files == 0 {
return Err(ConfigError::InvalidField("host.logging.file.max_files"));
if self.request_dump_retention_days == 0 {
return Err(ConfigError::InvalidField(
"host.logging.request_dump_retention_days",
));
}
resolve_relative_path(source_dir, &mut self.file.directory);
Ok(())
@@ -157,9 +174,10 @@ impl LoggingConfig {
pub struct FileLoggingConfig {
pub enabled: bool,
pub directory: PathBuf,
/// 至少保留的完整天数,默认 7 天;文件数量不参与清理。
#[serde(default = "default_file_retention_days")]
pub retention_days: usize,
pub max_file_size_mb: u64,
pub max_files: usize,
}
#[derive(Debug, thiserror::Error)]
+21 -3
View File
@@ -3,6 +3,7 @@
pub mod client_distribution;
pub mod config;
mod logging;
pub mod proxy_probe;
pub mod serve;
pub mod system_update;
pub mod workers;
@@ -13,7 +14,7 @@ use axum::Router;
use gateway_admin::ports::{
client_distribution::ClientDistributionResolver, system::SystemOperations,
};
use gateway_core::health::WorkerHealthSource;
use gateway_core::health::{HealthProbe, WorkerHealthSource};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::lifecycle::ConnectionLifecycle;
use gateway_core::task::{WorkerContribution, WorkerLeaderLeasePort};
@@ -29,7 +30,7 @@ use self::workers::WorkerSupervisor;
/// Host 初始化的能力集;字段全部私有,不暴露内部监督器或进程状态。
pub struct HostBundle {
config: HostConfig,
_log_guard: LogGuard,
log_guard: LogGuard,
cancellation: CancellationToken,
connections: Arc<ConnectionTracker>,
workers: WorkerSupervisor,
@@ -50,7 +51,7 @@ pub async fn initialize(config: HostConfig) -> Result<HostBundle, HostError> {
let client_distribution = Arc::new(RgAdguardClientDistribution::new());
Ok(HostBundle {
config,
_log_guard: log_guard,
log_guard,
cancellation,
connections,
workers,
@@ -81,11 +82,28 @@ impl HostBundle {
self.client_distribution.clone()
}
#[must_use]
pub fn proxy_probe<E>(
&self,
build_client: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E>
+ Send
+ Sync
+ 'static,
) -> Arc<dyn gateway_admin::ports::proxy::ProxyProbe> {
Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client))
}
#[must_use]
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
self.connections.clone()
}
/// 报告文件日志写入或归档失败,避免将缺失日志误报为完整。
#[must_use]
pub fn logging_health_probe(&self) -> Arc<dyn HealthProbe> {
self.log_guard.health_probe()
}
#[must_use]
pub fn worker_health(&self) -> Arc<dyn WorkerHealthSource> {
self.workers.health_source()
-385
View File
@@ -1,385 +0,0 @@
//! 进程结构化日志与按日期/大小轮转的文件 writer。
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use chrono::Utc;
use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuard};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _};
use crate::config::{FileLoggingConfig, LoggingConfig};
const LOG_FILE_PREFIX: &str = "codex-proxy-rs";
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oath";
/// OAuth 恢复记录含原始 AT/RT,文件日志开启时必须落到独立文件。
const OAUTH_RECOVERY_LOG_TARGET: &str = "oauth_recovery";
const REQUEST_DUMP_LOG_FILE_PREFIX: &str = "codex-proxy-rs-request-dump";
/// 请求转储含完整凭据与正文,只允许在显式开启时写入独立文件。
const REQUEST_DUMP_LOG_TARGET: &str = "request_dump";
/// non-blocking 日志 writer 的进程级守卫。
pub struct LogGuard {
_writers: Vec<WorkerGuard>,
}
#[derive(Debug, thiserror::Error)]
pub enum LogError {
#[error("log IO failed")]
Io(#[from] io::Error),
#[error("logging filter is invalid")]
InvalidFilter,
#[error("global tracing subscriber is already initialized")]
AlreadyInitialized,
#[error("logging size limit is too large")]
SizeOverflow,
}
/// 按自然日、单文件大小、保留天数和文件总数初始化结构化日志。
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
let file_filter = application_file_filter(&directive)?;
let recovery_file_filter = oauth_recovery_file_filter();
let request_dump_file_filter = request_dump_file_filter();
let stdout_filter =
EnvFilter::try_new(stdout_filter_directive(&directive, config.file.enabled))
.map_err(|_| LogError::InvalidFilter)?;
let mut guards = Vec::new();
let stdout_writer = config.stdout.then(|| {
let (writer, guard) = NonBlockingBuilder::default()
.thread_name("gateway-log-stdout")
.finish(io::stdout());
guards.push(guard);
writer
});
let (file_writer, recovery_file_writer) = if config.file.enabled {
let (file_writer, file_guard) =
rotating_file_writer(&config.file, LOG_FILE_PREFIX, "gateway-log-file")?;
let (recovery_file_writer, recovery_file_guard) = rotating_file_writer(
&config.file,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
"gateway-log-oauth-recovery-file",
)?;
guards.push(file_guard);
guards.push(recovery_file_guard);
(Some(file_writer), Some(recovery_file_writer))
} else {
(None, None)
};
let request_dump_file_writer = if config.request_dump {
let (writer, guard) = rotating_file_writer(
&config.file,
REQUEST_DUMP_LOG_FILE_PREFIX,
"gateway-log-request-dump-file",
)?;
guards.push(guard);
Some(writer)
} else {
None
};
let stdout_layer = stdout_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.compact()
.with_writer(writer)
.with_target(false)
.with_ansi(false)
.with_filter(stdout_filter)
});
let file_layer = file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(file_filter)
});
let recovery_file_layer = recovery_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(recovery_file_filter)
});
let request_dump_file_layer = request_dump_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(request_dump_file_filter)
});
tracing_subscriber::registry()
.with(stdout_layer)
.with(file_layer)
.with(recovery_file_layer)
.with(request_dump_file_layer)
.try_init()
.map_err(|_| LogError::AlreadyInitialized)?;
Ok(LogGuard { _writers: guards })
}
fn application_file_filter(directive: &str) -> Result<EnvFilter, LogError> {
let recovery_directive = format!("{OAUTH_RECOVERY_LOG_TARGET}=off")
.parse()
.expect("static OAuth recovery log directive is valid");
let request_dump_directive = format!("{REQUEST_DUMP_LOG_TARGET}=off")
.parse()
.expect("static request dump log directive is valid");
EnvFilter::try_new(directive)
.map(|filter| filter.add_directive(recovery_directive))
.map(|filter| filter.add_directive(request_dump_directive))
.map_err(|_| LogError::InvalidFilter)
}
fn oauth_recovery_file_filter() -> EnvFilter {
format!("off,{OAUTH_RECOVERY_LOG_TARGET}=info")
.parse()
.expect("static OAuth recovery log filter is valid")
}
fn request_dump_file_filter() -> EnvFilter {
format!("off,{REQUEST_DUMP_LOG_TARGET}=info")
.parse()
.expect("static request dump log filter is valid")
}
fn stdout_filter_directive(directive: &str, persistent_log_enabled: bool) -> String {
if !persistent_log_enabled {
format!("{directive},{REQUEST_DUMP_LOG_TARGET}=off")
} else {
"off,gateway_startup=info".to_owned()
}
}
fn rotating_file_writer(
config: &FileLoggingConfig,
file_prefix: &'static str,
thread_name: &'static str,
) -> Result<(NonBlocking, WorkerGuard), LogError> {
let maximum_bytes = config
.max_file_size_mb
.checked_mul(1024 * 1024)
.ok_or(LogError::SizeOverflow)?;
let writer = RotatingLogWriter::open(
config.directory.clone(),
file_prefix,
maximum_bytes,
config.retention_days,
config.max_files,
)?;
Ok(NonBlockingBuilder::default()
.thread_name(thread_name)
.finish(writer))
}
struct RotatingLogWriter {
directory: PathBuf,
file_prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
maximum_files: usize,
date: chrono::NaiveDate,
segment: usize,
bytes_written: u64,
file: File,
}
impl RotatingLogWriter {
fn open(
directory: PathBuf,
file_prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
maximum_files: usize,
) -> io::Result<Self> {
fs::create_dir_all(&directory)?;
let date = Utc::now().date_naive();
cleanup_log_files(&directory, file_prefix, date, retention_days, maximum_files)?;
let (segment, bytes_written) =
writable_log_segment(&directory, file_prefix, date, maximum_bytes)?;
let file = open_log_segment(&directory, file_prefix, date, segment)?;
Ok(Self {
directory,
file_prefix,
maximum_bytes,
retention_days,
maximum_files,
date,
segment,
bytes_written,
file,
})
}
fn rotate_if_required(&mut self, incoming_bytes: usize) -> io::Result<()> {
let date = Utc::now().date_naive();
let incoming_bytes = u64::try_from(incoming_bytes).unwrap_or(u64::MAX);
let day_changed = date != self.date;
let size_exceeded = !day_changed
&& self.bytes_written > 0
&& self.bytes_written.saturating_add(incoming_bytes) > self.maximum_bytes;
if !day_changed && !size_exceeded {
return Ok(());
}
self.file.flush()?;
if day_changed {
self.date = date;
self.segment = 0;
} else {
self.segment = self.segment.saturating_add(1);
}
self.file = open_log_segment(&self.directory, self.file_prefix, self.date, self.segment)?;
self.bytes_written = self.file.metadata()?.len();
cleanup_log_files(
&self.directory,
self.file_prefix,
self.date,
self.retention_days,
self.maximum_files,
)
}
}
impl Write for RotatingLogWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.rotate_if_required(buffer.len())?;
let written = self.file.write(buffer)?;
self.bytes_written = self
.bytes_written
.saturating_add(u64::try_from(written).unwrap_or(u64::MAX));
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
#[derive(Debug)]
struct ManagedLogFile {
date: chrono::NaiveDate,
segment: usize,
path: PathBuf,
}
fn writable_log_segment(
directory: &Path,
file_prefix: &str,
date: chrono::NaiveDate,
maximum_bytes: u64,
) -> io::Result<(usize, u64)> {
let latest = managed_log_files(directory, file_prefix)?
.into_iter()
.filter(|entry| entry.date == date)
.max_by_key(|entry| entry.segment);
let Some(latest) = latest else {
return Ok((0, 0));
};
let length = latest.path.metadata()?.len();
if length >= maximum_bytes {
Ok((latest.segment.saturating_add(1), 0))
} else {
Ok((latest.segment, length))
}
}
fn open_log_segment(
directory: &Path,
file_prefix: &str,
date: chrono::NaiveDate,
segment: usize,
) -> io::Result<File> {
OpenOptions::new()
.create(true)
.append(true)
.open(directory.join(log_file_name(file_prefix, date, segment)))
}
fn log_file_name(file_prefix: &str, date: chrono::NaiveDate, segment: usize) -> String {
if segment == 0 {
format!("{file_prefix}.{date}.log")
} else {
format!("{file_prefix}.{date}.{segment}.log")
}
}
fn managed_log_files(directory: &Path, file_prefix: &str) -> io::Result<Vec<ManagedLogFile>> {
Ok(fs::read_dir(directory)?
.filter_map(Result::ok)
.filter_map(|entry| {
parse_log_file_name(entry.file_name().to_string_lossy().as_ref(), file_prefix).map(
|(date, segment)| ManagedLogFile {
date,
segment,
path: entry.path(),
},
)
})
.collect())
}
fn parse_log_file_name(name: &str, file_prefix: &str) -> Option<(chrono::NaiveDate, usize)> {
let body = name
.strip_prefix(&format!("{file_prefix}."))?
.strip_suffix(".log")?;
let (date, segment) = body.split_once('.').map_or((body, 0), |(date, segment)| {
(date, segment.parse().ok().unwrap_or(usize::MAX))
});
if segment == usize::MAX {
return None;
}
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
.ok()
.map(|date| (date, segment))
}
fn cleanup_log_files(
directory: &Path,
file_prefix: &str,
today: chrono::NaiveDate,
retention_days: usize,
maximum_files: usize,
) -> io::Result<()> {
let retention_offset = i64::try_from(retention_days.saturating_sub(1)).unwrap_or(i64::MAX);
let cutoff = today
.checked_sub_signed(chrono::Duration::days(retention_offset))
.unwrap_or(chrono::NaiveDate::MIN);
let mut retained = Vec::new();
for entry in managed_log_files(directory, file_prefix)? {
if entry.date < cutoff {
fs::remove_file(entry.path)?;
} else {
retained.push(entry);
}
}
retained.sort_by(|left, right| {
right
.date
.cmp(&left.date)
.then_with(|| right.segment.cmp(&left.segment))
});
for entry in retained.into_iter().skip(maximum_files) {
fs::remove_file(entry.path)?;
}
Ok(())
}
@@ -0,0 +1,230 @@
//! 进程结构化日志与按日期/大小轮转的文件 writer。
use std::env;
use std::io;
use std::sync::Arc;
use gateway_core::health::HealthProbe;
use tracing_appender::non_blocking::{NonBlockingBuilder, WorkerGuard};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _};
use crate::config::LoggingConfig;
mod sink;
mod writer;
use sink::{FileLogGuard, FileLogSink, LogHealth};
use writer::RotatingLogWriter;
// 文件前缀统一为 codex-proxy-rs-<kebab-case 类别>;专用 tracing target 使用 snake_case。
const APPLICATION_LOG_FILE_PREFIX: &str = "codex-proxy-rs-application";
/// 内部分类标识;由 host.logging.oauth_recovery 控制是否写入独立文件。
const OAUTH_RECOVERY_LOG_TARGET: &str = "oauth_recovery";
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oauth-recovery";
/// 请求转储含完整凭据与正文,只允许在显式开启时写入独立文件。
const REQUEST_DUMP_LOG_TARGET: &str = "request_dump";
const REQUEST_DUMP_LOG_FILE_PREFIX: &str = "codex-proxy-rs-request-dump";
/// 文件日志在正常退出时排空队列并等待落盘;控制台日志单独持有守卫。
pub struct LogGuard {
_stdout: Vec<WorkerGuard>,
_files: Vec<FileLogGuard>,
health: Arc<LogHealth>,
}
#[derive(Debug, thiserror::Error)]
pub enum LogError {
#[error("log IO failed")]
Io(#[from] io::Error),
#[error("logging filter is invalid")]
InvalidFilter,
#[error("global tracing subscriber is already initialized")]
AlreadyInitialized,
#[error("logging size limit is too large")]
SizeOverflow,
}
/// 初始化日志;文件按完整日期留存,不以分片数量淘汰窗口内记录。
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
let file_filter = application_file_filter(&directive)?;
let recovery_file_filter = oauth_recovery_file_filter();
let request_dump_file_filter = request_dump_file_filter();
let stdout_filter =
EnvFilter::try_new(stdout_filter_directive(&directive, config.file.enabled))
.map_err(|_| LogError::InvalidFilter)?;
let mut guards = Vec::new();
let mut file_guards = Vec::new();
let health = Arc::new(LogHealth::default());
let stdout_writer = config.stdout.then(|| {
let (writer, guard) = NonBlockingBuilder::default()
.thread_name("gateway-log-stdout")
.finish(io::stdout());
guards.push(guard);
writer
});
let file_writer = if config.file.enabled {
let (writer, guard) = create_file_writer(
config,
config.file.retention_days,
APPLICATION_LOG_FILE_PREFIX,
"gateway-log-application-file",
Arc::clone(&health),
)?;
file_guards.push(guard);
Some(writer)
} else {
None
};
let recovery_file_writer = if config.oauth_recovery {
let (writer, guard) = create_file_writer(
config,
config.file.retention_days,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
"gateway-log-oauth-recovery-file",
Arc::clone(&health),
)?;
file_guards.push(guard);
Some(writer)
} else {
None
};
let request_dump_file_writer = if config.request_dump {
let (writer, guard) = create_file_writer(
config,
config.request_dump_retention_days,
REQUEST_DUMP_LOG_FILE_PREFIX,
"gateway-log-request-dump-file",
Arc::clone(&health),
)?;
file_guards.push(guard);
Some(writer)
} else {
None
};
let stdout_layer = stdout_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.compact()
.with_writer(writer)
.with_target(false)
.with_ansi(false)
.with_filter(stdout_filter)
});
let file_layer = file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(file_filter)
});
let recovery_file_layer = recovery_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(recovery_file_filter)
});
let request_dump_file_layer = request_dump_file_writer.map(|writer| {
tracing_subscriber::fmt::layer()
.json()
.with_writer(writer)
.with_target(true)
.with_file(true)
.with_line_number(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_current_span(true)
.with_span_list(true)
.with_filter(request_dump_file_filter)
});
tracing_subscriber::registry()
.with(stdout_layer)
.with(file_layer)
.with(recovery_file_layer)
.with(request_dump_file_layer)
.try_init()
.map_err(|_| LogError::AlreadyInitialized)?;
Ok(LogGuard {
_stdout: guards,
_files: file_guards,
health,
})
}
fn application_file_filter(directive: &str) -> Result<EnvFilter, LogError> {
let recovery_directive = format!("{OAUTH_RECOVERY_LOG_TARGET}=off")
.parse()
.expect("static OAuth recovery log directive is valid");
let request_dump_directive = format!("{REQUEST_DUMP_LOG_TARGET}=off")
.parse()
.expect("static request dump log directive is valid");
EnvFilter::try_new(directive)
.map(|filter| filter.add_directive(recovery_directive))
.map(|filter| filter.add_directive(request_dump_directive))
.map_err(|_| LogError::InvalidFilter)
}
fn oauth_recovery_file_filter() -> EnvFilter {
format!("off,{OAUTH_RECOVERY_LOG_TARGET}=info")
.parse()
.expect("static OAuth recovery log filter is valid")
}
fn request_dump_file_filter() -> EnvFilter {
format!("off,{REQUEST_DUMP_LOG_TARGET}=info")
.parse()
.expect("static request dump log filter is valid")
}
fn stdout_filter_directive(directive: &str, persistent_log_enabled: bool) -> String {
if !persistent_log_enabled {
format!("{directive},{OAUTH_RECOVERY_LOG_TARGET}=off,{REQUEST_DUMP_LOG_TARGET}=off")
} else {
"off,gateway_startup=info".to_owned()
}
}
impl LogGuard {
pub(crate) fn health_probe(&self) -> Arc<dyn HealthProbe> {
self.health.clone()
}
}
fn create_file_writer(
config: &LoggingConfig,
retention_days: usize,
prefix: &'static str,
thread_name: &'static str,
health: Arc<LogHealth>,
) -> Result<(FileLogSink, FileLogGuard), LogError> {
let maximum_bytes = config
.file
.max_file_size_mb
.checked_mul(1024 * 1024)
.ok_or(LogError::SizeOverflow)?;
let writer = RotatingLogWriter::open(
config.file.directory.clone(),
prefix,
maximum_bytes,
retention_days,
Arc::clone(&health),
)?;
Ok(FileLogSink::spawn(writer, thread_name, health)?)
}
@@ -0,0 +1,154 @@
//! 有界文件日志队列:拥堵时背压,正常退出时排空;写入失败显式影响健康状态。
use std::io::{self, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::thread::{self, JoinHandle};
use futures::future::BoxFuture;
use gateway_core::health::{HealthProbe, HealthState};
use tracing_subscriber::fmt::MakeWriter;
use super::writer::RotatingLogWriter;
const QUEUE_CAPACITY: usize = 64;
enum Command {
Write(Vec<u8>),
Shutdown,
}
#[derive(Default)]
pub(super) struct LogHealth {
write_failures: AtomicU64,
maintenance_failures: AtomicU64,
}
impl LogHealth {
pub(super) fn write_failed(&self, kind: io::ErrorKind) {
if self.write_failures.fetch_add(1, Ordering::Relaxed) == 0 {
eprintln!("file_logging: log completeness compromised; write failure ({kind:?})");
}
}
pub(super) fn maintenance_failed(&self, kind: io::ErrorKind) {
if self.maintenance_failures.fetch_add(1, Ordering::Relaxed) == 0 {
eprintln!("file_logging: archive/retention maintenance failed ({kind:?})");
}
}
}
impl HealthProbe for LogHealth {
fn name(&self) -> &'static str {
"file_logging"
}
fn check(&self) -> BoxFuture<'_, HealthState> {
Box::pin(async move {
let failures = self.write_failures.load(Ordering::Relaxed);
if failures > 0 {
return HealthState::Unhealthy(format!(
"log completeness compromised: {failures} write/flush failures since startup"
));
}
let failures = self.maintenance_failures.load(Ordering::Relaxed);
if failures > 0 {
return HealthState::Degraded(format!(
"log archive/retention maintenance failed {failures} times since startup"
));
}
HealthState::Healthy
})
}
}
#[derive(Clone)]
pub(super) struct FileLogSink {
sender: mpsc::SyncSender<Command>,
health: Arc<LogHealth>,
}
pub(super) struct FileLogGuard {
sender: mpsc::SyncSender<Command>,
worker: Option<JoinHandle<()>>,
health: Arc<LogHealth>,
}
impl FileLogSink {
pub(super) fn spawn(
mut writer: RotatingLogWriter,
name: &'static str,
health: Arc<LogHealth>,
) -> io::Result<(Self, FileLogGuard)> {
let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY);
let worker_health = Arc::clone(&health);
let worker = thread::Builder::new()
.name(name.to_owned())
.spawn(move || {
while let Ok(command) = receiver.recv() {
match command {
Command::Write(bytes) => {
if let Err(error) = writer.write_all(&bytes) {
worker_health.write_failed(error.kind());
}
}
Command::Shutdown => break,
}
}
if let Err(error) = writer.sync() {
worker_health.write_failed(error.kind());
}
})?;
Ok((
Self {
sender: sender.clone(),
health: Arc::clone(&health),
},
FileLogGuard {
sender,
worker: Some(worker),
health,
},
))
}
}
impl<'a> MakeWriter<'a> for FileLogSink {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
impl Write for FileLogSink {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.sender
.send(Command::Write(buffer.to_vec()))
.map_err(|_| {
self.health.write_failed(io::ErrorKind::BrokenPipe);
io::Error::new(io::ErrorKind::BrokenPipe, "file log worker unavailable")
})?;
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
// Files are unbuffered. The guard is the lifecycle owner of queue drain and sync.
Ok(())
}
}
impl Drop for FileLogGuard {
fn drop(&mut self) {
// Shutdown follows every accepted record. Unlike a timed guard, joining cannot
// silently abandon queued log records during a normal process shutdown.
let _ = self.sender.send(Command::Shutdown);
if self
.worker
.take()
.is_some_and(|worker| worker.join().is_err())
{
self.health.write_failed(io::ErrorKind::BrokenPipe);
}
}
}
@@ -0,0 +1,246 @@
//! 按大小轮转、压缩已关闭分片、按完整 UTC 日期组清理。
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chrono::{NaiveDate, Utc};
use flate2::{Compression, write::GzEncoder};
use super::sink::LogHealth;
pub(super) struct RotatingLogWriter {
directory: PathBuf,
prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
date: NaiveDate,
segment: usize,
bytes_written: u64,
file: File,
health: Arc<LogHealth>,
}
impl RotatingLogWriter {
pub(super) fn open(
directory: PathBuf,
prefix: &'static str,
maximum_bytes: u64,
retention_days: usize,
health: Arc<LogHealth>,
) -> io::Result<Self> {
fs::create_dir_all(&directory)?;
let date = Utc::now().date_naive();
cleanup_log_files(&directory, prefix, date, retention_days)?;
let latest = managed_log_files(&directory, prefix)?
.into_iter()
.filter(|entry| entry.date == date)
.max_by_key(|entry| (entry.segment, entry.compressed));
let (segment, bytes_written) = match latest {
Some(entry) if !entry.compressed && entry.path.metadata()?.len() < maximum_bytes => {
(entry.segment, entry.path.metadata()?.len())
}
Some(entry) => (next_segment(entry.segment)?, 0),
None => (0, 0),
};
let path = directory.join(log_file_name(prefix, date, segment));
let file = open_log_segment(&path)?;
let writer = Self {
directory,
prefix,
maximum_bytes,
retention_days,
date,
segment,
bytes_written,
file,
health,
};
// Recover unfinished archive work after restart; the active file is never compressed.
for entry in managed_log_files(&writer.directory, prefix)? {
if !entry.compressed
&& entry.path != path
&& let Err(error) = compress_log_file(&entry.path)
{
writer.health.maintenance_failed(error.kind());
}
}
Ok(writer)
}
fn rotate_if_required(&mut self, incoming_bytes: usize) -> io::Result<()> {
let date = Utc::now().date_naive();
let day_changed = date != self.date;
let size_exceeded = self.bytes_written > 0
&& self.bytes_written.saturating_add(incoming_bytes as u64) > self.maximum_bytes;
if !day_changed && !size_exceeded {
return Ok(());
}
let previous = self
.directory
.join(log_file_name(self.prefix, self.date, self.segment));
self.file.sync_all()?;
let segment = if day_changed {
managed_log_files(&self.directory, self.prefix)?
.into_iter()
.filter(|entry| entry.date == date)
.map(|entry| entry.segment)
.max()
.map_or(Ok(0), next_segment)?
} else {
next_segment(self.segment)?
};
let path = self
.directory
.join(log_file_name(self.prefix, date, segment));
let file = open_log_segment(&path)?;
// Commit rotation state only after opening the new file succeeds.
self.file = file;
self.date = date;
self.segment = segment;
self.bytes_written = self.file.metadata()?.len();
if let Err(error) = compress_log_file(&previous) {
self.health.maintenance_failed(error.kind());
}
// Maintenance errors must not discard the record that triggered rotation.
if let Err(error) =
cleanup_log_files(&self.directory, self.prefix, date, self.retention_days)
{
self.health.maintenance_failed(error.kind());
}
Ok(())
}
pub(super) fn sync(&mut self) -> io::Result<()> {
self.file.sync_all()
}
}
impl Write for RotatingLogWriter {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
self.rotate_if_required(buffer.len())?;
if let Err(error) = self.file.write_all(buffer) {
self.bytes_written = self.file.metadata()?.len();
return Err(error);
}
self.bytes_written = self.bytes_written.saturating_add(buffer.len() as u64);
Ok(buffer.len())
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
fn next_segment(segment: usize) -> io::Result<usize> {
segment
.checked_add(1)
.ok_or_else(|| io::Error::other("log segment sequence exhausted"))
}
fn open_log_segment(path: &Path) -> io::Result<File> {
OpenOptions::new().create(true).append(true).open(path)
}
fn log_file_name(prefix: &str, date: NaiveDate, segment: usize) -> String {
if segment == 0 {
format!("{prefix}.{date}.log")
} else {
format!("{prefix}.{date}.{segment}.log")
}
}
struct ManagedLogFile {
date: NaiveDate,
segment: usize,
compressed: bool,
path: PathBuf,
}
fn managed_log_files(directory: &Path, prefix: &str) -> io::Result<Vec<ManagedLogFile>> {
let mut files = Vec::new();
for entry in fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
let Some(body) = name.strip_prefix(&format!("{prefix}.")) else {
continue;
};
let (body, compressed) = match body.strip_suffix(".log.gz") {
Some(body) => (body, true),
None => match body.strip_suffix(".log") {
Some(body) => (body, false),
None => continue,
},
};
let (date, segment) = body
.split_once('.')
.map_or((body, Some(0)), |(date, segment)| {
(date, segment.parse::<usize>().ok())
});
if let (Ok(date), Some(segment)) = (NaiveDate::parse_from_str(date, "%Y-%m-%d"), segment) {
files.push(ManagedLogFile {
date,
segment,
compressed,
path: entry.path(),
});
}
}
Ok(files)
}
fn cleanup_log_files(
directory: &Path,
prefix: &str,
today: NaiveDate,
retention_days: usize,
) -> io::Result<()> {
let days =
u64::try_from(retention_days).map_err(|_| io::Error::other("log retention overflow"))?;
let cutoff = today
.checked_sub_days(chrono::Days::new(days))
.unwrap_or(NaiveDate::MIN);
let mut dates = BTreeMap::<NaiveDate, Vec<PathBuf>>::new();
for entry in managed_log_files(directory, prefix)? {
dates.entry(entry.date).or_default().push(entry.path);
}
for (date, paths) in dates {
if date >= cutoff {
continue;
}
// Keep a whole date if any segment was written more recently than its filename.
// This also protects archives recovered after clock corrections or manual restoration.
let mut latest = date;
for path in &paths {
let modified: chrono::DateTime<Utc> = path.metadata()?.modified()?.into();
latest = latest.max(modified.date_naive());
}
if latest >= cutoff {
continue;
}
for path in paths {
fs::remove_file(path)?;
}
}
Ok(())
}
fn compress_log_file(path: &Path) -> io::Result<()> {
let mut source = File::open(path)?;
let modified = source.metadata()?.modified()?;
let archive = path.with_extension("log.gz");
let temporary = path.with_extension("log.gz.tmp");
// Only a closed, owner-managed segment can have this temporary archive.
let output = File::create(&temporary)?;
let mut encoder = GzEncoder::new(output, Compression::fast());
io::copy(&mut source, &mut encoder)?;
let output = encoder.finish()?;
output.set_modified(modified)?;
output.sync_all()?;
fs::rename(&temporary, &archive)?;
// The original survives until a complete, synced archive has been published.
fs::remove_file(path)
}
@@ -0,0 +1,105 @@
//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试。
use std::{
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use async_trait::async_trait;
use gateway_admin::{model::proxies::ProxyTestResult, ports::proxy::ProxyProbe};
use gateway_core::account::OutboundProxy;
use serde::Deserialize;
pub struct HttpProxyProbe {
endpoint: String,
build_client: Arc<ProxyClientBuilder>,
}
type ProxyClientBuilder =
dyn Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, &'static str> + Send + Sync;
impl Default for HttpProxyProbe {
fn default() -> Self {
Self::new("https://api.ipify.org?format=json")
}
}
impl HttpProxyProbe {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")),
}
}
/// 由组合根注入与 Provider 请求一致的证书信任策略。
#[must_use]
pub fn with_client_builder<E>(
mut self,
build: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E> + Send + Sync + 'static,
) -> Self {
self.build_client = Arc::new(move |builder| {
build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置")
});
self
}
async fn exit_ip(&self, proxy: &OutboundProxy) -> Result<IpAddr, &'static str> {
let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?;
let builder = reqwest::Client::builder()
.no_proxy()
.proxy(proxy)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(reqwest::redirect::Policy::none());
let client = (self.build_client)(builder)?;
let mut response = client.get(&self.endpoint).send().await.map_err(|error| {
if error.is_timeout() {
"代理连接超时"
} else {
"代理连接失败,请检查地址、认证和网络"
}
})?;
if !response.status().is_success() {
return Err(
if response.status() == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
"代理认证失败"
} else {
"出口检测服务返回错误状态"
},
);
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "出口检测响应读取失败")?
{
if body.len() + chunk.len() > 1024 {
return Err("出口检测响应过大");
}
body.extend_from_slice(&chunk);
}
#[derive(Deserialize)]
struct Response {
ip: IpAddr,
}
serde_json::from_slice::<Response>(&body)
.map(|response| response.ip)
.map_err(|_| "出口检测响应不合法")
}
}
#[async_trait]
impl ProxyProbe for HttpProxyProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
let started = Instant::now();
let result = tokio::time::timeout(Duration::from_secs(15), self.exit_ip(proxy)).await;
let result = result.unwrap_or(Err("代理连接超时"));
ProxyTestResult {
success: result.is_ok(),
latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
exit_ip: result.as_ref().ok().copied(),
message: result.map_or_else(str::to_owned, |_| "连接成功".to_owned()),
}
}
}
+47 -1
View File
@@ -80,6 +80,51 @@ fn host_config_rejects_zero_drain_window() {
);
}
#[test]
fn logging_rejects_zero_retention_windows() {
for request_dump in [false, true] {
let mut config = valid_config();
if request_dump {
config.logging.request_dump_retention_days = 0;
} else {
config.logging.file.retention_days = 0;
}
assert!(
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.is_err()
);
}
}
#[test]
fn logging_defaults_to_seven_days_and_one_day_for_payloads_and_rejects_count_retention() {
let mut value = serde_json::json!({
"level": "info", "stdout": true,
"file": { "enabled": true, "directory": "logs", "max_file_size_mb": 20 }
});
let config: LoggingConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(config.request_dump_retention_days, 1);
assert_eq!(config.file.retention_days, 7);
assert!(!config.oauth_recovery);
value["file"]["max_files"] = serde_json::json!(20);
assert!(serde_json::from_value::<LoggingConfig>(value).is_err());
}
#[test]
fn dedicated_file_channels_can_be_enabled_without_application_sinks() {
for oauth_recovery in [false, true] {
let mut config = valid_config();
config.logging.stdout = false;
config.logging.file.enabled = false;
config.logging.oauth_recovery = oauth_recovery;
config.logging.request_dump = !oauth_recovery;
config
.resolve_and_validate(std::path::Path::new("/srv/gateway"))
.unwrap();
}
}
fn valid_config() -> HostConfig {
let system_update = SystemUpdateConfig {
update_state_file: PathBuf::from("update-state.json"),
@@ -101,9 +146,10 @@ fn valid_config() -> HostConfig {
directory: PathBuf::from(".runtime/logs"),
retention_days: 7,
max_file_size_mb: 100,
max_files: 30,
},
oauth_recovery: false,
request_dump: false,
request_dump_retention_days: 1,
},
system_update,
drain_timeout_seconds: 30,
@@ -1,16 +1,20 @@
use std::env;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use gateway_host::config::{FileLoggingConfig, HostConfig, ListenConfig, LoggingConfig};
use gateway_host::system_update::SystemUpdateConfig;
mod sink;
mod writer;
const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY";
const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD";
const REQUEST_DUMP_ENABLED_ENV: &str = "CPR_LOGGING_TEST_REQUEST_DUMP_ENABLED";
const APPLICATION_LOG_FILE_PREFIX: &str = "codex-proxy-rs.";
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oath.";
const APPLICATION_LOG_FILE_PREFIX: &str = "codex-proxy-rs-application.";
const OAUTH_RECOVERY_LOG_FILE_PREFIX: &str = "codex-proxy-rs-oauth-recovery.";
const REQUEST_DUMP_LOG_FILE_PREFIX: &str = "codex-proxy-rs-request-dump.";
const APPLICATION_LOG_TARGET: &str = "logging_test_application";
const APPLICATION_LOG_MARKER: &str = "application-file-filter-test";
@@ -37,9 +41,10 @@ fn logging_requires_at_least_one_sink() {
directory: PathBuf::from("logs"),
retention_days: 7,
max_file_size_mb: 100,
max_files: 30,
},
oauth_recovery: false,
request_dump: false,
request_dump_retention_days: 1,
},
system_update: SystemUpdateConfig::default(),
drain_timeout_seconds: 30,
@@ -143,8 +148,115 @@ fn sensitive_file_logging_is_separate_and_overrides_global_log_level() {
assert!(!application_log.contains(REQUEST_DUMP_SECRET));
}
#[test]
fn oauth_recovery_switch_controls_only_its_dedicated_file() {
const OAUTH_ENABLED_ENV: &str = "CPR_LOGGING_TEST_OAUTH_ENABLED";
const APPLICATION_ENABLED_ENV: &str = "CPR_LOGGING_TEST_APPLICATION_ENABLED";
const TOKEN: &str = "synthetic-oauth-token-must-stay-in-recovery-file";
if env::var_os(CHILD_PROCESS_ENV).is_some() {
let mut config =
logging_config(PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap()), true);
config.logging.oauth_recovery = env::var(OAUTH_ENABLED_ENV).unwrap() == "true";
config.logging.file.enabled = env::var(APPLICATION_ENABLED_ENV).unwrap() == "true";
config.logging.stdout = true;
with_logging(config, || {
tracing::info!(target: APPLICATION_LOG_TARGET, marker = APPLICATION_LOG_MARKER);
tracing::info!(target: OAUTH_RECOVERY_LOG_TARGET, access_token = TOKEN);
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, marker = REQUEST_DUMP_LOG_MARKER);
});
return;
}
for application_enabled in [false, true] {
for oauth_enabled in [false, true] {
let directory = tempfile::tempdir().unwrap();
let output = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"logging::oauth_recovery_switch_controls_only_its_dedicated_file",
"--nocapture",
])
.env(CHILD_PROCESS_ENV, "1")
.env(LOG_DIRECTORY_ENV, directory.path())
.env(OAUTH_ENABLED_ENV, oauth_enabled.to_string())
.env(APPLICATION_ENABLED_ENV, application_enabled.to_string())
.env("RUST_LOG", "info,oauth_recovery=trace,request_dump=trace")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!String::from_utf8_lossy(&output.stdout).contains(TOKEN));
assert!(!String::from_utf8_lossy(&output.stderr).contains(TOKEN));
assert_eq!(
log_file_set_exists(directory.path(), OAUTH_RECOVERY_LOG_FILE_PREFIX),
oauth_enabled
);
assert_eq!(
log_file_set_exists(directory.path(), APPLICATION_LOG_FILE_PREFIX),
application_enabled
);
if oauth_enabled {
assert!(
read_log_file_set(directory.path(), OAUTH_RECOVERY_LOG_FILE_PREFIX)
.contains(TOKEN)
);
}
if application_enabled {
let body = read_log_file_set(directory.path(), APPLICATION_LOG_FILE_PREFIX);
assert!(body.contains(APPLICATION_LOG_MARKER));
assert!(!body.contains(TOKEN));
} else {
assert!(String::from_utf8_lossy(&output.stdout).contains(APPLICATION_LOG_MARKER));
}
let dump = read_log_file_set(directory.path(), REQUEST_DUMP_LOG_FILE_PREFIX);
assert!(dump.contains(REQUEST_DUMP_LOG_MARKER));
assert!(!dump.contains(TOKEN));
}
}
}
fn write_sensitive_logs(directory: PathBuf, request_dump: bool) {
let config = HostConfig {
with_file_logging(directory, request_dump, || {
tracing::info!(
target: APPLICATION_LOG_TARGET,
marker = APPLICATION_LOG_MARKER,
"application file test record"
);
tracing::info!(
target: OAUTH_RECOVERY_LOG_TARGET,
provider = OAUTH_RECOVERY_PROVIDER,
marker = OAUTH_RECOVERY_LOG_MARKER,
"OAuth recovery test record"
);
tracing::info!(
target: REQUEST_DUMP_LOG_TARGET,
marker = REQUEST_DUMP_LOG_MARKER,
authorization = REQUEST_DUMP_SECRET,
"request dump test record"
);
});
}
fn with_file_logging(directory: PathBuf, request_dump: bool, write: impl FnOnce()) {
with_logging(logging_config(directory, request_dump), write);
}
fn with_logging(config: HostConfig, write: impl FnOnce()) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("create logging runtime");
let bundle = runtime
.block_on(gateway_host::initialize(config))
.expect("initialize logging");
write();
drop(bundle);
}
fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig {
HostConfig {
listen: ListenConfig {
host: "127.0.0.1".to_owned(),
port: 8080,
@@ -156,42 +268,17 @@ fn write_sensitive_logs(directory: PathBuf, request_dump: bool) {
file: FileLoggingConfig {
enabled: true,
directory,
retention_days: 1,
retention_days: 7,
max_file_size_mb: 1,
max_files: 1,
},
oauth_recovery: true,
request_dump,
request_dump_retention_days: 1,
},
system_update: SystemUpdateConfig::default(),
drain_timeout_seconds: 30,
worker_shutdown_timeout_seconds: 30,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("create logging runtime");
let bundle = runtime
.block_on(gateway_host::initialize(config))
.expect("initialize logging");
tracing::info!(
target: APPLICATION_LOG_TARGET,
marker = APPLICATION_LOG_MARKER,
"application file test record"
);
tracing::info!(
target: OAUTH_RECOVERY_LOG_TARGET,
provider = OAUTH_RECOVERY_PROVIDER,
marker = OAUTH_RECOVERY_LOG_MARKER,
"OAuth recovery test record"
);
tracing::info!(
target: REQUEST_DUMP_LOG_TARGET,
marker = REQUEST_DUMP_LOG_MARKER,
authorization = REQUEST_DUMP_SECRET,
"request dump test record"
);
drop(bundle);
}
}
fn read_log_file_set(directory: &Path, file_prefix: &str) -> String {
@@ -201,7 +288,10 @@ fn read_log_file_set(directory: &Path, file_prefix: &str) -> String {
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(file_prefix))
.is_some_and(|name| {
name.starts_with(file_prefix)
&& (name.ends_with(".log") || name.ends_with(".log.gz"))
})
})
.collect::<Vec<_>>();
files.sort();
@@ -209,7 +299,17 @@ fn read_log_file_set(directory: &Path, file_prefix: &str) -> String {
assert!(!files.is_empty(), "expected log file set {file_prefix}");
files
.into_iter()
.map(|path| fs::read_to_string(path).expect("read log file"))
.map(|path| {
if path.extension().is_some_and(|extension| extension == "gz") {
let mut body = String::new();
flate2::read::GzDecoder::new(fs::File::open(path).expect("open archive"))
.read_to_string(&mut body)
.expect("read complete archive");
body
} else {
fs::read_to_string(path).expect("read log file")
}
})
.collect()
}
@@ -218,10 +318,10 @@ fn log_file_set_exists(directory: &Path, file_prefix: &str) -> bool {
.expect("read log directory")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(file_prefix))
entry.file_name().to_str().is_some_and(|name| {
name.starts_with(file_prefix)
&& (name.ends_with(".log") || name.ends_with(".log.gz"))
})
})
}
@@ -0,0 +1,108 @@
use super::*;
use gateway_core::health::HealthState;
use std::time::Duration;
#[test]
fn file_queue_drains_every_record_before_normal_shutdown() {
if env::var_os(CHILD_PROCESS_ENV).is_some() {
with_file_logging(
PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap()),
true,
|| {
let payload = "x".repeat(16 * 1024);
for sequence in 0..2000 {
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, sequence, payload, "queue record");
}
},
);
return;
}
let directory = tempfile::tempdir().unwrap();
let output = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"logging::sink::file_queue_drains_every_record_before_normal_shutdown",
])
.env(CHILD_PROCESS_ENV, "1")
.env(LOG_DIRECTORY_ENV, directory.path())
.env("RUST_LOG", "off")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let body = read_log_file_set(directory.path(), REQUEST_DUMP_LOG_FILE_PREFIX);
let mut sequences = body
.lines()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).unwrap()["fields"]["sequence"]
.as_u64()
.unwrap()
})
.collect::<Vec<_>>();
sequences.sort_unstable();
assert_eq!(sequences, (0..2000).collect::<Vec<_>>());
}
#[test]
fn write_failure_marks_log_completeness_unhealthy_even_after_writes_recover() {
if env::var_os(CHILD_PROCESS_ENV).is_some() {
let directory = PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let bundle = runtime
.block_on(gateway_host::initialize(logging_config(
directory.clone(),
true,
)))
.unwrap();
let health = bundle.logging_health_probe();
assert_eq!(runtime.block_on(health.check()), HealthState::Healthy);
let blocked = directory.join(format!(
"{REQUEST_DUMP_LOG_FILE_PREFIX}{}.1.log",
chrono::Utc::now().date_naive()
));
fs::create_dir(&blocked).unwrap();
let payload = "x".repeat(1024 * 1024);
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, payload, marker="before-failure");
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, payload, marker="cannot-write");
runtime.block_on(async {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if matches!(health.check().await, HealthState::Unhealthy(_)) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
});
fs::remove_dir(&blocked).unwrap();
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, marker="after-recovery");
drop(bundle);
assert!(matches!(
runtime.block_on(health.check()),
HealthState::Unhealthy(_)
));
return;
}
let directory = tempfile::tempdir().unwrap();
let output = Command::new(env::current_exe().unwrap())
.args(["--nocapture", "--exact", "logging::sink::write_failure_marks_log_completeness_unhealthy_even_after_writes_recover"])
.env(CHILD_PROCESS_ENV, "1").env(LOG_DIRECTORY_ENV, directory.path())
.env("RUST_LOG", "off").output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stderr).contains("log completeness compromised"));
let body = read_log_file_set(directory.path(), REQUEST_DUMP_LOG_FILE_PREFIX);
assert!(body.contains("before-failure") && body.contains("after-recovery"));
assert!(!body.contains("cannot-write"));
}
@@ -0,0 +1,118 @@
use super::*;
use std::time::SystemTime;
#[test]
fn retention_preserves_complete_dates_across_compression_rotation_and_restart() {
if env::var_os(CHILD_PROCESS_ENV).is_some() {
with_file_logging(
PathBuf::from(env::var_os(LOG_DIRECTORY_ENV).unwrap()),
true,
|| {
let payload = "x".repeat(1024 * 1024);
for sequence in 0..25 {
tracing::info!(target: REQUEST_DUMP_LOG_TARGET, sequence, payload, "retention record");
tracing::info!(target: APPLICATION_LOG_TARGET, sequence, payload, "retention record");
tracing::info!(target: OAUTH_RECOVERY_LOG_TARGET, sequence, payload, "retention record");
}
},
);
return;
}
let directory = tempfile::tempdir().unwrap();
let today = chrono::Utc::now().date_naive();
let mut retained = Vec::new();
let mut expired = Vec::new();
for (prefix, days) in [
(APPLICATION_LOG_FILE_PREFIX, 7),
(OAUTH_RECOVERY_LOG_FILE_PREFIX, 7),
(REQUEST_DUMP_LOG_FILE_PREFIX, 1),
] {
let boundary = today - chrono::Days::new(days);
for segment in 1..=25 {
let path = directory
.path()
.join(format!("{prefix}{boundary}.{segment}.log"));
seed_log(&path, boundary, &format!("boundary-{segment}\n"));
retained.push(path);
}
let old_date = boundary - chrono::Days::new(1);
let path = directory.path().join(format!("{prefix}{old_date}.log"));
seed_log(&path, old_date, "expired date\n");
expired.push(path);
// If any old segment was recently written, protect its whole UTC date.
let restored_date = old_date - chrono::Days::new(1);
for segment in 0..=1 {
let path = directory
.path()
.join(format!("{prefix}{restored_date}.{segment}.log"));
seed_log(
&path,
if segment == 0 { today } else { restored_date },
"restored date\n",
);
retained.push(path);
}
}
let unrelated = directory.path().join("unmanaged.log");
fs::write(&unrelated, "unmanaged\n").unwrap();
// A crash before archive publication leaves the original and possibly a partial temporary file.
fs::write(
retained[0].with_extension("log.gz.tmp"),
"unfinished archive",
)
.unwrap();
for run in 1..=2 {
let output = Command::new(env::current_exe().unwrap())
.args(["--exact", "logging::writer::retention_preserves_complete_dates_across_compression_rotation_and_restart"])
.env(CHILD_PROCESS_ENV, "1").env(LOG_DIRECTORY_ENV, directory.path())
.env("RUST_LOG", "off,logging_test_application=info").output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
retained
.iter()
.all(|path| path.with_extension("log.gz").exists())
);
assert!(
expired
.iter()
.all(|path| !path.exists() && !path.with_extension("log.gz").exists())
);
assert!(unrelated.exists());
for prefix in [
APPLICATION_LOG_FILE_PREFIX,
OAUTH_RECOVERY_LOG_FILE_PREFIX,
REQUEST_DUMP_LOG_FILE_PREFIX,
] {
let body = read_log_file_set(directory.path(), prefix);
for sequence in 0..25 {
assert_eq!(
body.matches(&format!(r#""sequence":{sequence},"#)).count(),
run
);
}
for segment in 1..=25 {
assert!(
body.contains(&format!("boundary-{segment}\n")),
"whole boundary date must survive"
);
}
}
}
}
fn seed_log(path: &Path, date: chrono::NaiveDate, body: &str) {
fs::write(path, body).unwrap();
let modified = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
date.and_hms_opt(0, 0, 0).unwrap(),
chrono::Utc,
);
fs::File::open(path)
.unwrap()
.set_modified(SystemTime::from(modified))
.unwrap();
}
@@ -1,6 +1,7 @@
mod client_distribution;
mod config;
mod logging;
mod proxy_probe;
mod serve;
mod system_update;
mod workers;
@@ -0,0 +1,78 @@
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header},
};
#[tokio::test]
async fn proxy_probe_sends_authentication_through_explicit_proxy() {
let proxy_server = MockServer::start().await;
Mock::given(header("proxy-authorization", "Basic dXNlcjpwYXNzd29yZA=="))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"})))
.expect(1)
.mount(&proxy_server)
.await;
let proxy =
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())).unwrap();
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&proxy)
.await;
assert!(result.success);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
}
#[tokio::test]
async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_responses() {
for response in [
ResponseTemplate::new(407),
ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1/"),
ResponseTemplate::new(200).set_body_json(json!({"ip": "not-an-ip"})),
ResponseTemplate::new(200).set_body_string("a".repeat(1025)),
] {
let proxy_server = MockServer::start().await;
Mock::given(any())
.respond_with(response)
.expect(1)
.mount(&proxy_server)
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap())
.await;
assert!(!result.success);
assert!(result.exit_ip.is_none());
}
}
#[tokio::test]
async fn unavailable_proxy_never_falls_back_to_direct_connection() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip":"203.0.113.8"})))
.expect(0)
.mount(&target)
.await;
let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap();
drop(unused);
let result = HttpProxyProbe::new(target.uri()).test(&proxy).await;
assert!(!result.success);
}
#[tokio::test]
async fn invalid_certificate_configuration_should_not_fall_back_or_expose_details() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&target)
.await;
let result = HttpProxyProbe::new(target.uri())
.with_client_builder(|_| Err("private-certificate-path"))
.test(&OutboundProxy::parse(&target.uri()).unwrap())
.await;
assert!(!result.success);
assert!(!result.message.contains("private-certificate-path"));
}

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