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
29 changes: 29 additions & 0 deletions docs/p1-nonstream-upstream-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# P1 non-stream upstream error behavior

## Scope

This note documents the current non-stream `/v1/responses` behavior when the upstream Chat Completions request fails with an HTTP error.

## Current behavior

- The adapter returns a protocol-shaped Responses object with `status: failed`.
- The response body includes `object: response`, an empty `output`, zeroed `usage`, the original model alias, request metadata, and an `error` object with `type/code: upstream_error`.
- The adapter currently preserves the upstream HTTP status for non-stream requests.
- Streaming upstream errors continue to be delivered through SSE as `response.failed` followed by `[DONE]`.

## Reasoning

The P1-3 risk was that Codex could receive only a plain HTTP `{error: ...}` body and lose the Responses protocol chain. The runtime already emits a Responses `failed` body for non-stream upstream failures, so this milestone locks the behavior with a regression test instead of changing runtime code.

If real Codex subagent validation shows that non-2xx HTTP status still breaks the chain, the next policy change should be explicit: return HTTP 200 with `response.status = failed` for `/v1/responses` upstream errors while leaving `/v1/models` HTTP error behavior unchanged.

## Validation

Run locally:

```bash
cargo fmt --check
cargo test --test nonstream_upstream_error_regression
cargo test --test test_e2e
cargo test
```
133 changes: 133 additions & 0 deletions tests/nonstream_upstream_error_regression.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::post;
use axum::{Json, Router};
use codex_opencode_adapter::config::Config;
use codex_opencode_adapter::server::{self, AppState};
use codex_opencode_adapter::state::StateStore;
use codex_opencode_adapter::upstream::OpenCodeGoClient;
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, Semaphore};

#[derive(Clone)]
struct MockState {
received: Arc<Mutex<Vec<Value>>>,
}

#[tokio::test]
async fn nonstream_upstream_http_error_returns_responses_failed_body() {
let (upstream_addr, _mock, received) = start_error_upstream().await;
let adapter_addr = start_adapter(upstream_addr).await;
let client = reqwest::Client::new();

let resp = client
.post(format!("http://{adapter_addr}/v1/responses"))
.json(&json!({
"model": "opencode-go/deepseek-v4-flash",
"input": "Hello",
"stream": false,
"metadata": {"case": "nonstream-upstream-error"}
}))
.send()
.await
.unwrap();

assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
let body: Value = resp.json().await.unwrap();

assert_eq!(body["object"], "response");
assert_eq!(body["status"], "failed");
assert_eq!(body["model"], "opencode-go/deepseek-v4-flash");
assert_eq!(body["metadata"]["case"], "nonstream-upstream-error");
assert!(body["output"].as_array().unwrap().is_empty());
assert_eq!(body["usage"]["input_tokens"], 0);
assert_eq!(body["usage"]["output_tokens"], 0);
assert_eq!(body["usage"]["total_tokens"], 0);
assert_eq!(body["error"]["type"], "upstream_error");
assert_eq!(body["error"]["code"], "upstream_error");
assert!(body["error"]["message"]
.as_str()
.unwrap()
.contains("upstream unavailable"));

let received = received.lock().await;
assert_eq!(received.len(), 1);
assert_eq!(received[0]["model"], "deepseek-v4-flash");
}

async fn start_error_upstream() -> (
SocketAddr,
tokio::task::JoinHandle<()>,
Arc<Mutex<Vec<Value>>>,
) {
let received: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let state = MockState {
received: Arc::clone(&received),
};
let app = Router::new()
.route("/chat/completions", post(mock_chat_error))
.with_state(state);

let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(addr, handle, received)
}

async fn mock_chat_error(
AxumState(state): AxumState<MockState>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
state.received.lock().await.push(payload);
(
StatusCode::BAD_GATEWAY,
Json(json!({"error": {"message": "upstream unavailable"}})),
)
}

async fn start_adapter(upstream_addr: SocketAddr) -> SocketAddr {
let db_path = std::env::temp_dir().join(format!(
"nonstream_upstream_error_{}.sqlite",
uuid::Uuid::new_v4()
));
let config = Config {
host: "127.0.0.1".to_string(),
port: 0,
upstream_base: format!("http://{upstream_addr}"),
upstream_key: "test-api-key".to_string(),
local_token: None,
state_db: db_path.to_string_lossy().to_string(),
state_ttl_seconds: 21_600,
timeout_seconds: 30,
max_request_bytes: 8 * 1024 * 1024,
};
let client = OpenCodeGoClient::new(
&config.upstream_base,
&config.upstream_key,
config.timeout_seconds,
)
.unwrap();
let state = StateStore::new(&config.state_db, config.state_ttl_seconds).unwrap();
let app_state = AppState {
config,
client,
state,
capacity: Arc::new(Semaphore::new(10)),
};
let app = server::router(app_state);

let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
addr
}
Loading