mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: add codex model catalog
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+12
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤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<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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod catalog;
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user