Skip to content
Merged
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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,16 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0
# Discover a live rust-release run instead of pinning an expired artifact set.
CODEX_VERSION=$(gh run list \
-R openai/codex \
--workflow .github/workflows/rust-release.yml \
--json headBranch,status,conclusion \
--jq 'map(select(.status == "completed" and .conclusion == "success" and (.headBranch | startswith("rust-v"))))[0].headBranch | sub("^rust-v"; "")')
if [ -z "$CODEX_VERSION" ] || [ "$CODEX_VERSION" = "null" ]; then
echo "Unable to resolve a live rust-release version" >&2
exit 1
fi
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \
Expand Down
26 changes: 20 additions & 6 deletions codex-cli/scripts/install_native_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,13 @@ def main() -> int:
if not workflow_url:
workflow_url = DEFAULT_WORKFLOW_URL

workflow_id = workflow_url.rstrip("/").split("/")[-1]
print(f"Downloading native artifacts from workflow {workflow_id}...")
workflow_repo, workflow_id = _parse_workflow_reference(workflow_url)
print(f"Downloading native artifacts from {workflow_repo} workflow {workflow_id}...")

with _gha_group(f"Download native artifacts from workflow {workflow_id}"):
with _gha_group(f"Download native artifacts from {workflow_repo} workflow {workflow_id}"):
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
artifacts_dir = Path(artifacts_dir_str)
_download_artifacts(workflow_id, artifacts_dir)
_download_artifacts(workflow_repo, workflow_id, artifacts_dir)
install_binary_components(
artifacts_dir,
vendor_dir,
Expand Down Expand Up @@ -259,15 +259,29 @@ def fetch_rg(
return [results[target] for target in targets]


def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
def _parse_workflow_reference(workflow_url: str) -> tuple[str, str]:
parsed = urlparse(workflow_url)
parts = parsed.path.strip("/").split("/")
if parsed.netloc != "github.com" or len(parts) < 5 or parts[2] != "actions" or parts[3] != "runs":
raise ValueError(f"Unsupported workflow URL: {workflow_url}")

repo = f"{parts[0]}/{parts[1]}"
workflow_id = parts[4]
if not workflow_id.isdigit():
raise ValueError(f"Unsupported workflow URL: {workflow_url}")

return repo, workflow_id


def _download_artifacts(workflow_repo: str, workflow_id: str, dest_dir: Path) -> None:
cmd = [
"gh",
"run",
"download",
"--dir",
str(dest_dir),
"--repo",
"openai/codex",
workflow_repo,
workflow_id,
]
subprocess.check_call(cmd)
Expand Down
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
Expand Up @@ -2,8 +2,11 @@ use super::AuthRequestTelemetryContext;
use super::ModelClient;
use super::PendingUnauthorizedRetry;
use super::UnauthorizedRecoveryExecution;
use super::synthesize_chat_completions_output_items;
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"
)
));
}
Loading
Loading