diff --git a/docs/implementation-status.md b/docs/implementation-status.md index c73942fa5..7bdb8b02f 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -16,10 +16,10 @@ Update this file before each feature commit. | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, 401-triggered refresh and single retry for `/v1/responses`, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, and shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, and Chat Completions. Still missing durable release-time slot state, WebSocket/history fallback policy, durable quota cooldown persistence, and admin account CRUD. | | Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. | | Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/admin/api-keys`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, full admin API-key mutation/export/import routes, settings mutation/persistence, diagnostics, backend model refresh, model cache persistence, live Chat streaming transform, and WebSocket/history fallback policy. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + pending | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; Task 6 cache targeted tests before pending | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/admin/api-keys`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, full admin API-key mutation/export/import routes, settings mutation/persistence, diagnostics, live backend model refresh, live Chat streaming transform, and WebSocket/history fallback policy. | | Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. | | HTTP auth boundary | Completed | `cec4607` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | -| Model catalog | Partial | `6341336` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; full `fmt/test/clippy` before commit | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, and debug summary. Still missing backend Codex model fetching per plan, cache persistence, `/admin/refresh-models`, plan allowlist integration from fetched models, and full model metadata parity. | +| Model catalog | Partial | `6341336` + pending | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models`; full `fmt/test/clippy` before pending commit | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing including `none` and `minimal`, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, debug summary, SQLite-backed backend model snapshots by plan, cached backend catalog merge, and model-to-plan allowlist derivation. Still missing live backend Codex model fetching per plan, `/admin/refresh-models`, runtime pool allowlist refresh from fetched models, and full model metadata parity. | | Upstream lifecycle | Partial | `88cad87` + `225fbff` + `c2cf7a6` + `636fa79` + `ee0ed90` + `fdb9374` + `5bf012c` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` | `cargo test responses_route_rejects_non_codex_provider_models`; `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test --test codex_sse_usage_test`; `cargo test --test codex_client_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test codex_websocket_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | `/v1` enforces client API key shape, rejects non-Codex provider models through the model catalog, returns OpenAI-compatible errors, includes reusable SSE/usage helpers, sends Codex Desktop HTTP SSE requests through imported accounts, sends WebSocket `response.create` requests when `previous_response_id` requires upstream history, replays/captures encrypted Cookies, forwards Codex request context headers, normalizes `service_tier: fast` to upstream `priority`, collects `response.completed`, reconstructs missing assistant output from `response.output_item.done` or text deltas, synchronizes `output_text` for non-streaming clients, passes through Responses streaming SSE by default, adapts Chat Completions responses/chunks, refreshes and retries once after upstream 401 when a refresh token is stored, parses Retry-After plus Codex reset metadata from upstream errors, records 429 request attempts, classifies upstream 429/402/403 into account cooldown/quota/banned/Cloudflare state changes, retries non-streaming `/v1/responses`, HTTP SSE `/v1/responses` setup, and `/v1/chat/completions` with another imported account when available, records token usage, writes v1 lifecycle event logs, records refresh failure classification for upstream 401, and restores the account pool from SQLite on startup. Still missing SSE error/failed mapping, successful rate-limit header capture, WebSocket/history retry policy, durable 429 quota cooldown persistence, previous-response account affinity, and live Chat streaming transform. | | Codex WebSocket boundary | Completed | `cc0516d` + `b9c30ba` + `350ffbe` | `cargo test --test codex_websocket_test`; `cargo test --test v1_upstream_route_test v1_responses_should_use_websocket_for_previous_response_id_streaming`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe` | Adds `src/codex/websocket.rs` with tested transport classification for HTTP SSE versus WebSocket-only requests. `previous_response_id` and explicit WebSocket requests use typed transport selection, local-only `use_websocket` is not serialized into upstream JSON, WebSocket `response.create` carries Codex headers and parity fields, incoming WebSocket messages are emitted as SSE-compatible events, and `/v1/responses` returns WebSocket-backed `text/event-stream` output for `previous_response_id` streaming requests. | | Fingerprint updates | Partial | `dd26d31` + `e32220a` | `cargo test update_manifest_updates_app_version_and_build_number`; `cargo test fingerprint_updater_fetches_manifest_and_persists_history`; full `fmt/test/clippy` before commit | Parses a JSON update manifest and persists app version/build number history to SQLite. Still missing original appcast XML polling, update-state persistence, runtime header fingerprint selection from the latest stored fingerprint, and optional local Codex.app Chromium extraction. | diff --git a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md index 69295e373..5db289414 100644 --- a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md +++ b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md @@ -8,7 +8,7 @@ **Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency. -**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Remaining major work continues with WebSocket/history fallback policy, model refresh/cache, and scoped admin operations. +**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Pending Task 6 cache groundwork adds persisted backend model snapshots and cached `/v1/models*` reads. Remaining major work continues with WebSocket/history fallback policy, live model refresh, and scoped admin operations. --- @@ -94,9 +94,11 @@ - Modify: `src/http/v1.rs` - Test: `tests/model_catalog_test.rs` -- [ ] Write failing tests for backend model snapshots, suffix parsing including `none` and `minimal`, plan allowlist generation, and `/admin/refresh-models`. -- [ ] Run: `cargo test --test model_catalog_test` -- [ ] Add SQLite-backed model cache and refresh route using existing Codex accounts. +- [x] Write failing tests for backend model snapshots, suffix parsing including `none` and `minimal`, plan allowlist generation, cached `/v1/models/catalog`, and model snapshot storage. +- [x] Run: `cargo test --test model_catalog_test`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models` +- [x] Add SQLite-backed model snapshot cache and make `/v1/models*` use cached backend snapshots when present. +- [ ] Write failing tests for `/admin/refresh-models`. +- [ ] Add refresh route using existing imported Codex accounts, without proxy-pool support. - [ ] Run: `cargo test --test model_catalog_test` ### Task 7: Scoped Admin Account Operations diff --git a/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md b/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md index 9d58d38a5..c48616cad 100644 --- a/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md +++ b/docs/superpowers/specs/2026-06-12-openai-gpt-codex-parity-design.md @@ -18,7 +18,7 @@ OpenAI Chat compatibility is implemented as a real route, not an alias to Respon Responses compatibility keeps native Responses passthrough semantics but adds the TS implementation's in-scope fields: reasoning/service tier, tool choice, parallel tool calls, text formats, prompt cache key, include, client metadata, and Codex context headers. `previous_response_id` uses WebSocket transport rather than being rejected. -Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Commit `3ecd5b6` partially implements Task 5 for non-streaming `/v1/responses`: upstream 429/402/403 responses classify account state and retry another imported account when available. Commit `3960a35` extends that fallback to HTTP SSE Responses setup and Chat Completions. WebSocket-backed `previous_response_id` fallback remains a Task 5 follow-up because account affinity must be preserved. +Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Commit `3ecd5b6` partially implements Task 5 for non-streaming `/v1/responses`: upstream 429/402/403 responses classify account state and retry another imported account when available. Commit `3960a35` extends that fallback to HTTP SSE Responses setup and Chat Completions. Pending Task 6 cache groundwork stores backend model snapshots by account plan and lets `/v1/models*` read cached backend models when present. WebSocket-backed `previous_response_id` fallback remains a Task 5 follow-up because account affinity must be preserved. ## Components @@ -29,7 +29,7 @@ Current progress: Tasks 1-4 are implemented for the OpenAI GPT/Codex scope. Comm - `src/codex/client.rs`: HTTP SSE and WebSocket Codex transport, context headers, cookies, and rate-limit header capture. - `src/http/v1.rs`: Separate `/v1/responses` and `/v1/chat/completions` handlers sharing upstream lifecycle helpers. - `src/accounts/pool.rs` and repositories: fallback selection, quota/rate-limit updates, durable usage release, and account state mutation hooks. -- `src/models/catalog.rs`: static catalog plus persisted backend snapshots and admin refresh trigger. +- `src/models/catalog.rs`: static catalog plus persisted backend snapshots, plan allowlist derivation, and admin refresh trigger. - `src/http/admin.rs`: scoped account/API-key/account-health/quota/cookie/admin model operations needed to operate Codex-backed GPT compatibility. ## Data Flow diff --git a/migrations/0003_model_plan_snapshots.sql b/migrations/0003_model_plan_snapshots.sql new file mode 100644 index 000000000..21845cb54 --- /dev/null +++ b/migrations/0003_model_plan_snapshots.sql @@ -0,0 +1,5 @@ +create table if not exists model_plan_snapshots ( + plan_type text primary key, + models_json text not null, + fetched_at text not null +); diff --git a/src/http/v1.rs b/src/http/v1.rs index 75b442e3d..5bb8531f6 100644 --- a/src/http/v1.rs +++ b/src/http/v1.rs @@ -99,7 +99,7 @@ pub async fn responses( .unwrap_or_else(|_| default_body(default_model.clone())); let client_stream = body.stream.unwrap_or(true); let requested_model = body.model.clone().unwrap_or(default_model); - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; if !catalog.is_recognized_model_name(&requested_model) { return ( StatusCode::NOT_FOUND, @@ -348,7 +348,7 @@ pub async fn chat_completions( }; let client_stream = chat_request.stream; let requested_model = chat_request.model.clone(); - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; if !catalog.is_recognized_model_name(&requested_model) { return model_not_found_response().into_response(); } @@ -556,6 +556,23 @@ fn no_available_accounts_response() -> (StatusCode, Json) { ) } +async fn model_catalog_for_state(state: &AppState) -> ModelCatalog { + let Some(repo) = state.model_snapshot_repository() else { + return ModelCatalog::from_config(&state.config().model); + }; + match repo.list_plan_snapshots().await { + Ok(snapshots) if !snapshots.is_empty() => { + ModelCatalog::from_config_and_snapshots(&state.config().model, &snapshots) + } + Ok(_) => ModelCatalog::from_config(&state.config().model), + Err(error) => { + // 模型缓存只是加速和扩展 catalog;读取失败时回退静态配置,避免影响核心请求链路。 + tracing::warn!(?error, "failed to load cached model snapshots"); + ModelCatalog::from_config(&state.config().model) + } + } +} + fn default_body(default_model: String) -> ResponsesBody { ResponsesBody { model: Some(default_model), @@ -1664,7 +1681,7 @@ pub async fn models(State(state): State, headers: HeaderMap) -> impl I return missing_client_api_key_response(); } - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; let data = catalog .models() .iter() @@ -1684,7 +1701,7 @@ pub async fn model_catalog(State(state): State, headers: HeaderMap) -> return missing_client_api_key_response(); } - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; (StatusCode::OK, Json(json!(catalog.models()))) } @@ -1697,7 +1714,7 @@ pub async fn model_detail( return missing_client_api_key_response(); } - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; if catalog.model_info(&model_id).is_none() { return model_not_found_response(); } @@ -1713,7 +1730,7 @@ pub async fn model_info( return missing_client_api_key_response(); } - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; let Some(info) = catalog.model_info(&model_id) else { return model_not_found_response(); }; @@ -1725,7 +1742,7 @@ pub async fn debug_models(State(state): State, headers: HeaderMap) -> return missing_client_api_key_response(); } - let catalog = ModelCatalog::from_config(&state.config().model); + let catalog = model_catalog_for_state(&state).await; (StatusCode::OK, Json(json!(catalog.debug()))) } diff --git a/src/models/catalog.rs b/src/models/catalog.rs index 30ff1a1fa..7d2db12b2 100644 --- a/src/models/catalog.rs +++ b/src/models/catalog.rs @@ -1,20 +1,21 @@ use std::collections::{BTreeMap, BTreeSet}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::config::ModelConfig; const SERVICE_TIER_SUFFIXES: [&str; 2] = ["fast", "flex"]; -const REASONING_EFFORT_SUFFIXES: [&str; 4] = ["low", "medium", "high", "xhigh"]; +const REASONING_EFFORT_SUFFIXES: [&str; 6] = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const STATIC_REASONING_EFFORTS: [&str; 4] = ["low", "medium", "high", "xhigh"]; -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ReasoningEffortInfo { pub reasoning_effort: String, pub description: String, } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct CodexModelInfo { pub id: String, @@ -28,6 +29,14 @@ pub struct CodexModelInfo { pub supports_personality: bool, pub upgrade: Option, pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_context_window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub truncation_policy_limit: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -58,6 +67,69 @@ pub struct ModelCatalog { models: Vec, aliases: BTreeMap, default_model: String, + model_plan_index: BTreeMap>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BackendReasoningEffort { + #[serde(default, rename = "reasoning_effort", alias = "reasoningEffort")] + pub reasoning_effort: Option, + #[serde(default)] + pub effort: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BackendTruncationPolicy { + pub limit: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct BackendModelEntry { + pub slug: Option, + pub id: Option, + pub name: Option, + pub display_name: Option, + pub description: Option, + pub is_default: Option, + pub default_reasoning_effort: Option, + pub default_reasoning_level: Option, + #[serde(default)] + pub supported_reasoning_efforts: Vec, + #[serde(default)] + pub supported_reasoning_levels: Vec, + pub input_modalities: Option>, + pub output_modalities: Option>, + pub supports_personality: Option, + pub upgrade: Option, + #[serde(alias = "contextWindow")] + pub context_window: Option, + #[serde(alias = "maxContextWindow")] + pub max_context_window: Option, + #[serde(alias = "maxOutputTokens")] + pub max_output_tokens: Option, + #[serde(alias = "truncationPolicy")] + pub truncation_policy: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelPlanSnapshot { + pub plan_type: String, + pub models: Vec, +} + +impl ModelPlanSnapshot { + pub fn from_backend_entries( + plan_type: impl Into, + entries: Vec, + ) -> Self { + Self { + plan_type: plan_type.into(), + models: entries.into_iter().map(normalize_backend_model).collect(), + } + } } impl ModelCatalog { @@ -68,6 +140,44 @@ impl ModelCatalog { models: vec![default_model_info(config)], aliases, default_model, + model_plan_index: BTreeMap::new(), + } + } + + pub fn from_config_and_snapshots( + config: &ModelConfig, + snapshots: &[ModelPlanSnapshot], + ) -> Self { + if snapshots.is_empty() { + return Self::from_config(config); + } + + let aliases = normalize_aliases(&config.aliases); + let default_model = config.default_model.trim().to_string(); + let mut models_by_id = BTreeMap::new(); + let mut model_plan_index: BTreeMap> = BTreeMap::new(); + + for snapshot in snapshots { + for model in &snapshot.models { + // 后端模型是按账号 plan 返回的;catalog 需要去重展示,调度层仍要保留 model -> plans。 + models_by_id.insert(model.id.clone(), model.clone()); + let plans = model_plan_index.entry(model.id.clone()).or_default(); + if !plans.contains(&snapshot.plan_type) { + plans.push(snapshot.plan_type.clone()); + } + } + } + + let models = models_by_id.into_values().collect::>(); + if models.is_empty() { + return Self::from_config(config); + } + + Self { + models, + aliases, + default_model, + model_plan_index, } } @@ -98,6 +208,10 @@ impl ModelCatalog { } } + pub fn model_plan_allowlist(&self) -> BTreeMap> { + self.model_plan_index.clone() + } + pub fn is_recognized_model_name(&self, input: &str) -> bool { let trimmed = input.trim(); if trimmed.is_empty() { @@ -223,7 +337,7 @@ fn default_model_info(config: &ModelConfig) -> CodexModelInfo { display_name: id, description: "Codex default model".to_string(), is_default: true, - supported_reasoning_efforts: REASONING_EFFORT_SUFFIXES + supported_reasoning_efforts: STATIC_REASONING_EFFORTS .into_iter() .map(|effort| ReasoningEffortInfo { reasoning_effort: effort.to_string(), @@ -236,5 +350,78 @@ fn default_model_info(config: &ModelConfig) -> CodexModelInfo { supports_personality: false, upgrade: None, source: "static".to_string(), + context_window: None, + max_context_window: None, + max_output_tokens: None, + truncation_policy_limit: None, } } + +fn normalize_backend_model(raw: BackendModelEntry) -> CodexModelInfo { + let id = first_non_empty([raw.slug.as_deref(), raw.id.as_deref(), raw.name.as_deref()]) + .unwrap_or("unknown") + .to_string(); + let display_name = first_non_empty([raw.display_name.as_deref(), raw.name.as_deref()]) + .unwrap_or(&id) + .to_string(); + let reasoning_entries = if raw.supported_reasoning_efforts.is_empty() { + raw.supported_reasoning_levels + } else { + raw.supported_reasoning_efforts + }; + let mut supported_reasoning_efforts = reasoning_entries + .into_iter() + .filter_map(normalize_backend_reasoning_effort) + .collect::>(); + if supported_reasoning_efforts.is_empty() { + supported_reasoning_efforts.push(ReasoningEffortInfo { + reasoning_effort: "medium".to_string(), + description: "Default".to_string(), + }); + } + let default_reasoning_effort = first_non_empty([ + raw.default_reasoning_effort.as_deref(), + raw.default_reasoning_level.as_deref(), + ]) + .unwrap_or("medium") + .to_string(); + + CodexModelInfo { + id, + display_name, + description: raw.description.unwrap_or_default(), + is_default: raw.is_default.unwrap_or(false), + supported_reasoning_efforts, + default_reasoning_effort, + input_modalities: raw + .input_modalities + .unwrap_or_else(|| vec!["text".to_string()]), + output_modalities: raw + .output_modalities + .unwrap_or_else(|| vec!["text".to_string()]), + supports_personality: raw.supports_personality.unwrap_or(false), + upgrade: raw.upgrade, + source: "backend".to_string(), + context_window: raw.context_window, + max_context_window: raw.max_context_window, + max_output_tokens: raw.max_output_tokens, + truncation_policy_limit: raw.truncation_policy.and_then(|policy| policy.limit), + } +} + +fn normalize_backend_reasoning_effort(raw: BackendReasoningEffort) -> Option { + let reasoning_effort = + first_non_empty([raw.reasoning_effort.as_deref(), raw.effort.as_deref()])?.to_string(); + Some(ReasoningEffortInfo { + description: raw.description.unwrap_or_default(), + reasoning_effort, + }) +} + +fn first_non_empty<'a>(values: impl IntoIterator>) -> Option<&'a str> { + values + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 1b1f77cd5..27e868291 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1 +1,2 @@ pub mod catalog; +pub mod repository; diff --git a/src/models/repository.rs b/src/models/repository.rs new file mode 100644 index 000000000..502b660d1 --- /dev/null +++ b/src/models/repository.rs @@ -0,0 +1,62 @@ +use chrono::Utc; +use sqlx::{Row, SqlitePool}; +use thiserror::Error; + +use crate::models::catalog::{CodexModelInfo, ModelPlanSnapshot}; + +#[derive(Debug, Error)] +pub enum ModelSnapshotRepositoryError { + #[error("model snapshot database error: {0}")] + Database(#[from] sqlx::Error), + #[error("model snapshot json error: {0}")] + Json(#[from] serde_json::Error), +} + +pub type ModelSnapshotRepositoryResult = Result; + +#[derive(Debug, Clone)] +pub struct ModelSnapshotRepository { + pool: SqlitePool, +} + +impl ModelSnapshotRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn replace_plan_snapshot( + &self, + snapshot: &ModelPlanSnapshot, + ) -> ModelSnapshotRepositoryResult<()> { + let models_json = serde_json::to_string(&snapshot.models)?; + let fetched_at = Utc::now().to_rfc3339(); + sqlx::query( + "insert into model_plan_snapshots (plan_type, models_json, fetched_at) values (?, ?, ?) \ + on conflict(plan_type) do update set models_json = excluded.models_json, fetched_at = excluded.fetched_at", + ) + .bind(&snapshot.plan_type) + .bind(models_json) + .bind(fetched_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn list_plan_snapshots( + &self, + ) -> ModelSnapshotRepositoryResult> { + let rows = sqlx::query( + "select plan_type, models_json from model_plan_snapshots order by plan_type asc", + ) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + let plan_type: String = row.get("plan_type"); + let models_json: String = row.get("models_json"); + let models = serde_json::from_str::>(&models_json)?; + Ok(ModelPlanSnapshot { plan_type, models }) + }) + .collect() + } +} diff --git a/src/state.rs b/src/state.rs index 2b0d97632..511936e03 100644 --- a/src/state.rs +++ b/src/state.rs @@ -14,6 +14,7 @@ use crate::config::AppConfig; use crate::cookies::repository::CookieRepository; use crate::crypto::SecretBox; use crate::logs::repository::EventLogRepository; +use crate::models::repository::ModelSnapshotRepository; #[derive(Clone)] pub struct AppState { @@ -162,6 +163,10 @@ impl AppState { Some(ClientApiKeyRepository::new(self.db()?.clone())) } + pub fn model_snapshot_repository(&self) -> Option { + Some(ModelSnapshotRepository::new(self.db()?.clone())) + } + pub fn api_key_hasher(&self) -> Option<&ApiKeyHasher> { self.services.api_key_hasher.as_ref() } diff --git a/tests/model_catalog_test.rs b/tests/model_catalog_test.rs index 3d37ec4bb..e2eb503e8 100644 --- a/tests/model_catalog_test.rs +++ b/tests/model_catalog_test.rs @@ -2,7 +2,14 @@ use std::collections::BTreeMap; use codex_proxy_rs::{ config::ModelConfig, - models::catalog::{ModelCatalog, ParsedModelName}, + models::{ + catalog::{ + BackendModelEntry, BackendReasoningEffort, BackendTruncationPolicy, ModelCatalog, + ModelPlanSnapshot, ParsedModelName, + }, + repository::ModelSnapshotRepository, + }, + storage::db::connect_sqlite, }; #[test] @@ -42,3 +49,97 @@ fn model_catalog_should_fall_back_to_default_for_unknown_model_names() { assert_eq!(parsed.model_id, "gpt-5.5"); assert_eq!(parsed.reasoning_effort, Some("medium".to_string())); } + +#[test] +fn model_catalog_should_parse_none_and_minimal_reasoning_suffixes() { + let catalog = ModelCatalog::from_config(&ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: BTreeMap::new(), + }); + + let none = catalog.parse_model_name("gpt-5.5-none"); + let minimal = catalog.parse_model_name("gpt-5.5-minimal-fast"); + + assert_eq!(none.reasoning_effort, Some("none".to_string())); + assert_eq!(minimal.reasoning_effort, Some("minimal".to_string())); + assert_eq!(minimal.service_tier, Some("fast".to_string())); +} + +#[test] +fn model_catalog_should_merge_backend_snapshots_and_build_plan_allowlist() { + let snapshot = ModelPlanSnapshot::from_backend_entries( + "plus", + vec![BackendModelEntry { + slug: Some("gpt-6".to_string()), + display_name: Some("GPT-6".to_string()), + description: Some("Backend model".to_string()), + is_default: Some(true), + default_reasoning_level: Some("minimal".to_string()), + supported_reasoning_levels: vec![BackendReasoningEffort { + effort: Some("minimal".to_string()), + description: Some("Minimal".to_string()), + ..BackendReasoningEffort::default() + }], + input_modalities: Some(vec!["text".to_string(), "image".to_string()]), + output_modalities: Some(vec!["text".to_string()]), + supports_personality: Some(true), + context_window: Some(200_000), + max_output_tokens: Some(16_384), + truncation_policy: Some(BackendTruncationPolicy { + limit: Some(131_072), + }), + ..BackendModelEntry::default() + }], + ); + let catalog = ModelCatalog::from_config_and_snapshots( + &ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: BTreeMap::new(), + }, + &[snapshot], + ); + + let info = catalog.model_info("gpt-6").unwrap(); + assert_eq!(info.display_name, "GPT-6"); + assert_eq!(info.default_reasoning_effort, "minimal"); + assert_eq!( + info.supported_reasoning_efforts[0].reasoning_effort, + "minimal" + ); + assert_eq!(info.context_window, Some(200_000)); + assert_eq!(info.truncation_policy_limit, Some(131_072)); + assert_eq!( + catalog.model_plan_allowlist().get("gpt-6").unwrap(), + &vec!["plus".to_string()] + ); +} + +#[tokio::test] +async fn model_snapshot_repository_should_replace_and_load_plan_snapshots() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("models.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .unwrap(); + let repo = ModelSnapshotRepository::new(pool); + let snapshot = ModelPlanSnapshot::from_backend_entries( + "team", + vec![BackendModelEntry { + id: Some("gpt-team".to_string()), + name: Some("GPT Team".to_string()), + ..BackendModelEntry::default() + }], + ); + + repo.replace_plan_snapshot(&snapshot).await.unwrap(); + let loaded = repo.list_plan_snapshots().await.unwrap(); + + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].plan_type, "team"); + assert_eq!(loaded[0].models[0].id, "gpt-team"); + assert_eq!(loaded[0].models[0].source, "backend"); +} diff --git a/tests/routes_responses_test.rs b/tests/routes_responses_test.rs index 26fbd49fb..585db58c1 100644 --- a/tests/routes_responses_test.rs +++ b/tests/routes_responses_test.rs @@ -17,6 +17,10 @@ use codex_proxy_rs::{ UsageStatsConfig, }, crypto::SecretBox, + models::{ + catalog::{BackendModelEntry, ModelPlanSnapshot}, + repository::ModelSnapshotRepository, + }, state::AppState, storage::db::connect_sqlite, }; @@ -108,6 +112,38 @@ async fn test_app_with_client_api_key() -> (Router, String, tempfile::TempDir) { (app, generated.plaintext, dir) } +async fn test_app_with_backend_model_snapshot() -> (Router, String, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("routes-models.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let hasher = ApiKeyHasher::new([54u8; 32]); + let generated = hasher.generate_client_api_key("test"); + ClientApiKeyRepository::new(pool.clone()) + .insert_generated("test", &generated) + .await + .unwrap(); + let snapshot = ModelPlanSnapshot::from_backend_entries( + "plus", + vec![BackendModelEntry { + id: Some("gpt-6".to_string()), + name: Some("GPT-6".to_string()), + ..BackendModelEntry::default() + }], + ); + ModelSnapshotRepository::new(pool.clone()) + .replace_plan_snapshot(&snapshot) + .await + .unwrap(); + let app = build_router(AppState::with_pool_secret_and_api_key_hasher( + test_config_with_database(url), + pool, + SecretBox::new([55u8; 32]), + hasher, + )); + (app, generated.plaintext, dir) +} + #[tokio::test] async fn v1_requires_client_api_key_not_admin_cookie() { let app = build_router(AppState::new(test_config())); @@ -219,6 +255,29 @@ async fn model_catalog_route_returns_codex_metadata_without_alias_entries() { ); } +#[tokio::test] +async fn model_catalog_route_returns_cached_backend_models() { + let (app, api_key, _dir) = test_app_with_backend_model_snapshot().await; + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models/catalog") + .header("authorization", format!("Bearer {api_key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body.as_array().unwrap().len(), 1); + assert_eq!(body[0]["id"], "gpt-6"); + assert_eq!(body[0]["source"], "backend"); +} + #[tokio::test] async fn model_detail_route_returns_openai_model_for_known_model() { let (app, api_key, _dir) = test_app_with_client_api_key().await; diff --git a/tests/storage_schema_test.rs b/tests/storage_schema_test.rs index 41e7de874..c34c09c94 100644 --- a/tests/storage_schema_test.rs +++ b/tests/storage_schema_test.rs @@ -8,12 +8,12 @@ async fn sqlite_schema_creates_accounts_and_event_tables() { let pool = connect_sqlite(&url).await.unwrap(); let row: (i64,) = sqlx::query_as( - "select count(*) from sqlite_master where type = 'table' and name in ('accounts', 'client_api_keys', 'event_logs')", + "select count(*) from sqlite_master where type = 'table' and name in ('accounts', 'client_api_keys', 'event_logs', 'model_plan_snapshots')", ) .fetch_one(&pool) .await .unwrap(); - assert_eq!(row.0, 3); + assert_eq!(row.0, 4); } #[tokio::test]