mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
9 Commits
rust-v0.11
...
codex/rest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b4cc8c6a6 | ||
|
|
e678ab7513 | ||
|
|
5173b3a0e6 | ||
|
|
bec0aa12f0 | ||
|
|
39097ab65d | ||
|
|
3a22e10172 | ||
|
|
c9e706f8b6 | ||
|
|
8a19dbb177 | ||
|
|
6edb865cc6 |
1
.bazelrc
1
.bazelrc
@@ -124,7 +124,6 @@ build:argument-comment-lint --@rules_rust//rust/toolchain/channel=nightly
|
||||
common:ci-windows --config=ci-bazel
|
||||
common:ci-windows --build_metadata=TAG_os=windows
|
||||
common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
|
||||
common:ci-windows --repository_cache=D:/a/.cache/bazel-repo-cache
|
||||
|
||||
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
|
||||
|
||||
31
.github/actions/setup-bazel-ci/action.yml
vendored
31
.github/actions/setup-bazel-ci/action.yml
vendored
@@ -9,9 +9,9 @@ inputs:
|
||||
required: false
|
||||
default: "false"
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether the Bazel repository cache key was restored exactly.
|
||||
value: ${{ steps.cache_bazel_repository_restore.outputs.cache-hit }}
|
||||
repository-cache-path:
|
||||
description: Filesystem path used for the Bazel repository cache.
|
||||
value: ${{ steps.configure_bazel_repository_cache.outputs.repository-cache-path }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -41,17 +41,16 @@ runs:
|
||||
- name: Set up Bazel
|
||||
uses: bazelbuild/setup-bazelisk@v3
|
||||
|
||||
# Restore bazel repository cache so we don't have to redownload all the external dependencies
|
||||
# on every CI run.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
key: bazel-cache-${{ inputs.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ inputs.target }}
|
||||
- name: Configure Bazel repository cache
|
||||
id: configure_bazel_repository_cache
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Keep the repository cache under HOME on all runners. Windows `D:\a`
|
||||
# cache paths match `.bazelrc`, but `actions/cache/restore` currently
|
||||
# returns HTTP 400 for that path in the Windows clippy job.
|
||||
$repositoryCachePath = Join-Path $HOME '.cache/bazel-repo-cache'
|
||||
"repository-cache-path=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
"BAZEL_REPOSITORY_CACHE=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Configure Bazel output root (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -65,10 +64,6 @@ runs:
|
||||
$repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB"
|
||||
"BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
if (-not $hasDDrive) {
|
||||
$repositoryCache = Join-Path $env:USERPROFILE '.cache\bazel-repo-cache'
|
||||
"BAZEL_REPOSITORY_CACHE=$repositoryCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
- name: Expose MSVC SDK environment (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
1
.github/workflows/README.md
vendored
1
.github/workflows/README.md
vendored
@@ -11,6 +11,7 @@ The workflows in this directory are split so that pull requests get fast, review
|
||||
- `rust-ci.yml` keeps the Cargo-native PR checks intentionally small:
|
||||
- `cargo fmt --check`
|
||||
- `cargo shear`
|
||||
- Linux `codex-core` remote-env smoke tests in Docker
|
||||
- `argument-comment-lint` on Linux, macOS, and Windows
|
||||
- `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes
|
||||
|
||||
|
||||
54
.github/workflows/bazel.yml
vendored
54
.github/workflows/bazel.yml
vendored
@@ -58,6 +58,20 @@ jobs:
|
||||
target: ${{ matrix.target }}
|
||||
install-test-prereqs: "true"
|
||||
|
||||
# Restore the Bazel repository cache explicitly so external dependencies
|
||||
# do not need to be re-downloaded on every CI run. Keep restore failures
|
||||
# non-fatal so transient cache-service errors degrade to a cold build
|
||||
# instead of failing the job.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ matrix.target }}
|
||||
|
||||
- name: Check MODULE.bazel.lock is up to date
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
shell: bash
|
||||
@@ -112,12 +126,11 @@ jobs:
|
||||
# Save bazel repository cache explicitly; make non-fatal so cache uploading
|
||||
# never fails the overall job. Only save when key wasn't hit.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
|
||||
if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
|
||||
clippy:
|
||||
@@ -148,6 +161,20 @@ jobs:
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
# Restore the Bazel repository cache explicitly so external dependencies
|
||||
# do not need to be re-downloaded on every CI run. Keep restore failures
|
||||
# non-fatal so transient cache-service errors degrade to a cold build
|
||||
# instead of failing the job.
|
||||
- name: Restore bazel repository cache
|
||||
id: cache_bazel_repository_restore
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ matrix.target }}
|
||||
|
||||
- name: Set up Bazel execution logs
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -159,6 +186,18 @@ jobs:
|
||||
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
bazel_clippy_args=(
|
||||
--config=clippy
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
|
||||
--build_metadata=TAG_job=clippy
|
||||
)
|
||||
if [[ "${RUNNER_OS}" == "Windows" ]]; then
|
||||
# Some explicit targets pulled in through //codex-rs/... are
|
||||
# intentionally incompatible with `//:local_windows`, but the lint
|
||||
# aspect still traverses their compatible Rust deps.
|
||||
bazel_clippy_args+=(--skip_incompatible_explicit_targets)
|
||||
fi
|
||||
|
||||
bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)"
|
||||
bazel_targets=()
|
||||
while IFS= read -r target; do
|
||||
@@ -168,9 +207,7 @@ jobs:
|
||||
./.github/scripts/run-bazel-ci.sh \
|
||||
-- \
|
||||
build \
|
||||
--config=clippy \
|
||||
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
|
||||
--build_metadata=TAG_job=clippy \
|
||||
"${bazel_clippy_args[@]}" \
|
||||
-- \
|
||||
"${bazel_targets[@]}"
|
||||
|
||||
@@ -186,10 +223,9 @@ jobs:
|
||||
# Save bazel repository cache explicitly; make non-fatal so cache uploading
|
||||
# never fails the overall job. Only save when key wasn't hit.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true'
|
||||
if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/bazel-repo-cache
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
|
||||
145
.github/workflows/rust-ci.yml
vendored
145
.github/workflows/rust-ci.yml
vendored
@@ -85,6 +85,148 @@ jobs:
|
||||
- name: cargo shear
|
||||
run: cargo shear
|
||||
|
||||
remote_tests_linux:
|
||||
name: Remote tests - Linux
|
||||
runs-on:
|
||||
group: codex-runners
|
||||
labels: codex-linux-x64
|
||||
timeout-minutes: 45
|
||||
needs: changed
|
||||
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
env:
|
||||
USE_SCCACHE: "true"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
SCCACHE_CACHE_SIZE: 10G
|
||||
CODEX_TEST_REMOTE_ENV_TARGET: x86_64-unknown-linux-gnu
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- name: Set up Node.js for js_repl tests
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version-file: codex-rs/node-version.txt
|
||||
- name: Install Linux build dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
|
||||
fi
|
||||
- name: Install DotSlash
|
||||
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
|
||||
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
|
||||
with:
|
||||
targets: ${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}
|
||||
- name: Compute lockfile hash
|
||||
id: lockhash
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore cargo home cache
|
||||
id: cache_cargo_home_restore
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: cargo-home-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
|
||||
restore-keys: |
|
||||
cargo-home-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-
|
||||
- name: Install sccache
|
||||
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
with:
|
||||
tool: sccache
|
||||
version: 0.7.5
|
||||
- name: Configure sccache backend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
|
||||
echo "Using sccache GitHub backend"
|
||||
else
|
||||
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
|
||||
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
|
||||
echo "Using sccache local disk + actions/cache fallback"
|
||||
fi
|
||||
- name: Enable sccache wrapper
|
||||
shell: bash
|
||||
run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
- name: Restore sccache cache (fallback)
|
||||
if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }}
|
||||
id: cache_sccache_restore
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.sccache/
|
||||
key: sccache-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
sccache-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-${{ steps.lockhash.outputs.hash }}-
|
||||
sccache-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-
|
||||
- name: Enable unprivileged user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
- name: Set up remote test env (Docker)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env
|
||||
source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh"
|
||||
echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV"
|
||||
- name: Build remote exec-server binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build -p codex-exec-server --bin codex-exec-server --target ${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}
|
||||
binary_path="${GITHUB_WORKSPACE}/codex-rs/target/${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}/debug/codex-exec-server"
|
||||
echo "CARGO_BIN_EXE_codex_exec_server=${binary_path}" >> "$GITHUB_ENV"
|
||||
- name: Remote tests
|
||||
id: remote_tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test -p codex-core --target ${{ env.CODEX_TEST_REMOTE_ENV_TARGET }} --lib unified_exec::tests::unified_exec_uses_remote_exec_server_when_configured -- --exact
|
||||
cargo test -p codex-core --target ${{ env.CODEX_TEST_REMOTE_ENV_TARGET }} --lib unified_exec::tests::remote_exec_server_rejects_inherited_fd_launches -- --exact
|
||||
cargo test -p codex-core --target ${{ env.CODEX_TEST_REMOTE_ENV_TARGET }} --test all suite::remote_env::remote_test_env_can_connect_and_use_filesystem -- --exact
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
- name: Save cargo home cache
|
||||
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: cargo-home-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }}
|
||||
- name: Save sccache cache (fallback)
|
||||
if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/.sccache/
|
||||
key: sccache-${{ runner.os }}-${{ env.CODEX_TEST_REMOTE_ENV_TARGET }}-remote-tests-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }}
|
||||
- name: Tear down remote test env
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
if [[ "${{ steps.remote_tests.outcome }}" != "success" ]]; then
|
||||
docker logs codex-remote-test-env || true
|
||||
fi
|
||||
docker rm -f codex-remote-test-env >/dev/null 2>&1 || true
|
||||
|
||||
argument_comment_lint_package:
|
||||
name: Argument comment lint package
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -194,6 +336,7 @@ jobs:
|
||||
changed,
|
||||
general,
|
||||
cargo_shear,
|
||||
remote_tests_linux,
|
||||
argument_comment_lint_package,
|
||||
argument_comment_lint_prebuilt,
|
||||
]
|
||||
@@ -206,6 +349,7 @@ jobs:
|
||||
echo "argpkg : ${{ needs.argument_comment_lint_package.result }}"
|
||||
echo "arglint: ${{ needs.argument_comment_lint_prebuilt.result }}"
|
||||
echo "general: ${{ needs.general.result }}"
|
||||
echo "remote : ${{ needs.remote_tests_linux.result }}"
|
||||
echo "shear : ${{ needs.cargo_shear.result }}"
|
||||
|
||||
# If nothing relevant changed (PR touching only root README, etc.),
|
||||
@@ -226,4 +370,5 @@ jobs:
|
||||
if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then
|
||||
[[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; }
|
||||
[[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; }
|
||||
[[ '${{ needs.remote_tests_linux.result }}' == 'success' ]] || { echo 'remote_tests_linux failed'; exit 1; }
|
||||
fi
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2552,6 +2552,7 @@ dependencies = [
|
||||
"codex-process-hardening",
|
||||
"ctor 0.6.3",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -6,11 +6,16 @@ use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
pub(crate) const SPAWN_AGENT_DEVELOPER_INSTRUCTIONS: &str = r#"<spawned_agent_context>
|
||||
You are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.
|
||||
</spawned_agent_context>"#;
|
||||
|
||||
impl ToolHandler for Handler {
|
||||
type Output = SpawnAgentResult;
|
||||
|
||||
@@ -78,6 +83,17 @@ impl ToolHandler for Handler {
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
config.developer_instructions = Some(
|
||||
if let Some(existing_instructions) = config.developer_instructions.take() {
|
||||
DeveloperInstructions::new(existing_instructions)
|
||||
.concat(DeveloperInstructions::new(
|
||||
SPAWN_AGENT_DEVELOPER_INSTRUCTIONS,
|
||||
))
|
||||
.into_text()
|
||||
} else {
|
||||
DeveloperInstructions::new(SPAWN_AGENT_DEVELOPER_INSTRUCTIONS).into_text()
|
||||
},
|
||||
);
|
||||
|
||||
let spawn_source = thread_spawn_source(
|
||||
session.conversation_id,
|
||||
|
||||
@@ -165,10 +165,11 @@ impl ProviderAuthCommandFixture {
|
||||
fn new(tokens: &[&str]) -> std::io::Result<Self> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let tokens_file = tempdir.path().join("tokens.txt");
|
||||
let token_line_ending = if cfg!(windows) { "\r\n" } else { "\n" };
|
||||
let mut token_file_contents = String::new();
|
||||
for token in tokens {
|
||||
token_file_contents.push_str(token);
|
||||
token_file_contents.push('\n');
|
||||
token_file_contents.push_str(token_line_ending);
|
||||
}
|
||||
std::fs::write(&tokens_file, token_file_contents)?;
|
||||
|
||||
@@ -195,23 +196,28 @@ mv tokens.next tokens.txt
|
||||
|
||||
#[cfg(windows)]
|
||||
let (command, args) = {
|
||||
let script_path = tempdir.path().join("print-token.ps1");
|
||||
let script_path = tempdir.path().join("print-token.cmd");
|
||||
std::fs::write(
|
||||
&script_path,
|
||||
r#"$lines = @(Get-Content -Path tokens.txt)
|
||||
if ($lines.Count -eq 0) { exit 1 }
|
||||
Write-Output $lines[0]
|
||||
$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
|
||||
r#"@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
set "first_line="
|
||||
<tokens.txt set /p "first_line="
|
||||
if not defined first_line exit /b 1
|
||||
setlocal EnableDelayedExpansion
|
||||
echo(!first_line!
|
||||
endlocal
|
||||
more +1 tokens.txt > tokens.next
|
||||
move /y tokens.next tokens.txt >nul
|
||||
"#,
|
||||
)?;
|
||||
(
|
||||
"powershell.exe".to_string(),
|
||||
"cmd.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-ExecutionPolicy".to_string(),
|
||||
"Bypass".to_string(),
|
||||
"-File".to_string(),
|
||||
".\\print-token.ps1".to_string(),
|
||||
"/d".to_string(),
|
||||
"/s".to_string(),
|
||||
"/c".to_string(),
|
||||
".\\print-token.cmd".to_string(),
|
||||
],
|
||||
)
|
||||
};
|
||||
@@ -227,7 +233,7 @@ $lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
|
||||
ModelProviderAuthInfo {
|
||||
command: self.command.clone(),
|
||||
args: self.args.clone(),
|
||||
timeout_ms: non_zero_u64(/*value*/ 1_000),
|
||||
timeout_ms: non_zero_u64(/*value*/ 10_000),
|
||||
refresh_interval_ms: 60_000,
|
||||
cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) {
|
||||
Ok(cwd) => cwd,
|
||||
|
||||
@@ -35,6 +35,7 @@ const REQUESTED_MODEL: &str = "gpt-5.1";
|
||||
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const ROLE_MODEL: &str = "gpt-5.1-codex-max";
|
||||
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
const SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS: &str = "You are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.";
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
let is_zstd = req
|
||||
@@ -413,6 +414,99 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawned_multi_agent_v2_child_receives_xml_tagged_developer_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "worker",
|
||||
}))?;
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-1"),
|
||||
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
ev_completed("resp-turn1-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _child_request_log = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| {
|
||||
body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID)
|
||||
},
|
||||
sse(vec![
|
||||
ev_response_created("resp-child-1"),
|
||||
ev_completed("resp-child-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _turn1_followup = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-2"),
|
||||
ev_assistant_message("msg-turn1-2", "parent done"),
|
||||
ev_completed("resp-turn1-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Collab)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
config.developer_instructions = Some("Parent developer instructions.".to_string());
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let child_request = loop {
|
||||
if let Some(request) = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|request| {
|
||||
body_contains(request, CHILD_PROMPT)
|
||||
&& body_contains(request, "<spawned_agent_context>")
|
||||
&& body_contains(request, SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS)
|
||||
&& !body_contains(request, SPAWN_CALL_ID)
|
||||
})
|
||||
{
|
||||
break request;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("timed out waiting for spawned child request with developer context");
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
assert!(body_contains(
|
||||
&child_request,
|
||||
"Parent developer instructions."
|
||||
));
|
||||
assert!(body_contains(&child_request, "<spawned_agent_context>"));
|
||||
assert!(body_contains(
|
||||
&child_request,
|
||||
SPAWNED_AGENT_DEVELOPER_INSTRUCTIONS
|
||||
));
|
||||
assert!(body_contains(&child_request, CHILD_PROMPT));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -26,3 +26,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -35,18 +35,20 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
- Listens on the provided port or an ephemeral port if `--port` is not specified.
|
||||
- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer <key>` set. All original request headers (except any incoming `Authorization`) are forwarded upstream, with `Host` overridden to `api.openai.com`. For other requests, it responds with `403`.
|
||||
- Optionally writes a single-line JSON file with server info, currently `{ "port": <u16>, "pid": <u32> }`.
|
||||
- Optionally writes request/response JSON dumps to a directory. Each accepted request gets a pair of files that share a sequence/timestamp prefix, for example `000001-1846179912345-request.json` and `000001-1846179912345-response.json`. Header values are dumped in full except `Authorization` and any header whose name includes `cookie`, which are redacted. Bodies are written as parsed JSON when possible, otherwise as UTF-8 text.
|
||||
- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code `0`. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>]
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>] [--dump-dir <DIR>]
|
||||
```
|
||||
|
||||
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
|
||||
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT>, "pid": <PID> }` once listening.
|
||||
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
|
||||
- `--upstream-url <URL>`: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`.
|
||||
- `--dump-dir <DIR>`: If set, writes one request JSON file and one response JSON file per accepted proxy call under this directory. Filenames use a shared sequence/timestamp prefix so each pair is easy to correlate.
|
||||
- Authentication is fixed to `Authorization: Bearer <key>` to match the Codex CLI expectations.
|
||||
|
||||
For Azure, for example (ensure your deployment accepts `Authorization: Bearer <key>`):
|
||||
|
||||
360
codex-rs/responses-api-proxy/src/dump.rs
Normal file
360
codex-rs/responses-api-proxy/src/dump.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
|
||||
const AUTHORIZATION_HEADER_NAME: &str = "authorization";
|
||||
const REDACTED_HEADER_VALUE: &str = "[REDACTED]";
|
||||
|
||||
pub(crate) struct ExchangeDumper {
|
||||
dump_dir: PathBuf,
|
||||
next_sequence: AtomicU64,
|
||||
}
|
||||
|
||||
impl ExchangeDumper {
|
||||
pub(crate) fn new(dump_dir: PathBuf) -> io::Result<Self> {
|
||||
fs::create_dir_all(&dump_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
dump_dir,
|
||||
next_sequence: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn dump_request(
|
||||
&self,
|
||||
method: &Method,
|
||||
url: &str,
|
||||
headers: &[Header],
|
||||
body: &[u8],
|
||||
) -> io::Result<ExchangeDump> {
|
||||
let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let timestamp_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_millis());
|
||||
let prefix = format!("{sequence:06}-{timestamp_ms}");
|
||||
|
||||
let request_path = self.dump_dir.join(format!("{prefix}-request.json"));
|
||||
let response_path = self.dump_dir.join(format!("{prefix}-response.json"));
|
||||
|
||||
let request_dump = RequestDump {
|
||||
method: method.as_str().to_string(),
|
||||
url: url.to_string(),
|
||||
headers: headers.iter().map(HeaderDump::from).collect(),
|
||||
body: dump_body(body),
|
||||
};
|
||||
|
||||
write_json_dump(&request_path, &request_dump)?;
|
||||
|
||||
Ok(ExchangeDump { response_path })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ExchangeDump {
|
||||
response_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ExchangeDump {
|
||||
pub(crate) fn tee_response_body<R: Read>(
|
||||
self,
|
||||
status: u16,
|
||||
headers: &HeaderMap,
|
||||
response_body: R,
|
||||
) -> ResponseBodyDump<R> {
|
||||
ResponseBodyDump {
|
||||
response_body,
|
||||
response_path: self.response_path,
|
||||
status,
|
||||
headers: headers.iter().map(HeaderDump::from).collect(),
|
||||
body: Vec::new(),
|
||||
dump_written: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ResponseBodyDump<R> {
|
||||
response_body: R,
|
||||
response_path: PathBuf,
|
||||
status: u16,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Vec<u8>,
|
||||
dump_written: bool,
|
||||
}
|
||||
|
||||
impl<R> ResponseBodyDump<R> {
|
||||
fn write_dump_if_needed(&mut self) {
|
||||
if self.dump_written {
|
||||
return;
|
||||
}
|
||||
|
||||
self.dump_written = true;
|
||||
|
||||
let response_dump = ResponseDump {
|
||||
status: self.status,
|
||||
headers: std::mem::take(&mut self.headers),
|
||||
body: dump_body(&self.body),
|
||||
};
|
||||
|
||||
if let Err(err) = write_json_dump(&self.response_path, &response_dump) {
|
||||
eprintln!(
|
||||
"responses-api-proxy failed to write {}: {err}",
|
||||
self.response_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> Read for ResponseBodyDump<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let bytes_read = self.response_body.read(buf)?;
|
||||
if bytes_read == 0 {
|
||||
self.write_dump_if_needed();
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
self.body.extend_from_slice(&buf[..bytes_read]);
|
||||
Ok(bytes_read)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> Drop for ResponseBodyDump<R> {
|
||||
fn drop(&mut self) {
|
||||
self.write_dump_if_needed();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RequestDump {
|
||||
method: String,
|
||||
url: String,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResponseDump {
|
||||
status: u16,
|
||||
headers: Vec<HeaderDump>,
|
||||
body: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HeaderDump {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl From<&Header> for HeaderDump {
|
||||
fn from(header: &Header) -> Self {
|
||||
let name = header.field.as_str().to_string();
|
||||
let value = if should_redact_header(&name) {
|
||||
REDACTED_HEADER_VALUE.to_string()
|
||||
} else {
|
||||
header.value.as_str().to_string()
|
||||
};
|
||||
|
||||
Self { name, value }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&reqwest::header::HeaderName, &reqwest::header::HeaderValue)> for HeaderDump {
|
||||
fn from(header: (&reqwest::header::HeaderName, &reqwest::header::HeaderValue)) -> Self {
|
||||
let name = header.0.as_str();
|
||||
let value = if should_redact_header(name) {
|
||||
REDACTED_HEADER_VALUE.to_string()
|
||||
} else {
|
||||
String::from_utf8_lossy(header.1.as_bytes()).into_owned()
|
||||
};
|
||||
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_redact_header(name: &str) -> bool {
|
||||
name.eq_ignore_ascii_case(AUTHORIZATION_HEADER_NAME)
|
||||
|| name.to_ascii_lowercase().contains("cookie")
|
||||
}
|
||||
|
||||
fn dump_body(body: &[u8]) -> Value {
|
||||
serde_json::from_slice(body)
|
||||
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(body).into_owned()))
|
||||
}
|
||||
|
||||
fn write_json_dump(path: &PathBuf, dump: &impl Serialize) -> io::Result<()> {
|
||||
let mut bytes = serde_json::to_vec_pretty(dump)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
bytes.push(b'\n');
|
||||
fs::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde_json::json;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
|
||||
use super::ExchangeDumper;
|
||||
|
||||
static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn dump_request_writes_redacted_headers_and_json_body() {
|
||||
let dump_dir = test_dump_dir();
|
||||
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
||||
let headers = vec![
|
||||
Header::from_bytes(&b"Authorization"[..], &b"Bearer secret"[..])
|
||||
.expect("authorization header"),
|
||||
Header::from_bytes(&b"Cookie"[..], &b"user-session=secret"[..]).expect("cookie header"),
|
||||
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
|
||||
.expect("content-type header"),
|
||||
];
|
||||
|
||||
let exchange_dump = dumper
|
||||
.dump_request(
|
||||
&Method::Post,
|
||||
"/v1/responses",
|
||||
&headers,
|
||||
br#"{"model":"gpt-5.4"}"#,
|
||||
)
|
||||
.expect("dump request");
|
||||
|
||||
let request_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-request.json"))
|
||||
.expect("read request dump");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&request_dump).expect("parse request dump"),
|
||||
json!({
|
||||
"method": "POST",
|
||||
"url": "/v1/responses",
|
||||
"headers": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "Cookie",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"model": "gpt-5.4"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
exchange_dump
|
||||
.response_path
|
||||
.file_name()
|
||||
.expect("response dump file name")
|
||||
.to_string_lossy()
|
||||
.ends_with("-response.json")
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_dump_streams_body_and_writes_response_file() {
|
||||
let dump_dir = test_dump_dir();
|
||||
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
||||
let exchange_dump = dumper
|
||||
.dump_request(&Method::Post, "/v1/responses", &[], b"{}")
|
||||
.expect("dump request");
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
|
||||
headers.insert(
|
||||
"set-cookie",
|
||||
HeaderValue::from_static("user-session=secret"),
|
||||
);
|
||||
|
||||
let mut response_body = String::new();
|
||||
exchange_dump
|
||||
.tee_response_body(
|
||||
/*status*/ 200,
|
||||
&headers,
|
||||
Cursor::new(b"data: hello\n\n".to_vec()),
|
||||
)
|
||||
.read_to_string(&mut response_body)
|
||||
.expect("read response body");
|
||||
|
||||
let response_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-response.json"))
|
||||
.expect("read response dump");
|
||||
|
||||
assert_eq!(response_body, "data: hello\n\n");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&response_dump).expect("parse response dump"),
|
||||
json!({
|
||||
"status": 200,
|
||||
"headers": [
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "text/event-stream"
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"value": "[REDACTED]"
|
||||
},
|
||||
{
|
||||
"name": "set-cookie",
|
||||
"value": "[REDACTED]"
|
||||
}
|
||||
],
|
||||
"body": "data: hello\n\n"
|
||||
})
|
||||
);
|
||||
|
||||
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
||||
}
|
||||
|
||||
fn test_dump_dir() -> std::path::PathBuf {
|
||||
let test_id = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);
|
||||
let dump_dir = std::env::temp_dir().join(format!(
|
||||
"codex-responses-api-proxy-dump-test-{}-{test_id}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dump_dir).expect("create test dump dir");
|
||||
dump_dir
|
||||
}
|
||||
|
||||
fn dump_file_with_suffix(dump_dir: &std::path::Path, suffix: &str) -> std::path::PathBuf {
|
||||
let mut matches = fs::read_dir(dump_dir)
|
||||
.expect("read dump dir")
|
||||
.map(|entry| entry.expect("read dump entry").path())
|
||||
.filter(|path| path.to_string_lossy().ends_with(suffix))
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort();
|
||||
|
||||
assert_eq!(matches.len(), 1);
|
||||
matches.pop().expect("single dump file")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
@@ -27,7 +28,9 @@ use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
mod dump;
|
||||
mod read_api_key;
|
||||
use dump::ExchangeDumper;
|
||||
use read_api_key::read_auth_header_from_stdin;
|
||||
|
||||
/// CLI arguments for the proxy.
|
||||
@@ -49,6 +52,10 @@ pub struct Args {
|
||||
/// Absolute URL the proxy should forward requests to (defaults to OpenAI).
|
||||
#[arg(long, default_value = "https://api.openai.com/v1/responses")]
|
||||
pub upstream_url: String,
|
||||
|
||||
/// Directory where request/response dumps should be written as JSON.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub dump_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -79,6 +86,12 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
upstream_url,
|
||||
host_header,
|
||||
});
|
||||
let dump_dir = args
|
||||
.dump_dir
|
||||
.map(ExchangeDumper::new)
|
||||
.transpose()
|
||||
.context("creating --dump-dir")?
|
||||
.map(Arc::new);
|
||||
|
||||
let (listener, bound_addr) = bind_listener(args.port)?;
|
||||
if let Some(path) = args.server_info.as_ref() {
|
||||
@@ -100,13 +113,20 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
for request in server.incoming_requests() {
|
||||
let client = client.clone();
|
||||
let forward_config = forward_config.clone();
|
||||
let dump_dir = dump_dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" {
|
||||
let _ = request.respond(Response::new_empty(StatusCode(200)));
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
if let Err(e) = forward_request(&client, auth_header, &forward_config, request) {
|
||||
if let Err(e) = forward_request(
|
||||
&client,
|
||||
auth_header,
|
||||
&forward_config,
|
||||
dump_dir.as_deref(),
|
||||
request,
|
||||
) {
|
||||
eprintln!("forwarding error: {e}");
|
||||
}
|
||||
});
|
||||
@@ -144,6 +164,7 @@ fn forward_request(
|
||||
client: &Client,
|
||||
auth_header: &'static str,
|
||||
config: &ForwardConfig,
|
||||
dump_dir: Option<&ExchangeDumper>,
|
||||
mut req: Request,
|
||||
) -> Result<()> {
|
||||
// Only allow POST /v1/responses exactly, no query string.
|
||||
@@ -159,8 +180,18 @@ fn forward_request(
|
||||
|
||||
// Read request body
|
||||
let mut body = Vec::new();
|
||||
let mut reader = req.as_reader();
|
||||
std::io::Read::read_to_end(&mut reader, &mut body)?;
|
||||
let reader = req.as_reader();
|
||||
reader.read_to_end(&mut body)?;
|
||||
|
||||
let exchange_dump = dump_dir.and_then(|dump_dir| {
|
||||
dump_dir
|
||||
.dump_request(&method, &url_path, req.headers(), &body)
|
||||
.map_err(|err| {
|
||||
eprintln!("responses-api-proxy failed to dump request: {err}");
|
||||
err
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
// Build headers for upstream, forwarding everything from the incoming
|
||||
// request except Authorization (we replace it below).
|
||||
@@ -224,10 +255,17 @@ fn forward_request(
|
||||
}
|
||||
});
|
||||
|
||||
let response_body: Box<dyn Read + Send> = if let Some(exchange_dump) = exchange_dump {
|
||||
let headers = upstream_resp.headers().clone();
|
||||
Box::new(exchange_dump.tee_response_body(status.as_u16(), &headers, upstream_resp))
|
||||
} else {
|
||||
Box::new(upstream_resp)
|
||||
};
|
||||
|
||||
let response = Response::new(
|
||||
StatusCode(status.as_u16()),
|
||||
response_headers,
|
||||
upstream_resp,
|
||||
response_body,
|
||||
content_length,
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -5,9 +5,16 @@ set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
# Resolve the dynamic targets before printing anything so callers do not
|
||||
# continue with a partial list if `bazel query` fails.
|
||||
manual_rust_test_targets="$(bazel query 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))')"
|
||||
manual_rust_test_targets=""
|
||||
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
|
||||
# Resolve the dynamic targets before printing anything so callers do not
|
||||
# continue with a partial list if `bazel query` fails.
|
||||
#
|
||||
# The generated manual `*-unit-tests-bin` targets pull in Windows-incompatible
|
||||
# V8/Python dependencies under gnullvm, so only include them on platforms
|
||||
# where they currently analyze successfully.
|
||||
manual_rust_test_targets="$(bazel query 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))')"
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"//codex-rs/..." \
|
||||
@@ -17,4 +24,6 @@ printf '%s\n' \
|
||||
# underlying `rust_test` binaries. Add the internal manual `*-unit-tests-bin`
|
||||
# targets explicitly so inline `#[cfg(test)]` code is linted like
|
||||
# `cargo clippy --tests`.
|
||||
printf '%s\n' "${manual_rust_test_targets}"
|
||||
if [[ -n "${manual_rust_test_targets}" ]]; then
|
||||
printf '%s\n' "${manual_rust_test_targets}"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user