use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::{Value, json};
use crate::app_state::AppState;
use crate::config::UpstreamProvider;
use crate::model_catalog::ModelCatalogCache;
use crate::subscription::{SubscriptionProvider, SubscriptionReader};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRouteError {
ModelRequired,
NotFound(String),
Ambiguous(String),
}
impl std::fmt::Display for ModelRouteError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ModelRequired => {
formatter.write_str("model is required when UPSTREAM_PROVIDER=auto")
}
Self::NotFound(message) | Self::Ambiguous(message) => formatter.write_str(message),
}
}
}
pub(crate) fn model_route_error_response(error: &ModelRouteError) -> Response {
let (status, error_type) = match error {
ModelRouteError::ModelRequired | ModelRouteError::Ambiguous(_) => {
(StatusCode::BAD_REQUEST, "invalid_request_error")
}
ModelRouteError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found_error"),
};
crate::proxy::error_response(status, error_type, &error.to_string())
}
pub(crate) fn model_not_found_response(model: &str) -> Response {
model_route_error_response(&ModelRouteError::NotFound(format!(
"model '{model}' is not available"
)))
}
const fn provider_owner(provider: SubscriptionProvider) -> &'static str {
match provider {
SubscriptionProvider::Claude => "anthropic",
SubscriptionProvider::Codex => "openai",
SubscriptionProvider::Gemini => "google",
SubscriptionProvider::Qwen => "qwen",
}
}
fn provider_hint(model: &str) -> Option<SubscriptionProvider> {
if model.starts_with("claude-") {
Some(SubscriptionProvider::Claude)
} else if model.starts_with("gpt-")
|| model.starts_with("codex-")
|| model
.strip_prefix('o')
.and_then(|suffix| suffix.chars().next())
.is_some_and(|character| character.is_ascii_digit())
{
Some(SubscriptionProvider::Codex)
} else if model.starts_with("gemini-") {
Some(SubscriptionProvider::Gemini)
} else if model.starts_with("qwen-") {
Some(SubscriptionProvider::Qwen)
} else {
None
}
}
fn providers_for_model(model: &str, catalogs: &ModelCatalogCache) -> Vec<SubscriptionProvider> {
SubscriptionProvider::ALL
.into_iter()
.filter(|provider| catalogs.models(*provider).iter().any(|id| id == model))
.collect()
}
#[must_use]
pub fn provider_for_model(
model: &str,
catalogs: &ModelCatalogCache,
) -> Option<SubscriptionProvider> {
let providers = providers_for_model(model, catalogs);
if providers.len() == 1 {
return providers.first().copied();
}
provider_hint(model).filter(|provider| providers.contains(provider))
}
pub fn available_provider_for_model(
model: &str,
available: &[SubscriptionProvider],
catalogs: &ModelCatalogCache,
) -> Result<SubscriptionProvider, ModelRouteError> {
let advertised = providers_for_model(model, catalogs);
if advertised.is_empty() {
return Err(ModelRouteError::NotFound(format!(
"model '{model}' is not advertised by any subscription"
)));
}
let provider = provider_hint(model)
.filter(|provider| advertised.contains(provider))
.or_else(|| {
let healthy = advertised
.iter()
.copied()
.filter(|provider| available.contains(provider))
.collect::<Vec<_>>();
(healthy.len() == 1).then(|| healthy[0])
})
.or_else(|| (advertised.len() == 1).then(|| advertised[0]))
.ok_or_else(|| {
let providers = advertised
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
ModelRouteError::Ambiguous(format!(
"model '{model}' is advertised by multiple subscriptions ({providers}); pin \
UPSTREAM_PROVIDER to disambiguate"
))
})?;
available
.contains(&provider)
.then_some(provider)
.ok_or_else(|| {
ModelRouteError::NotFound(format!(
"model '{model}' has no healthy {provider} credential"
))
})
}
pub async fn healthy_providers(
client: &reqwest::Client,
readers: &[SubscriptionReader],
token_cache: &crate::refresh::TokenCache,
now_ms: i64,
) -> Vec<SubscriptionProvider> {
let checks = SubscriptionProvider::ALL
.into_iter()
.map(|provider| async move {
let reader = readers
.iter()
.find(|reader| reader.provider() == provider)?;
let disk_token = reader.read_token().ok()?;
let token = token_cache
.get_fresh(client, provider, disk_token, now_ms)
.await;
if token_cache.evidence(provider) == Some(crate::refresh::CredentialEvidence::Rejected)
{
tracing::debug!("{provider} credential was rejected upstream; not routable");
return None;
}
if !token.is_expired(now_ms) {
return Some(provider);
}
tracing::debug!(
"{provider} credential is stamped expired and could not be refreshed; keeping it \
routable until an upstream rejects it"
);
Some(provider)
});
futures_util::future::join_all(checks)
.await
.into_iter()
.flatten()
.collect()
}
#[must_use]
pub fn model_catalog(providers: &[SubscriptionProvider], catalogs: &ModelCatalogCache) -> Value {
let now = chrono::Utc::now().timestamp();
let using_fallback = providers
.iter()
.any(|provider| catalogs.status(*provider).using_fallback);
let healthy_providers = providers
.iter()
.map(|provider| provider.as_str())
.collect::<Vec<_>>();
let data = providers
.iter()
.flat_map(|provider| {
let owner = provider_owner(*provider);
catalogs.models(*provider).into_iter().map(move |id| {
json!({
"id": id,
"object": "model",
"created": now,
"owned_by": owner,
})
})
})
.collect::<Vec<_>>();
json!({
"object": "list",
"data": data,
"using_fallback": using_fallback,
"healthy_providers": healthy_providers,
})
}
#[must_use]
pub async fn pinned_model_catalog(state: &AppState, provider: SubscriptionProvider) -> Value {
let healthy = healthy_providers(
&state.client,
&state.subscription_readers,
&state.subscription_cache,
chrono::Utc::now().timestamp_millis(),
)
.await;
if healthy.contains(&provider) {
model_catalog(&[provider], &state.model_catalogs)
} else {
model_catalog(&[], &state.model_catalogs)
}
}
pub async fn models(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Err(response) = crate::proxy::authenticate_client(&state, &headers) {
return *response;
}
let models = match state.upstream_provider {
UpstreamProvider::Auto => model_catalog(
&healthy_providers(
&state.client,
&state.subscription_readers,
&state.subscription_cache,
chrono::Utc::now().timestamp_millis(),
)
.await,
&state.model_catalogs,
),
UpstreamProvider::Anthropic => {
pinned_model_catalog(&state, SubscriptionProvider::Claude).await
}
UpstreamProvider::Gonka => state.gonka.as_ref().map_or_else(
|| crate::gonka::list_models(&crate::config::default_gonka_model()),
|gonka| crate::gonka::list_models(&gonka.model),
),
UpstreamProvider::Crater => crate::crater::list_models(),
UpstreamProvider::Codex => pinned_model_catalog(&state, SubscriptionProvider::Codex).await,
UpstreamProvider::Qwen => pinned_model_catalog(&state, SubscriptionProvider::Qwen).await,
UpstreamProvider::Gemini => {
pinned_model_catalog(&state, SubscriptionProvider::Gemini).await
}
UpstreamProvider::OpenAICompatible => {
crate::provider_proxy::openai_compatible_models(&state)
}
};
(StatusCode::OK, axum::Json(models)).into_response()
}
pub async fn route_anthropic_request(
state: &AppState,
request: Request,
) -> Result<(AppState, Request), Response> {
let path = request.uri().path().to_string();
let (parts, body) = request.into_parts();
let body_bytes = axum::body::to_bytes(body, 10 * 1024 * 1024)
.await
.map_err(|error| {
crate::proxy::error_response(
StatusCode::BAD_REQUEST,
"invalid_request_error",
&format!("Failed to read request body: {error}"),
)
})?;
let routing_body = serde_json::from_slice(&body_bytes).map_err(|error| {
crate::proxy::error_response(
StatusCode::BAD_REQUEST,
"invalid_request_error",
&format!("Failed to parse request body as JSON: {error}"),
)
})?;
let routed = if path.ends_with("/messages") || path.ends_with("/messages/count_tokens") {
route_state(state, &routing_body)
.await
.map_err(|error| model_route_error_response(&error))?
} else {
route_provider(state, SubscriptionProvider::Claude)
.await
.map_err(|error| {
crate::proxy::error_response(
StatusCode::BAD_REQUEST,
"invalid_request_error",
&error,
)
})?
};
Ok((routed, Request::from_parts(parts, Body::from(body_bytes))))
}
pub async fn route_provider(
state: &AppState,
provider: SubscriptionProvider,
) -> Result<AppState, String> {
let healthy = healthy_providers(
&state.client,
&state.subscription_readers,
&state.subscription_cache,
chrono::Utc::now().timestamp_millis(),
)
.await;
let reader = state
.subscription_readers
.iter()
.find(|reader| reader.provider() == provider)
.filter(|_| healthy.contains(&provider))
.cloned()
.ok_or_else(|| format!("no healthy {provider} credential is available"))?;
let mut routed = state.clone();
routed.upstream_provider = match provider {
SubscriptionProvider::Claude => UpstreamProvider::Anthropic,
SubscriptionProvider::Codex => UpstreamProvider::Codex,
SubscriptionProvider::Gemini => UpstreamProvider::Gemini,
SubscriptionProvider::Qwen => UpstreamProvider::Qwen,
};
if provider != SubscriptionProvider::Claude {
routed.account_router = None;
routed.subscription_reader = Some(reader);
}
Ok(routed)
}
pub async fn route_state(state: &AppState, body: &Value) -> Result<AppState, ModelRouteError> {
if state.upstream_provider != UpstreamProvider::Auto {
return Ok(state.clone());
}
let model = body
.get("model")
.and_then(Value::as_str)
.filter(|model| !model.is_empty())
.ok_or(ModelRouteError::ModelRequired)?;
let provider = available_provider_for_model(
model,
&healthy_providers(
&state.client,
&state.subscription_readers,
&state.subscription_cache,
chrono::Utc::now().timestamp_millis(),
)
.await,
&state.model_catalogs,
)?;
let mut routed = route_provider(state, provider).await.map_err(|_| {
ModelRouteError::NotFound(format!(
"model '{model}' has no healthy {provider} credential"
))
})?;
if provider != SubscriptionProvider::Claude {
routed.bridge_model = Some(model.to_string());
}
Ok(routed)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::Query;
use axum::http::{HeaderMap, Request};
use axum::routing::get;
use http_body_util::BodyExt;
use std::fs;
use std::sync::Arc;
use tempfile::tempdir;
use tower::ServiceExt;
fn auto_state(readers: Vec<SubscriptionReader>, data_dir: &std::path::Path) -> AppState {
AppState {
client: reqwest::Client::new(),
token_manager: crate::token::TokenManager::new("test-secret"),
oauth_provider: crate::oauth::OAuthProvider::new(&data_dir.to_string_lossy()),
account_router: None,
subscription_reader: None,
subscription_base_url: None,
subscription_readers: readers,
model_catalogs: Arc::new(ModelCatalogCache::new()),
subscription_cache: Arc::new(crate::refresh::TokenCache::new()),
upstream_base_url: "https://api.anthropic.com".to_string(),
upstream_provider: UpstreamProvider::Auto,
gonka: None,
bridge_model: None,
crater: None,
openai_compatible: crate::config::default_openai_compatible_config(),
provider_store: crate::providers::ProviderStore::open(data_dir, "test-secret").unwrap(),
logger: log_lazy::LogLazy::new(),
admin: Arc::new(crate::admin::AdminClaim::load(
None,
data_dir,
std::time::Duration::from_secs(60),
)),
admin_key: None,
allow_anonymous_admin: false,
metrics: Arc::new(crate::metrics::Metrics::default()),
audit: Arc::new(crate::audit::AuditLog::to_path(None)),
request_log: Arc::new(crate::request_log::RequestLog::new(
data_dir.join("requests.jsonl"),
1024 * 1024,
)),
activitypub_actor_base_url: "https://router.example".to_string(),
activitypub_public_key_pem: crate::config::default_activitypub_public_key_pem(),
mpp: crate::config::default_mpp_config(),
login_manager: crate::login::LoginManager::new(crate::login::LoginConfig::default()),
}
}
#[test]
fn catalog_unions_models_with_their_real_owners() {
let catalogs = ModelCatalogCache::new();
let catalog = model_catalog(
&[SubscriptionProvider::Claude, SubscriptionProvider::Codex],
&catalogs,
);
let data = catalog["data"].as_array().unwrap();
assert!(
data.iter()
.any(|m| m["id"] == "claude-opus-4-7" && m["owned_by"] == "anthropic")
);
assert!(
data.iter()
.any(|m| m["id"] == "gpt-5" && m["owned_by"] == "openai")
);
assert_eq!(catalog["using_fallback"], true);
assert_eq!(catalog["healthy_providers"], json!(["claude", "codex"]));
let unavailable = model_catalog(&[], &catalogs);
assert_eq!(unavailable["data"], json!([]));
assert_eq!(unavailable["using_fallback"], false);
assert_eq!(unavailable["healthy_providers"], json!([]));
}
#[tokio::test]
async fn models_omits_a_rejected_provider_and_names_only_healthy_ones() {
let data = tempdir().unwrap();
let claude = tempdir().unwrap();
let codex = tempdir().unwrap();
fs::write(
claude.path().join(".credentials.json"),
r#"{"claudeAiOauth":{"accessToken":"revoked"}}"#,
)
.unwrap();
fs::write(
codex.path().join("auth.json"),
r#"{"tokens":{"access_token":"healthy"}}"#,
)
.unwrap();
let state = auto_state(
vec![
SubscriptionReader::new(SubscriptionProvider::Claude, claude.path()),
SubscriptionReader::new(SubscriptionProvider::Codex, codex.path()),
],
data.path(),
);
state
.subscription_cache
.record_credential_rejected(SubscriptionProvider::Claude);
let client_token = state.token_manager.issue_token(1, "catalog test").unwrap();
let app = axum::Router::new()
.route("/v1/models", get(models))
.with_state(state.clone());
let response = app
.oneshot(
Request::builder()
.uri("/v1/models")
.header("authorization", format!("Bearer {client_token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let catalog: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(catalog["healthy_providers"], json!(["codex"]));
assert_eq!(catalog["using_fallback"], true);
assert!(
catalog["data"]
.as_array()
.unwrap()
.iter()
.all(|model| model["owned_by"] == "openai")
);
let error = route_state(&state, &json!({"model": "claude-opus-4-7"}))
.await
.err()
.expect("rejected Claude credential should not be routable");
assert!(error.to_string().contains("no healthy claude credential"));
}
#[tokio::test]
async fn model_catalog_routes_require_a_valid_client_token() {
let data = tempdir().unwrap();
let state = auto_state(Vec::new(), data.path());
let valid_token = state.token_manager.issue_token(1, "catalog test").unwrap();
let app = axum::Router::new()
.route("/v1/models", get(models))
.route("/api/codex/v1/models", get(models))
.with_state(state);
for path in ["/v1/models", "/api/codex/v1/models"] {
for authorization in [None, Some("Bearer la_sk_garbage")] {
let mut request = Request::builder().uri(path);
if let Some(value) = authorization {
request = request.header("authorization", value);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{path} accepted {authorization:?}"
);
}
let response = app
.clone()
.oneshot(
Request::builder()
.uri(path)
.header("authorization", format!("Bearer {valid_token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{path} rejected a valid token"
);
}
}
#[tokio::test]
async fn automatic_messages_authenticate_before_model_routing() {
let data = tempdir().unwrap();
let state = auto_state(Vec::new(), data.path());
let app = axum::Router::new()
.route(
"/v1/messages",
axum::routing::post(crate::proxy::proxy_handler),
)
.with_state(state);
let bodies = [
json!({"model": "claude-opus-4-7", "max_tokens": 1, "messages": []}),
json!({"model": "totally-made-up-xyz", "max_tokens": 1, "messages": []}),
json!({"max_tokens": 1}),
];
for authorization in [None, Some("Bearer la_sk_invalid-before-routing")] {
let mut responses = Vec::new();
for body in &bodies {
let mut request = Request::builder()
.method("POST")
.uri("/v1/messages")
.header("content-type", "application/json");
if let Some(value) = authorization {
request = request.header("authorization", value);
}
let response = app
.clone()
.oneshot(request.body(Body::from(body.to_string())).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
responses.push(response.into_body().collect().await.unwrap().to_bytes());
}
assert!(responses.windows(2).all(|pair| pair[0] == pair[1]));
}
}
#[tokio::test]
async fn malformed_client_tokens_return_a_fixed_message() {
let data = tempdir().unwrap();
let state = auto_state(Vec::new(), data.path());
let app = axum::Router::new()
.route("/v1/models", get(models))
.with_state(state);
let malformed = ["wrong-prefix", "la_sk_zzzzQQQrandom.stuff.here"];
let mut responses = Vec::new();
for token in malformed {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/models")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
responses.push(response.into_body().collect().await.unwrap().to_bytes());
}
assert!(responses.windows(2).all(|pair| pair[0] == pair[1]));
let payload: Value = serde_json::from_slice(&responses[0]).unwrap();
assert_eq!(payload["error"]["message"], "invalid token");
}
#[test]
fn model_ids_route_to_the_subscription_that_serves_them() {
assert_eq!(
provider_for_model("gpt-5", &ModelCatalogCache::new()),
Some(SubscriptionProvider::Codex)
);
assert_eq!(
provider_for_model("claude-opus-4-7", &ModelCatalogCache::new()),
Some(SubscriptionProvider::Claude)
);
assert_eq!(
provider_for_model("gemini-2.5-pro", &ModelCatalogCache::new()),
Some(SubscriptionProvider::Gemini)
);
let catalogs = ModelCatalogCache::new();
assert_eq!(provider_for_model("made-up-model", &catalogs), None);
assert_eq!(
available_provider_for_model("gpt-5", &[SubscriptionProvider::Codex], &catalogs,),
Ok(SubscriptionProvider::Codex)
);
assert!(
available_provider_for_model("gpt-5", &[SubscriptionProvider::Claude], &catalogs,)
.unwrap_err()
.to_string()
.contains("no healthy codex credential")
);
let error =
available_provider_for_model("gpt-4o", &[SubscriptionProvider::Claude], &catalogs)
.unwrap_err();
assert!(error.to_string().contains("not advertised"));
assert!(!error.to_string().contains("claude credential"));
assert!(matches!(
available_provider_for_model("made-up-model", &[], &catalogs),
Err(ModelRouteError::NotFound(_))
));
}
#[test]
fn newly_discovered_model_is_immediately_routable() {
let catalogs = ModelCatalogCache::new();
catalogs.record_success(SubscriptionProvider::Codex, vec!["gpt-5.6-sol".to_string()]);
assert_eq!(
available_provider_for_model("gpt-5.6-sol", &[SubscriptionProvider::Codex], &catalogs,),
Ok(SubscriptionProvider::Codex)
);
assert!(
available_provider_for_model("gpt-5", &[SubscriptionProvider::Codex], &catalogs,)
.is_err()
);
}
#[tokio::test]
async fn openai_request_rejects_unknown_model_in_pinned_and_auto_modes() {
for provider in [UpstreamProvider::Anthropic, UpstreamProvider::Auto] {
let data = tempdir().unwrap();
let mut state = auto_state(Vec::new(), data.path());
state.upstream_provider = provider;
let response = crate::proxy::openai_chat_completions(
State(state),
Query(std::collections::BTreeMap::default()),
HeaderMap::new(),
Ok(axum::Json(json!({
"model": "totally-made-up-model-xyz",
"messages": [{"role": "user", "content": "hello"}]
}))),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["type"], "not_found_error");
assert!(
json["error"]["message"]
.as_str()
.unwrap()
.contains("totally-made-up-model-xyz")
);
}
}
#[tokio::test]
async fn missing_credentials_are_not_healthy() {
let live = tempdir().unwrap();
let absent = tempdir().unwrap();
fs::write(
live.path().join("auth.json"),
r#"{"tokens":{"access_token":"live"}}"#,
)
.unwrap();
let readers = vec![
SubscriptionReader::new(SubscriptionProvider::Codex, live.path()),
SubscriptionReader::new(SubscriptionProvider::Gemini, absent.path()),
];
assert_eq!(
healthy_providers(
&reqwest::Client::new(),
&readers,
&crate::refresh::TokenCache::new(),
2000,
)
.await,
vec![SubscriptionProvider::Codex]
);
}
#[tokio::test]
async fn expired_credential_stays_routable_without_an_upstream_rejection() {
let expired = tempdir().unwrap();
fs::write(
expired.path().join("oauth_creds.json"),
r#"{"access_token":"old","expiry_date":1000}"#,
)
.unwrap();
let readers = vec![SubscriptionReader::new(
SubscriptionProvider::Gemini,
expired.path(),
)];
let cache = crate::refresh::TokenCache::new();
assert_eq!(
healthy_providers(&reqwest::Client::new(), &readers, &cache, 2000).await,
vec![SubscriptionProvider::Gemini]
);
cache.record_credential_rejected(SubscriptionProvider::Gemini);
assert!(
healthy_providers(&reqwest::Client::new(), &readers, &cache, 2000)
.await
.is_empty()
);
}
#[tokio::test]
async fn rejected_credential_is_unhealthy_even_without_an_expiry_timestamp() {
let credential = tempdir().unwrap();
fs::write(
credential.path().join("auth.json"),
r#"{"tokens":{"access_token":"revoked"}}"#,
)
.unwrap();
let readers = vec![SubscriptionReader::new(
SubscriptionProvider::Codex,
credential.path(),
)];
let cache = crate::refresh::TokenCache::new();
cache.record_credential_rejected(SubscriptionProvider::Codex);
assert!(
healthy_providers(&reqwest::Client::new(), &readers, &cache, 2000)
.await
.is_empty()
);
}
#[tokio::test]
async fn expired_credentials_with_a_cached_refresh_are_healthy() {
let claude = tempdir().unwrap();
fs::write(
claude.path().join(".credentials.json"),
r#"{"claudeAiOauth":{"accessToken":"expired","refreshToken":"refresh","expiresAt":1000}}"#,
)
.unwrap();
let readers = vec![SubscriptionReader::new(
SubscriptionProvider::Claude,
claude.path(),
)];
let cache = crate::refresh::TokenCache::new();
cache.store_refreshed(
SubscriptionProvider::Claude,
"primary",
crate::subscription::SubscriptionToken {
access_token: "fresh".into(),
refresh_token: Some("refresh".into()),
expires_at_ms: Some(3000),
account_id: None,
resource_url: None,
},
);
assert_eq!(
healthy_providers(&reqwest::Client::new(), &readers, &cache, 2000).await,
vec![SubscriptionProvider::Claude]
);
}
#[tokio::test]
async fn automatic_state_selects_the_models_healthy_reader() {
let data = tempdir().unwrap();
let codex = tempdir().unwrap();
fs::write(
codex.path().join("auth.json"),
r#"{"tokens":{"access_token":"live"}}"#,
)
.unwrap();
let state = auto_state(
vec![SubscriptionReader::new(
SubscriptionProvider::Codex,
codex.path(),
)],
data.path(),
);
let routed = route_state(&state, &json!({"model": "gpt-5"}))
.await
.unwrap();
assert_eq!(routed.upstream_provider, UpstreamProvider::Codex);
assert_eq!(routed.bridge_model.as_deref(), Some("gpt-5"));
assert_eq!(
routed.subscription_reader.unwrap().provider(),
SubscriptionProvider::Codex
);
assert!(
route_state(&state, &json!({"model": "claude-opus-4-7"}))
.await
.is_err()
);
}
#[tokio::test]
async fn automatic_state_never_uses_a_claude_alias_for_an_unadvertised_openai_model() {
let data = tempdir().unwrap();
let claude = tempdir().unwrap();
let codex = tempdir().unwrap();
fs::write(
claude.path().join(".credentials.json"),
r#"{"claudeAiOauth":{"accessToken":"claude-live"}}"#,
)
.unwrap();
fs::write(
codex.path().join("auth.json"),
r#"{"tokens":{"access_token":"codex-live"}}"#,
)
.unwrap();
let state = auto_state(
vec![
SubscriptionReader::new(SubscriptionProvider::Claude, claude.path()),
SubscriptionReader::new(SubscriptionProvider::Codex, codex.path()),
],
data.path(),
);
state.model_catalogs.record_success(
SubscriptionProvider::Claude,
vec!["claude-opus-4-7".to_string()],
);
state
.model_catalogs
.record_success(SubscriptionProvider::Codex, vec!["gpt-5.6-sol".to_string()]);
let error = route_state(&state, &json!({"model": "gpt-5"}))
.await
.err()
.expect("an unadvertised model must not cross vendors through an alias");
assert!(error.to_string().contains("not advertised"));
assert!(!error.to_string().contains("claude credential"));
state.model_catalogs.record_success(
SubscriptionProvider::Claude,
vec!["gpt-5".to_string(), "claude-opus-4-7".to_string()],
);
state.model_catalogs.record_success(
SubscriptionProvider::Codex,
vec!["gpt-5".to_string(), "gpt-5.6-sol".to_string()],
);
let routed = route_state(&state, &json!({"model": "gpt-5"}))
.await
.expect("an OpenAI-shaped collision must route to Codex");
assert_eq!(routed.upstream_provider, UpstreamProvider::Codex);
assert_eq!(routed.bridge_model.as_deref(), Some("gpt-5"));
}
#[test]
fn catalog_collisions_use_vendor_namespaces_and_reject_ambiguous_names() {
let catalogs = ModelCatalogCache::new();
catalogs.record_success(
SubscriptionProvider::Claude,
vec!["gpt-5".to_string(), "shared-model".to_string()],
);
catalogs.record_success(
SubscriptionProvider::Codex,
vec!["gpt-5".to_string(), "shared-model".to_string()],
);
assert_eq!(
available_provider_for_model(
"gpt-5",
&[SubscriptionProvider::Claude, SubscriptionProvider::Codex],
&catalogs,
),
Ok(SubscriptionProvider::Codex)
);
let error = available_provider_for_model(
"shared-model",
&[SubscriptionProvider::Claude, SubscriptionProvider::Codex],
&catalogs,
)
.expect_err("an unqualified collision must require disambiguation");
assert!(error.to_string().contains("multiple subscriptions"));
assert_eq!(
available_provider_for_model("shared-model", &[SubscriptionProvider::Codex], &catalogs,),
Ok(SubscriptionProvider::Codex)
);
}
}