diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 701aa3e4c2..3c7a645a18 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -112,6 +112,8 @@ impl ModelClient { /// For Chat providers, the underlying stream is optionally aggregated /// based on the `show_raw_agent_reasoning` flag in the config. pub async fn stream(&self, prompt: &PromptBuilder) -> Result { + self.validate_prompt_wire_api(prompt)?; + match self.provider.wire_api { WireApi::Responses => self.stream_responses_api(prompt).await, WireApi::Chat => { @@ -132,6 +134,17 @@ impl ModelClient { } } + fn validate_prompt_wire_api(&self, prompt: &PromptBuilder) -> Result<()> { + if prompt.wire_api != self.provider.wire_api { + return Err(CodexErr::UnsupportedOperation(format!( + "prompt configured for {:?} wire API but provider {} expects {:?}", + prompt.wire_api, self.provider.name, self.provider.wire_api + ))); + } + + Ok(()) + } + /// Streams a turn via the OpenAI Chat Completions API. /// /// This path is only used when the provider is configured with diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2afac467de..4d28deea29 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -5,6 +5,8 @@ use crate::model_family::ModelFamily; use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; +use codex_api::CompactionInput; +use codex_api::Prompt; pub use codex_api::common::ResponseEvent; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::models::ResponseItem; @@ -18,7 +20,6 @@ use std::pin::Pin; use std::task::Context; use std::task::Poll; use tokio::sync::mpsc; -use codex_api::{CompactionInput, Prompt}; /// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index c66bbdc78f..24c7585c10 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -10,6 +10,7 @@ use codex_core::ModelProviderInfo; use codex_core::PromptBuilder; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::error::CodexErr; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemContent; @@ -41,21 +42,7 @@ async fn run_request(input: Vec) -> Value { .mount(&server) .await; - let provider = ModelProviderInfo { - name: "mock".into(), - base_url: Some(format!("{}/v1", server.uri())), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Chat, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(5_000), - requires_openai_auth: false, - }; + let provider = chat_provider(format!("{}/v1", server.uri())); let codex_home = match TempDir::new() { Ok(dir) => dir, @@ -184,6 +171,24 @@ fn first_assistant(messages: &[Value]) -> &Value { } } +fn chat_provider(base_url: String) -> ModelProviderInfo { + ModelProviderInfo { + name: "mock".into(), + base_url: Some(base_url), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn omits_reasoning_when_none_present() { skip_if_no_network!(); @@ -316,3 +321,52 @@ async fn suppresses_duplicate_assistant_messages() { Value::String("dup".into()) ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn errors_on_mismatched_prompt_wire_api() { + let provider = chat_provider("https://example.com/v1".into()); + + let codex_home = match TempDir::new() { + Ok(dir) => dir, + Err(e) => panic!("failed to create TempDir: {e}"), + }; + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.show_raw_agent_reasoning = true; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let conversation_id = ConversationId::new(); + + let otel_event_manager = OtelEventManager::new( + conversation_id, + config.model.as_str(), + config.model_family.slug.as_str(), + None, + Some("test@test.com".to_string()), + Some(AuthMode::ChatGPT), + false, + "test".to_string(), + ); + + let client = ModelClient::new( + Arc::clone(&config), + None, + otel_event_manager, + provider, + effort, + summary, + conversation_id, + codex_protocol::protocol::SessionSource::Exec, + ); + + let prompt = PromptBuilder::new().with_input(vec![user_message("u1")]); + + let err = match client.stream(&prompt).await { + Ok(_) => panic!("wire API mismatch should error before sending request"), + Err(e) => e, + }; + assert!(matches!(err, CodexErr::UnsupportedOperation(_))); +}