mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Improve Codex model fetching
This commit is contained in:
@@ -67,6 +67,21 @@ struct ProviderQueryKeyFetchResult {
|
|||||||
has_success: bool,
|
has_success: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_query_codex_preset_fallback(
|
||||||
|
provider: &StoredProviderCatalogProvider,
|
||||||
|
) -> Option<ProviderQueryKeyFetchResult> {
|
||||||
|
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let models = preset_models_for_provider(&provider.provider_type)?;
|
||||||
|
Some(ProviderQueryKeyFetchResult {
|
||||||
|
models: aggregate_models_for_cache(&models),
|
||||||
|
error: None,
|
||||||
|
from_cache: false,
|
||||||
|
has_success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct ProviderQueryTestCandidate {
|
struct ProviderQueryTestCandidate {
|
||||||
endpoint: StoredProviderCatalogEndpoint,
|
endpoint: StoredProviderCatalogEndpoint,
|
||||||
@@ -1664,6 +1679,9 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
Ok(outcome) => outcome,
|
Ok(outcome) => outcome,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
all_errors.push(err);
|
all_errors.push(err);
|
||||||
|
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
|
||||||
|
return Ok(fallback);
|
||||||
|
}
|
||||||
return Ok(ProviderQueryKeyFetchResult {
|
return Ok(ProviderQueryKeyFetchResult {
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
error: Some(all_errors.join("; ")),
|
error: Some(all_errors.join("; ")),
|
||||||
@@ -1685,6 +1703,12 @@ async fn provider_query_fetch_models_for_key(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if unique_models.is_empty() && !all_errors.is_empty() {
|
||||||
|
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
|
||||||
|
return Ok(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut error = if all_errors.is_empty() {
|
let mut error = if all_errors.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -346,6 +346,112 @@ async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpo
|
|||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_when_token_invalidated(
|
||||||
|
) {
|
||||||
|
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||||
|
let execution_runtime = Router::new().route(
|
||||||
|
"/v1/execute/sync",
|
||||||
|
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||||
|
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
|
||||||
|
async move {
|
||||||
|
*execution_runtime_hits_inner
|
||||||
|
.lock()
|
||||||
|
.expect("mutex should lock") += 1;
|
||||||
|
assert_eq!(
|
||||||
|
plan.url,
|
||||||
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.128.0-alpha.1"
|
||||||
|
);
|
||||||
|
Json(json!({
|
||||||
|
"request_id": "req-provider-query-codex-invalidated",
|
||||||
|
"status_code": 403,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"json_body": {
|
||||||
|
"error": {
|
||||||
|
"message": "Your authentication token has been invalidated. Please sign in again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
|
let mut provider = sample_provider("provider-codex", "Codex", 10);
|
||||||
|
provider.provider_type = "codex".to_string();
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![provider],
|
||||||
|
vec![sample_endpoint(
|
||||||
|
"endpoint-codex-responses",
|
||||||
|
"provider-codex",
|
||||||
|
"openai:responses",
|
||||||
|
"https://chatgpt.com/backend-api/codex",
|
||||||
|
)],
|
||||||
|
vec![sample_key(
|
||||||
|
"key-codex-invalidated",
|
||||||
|
"provider-codex",
|
||||||
|
"openai:responses",
|
||||||
|
"invalidated-token",
|
||||||
|
)],
|
||||||
|
));
|
||||||
|
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
|
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||||
|
provider_catalog_repository,
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
||||||
|
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||||
|
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||||
|
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||||
|
.json(&json!({
|
||||||
|
"provider_id": "provider-codex",
|
||||||
|
"api_key_id": "key-codex-invalidated"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||||
|
assert_eq!(payload["success"], json!(true));
|
||||||
|
assert_eq!(payload["data"]["error"], serde_json::Value::Null);
|
||||||
|
let model_ids = payload["data"]["models"]
|
||||||
|
.as_array()
|
||||||
|
.expect("models should be an array")
|
||||||
|
.iter()
|
||||||
|
.map(|model| model["id"].as_str().expect("model id"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
model_ids,
|
||||||
|
vec![
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
"gpt-5.3-codex-spark",
|
||||||
|
"gpt-5.4",
|
||||||
|
"gpt-5.4-mini",
|
||||||
|
"gpt-5.5",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats() {
|
async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats() {
|
||||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ pub fn extract_error_message(value: &Value) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_models_fetch_url(
|
pub fn build_models_fetch_url(
|
||||||
_provider_type: &str,
|
provider_type: &str,
|
||||||
endpoint_api_format: &str,
|
endpoint_api_format: &str,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
) -> Option<(String, String)> {
|
) -> Option<(String, String)> {
|
||||||
@@ -66,7 +66,10 @@ pub fn build_models_fetch_url(
|
|||||||
if !endpoint_supports_rust_models_fetch(&api_format) {
|
if !endpoint_supports_rust_models_fetch(&api_format) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let url = if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||||
|
let url = if provider_type == "codex" && api_format.starts_with("openai:") {
|
||||||
|
build_codex_models_url(base_url)
|
||||||
|
} else if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
||||||
build_v1_models_url(base_url)
|
build_v1_models_url(base_url)
|
||||||
} else if api_format.starts_with("gemini:") {
|
} else if api_format.starts_with("gemini:") {
|
||||||
build_gemini_models_url(base_url)
|
build_gemini_models_url(base_url)
|
||||||
@@ -115,23 +118,20 @@ pub fn parse_models_response_page(
|
|||||||
items
|
items
|
||||||
} else if let Some(items) = body.as_array() {
|
} else if let Some(items) = body.as_array() {
|
||||||
items
|
items
|
||||||
|
} else if let Some(items) = body.get("models").and_then(Value::as_array) {
|
||||||
|
items
|
||||||
} else {
|
} else {
|
||||||
return Err("models response is missing data array".to_string());
|
return Err("models response is missing data array".to_string());
|
||||||
};
|
};
|
||||||
for item in items {
|
for item in items {
|
||||||
let Some(model_id) = item
|
let Some(model_id) = model_id_from_openai_like_item(item) else {
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !seen.insert(model_id.to_string()) {
|
if !seen.insert(model_id.clone()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fetched_model_ids.push(model_id.to_string());
|
fetched_model_ids.push(model_id.clone());
|
||||||
cached_models.push(normalize_cached_model(item, model_id, &api_format));
|
cached_models.push(normalize_cached_model(item, &model_id, &api_format));
|
||||||
}
|
}
|
||||||
} else if api_format.starts_with("gemini:") {
|
} else if api_format.starts_with("gemini:") {
|
||||||
let items = body
|
let items = body
|
||||||
@@ -229,7 +229,7 @@ pub fn endpoint_supports_rust_models_fetch(api_format: &str) -> bool {
|
|||||||
pub fn provider_type_uses_preset_models(provider_type: &str) -> bool {
|
pub fn provider_type_uses_preset_models(provider_type: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||||
"codex" | "kiro" | "claude_code" | "gemini_cli"
|
"kiro" | "claude_code" | "gemini_cli"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,34 +258,11 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option<Vec<Value>> {
|
|||||||
preset_model("claude-haiku-4-5-20251001", "anthropic", "Claude Haiku 4.5", "claude:messages"),
|
preset_model("claude-haiku-4-5-20251001", "anthropic", "Claude Haiku 4.5", "claude:messages"),
|
||||||
],
|
],
|
||||||
"codex" => vec![
|
"codex" => vec![
|
||||||
preset_model("gpt-5", "openai", "GPT-5", "openai:responses"),
|
preset_model("gpt-5.5", "openai", "GPT-5.5", "openai:responses"),
|
||||||
preset_model("gpt-image-1", "openai", "GPT Image 1", "openai:image"),
|
|
||||||
preset_model("gpt-image-1.5", "openai", "GPT Image 1.5", "openai:image"),
|
|
||||||
preset_model("gpt-image-1-mini", "openai", "GPT Image 1 Mini", "openai:image"),
|
|
||||||
preset_model("gpt-image-2", "openai", "GPT Image 2", "openai:image"),
|
|
||||||
preset_model("chatgpt-image-latest", "openai", "ChatGPT Image Latest", "openai:image"),
|
|
||||||
preset_model("dall-e-2", "openai", "DALL-E 2", "openai:image"),
|
|
||||||
preset_model("dall-e-3", "openai", "DALL-E 3", "openai:image"),
|
|
||||||
preset_model("gpt-5-codex", "openai", "GPT-5 Codex", "openai:responses"),
|
|
||||||
preset_model("gpt-5-codex-mini", "openai", "GPT-5 Codex Mini", "openai:responses"),
|
|
||||||
preset_model("gpt-5.1", "openai", "GPT-5.1", "openai:responses"),
|
|
||||||
preset_model("gpt-5.1-codex", "openai", "GPT-5.1 Codex", "openai:responses"),
|
|
||||||
preset_model(
|
|
||||||
"gpt-5.1-codex-mini",
|
|
||||||
"openai",
|
|
||||||
"GPT-5.1 Codex Mini",
|
|
||||||
"openai:responses",
|
|
||||||
),
|
|
||||||
preset_model(
|
|
||||||
"gpt-5.1-codex-max",
|
|
||||||
"openai",
|
|
||||||
"GPT-5.1 Codex Max",
|
|
||||||
"openai:responses",
|
|
||||||
),
|
|
||||||
preset_model("gpt-5.2", "openai", "GPT-5.2", "openai:responses"),
|
|
||||||
preset_model("gpt-5.2-codex", "openai", "GPT-5.2 Codex", "openai:responses"),
|
|
||||||
preset_model("gpt-5.3-codex", "openai", "GPT-5.3 Codex", "openai:responses"),
|
|
||||||
preset_model("gpt-5.4", "openai", "GPT-5.4", "openai:responses"),
|
preset_model("gpt-5.4", "openai", "GPT-5.4", "openai:responses"),
|
||||||
|
preset_model("gpt-5.4-mini", "openai", "GPT-5.4 Mini", "openai:responses"),
|
||||||
|
preset_model("gpt-5.3-codex", "openai", "GPT-5.3 Codex", "openai:responses"),
|
||||||
|
preset_model("gpt-5.3-codex-spark", "openai", "GPT-5.3 Codex Spark", "openai:responses"),
|
||||||
],
|
],
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
@@ -485,6 +462,37 @@ fn build_v1_models_url(base_url: &str) -> Option<String> {
|
|||||||
Some(url)
|
Some(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_codex_models_url(base_url: &str) -> Option<String> {
|
||||||
|
let (trimmed_base_url, query) = split_url_query(base_url);
|
||||||
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
|
if trimmed_base_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||||
|
trimmed_base_url.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed_base_url}/models")
|
||||||
|
};
|
||||||
|
let mut has_client_version = false;
|
||||||
|
if let Some(query) = query.filter(|value| !value.trim().is_empty()) {
|
||||||
|
has_client_version = query.split('&').any(|part| {
|
||||||
|
part.split_once('=')
|
||||||
|
.map(|(key, _)| key)
|
||||||
|
.unwrap_or(part)
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("client_version")
|
||||||
|
});
|
||||||
|
url.push('?');
|
||||||
|
url.push_str(query);
|
||||||
|
}
|
||||||
|
if !has_client_version {
|
||||||
|
let separator = if url.contains('?') { '&' } else { '?' };
|
||||||
|
url.push(separator);
|
||||||
|
url.push_str("client_version=0.128.0-alpha.1");
|
||||||
|
}
|
||||||
|
Some(url)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_gemini_models_url(base_url: &str) -> Option<String> {
|
fn build_gemini_models_url(base_url: &str) -> Option<String> {
|
||||||
let (trimmed_base_url, base_query) = split_url_query(base_url);
|
let (trimmed_base_url, base_query) = split_url_query(base_url);
|
||||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
@@ -506,6 +514,24 @@ fn build_gemini_models_url(base_url: &str) -> Option<String> {
|
|||||||
Some(url)
|
Some(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn model_id_from_openai_like_item(item: &Value) -> Option<String> {
|
||||||
|
if let Some(value) = item
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return Some(value.trim_start_matches("models/").to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
["id", "model", "slug", "name"].iter().find_map(|field| {
|
||||||
|
item.get(*field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| value.trim_start_matches("models/").to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn split_url_query(base_url: &str) -> (&str, Option<&str>) {
|
fn split_url_query(base_url: &str) -> (&str, Option<&str>) {
|
||||||
let trimmed = base_url.trim();
|
let trimmed = base_url.trim();
|
||||||
trimmed
|
trimmed
|
||||||
@@ -707,6 +733,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_models_fetch_url_uses_codex_backend_models_endpoint() {
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url(
|
||||||
|
"codex",
|
||||||
|
"openai:responses",
|
||||||
|
"https://chatgpt.com/backend-api/codex"
|
||||||
|
),
|
||||||
|
Some((
|
||||||
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.128.0-alpha.1"
|
||||||
|
.to_string(),
|
||||||
|
"openai:responses".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_models_response_normalizes_openai_payload() {
|
fn parse_models_response_normalizes_openai_payload() {
|
||||||
let parsed = parse_models_response(
|
let parsed = parse_models_response(
|
||||||
@@ -721,6 +763,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_models_response_accepts_codex_models_array_payload() {
|
||||||
|
let parsed = parse_models_response(
|
||||||
|
"openai:responses",
|
||||||
|
&json!({"models": [{"id": "gpt-5-codex"}, {"slug": "gpt-5.4"}]}),
|
||||||
|
)
|
||||||
|
.expect("response should parse");
|
||||||
|
assert_eq!(
|
||||||
|
parsed.fetched_model_ids,
|
||||||
|
vec!["gpt-5-codex".to_string(), "gpt-5.4".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.cached_models[0]["api_formats"],
|
||||||
|
json!(["openai:responses"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_models_response_page_reads_claude_pagination_state() {
|
fn parse_models_response_page_reads_claude_pagination_state() {
|
||||||
let parsed = parse_models_response_page(
|
let parsed = parse_models_response_page(
|
||||||
@@ -822,6 +881,19 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn preset_models_cover_codex_catalog() {
|
fn preset_models_cover_codex_catalog() {
|
||||||
let models = preset_models_for_provider("codex").expect("preset models should exist");
|
let models = preset_models_for_provider("codex").expect("preset models should exist");
|
||||||
assert!(models.iter().any(|model| model["id"] == "gpt-5.4"));
|
let model_ids = models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model["id"].as_str().expect("model id"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
model_ids,
|
||||||
|
vec![
|
||||||
|
"gpt-5.5",
|
||||||
|
"gpt-5.4",
|
||||||
|
"gpt-5.4-mini",
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
"gpt-5.3-codex-spark",
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ pub async fn fetch_models_from_transports(
|
|||||||
.trim()
|
.trim()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if let Some(models) = preset_models_for_provider(&provider_type) {
|
if let Some(models) = preset_models_for_provider(&provider_type) {
|
||||||
|
if provider_type == "codex" {
|
||||||
|
return fetch_standard_models(runtime, transports).await;
|
||||||
|
}
|
||||||
if provider_type == "gemini_cli" {
|
if provider_type == "gemini_cli" {
|
||||||
return fetch_gemini_cli_models(runtime, first_transport, models).await;
|
return fetch_gemini_cli_models(runtime, first_transport, models).await;
|
||||||
}
|
}
|
||||||
@@ -1079,13 +1082,14 @@ mod tests {
|
|||||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::fetch_models_from_transports;
|
use crate::fetch_models_from_transports;
|
||||||
use crate::transport::ModelFetchTransportRuntime;
|
use crate::transport::ModelFetchTransportRuntime;
|
||||||
|
|
||||||
struct TestRuntime {
|
struct TestRuntime {
|
||||||
executed_urls: Arc<Mutex<Vec<String>>>,
|
executed_urls: Arc<Mutex<Vec<String>>>,
|
||||||
|
response_body: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -1119,11 +1123,7 @@ mod tests {
|
|||||||
status_code: 200,
|
status_code: 200,
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Some(ResponseBody {
|
body: Some(ResponseBody {
|
||||||
json_body: Some(json!({
|
json_body: Some(self.response_body.clone()),
|
||||||
"models": [{
|
|
||||||
"name": "publishers/google/models/gemini-3.1-pro-preview"
|
|
||||||
}]
|
|
||||||
})),
|
|
||||||
body_bytes_b64: None,
|
body_bytes_b64: None,
|
||||||
}),
|
}),
|
||||||
telemetry: None,
|
telemetry: None,
|
||||||
@@ -1188,12 +1188,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_codex_transport() -> GatewayProviderTransportSnapshot {
|
||||||
|
let mut transport = sample_custom_aiplatform_transport();
|
||||||
|
transport.provider.provider_type = "codex".to_string();
|
||||||
|
transport.provider.name = "Codex".to_string();
|
||||||
|
transport.endpoint.api_format = "openai:responses".to_string();
|
||||||
|
transport.endpoint.api_family = Some("openai".to_string());
|
||||||
|
transport.endpoint.endpoint_kind = Some("responses".to_string());
|
||||||
|
transport.endpoint.base_url = "https://chatgpt.com/backend-api/codex".to_string();
|
||||||
|
transport.endpoint.custom_path = Some("/responses".to_string());
|
||||||
|
transport.key.api_formats = Some(vec!["openai:responses".to_string()]);
|
||||||
|
transport.key.decrypted_api_key = "access-token".to_string();
|
||||||
|
transport
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn custom_aiplatform_transport_uses_vertex_models_fetch_path_and_normalizes_chat_format()
|
async fn custom_aiplatform_transport_uses_vertex_models_fetch_path_and_normalizes_chat_format()
|
||||||
{
|
{
|
||||||
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||||
let runtime = TestRuntime {
|
let runtime = TestRuntime {
|
||||||
executed_urls: Arc::clone(&executed_urls),
|
executed_urls: Arc::clone(&executed_urls),
|
||||||
|
response_body: json!({
|
||||||
|
"models": [{
|
||||||
|
"name": "publishers/google/models/gemini-3.1-pro-preview"
|
||||||
|
}]
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let outcome =
|
let outcome =
|
||||||
fetch_models_from_transports(&runtime, &[sample_custom_aiplatform_transport()])
|
fetch_models_from_transports(&runtime, &[sample_custom_aiplatform_transport()])
|
||||||
@@ -1212,4 +1231,28 @@ mod tests {
|
|||||||
Some("gemini:generate_content")
|
Some("gemini:generate_content")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn codex_transport_fetches_upstream_models_instead_of_preset_catalog() {
|
||||||
|
let executed_urls = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let runtime = TestRuntime {
|
||||||
|
executed_urls: Arc::clone(&executed_urls),
|
||||||
|
response_body: json!({
|
||||||
|
"models": [{
|
||||||
|
"id": "gpt-5.4-upstream"
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let outcome = fetch_models_from_transports(&runtime, &[sample_codex_transport()])
|
||||||
|
.await
|
||||||
|
.expect("models fetch should succeed");
|
||||||
|
|
||||||
|
let urls = executed_urls.lock().expect("executed_urls lock");
|
||||||
|
assert_eq!(
|
||||||
|
urls.as_slice(),
|
||||||
|
&["https://chatgpt.com/backend-api/codex/models?client_version=0.128.0-alpha.1"]
|
||||||
|
);
|
||||||
|
assert_eq!(outcome.fetched_model_ids, vec!["gpt-5.4-upstream"]);
|
||||||
|
assert_eq!(outcome.cached_models.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use aether_provider_transport::{
|
|||||||
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::build_models_fetch_url;
|
use crate::build_models_fetch_url;
|
||||||
|
|
||||||
@@ -85,7 +85,13 @@ pub async fn build_standard_models_fetch_execution_plan(
|
|||||||
) -> Result<ExecutionPlan, String> {
|
) -> Result<ExecutionPlan, String> {
|
||||||
let api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
let api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
|
||||||
let provider_api_format = api_format.clone();
|
let provider_api_format = api_format.clone();
|
||||||
let mut headers = standard_models_fetch_headers(&api_format, &transport.provider.provider_type);
|
let provider_type = transport.provider.provider_type.trim().to_ascii_lowercase();
|
||||||
|
let is_codex_openai_models_fetch =
|
||||||
|
provider_type == "codex" && api_format.starts_with("openai:");
|
||||||
|
let mut headers = standard_models_fetch_headers(&api_format, &provider_type);
|
||||||
|
if is_codex_openai_models_fetch {
|
||||||
|
headers.insert("accept".to_string(), "application/json".to_string());
|
||||||
|
}
|
||||||
let mut protected_headers = Vec::<String>::new();
|
let mut protected_headers = Vec::<String>::new();
|
||||||
|
|
||||||
if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
if api_format.starts_with("openai:") || api_format.starts_with("claude:") {
|
||||||
@@ -101,6 +107,16 @@ pub async fn build_standard_models_fetch_execution_plan(
|
|||||||
&auth_header_name,
|
&auth_header_name,
|
||||||
&auth_header_value,
|
&auth_header_value,
|
||||||
);
|
);
|
||||||
|
if is_codex_openai_models_fetch {
|
||||||
|
if let Some(account_id) = extract_codex_account_id(transport) {
|
||||||
|
insert_non_empty_auth_header(
|
||||||
|
&mut headers,
|
||||||
|
&mut protected_headers,
|
||||||
|
"chatgpt-account-id",
|
||||||
|
&account_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
|
headers = apply_fetch_header_rules(transport, headers, &protected_headers)?;
|
||||||
ensure_upstream_auth_header(&mut headers, &auth_header_name, &auth_header_value);
|
ensure_upstream_auth_header(&mut headers, &auth_header_name, &auth_header_value);
|
||||||
} else {
|
} else {
|
||||||
@@ -481,6 +497,23 @@ fn append_query_param(mut url: String, key: &str, value: &str) -> String {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_codex_account_id(transport: &GatewayProviderTransportSnapshot) -> Option<String> {
|
||||||
|
let raw = transport.key.decrypted_auth_config.as_deref()?.trim();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str::<Value>(raw).ok().and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("account_id")
|
||||||
|
.or_else(|| value.get("chatgpt_account_id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn insert_non_empty_auth_header(
|
fn insert_non_empty_auth_header(
|
||||||
headers: &mut BTreeMap<String, String>,
|
headers: &mut BTreeMap<String, String>,
|
||||||
protected_headers: &mut Vec<String>,
|
protected_headers: &mut Vec<String>,
|
||||||
@@ -648,6 +681,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builds_codex_models_fetch_plan_with_account_header() {
|
||||||
|
let runtime = TestRuntime {
|
||||||
|
oauth_auth: Some(
|
||||||
|
aether_provider_transport::LocalResolvedOAuthRequestAuth::Header {
|
||||||
|
name: "authorization".to_string(),
|
||||||
|
value: "Bearer access-token".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
proxy: None,
|
||||||
|
};
|
||||||
|
let mut transport = sample_transport("codex", "openai:responses", "oauth");
|
||||||
|
transport.endpoint.base_url = "https://chatgpt.com/backend-api/codex".to_string();
|
||||||
|
transport.key.decrypted_auth_config = Some(r#"{"account_id":"account-1"}"#.to_string());
|
||||||
|
|
||||||
|
let plan = build_models_fetch_execution_plan(&runtime, &transport)
|
||||||
|
.await
|
||||||
|
.expect("plan");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.url,
|
||||||
|
"https://chatgpt.com/backend-api/codex/models?client_version=0.128.0-alpha.1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.headers.get("authorization").map(String::as_str),
|
||||||
|
Some("Bearer access-token")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.headers.get("chatgpt-account-id").map(String::as_str),
|
||||||
|
Some("account-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.headers.get("accept").map(String::as_str),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn builds_claude_models_fetch_plan_with_pagination() {
|
async fn builds_claude_models_fetch_plan_with_pagination() {
|
||||||
let runtime = TestRuntime {
|
let runtime = TestRuntime {
|
||||||
|
|||||||
Reference in New Issue
Block a user