mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## Why Providers need to serve Codex model metadata independently of their inference endpoint. A custom inference `base_url` alone should not opt API-key sessions into remote catalog discovery. ## What changed - Add `model_catalog_url` to provider configuration and thread-config serialization. Fetch the full catalog URL with provider authentication, headers, query parameters, and `client_version`, while preserving inference routing. - Require an explicit catalog URL for API-key discovery with a custom base URL. Keep default OpenAI discovery on the Codex backend, and apply `api_key_model_discovery` gating to provider API keys and cached catalogs. - Include the catalog URL in cache identity so different catalogs do not share cached metadata. - Limit explicitly configured catalog responses to 1 MiB, reject redirects, and suppress URL and response diagnostics that could expose credentials. ## Testing Add coverage for catalog configuration round-trips, query encoding, authentication and header reuse, cache identity and discovery gating, oversized responses, and redirect rejection without credential forwarding. Add an integration test verifying that catalog model metadata and instructions reach conversation requests. GitOrigin-RevId: 7f6ac5eec257278685e1867a6bcebae6514a365a
215 lines
7.5 KiB
Rust
215 lines
7.5 KiB
Rust
use codex_api::AuthProvider;
|
|
use codex_api::ModelsClient;
|
|
use codex_api::Provider;
|
|
use codex_api::RetryConfig;
|
|
use codex_client::ReqwestTransport;
|
|
use codex_http_client::HttpClientBuilder;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::openai_models::ConfigShellToolType;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::openai_models::ModelVisibility;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_protocol::openai_models::TruncationPolicyConfig;
|
|
use codex_protocol::openai_models::default_input_modalities;
|
|
use http::HeaderMap;
|
|
use http::Method;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct DummyAuth;
|
|
|
|
impl AuthProvider for DummyAuth {
|
|
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
|
|
}
|
|
|
|
fn provider(base_url: &str) -> Provider {
|
|
Provider {
|
|
name: "test".to_string(),
|
|
base_url: base_url.to_string(),
|
|
query_params: None,
|
|
headers: HeaderMap::new(),
|
|
retry: RetryConfig {
|
|
max_attempts: 1,
|
|
base_delay: std::time::Duration::from_millis(1),
|
|
retry_429: false,
|
|
retry_5xx: true,
|
|
retry_transport: true,
|
|
},
|
|
stream_idle_timeout: std::time::Duration::from_secs(1),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_models_response_reports_bounded_decode_metadata() {
|
|
let server = MockServer::start().await;
|
|
for (body, category, column_offset) in [
|
|
(
|
|
json!({ "data": [{ "id": "private-model" }] }).to_string(),
|
|
"Data",
|
|
0,
|
|
),
|
|
(
|
|
json!({ "models": "private-value".repeat(4096) }).to_string(),
|
|
"Data",
|
|
1,
|
|
),
|
|
(r#"{"models":]}"#.to_string(), "Syntax", 1),
|
|
(r#"{"models":["#.to_string(), "Eof", 0),
|
|
] {
|
|
let response = Mock::given(method("GET"))
|
|
.and(path("/models"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(&body))
|
|
.expect(1)
|
|
.mount_as_scoped(&server)
|
|
.await;
|
|
let transport = ReqwestTransport::from_http_client(
|
|
HttpClientBuilder::new()
|
|
.build_direct()
|
|
.expect("test HTTP client should build"),
|
|
);
|
|
let provider = provider(&server.uri());
|
|
let request_url = ModelsClient::<ReqwestTransport>::request_url(&provider, "0.1.0");
|
|
let client = ModelsClient::new(transport, provider, Arc::new(DummyAuth));
|
|
|
|
let error = client
|
|
.list_models(
|
|
request_url,
|
|
HeaderMap::new(),
|
|
/*response_body_limit_bytes*/ None,
|
|
)
|
|
.await
|
|
.expect_err("invalid catalog should fail to decode");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
format!(
|
|
"stream error: failed to decode models response: {category} at line 1 column {} (body: {} bytes)",
|
|
body.len() - column_offset,
|
|
body.len()
|
|
)
|
|
);
|
|
drop(response);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn models_client_hits_models_endpoint() {
|
|
let server = MockServer::start().await;
|
|
let base_url = format!("{}/api/codex", server.uri());
|
|
|
|
let response = ModelsResponse {
|
|
models: vec![ModelInfo {
|
|
slug: "gpt-test".to_string(),
|
|
display_name: "gpt-test".to_string(),
|
|
description: Some("desc".to_string()),
|
|
default_reasoning_level: Some(ReasoningEffort::Medium),
|
|
supported_reasoning_levels: vec![
|
|
ReasoningEffortPreset {
|
|
effort: ReasoningEffort::Low,
|
|
description: ReasoningEffort::Low.to_string(),
|
|
},
|
|
ReasoningEffortPreset {
|
|
effort: ReasoningEffort::Medium,
|
|
description: ReasoningEffort::Medium.to_string(),
|
|
},
|
|
ReasoningEffortPreset {
|
|
effort: ReasoningEffort::High,
|
|
description: ReasoningEffort::High.to_string(),
|
|
},
|
|
],
|
|
shell_type: ConfigShellToolType::UnifiedExec,
|
|
visibility: ModelVisibility::List,
|
|
supported_in_api: true,
|
|
priority: 1,
|
|
additional_speed_tiers: Vec::new(),
|
|
service_tiers: Vec::new(),
|
|
default_service_tier: None,
|
|
available_access_programs: None,
|
|
upgrade: None,
|
|
model_messages: None,
|
|
include_skills_usage_instructions: false,
|
|
include_plugin_usage_instructions: false,
|
|
include_apps_usage_instructions: false,
|
|
supports_reasoning_summary_parameter: true,
|
|
default_reasoning_summary: ReasoningSummary::Auto,
|
|
support_verbosity: false,
|
|
default_verbosity: None,
|
|
availability_nux: None,
|
|
apply_patch_tool_type: None,
|
|
web_search_tool_type: Default::default(),
|
|
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
|
|
supports_image_detail_original: false,
|
|
context_window: Some(272_000),
|
|
max_context_window: None,
|
|
auto_compact_token_limit: None,
|
|
comp_hash: None,
|
|
effective_context_window_percent: 95,
|
|
experimental_supported_tools: Vec::new(),
|
|
input_modalities: default_input_modalities(),
|
|
used_fallback_model_metadata: false,
|
|
supports_search_tool: false,
|
|
supports_experimental_context: false,
|
|
use_responses_lite: false,
|
|
supports_reasoning_effort_updates: true,
|
|
guardian: None,
|
|
node_repl_auto_review_required: true,
|
|
node_repl_disabled: true,
|
|
auto_review_model_override: None,
|
|
model_specialty: None,
|
|
tool_mode: None,
|
|
multi_agent_version: None,
|
|
multi_agent_reasoning_effort: None,
|
|
}],
|
|
};
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/codex/models"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/json")
|
|
.set_body_json(&response),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let transport = ReqwestTransport::from_http_client(
|
|
HttpClientBuilder::new()
|
|
.build_direct()
|
|
.expect("test HTTP client should build"),
|
|
);
|
|
let provider = provider(&base_url);
|
|
let request_url = ModelsClient::<ReqwestTransport>::request_url(&provider, "0.1.0");
|
|
let client = ModelsClient::new(transport, provider, Arc::new(DummyAuth));
|
|
|
|
let (models, _) = client
|
|
.list_models(
|
|
request_url,
|
|
HeaderMap::new(),
|
|
/*response_body_limit_bytes*/ None,
|
|
)
|
|
.await
|
|
.expect("models request should succeed");
|
|
|
|
assert_eq!(models.len(), 1);
|
|
assert_eq!(models[0].slug, "gpt-test");
|
|
assert!(models[0].node_repl_auto_review_required);
|
|
assert!(models[0].node_repl_disabled);
|
|
|
|
let received = server
|
|
.received_requests()
|
|
.await
|
|
.expect("should capture requests");
|
|
assert_eq!(received.len(), 1);
|
|
assert_eq!(received[0].method, Method::GET.as_str());
|
|
assert_eq!(received[0].url.path(), "/api/codex/models");
|
|
}
|