Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 97 additions & 9 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
Expand Down Expand Up @@ -1163,18 +1164,13 @@ impl ModelClientSession {
None,
)
})?;
let completion_items = synthesize_chat_completions_output_items(&assistant_text);

let (tx_event, rx_event) = mpsc::channel(8);
let _ = tx_event.try_send(Ok(ResponseEvent::Created));
let _ = tx_event.try_send(Ok(ResponseEvent::OutputItemDone(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: assistant_text,
}],
end_turn: None,
phase: None,
})));
for item in completion_items {
let _ = tx_event.try_send(Ok(ResponseEvent::OutputItemDone(item)));
}
let _ = tx_event.try_send(Ok(ResponseEvent::Completed {
response_id,
token_usage: None,
Expand Down Expand Up @@ -1588,6 +1584,98 @@ fn extract_chat_completions_text(response_json: &JsonValue) -> Option<String> {
})
}

fn synthesize_chat_completions_output_items(text: &str) -> Vec<ResponseItem> {
let parsed = parse_claude_tool_wrapper_blocks(text);
if parsed.is_empty() {
return vec![assistant_message_item(text.to_string())];
}

let mut items = Vec::new();
for item in parsed {
items.push(item);
}
items
}

fn assistant_message_item(text: String) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text }],
end_turn: None,
phase: None,
}
}

fn parse_claude_tool_wrapper_blocks(text: &str) -> Vec<ResponseItem> {
let mut items = Vec::new();
let mut remaining = text;
let mut tool_index = 0usize;

while let Some(start) = remaining.find("<tool_call>") {
let before = &remaining[..start];
if !before.is_empty() {
items.push(assistant_message_item(before.to_string()));
}

let after_open = &remaining[start + "<tool_call>".len()..];
let Some(end_call) = after_open.find("</tool_call>") else {
return vec![assistant_message_item(text.to_string())];
};
let tool_call_payload = after_open[..end_call].trim();
let after_call = &after_open[end_call + "</tool_call>".len()..];

let Some(result_start) = after_call.find("<tool_result>") else {
return vec![assistant_message_item(text.to_string())];
};
let between = &after_call[..result_start];
if !between.is_empty() {
items.push(assistant_message_item(between.to_string()));
}

let after_result_open = &after_call[result_start + "<tool_result>".len()..];
let Some(end_result) = after_result_open.find("</tool_result>") else {
return vec![assistant_message_item(text.to_string())];
};
let tool_result_payload = after_result_open[..end_result].trim();
remaining = &after_result_open[end_result + "</tool_result>".len()..];

let Ok(tool_call_json) = serde_json::from_str::<JsonValue>(tool_call_payload) else {
return vec![assistant_message_item(text.to_string())];
};
let Some(name) = tool_call_json
.get("name")
.and_then(JsonValue::as_str)
.map(ToString::to_string)
else {
return vec![assistant_message_item(text.to_string())];
};
let arguments = tool_call_json
.get("arguments")
.cloned()
.unwrap_or(JsonValue::Object(serde_json::Map::new()));
let call_id = format!("chat-completions-tool-call-{tool_index}");
tool_index += 1;
items.push(ResponseItem::FunctionCall {
id: None,
name,
namespace: None,
arguments: arguments.to_string(),
call_id: call_id.clone(),
});
items.push(ResponseItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload::from_text(tool_result_payload.to_string()),
});
}

if !remaining.is_empty() {
items.push(assistant_message_item(remaining.to_string()));
}

items
}

fn map_response_stream<S>(
api_stream: S,
session_telemetry: SessionTelemetry,
Expand Down
56 changes: 56 additions & 0 deletions codex-rs/core/src/client_tests.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
use super::synthesize_chat_completions_output_items;
use super::UnauthorizedRecoveryExecution;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
Expand Down Expand Up @@ -116,3 +119,56 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
assert_eq!(auth_context.recovery_mode, Some("managed"));
assert_eq!(auth_context.recovery_phase, Some("refresh_token"));
}

#[test]
fn synthesize_chat_completions_output_items_converts_claude_tool_wrappers() {
let items = synthesize_chat_completions_output_items(
"before<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}</tool_call><tool_result>ok</tool_result>after",
);

assert_eq!(items.len(), 4);
assert!(matches!(
&items[0],
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& matches!(
content.first(),
Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "before"
)
));
assert!(matches!(
&items[1],
ResponseItem::FunctionCall { name, arguments, .. }
if name == "shell" && arguments == "{\"command\":\"pwd\"}"
));
assert!(matches!(
&items[2],
ResponseItem::FunctionCallOutput { output, .. }
if matches!(&output.body, FunctionCallOutputBody::Text(text) if text == "ok")
));
assert!(matches!(
&items[3],
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& matches!(
content.first(),
Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "after"
)
));
}

#[test]
fn synthesize_chat_completions_output_items_leaves_plain_text_unchanged() {
let items = synthesize_chat_completions_output_items("plain assistant text");

assert_eq!(items.len(), 1);
assert!(matches!(
&items[0],
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& matches!(
content.first(),
Some(codex_protocol::models::ContentItem::OutputText { text }) if text == "plain assistant text"
)
));
}
19 changes: 11 additions & 8 deletions codex-rs/core/src/models_manager/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,10 +474,7 @@ impl ModelsManager {
candidates: &[ModelInfo],
config: &Config,
) -> ModelInfo {
// First use the normal longest-prefix match. If that misses, allow a narrowly scoped
// retry for namespaced slugs like `custom/gpt-5.3-codex`.
let remote = Self::find_model_by_longest_prefix(model, candidates)
.or_else(|| Self::find_model_by_namespaced_suffix(model, candidates));
let remote = Self::find_metadata_candidate(model, candidates);
let model_info = if let Some(remote) = remote {
ModelInfo {
slug: model.to_string(),
Expand All @@ -490,6 +487,15 @@ impl ModelsManager {
model_info::with_config_overrides(model_info, config)
}

/// Reuse the same metadata lookup rules for both direct model selection and provider-backed
/// model listings.
fn find_metadata_candidate(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
// First use the normal longest-prefix match. If that misses, allow a narrowly scoped
// retry for namespaced slugs like `custom/gpt-5.3-codex`.
Self::find_model_by_longest_prefix(model, candidates)
.or_else(|| Self::find_model_by_namespaced_suffix(model, candidates))
}

/// Refresh models if the provided ETag differs from the cached ETag.
///
/// Uses `Online` strategy to fetch latest models when ETags differ.
Expand Down Expand Up @@ -1010,10 +1016,7 @@ impl ModelsManager {
return None;
}

let mut candidate = bundled_models
.iter()
.find(|bundled| bundled.slug == model_id)
.cloned()
let mut candidate = Self::find_metadata_candidate(&model_id, &bundled_models)
.unwrap_or_else(|| model_info::model_info_from_slug(&model_id));
candidate.slug = model_id.clone();
if candidate.display_name.is_empty() {
Expand Down
83 changes: 83 additions & 0 deletions codex-rs/core/src/models_manager/manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::config::ConfigBuilder;
use crate::model_provider_info::WireApi;
use chrono::Utc;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::responses::mount_models_once;
use pretty_assertions::assert_eq;
use serde_json::json;
Expand Down Expand Up @@ -827,6 +828,88 @@ async fn models_dev_provider_match_uses_base_url_host_when_name_is_custom() {
);
}

#[tokio::test]
async fn models_dev_provider_alias_model_inherits_canonical_reasoning_metadata() {
let models_dev_server = MockServer::start().await;
let _models_dev = wiremock::Mock::given(method("GET"))
.and(path("/api.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"azure": {
"id": "azure",
"name": "Azure",
"api": "https://azure.example.com/openai",
"env": ["AZURE_OPENAI_API_KEY"],
"models": {
"azure/gpt-5.4-pro": {
"id": "azure/gpt-5.4-pro",
"name": "Azure GPT 5.4 Pro",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"limit": {"context": 128000, "output": 4096},
"options": {}
}
}
}
})))
.expect(1)
.mount_as_scoped(&models_dev_server)
.await;

let codex_home = tempdir().expect("temp dir");
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused"));
let provider = ModelProviderInfo {
name: "Azure OpenAI".to_string(),
base_url: Some("https://azure.example.com/openai".to_string()),
env_key: Some("AZURE_OPENAI_API_KEY".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: Some(
[("api-version".to_string(), "2025-04-01-preview".to_string())]
.into_iter()
.collect(),
),
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(5_000),
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
};
let manager = ModelsManager::with_provider_and_models_dev_url_for_tests(
codex_home.path().to_path_buf(),
auth_manager,
provider,
format!("{}/api.json", models_dev_server.uri()),
);

let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await;
let preset = available
.iter()
.find(|preset| preset.model == "azure/gpt-5.4-pro")
.expect("expected Azure alias model to be listed");

assert_eq!(preset.default_reasoning_effort, ReasoningEffort::Medium);
assert_eq!(
preset
.supported_reasoning_efforts
.iter()
.map(|preset| preset.effort)
.collect::<Vec<_>>(),
vec![
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
]
);
}

#[tokio::test]
async fn non_openai_provider_falls_back_to_provider_models_when_models_dev_has_no_match() {
let models_dev_server = MockServer::start().await;
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/stream_events_utils_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message()
assert_eq!(last_assistant_message_from_item(&item, true), None);
}


#[tokio::test]
async fn save_image_generation_result_saves_base64_to_png_in_temp_dir() {
let expected_path = std::env::temp_dir().join("ig_save_base64.png");
Expand Down
Loading
Loading