feat: add codex model catalog

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-12 01:04:35 +08:00
co-authored by Codex
parent 6f0f301cad
commit 6341336007
10 changed files with 528 additions and 32 deletions
+12
View File
@@ -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.
+3 -2
View File
@@ -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. |
@@ -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.
+5 -1
View File
@@ -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))
+100 -26
View File
@@ -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<String>,
}
pub async fn responses(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
pub async fn responses(
State(state): State<AppState>,
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::<ResponsesBody>(&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<AppState>, 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::<Vec<_>>();
(
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<AppState>, 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<AppState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> 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<AppState>,
headers: HeaderMap,
Path(model_id): Path<String>,
) -> 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<AppState>, 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<Value>) {
(
StatusCode::UNAUTHORIZED,
Json(openai_error("Missing client API key", "invalid_api_key")),
)
}
fn model_not_found_response() -> (StatusCode, Json<Value>) {
(
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"
})
}
+1
View File
@@ -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;
+240
View File
@@ -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<ReasoningEffortInfo>,
pub default_reasoning_effort: String,
pub input_modalities: Vec<String>,
pub output_modalities: Vec<String>,
pub supports_personality: bool,
pub upgrade: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedModelName {
pub model_id: String,
pub reasoning_effort: Option<String>,
pub service_tier: Option<String>,
}
#[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<ModelDebugEntry>,
}
#[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<CodexModelInfo>,
aliases: BTreeMap<String, String>,
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<CodexModelInfo> {
self.models.clone()
}
pub fn model_info(&self, model_id: &str) -> Option<CodexModelInfo> {
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(&current).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<String>,
service_tier: Option<String>,
}
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<String> {
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<String, String>) -> BTreeMap<String, String> {
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(),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod catalog;
+44
View File
@@ -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()));
}
+120 -2
View File
@@ -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);
}