diff --git a/docs/api.md b/docs/api.md index 646c059cd..21d74d39e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,6 +27,18 @@ event: error data: {"error":{"message":"Upstream failed","type":"server_error","param":null,"code":"upstream_error"}} ``` +### Model Routes + +`GET /v1/models` returns the OpenAI-compatible model list. The default static Codex model is `gpt-5.5`. + +`GET /v1/models/catalog` returns Codex model metadata for UI use. Aliases are used for request parsing but are not exposed as standalone models. + +`GET /v1/models/{id}` returns an OpenAI-compatible model object or a `404` OpenAI error body with `code=model_not_found`. + +`GET /v1/models/{id}/info` returns the extended Codex catalog entry for a known model. + +Model name parsing supports configured aliases plus `-low`, `-medium`, `-high`, `-xhigh`, `-fast`, and `-flex` suffixes. + ## `/admin/*` Admin endpoints are authenticated only by HttpOnly admin session cookies. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 8142eb3cc..1687bdbc4 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -16,10 +16,11 @@ Update this file before each feature commit. | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` | `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`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, 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, and capacity summary. Still missing settings-backed scheduling config, SQLite repository integration for pool snapshots/release usage, upstream retry/fallback wiring, 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` | `cargo test chat_completion_translates_to_codex_response_request`; full `fmt/test/clippy` before commit | Adds OpenAI Chat request types, Codex Responses request type, translation, and OpenAI-compatible error body helper. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` | `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`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. Still missing admin account/auth/API-key management routes, settings mutation/persistence, diagnostics, usage stats, model catalog/detail/refresh routes, and additional Responses routes. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `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`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. Still missing admin account/auth/API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, and additional Responses routes. | | 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` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_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 `Bearer cpr_...`, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | -| Upstream lifecycle | Partial | `88cad87` + `225fbff` | `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`; full `fmt/test/clippy` before commit | `/v1` currently enforces client API key shape, rejects non-Codex provider models, returns OpenAI-compatible errors, and includes reusable SSE/usage helpers. It does not yet acquire accounts, forward to Codex, replay/capture Cookies, stream/collect upstream responses, capture rate-limit headers, retry across accounts, record usage, or preserve previous-response/session affinity. | +| Model catalog | Partial | `pending` | `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. | +| Upstream lifecycle | Partial | `88cad87` + `225fbff` | `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`; full `fmt/test/clippy` before commit | `/v1` currently enforces client API key shape, rejects non-Codex provider models through the model catalog, returns OpenAI-compatible errors, and includes reusable SSE/usage helpers. It does not yet acquire accounts, forward to Codex, replay/capture Cookies, stream/collect upstream responses, capture rate-limit headers, retry across accounts, record usage, or preserve previous-response/session affinity. | | Codex WebSocket boundary | Completed | `cc0516d` | `cargo test --test codex_websocket_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds `src/codex/websocket.rs` with tested transport classification for HTTP SSE versus WebSocket-only requests. `previous_response_id` and explicit WebSocket requests now fail through a typed boundary instead of being silently downgraded; real WebSocket transport remains an upstream lifecycle follow-up. | | 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. | | Runtime docs and packaging | Completed | `33b63f3` | `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; `cargo run`; `curl -s -i http://127.0.0.1:8080/health` | README scope, `.env.example`, Dockerfile, compose file, and health smoke test are complete. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index b7cd9853c..6b794fbf2 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2961,6 +2961,7 @@ git commit -m "docs: document codex-proxy-rs scope" - [x] Extend account scheduling primitives against the original TypeScript lifecycle. `AccountPool` now covers max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, model-plan filtering, cached quota skip, exclude IDs, preferred account selection for session affinity, Cloudflare cooldown skip, request-staggering metadata, and capacity summary. - [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix. - [x] Add read-only `/admin/settings` for the retained Rust settings surface. The response is admin-session gated, lower camelCase, and excludes proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields. Persistent settings mutation remains a separate task. +- [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks. ## 2026-06-11 Original Project Gap Audit @@ -2976,7 +2977,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Read-only `/admin/settings` is complete; persistent mutation and config-store writeback remain. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings. - [ ] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. Keep production-local gating for sensitive debug endpoints. - [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and `/admin/usage-stats/summary` plus paginated or bounded history queries. Keep log file rotation and SQLite event pagination. -- [ ] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. +- [ ] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. Static defaults, aliases, suffix parsing, read-only model routes, and debug summary are complete. Backend fetch/cache, plan snapshots, and admin refresh remain. - [ ] **Account scheduling parity:** extend account acquisition beyond `active + least last_used`. Completed in the in-memory pool: max concurrent slots per account, stale slot cleanup, least-used/round-robin/sticky strategies, quota-cache skip, tier priority, model-plan filtering, exclude IDs, preferred account from session affinity, Cloudflare cooldown skip, request-staggering metadata, and capacity summary. Remaining: settings-backed scheduling config, pool snapshots from SQLite, release-time usage accounting through the repository, and upstream retry/fallback wiring. - [ ] **Upstream Codex lifecycle:** implement account acquire -> Codex request -> SSE/collect -> rate-limit capture -> usage release -> retry/fallback. Include Cookie replay/capture, Codex Desktop headers, `x-codex-turn-state`, `x-client-request-id`, upstream error classification, 401-triggered refresh, 429 quota cache, 402 quota exhausted, 403 ban/Cloudflare distinction, path-block Cookie clearing, and OpenAI-compatible error mapping. - [ ] **Session affinity and previous response:** persist an in-memory response-id to account mapping with TTL, prompt-cache/conversation identity, turn-state replay, and preferred account selection. `src/codex/websocket.rs` must either implement the WebSocket path with verified TLS/header behavior or the API must reject WebSocket-only cases explicitly with documented limitations until fingerprint parity is proven. diff --git a/src/app.rs b/src/app.rs index ec5607a03..ee407c1be 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use crate::{ admin::{login, logs, settings}, health::health, middleware::attach_request_id, - v1::{models, responses}, + v1::{debug_models, model_catalog, model_detail, model_info, models, responses}, }, state::AppState, }; @@ -21,6 +21,10 @@ pub fn build_router(state: AppState) -> Router { .route("/v1/responses", post(responses)) .route("/v1/chat/completions", post(responses)) .route("/v1/models", get(models)) + .route("/v1/models/catalog", get(model_catalog)) + .route("/v1/models/{model_id}", get(model_detail)) + .route("/v1/models/{model_id}/info", get(model_info)) + .route("/debug/models", get(debug_models)) .route("/admin/login", post(login)) .route("/admin/logs", get(logs)) .route("/admin/settings", get(settings)) diff --git a/src/http/v1.rs b/src/http/v1.rs index cd957dc6a..fd03c920d 100644 --- a/src/http/v1.rs +++ b/src/http/v1.rs @@ -1,36 +1,41 @@ use axum::{ body::Bytes, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, Json, }; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; -use crate::{http::auth::client_api_key, translation::codex_to_openai::openai_error}; +use crate::{ + http::auth::client_api_key, models::catalog::ModelCatalog, state::AppState, + translation::codex_to_openai::openai_error, +}; -const DEFAULT_CODEX_MODEL: &str = "gpt-5.5"; +const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000; #[derive(Deserialize)] struct ResponsesBody { model: Option, } -pub async fn responses(headers: HeaderMap, body: Bytes) -> impl IntoResponse { +pub async fn responses( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { if !has_client_api_key(&headers) { - return ( - StatusCode::UNAUTHORIZED, - Json(openai_error("Missing client API key", "invalid_api_key")), - ); + return missing_client_api_key_response(); } + let default_model = state.config().model.default_model.clone(); let body = serde_json::from_slice::(&body).unwrap_or_else(|_| ResponsesBody { - model: Some(DEFAULT_CODEX_MODEL.to_string()), + model: Some(default_model.clone()), }); - let model = body - .model - .unwrap_or_else(|| DEFAULT_CODEX_MODEL.to_string()); - if !(model.starts_with("gpt") || model.starts_with("codex") || model.starts_with('o')) { + let model = body.model.unwrap_or(default_model); + let catalog = ModelCatalog::from_config(&state.config().model); + if !catalog.is_recognized_model_name(&model) { return ( StatusCode::NOT_FOUND, Json(openai_error("Model not found", "model_not_found")), @@ -46,30 +51,99 @@ pub async fn responses(headers: HeaderMap, body: Bytes) -> impl IntoResponse { ) } -pub async fn models(headers: HeaderMap) -> impl IntoResponse { +pub async fn models(State(state): State, headers: HeaderMap) -> impl IntoResponse { if !has_client_api_key(&headers) { - return ( - StatusCode::UNAUTHORIZED, - Json(openai_error("Missing client API key", "invalid_api_key")), - ); + return missing_client_api_key_response(); } + let catalog = ModelCatalog::from_config(&state.config().model); + let data = catalog + .models() + .iter() + .map(|model| openai_model_json(&model.id)) + .collect::>(); ( StatusCode::OK, Json(json!({ "object": "list", - "data": [ - { - "id": DEFAULT_CODEX_MODEL, - "object": "model", - "created": 0, - "owned_by": "openai" - } - ] + "data": data })), ) } +pub async fn model_catalog(State(state): State, headers: HeaderMap) -> impl IntoResponse { + if !has_client_api_key(&headers) { + return missing_client_api_key_response(); + } + + let catalog = ModelCatalog::from_config(&state.config().model); + (StatusCode::OK, Json(json!(catalog.models()))) +} + +pub async fn model_detail( + State(state): State, + headers: HeaderMap, + Path(model_id): Path, +) -> impl IntoResponse { + if !has_client_api_key(&headers) { + return missing_client_api_key_response(); + } + + let catalog = ModelCatalog::from_config(&state.config().model); + if catalog.model_info(&model_id).is_none() { + return model_not_found_response(); + } + (StatusCode::OK, Json(openai_model_json(&model_id))) +} + +pub async fn model_info( + State(state): State, + headers: HeaderMap, + Path(model_id): Path, +) -> impl IntoResponse { + if !has_client_api_key(&headers) { + return missing_client_api_key_response(); + } + + let catalog = ModelCatalog::from_config(&state.config().model); + let Some(info) = catalog.model_info(&model_id) else { + return model_not_found_response(); + }; + (StatusCode::OK, Json(json!(info))) +} + +pub async fn debug_models(State(state): State, headers: HeaderMap) -> impl IntoResponse { + if !has_client_api_key(&headers) { + return missing_client_api_key_response(); + } + + let catalog = ModelCatalog::from_config(&state.config().model); + (StatusCode::OK, Json(json!(catalog.debug()))) +} + fn has_client_api_key(headers: &HeaderMap) -> bool { client_api_key(headers).is_some() } + +fn missing_client_api_key_response() -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(openai_error("Missing client API key", "invalid_api_key")), + ) +} + +fn model_not_found_response() -> (StatusCode, Json) { + ( + StatusCode::NOT_FOUND, + Json(openai_error("Model not found", "model_not_found")), + ) +} + +fn openai_model_json(id: &str) -> Value { + json!({ + "id": id, + "object": "model", + "created": MODEL_CREATED_TIMESTAMP, + "owned_by": "openai" + }) +} diff --git a/src/lib.rs b/src/lib.rs index 684e949f0..ed5363673 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod error; pub mod fingerprint; pub mod http; pub mod logs; +pub mod models; pub mod pagination; pub mod state; pub mod storage; diff --git a/src/models/catalog.rs b/src/models/catalog.rs new file mode 100644 index 000000000..30ff1a1fa --- /dev/null +++ b/src/models/catalog.rs @@ -0,0 +1,240 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::config::ModelConfig; + +const SERVICE_TIER_SUFFIXES: [&str; 2] = ["fast", "flex"]; +const REASONING_EFFORT_SUFFIXES: [&str; 4] = ["low", "medium", "high", "xhigh"]; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReasoningEffortInfo { + pub reasoning_effort: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodexModelInfo { + pub id: String, + pub display_name: String, + pub description: String, + pub is_default: bool, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: String, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub supports_personality: bool, + pub upgrade: Option, + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedModelName { + pub model_id: String, + pub reasoning_effort: Option, + pub service_tier: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelStoreDebug { + pub total_models: usize, + pub static_models: usize, + pub alias_count: usize, + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelDebugEntry { + pub id: String, + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelCatalog { + models: Vec, + aliases: BTreeMap, + default_model: String, +} + +impl ModelCatalog { + pub fn from_config(config: &ModelConfig) -> Self { + let default_model = config.default_model.trim().to_string(); + let aliases = normalize_aliases(&config.aliases); + Self { + models: vec![default_model_info(config)], + aliases, + default_model, + } + } + + pub fn models(&self) -> Vec { + self.models.clone() + } + + pub fn model_info(&self, model_id: &str) -> Option { + self.models + .iter() + .find(|model| model.id == model_id) + .cloned() + } + + pub fn debug(&self) -> ModelStoreDebug { + ModelStoreDebug { + total_models: self.models.len(), + static_models: self.models.len(), + alias_count: self.aliases.len(), + models: self + .models + .iter() + .map(|model| ModelDebugEntry { + id: model.id.clone(), + source: model.source.clone(), + }) + .collect(), + } + } + + pub fn is_recognized_model_name(&self, input: &str) -> bool { + let trimmed = input.trim(); + if trimmed.is_empty() { + return false; + } + if self.aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() { + return true; + } + + let stripped = strip_known_model_suffixes(trimmed); + if stripped.model_name == trimmed + || (stripped.reasoning_effort.is_none() && stripped.service_tier.is_none()) + { + return false; + } + self.aliases.contains_key(&stripped.model_name) + || self.model_info(&stripped.model_name).is_some() + } + + pub fn parse_model_name(&self, input: &str) -> ParsedModelName { + let trimmed = input.trim(); + if self.aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() { + return ParsedModelName { + model_id: self.resolve_model_id(trimmed), + reasoning_effort: None, + service_tier: None, + }; + } + + let stripped = strip_known_model_suffixes(trimmed); + ParsedModelName { + model_id: self.resolve_model_id(&stripped.model_name), + reasoning_effort: stripped.reasoning_effort, + service_tier: stripped.service_tier, + } + } + + pub fn build_display_model_name(parsed: &ParsedModelName) -> String { + let mut name = parsed.model_id.clone(); + if let Some(reasoning_effort) = &parsed.reasoning_effort { + name.push('-'); + name.push_str(reasoning_effort); + } + if let Some(service_tier) = &parsed.service_tier { + name.push('-'); + name.push_str(service_tier); + } + name + } + + fn resolve_model_id(&self, input: &str) -> String { + let resolved = self.resolve_alias_chain(input); + if self.model_info(&resolved).is_some() { + return resolved; + } + self.default_model.clone() + } + + fn resolve_alias_chain(&self, input: &str) -> String { + let original = input.trim(); + let mut current = original.to_string(); + let mut seen = BTreeSet::new(); + for _ in 0..20 { + let Some(target) = self.aliases.get(¤t).map(|value| value.trim()) else { + return current; + }; + if !seen.insert(current.clone()) || seen.contains(target) { + return original.to_string(); + } + current = target.to_string(); + } + original.to_string() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StrippedModelName { + model_name: String, + reasoning_effort: Option, + service_tier: Option, +} + +fn strip_known_model_suffixes(input: &str) -> StrippedModelName { + let mut remaining = input.trim().to_string(); + let service_tier = take_suffix(&mut remaining, &SERVICE_TIER_SUFFIXES); + let reasoning_effort = take_suffix(&mut remaining, &REASONING_EFFORT_SUFFIXES); + StrippedModelName { + model_name: remaining, + reasoning_effort, + service_tier, + } +} + +fn take_suffix(remaining: &mut String, suffixes: &[&str]) -> Option { + let suffix = suffixes + .iter() + .find(|suffix| remaining.ends_with(&format!("-{suffix}")))?; + let truncate_to = remaining.len() - suffix.len() - 1; + remaining.truncate(truncate_to); + Some((*suffix).to_string()) +} + +fn normalize_aliases(input: &BTreeMap) -> BTreeMap { + input + .iter() + .filter_map(|(alias, target)| { + let alias = alias.trim(); + let target = target.trim(); + (!alias.is_empty() && !target.is_empty()) + .then(|| (alias.to_string(), target.to_string())) + }) + .collect() +} + +fn default_model_info(config: &ModelConfig) -> CodexModelInfo { + let id = config.default_model.trim().to_string(); + let default_reasoning_effort = config + .default_reasoning_effort + .clone() + .unwrap_or_else(|| "medium".to_string()); + CodexModelInfo { + id: id.clone(), + display_name: id, + description: "Codex default model".to_string(), + is_default: true, + supported_reasoning_efforts: REASONING_EFFORT_SUFFIXES + .into_iter() + .map(|effort| ReasoningEffortInfo { + reasoning_effort: effort.to_string(), + description: effort.to_string(), + }) + .collect(), + default_reasoning_effort, + input_modalities: vec!["text".to_string(), "image".to_string()], + output_modalities: vec!["text".to_string()], + supports_personality: false, + upgrade: None, + source: "static".to_string(), + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 000000000..1b1f77cd5 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod catalog; diff --git a/tests/model_catalog_test.rs b/tests/model_catalog_test.rs new file mode 100644 index 000000000..3d37ec4bb --- /dev/null +++ b/tests/model_catalog_test.rs @@ -0,0 +1,44 @@ +use std::collections::BTreeMap; + +use codex_proxy_rs::{ + config::ModelConfig, + models::catalog::{ModelCatalog, ParsedModelName}, +}; + +#[test] +fn model_catalog_should_parse_alias_reasoning_and_service_tier_suffixes() { + let mut aliases = BTreeMap::new(); + aliases.insert("codex-fast".to_string(), "gpt-5.5".to_string()); + let catalog = ModelCatalog::from_config(&ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases, + }); + + let parsed = catalog.parse_model_name("codex-fast-high-flex"); + + assert_eq!( + parsed, + ParsedModelName { + model_id: "gpt-5.5".to_string(), + reasoning_effort: Some("high".to_string()), + service_tier: Some("flex".to_string()) + } + ); +} + +#[test] +fn model_catalog_should_fall_back_to_default_for_unknown_model_names() { + let catalog = ModelCatalog::from_config(&ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: BTreeMap::new(), + }); + + let parsed = catalog.parse_model_name("unknown-medium"); + + assert_eq!(parsed.model_id, "gpt-5.5"); + assert_eq!(parsed.reasoning_effort, Some("medium".to_string())); +} diff --git a/tests/routes_responses_test.rs b/tests/routes_responses_test.rs index c73e59f48..129c8c14b 100644 --- a/tests/routes_responses_test.rs +++ b/tests/routes_responses_test.rs @@ -4,7 +4,7 @@ use axum::{ body::{to_bytes, Body}, http::{Request, StatusCode}, }; -use serde_json::Value; +use serde_json::{json, Value}; use tower::ServiceExt; use codex_proxy_rs::{ @@ -18,6 +18,8 @@ use codex_proxy_rs::{ }; fn test_config() -> AppConfig { + let mut aliases = BTreeMap::new(); + aliases.insert("codex-fast".to_string(), "gpt-5.5".to_string()); AppConfig { server: ServerConfig { host: "127.0.0.1".to_string(), @@ -30,7 +32,7 @@ fn test_config() -> AppConfig { default_model: "gpt-5.5".to_string(), default_reasoning_effort: None, service_tier: None, - aliases: BTreeMap::new(), + aliases, }, auth: AuthConfig { refresh_margin_seconds: 300, @@ -156,4 +158,120 @@ async fn models_route_returns_openai_compatible_codex_model_list() { assert_eq!(body["object"], "list"); assert_eq!(body["data"][0]["object"], "model"); assert_eq!(body["data"][0]["id"], "gpt-5.5"); + assert_eq!(body["data"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn model_catalog_route_returns_codex_metadata_without_alias_entries() { + let app = build_router(AppState::new(test_config())); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models/catalog") + .header("authorization", "Bearer cpr_test") + .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-5.5"); + assert_eq!(body[0]["isDefault"], true); + assert_eq!(body[0]["defaultReasoningEffort"], "medium"); + assert_eq!( + body[0]["supportedReasoningEfforts"][0]["reasoningEffort"], + "low" + ); +} + +#[tokio::test] +async fn model_detail_route_returns_openai_model_for_known_model() { + let app = build_router(AppState::new(test_config())); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models/gpt-5.5") + .header("authorization", "Bearer cpr_test") + .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["id"], "gpt-5.5"); + assert_eq!(body["object"], "model"); +} + +#[tokio::test] +async fn model_detail_route_rejects_unknown_model_with_openai_error() { + let app = build_router(AppState::new(test_config())); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models/not-a-codex-model") + .header("authorization", "Bearer cpr_test") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"]["code"], "model_not_found"); +} + +#[tokio::test] +async fn model_info_route_returns_extended_catalog_entry() { + let app = build_router(AppState::new(test_config())); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models/gpt-5.5/info") + .header("authorization", "Bearer cpr_test") + .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["id"], "gpt-5.5"); + assert_eq!(body["outputModalities"], json!(["text"])); +} + +#[tokio::test] +async fn debug_models_route_returns_model_store_summary() { + let app = build_router(AppState::new(test_config())); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/debug/models") + .header("authorization", "Bearer cpr_test") + .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["totalModels"], 1); + assert_eq!(body["aliasCount"], 1); }