Skip to content

Commit 44a4e2e

Browse files
Eunho JungEunho Jung
authored andcommitted
Release v0.6.3 Claude Code web compatibility
1 parent 821db29 commit 44a4e2e

18 files changed

Lines changed: 450 additions & 133 deletions

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ GPT-5.6 requests can instead use the public Responses-shaped object:
410410
- Responses Lite uses `all_turns` as the Codex wire default. An explicitly different context is rejected instead of silently overwritten; use `responses_lite: false` when the backend route supports classic Responses and another context is required.
411411
- Remote compact keeps its existing private Codex `reasoning_effort` field but does not accept public `reasoning.mode` or `reasoning.context`.
412412

413-
Anthropic `thinking` values map as `enabled → high`, `adaptive → medium`, and `disabled → none`. Claude Code's `output_config.effort` takes precedence over the adaptive default and supports `low`, `medium`, `high`, `xhigh`, and `max`. The supported reasoning context and verbosity extensions are also available on `/v1/messages`, image generation, and inspection requests; Pro and non-null public cache policy/breakpoints or `safety_identifier` fail explicitly on the private Codex provider.
413+
Anthropic `thinking` values map as `enabled → high`, `adaptive → medium`, and `disabled → none`. Claude Code's `output_config.effort` takes precedence over adaptive or enabled thinking and supports `low`, `medium`, `high`, `xhigh`, and `max`. Call-level `thinking.disabled` takes precedence over ambient `output_config.effort`, so Claude Code WebSearch/WebFetch auxiliary calls use `none` instead of failing the compatibility check with HTTP 400. The supported reasoning context and verbosity extensions are also available on `/v1/messages`, image generation, and inspection requests; Pro and non-null public cache policy/breakpoints or `safety_identifier` fail explicitly on the private Codex provider.
414414

415415
The pinned official Codex HTTP request has no `stop` field. Omitted, `null`, and empty stop values are omitted from the private request; any non-empty OpenAI `stop` or Anthropic `stop_sequences` value returns HTTP 400 before the private transport starts.
416416

@@ -613,9 +613,13 @@ The `/v1/messages` endpoint implements the Anthropic Messages gateway shape used
613613
Start the proxy first. `CODEX_AS_API_MODEL` is the fallback used when Claude Code sends a built-in Anthropic model name:
614614

615615
```bash
616-
CODEX_AS_API_MODEL=gpt-5.6-terra codex-as-api
616+
CODEX_AS_API_MODEL=gpt-5.6-terra \
617+
CODEX_AS_API_RESPONSES_LITE=off \
618+
codex-as-api
617619
```
618620

621+
`CODEX_AS_API_RESPONSES_LITE=off` is required for Claude Code hosted WebSearch on GPT-5.6. Official Codex Responses Lite uses a client-side standalone `web.run` tool, while this gateway receives Anthropic's hosted `web_search` declaration and has no standalone executor; classic Responses preserves that hosted tool. WebFetch does not use a hosted Responses tool, but still needs the disabled-thinking precedence fix when process-level effort is enabled.
622+
619623
To keep the built-in Fable, Opus, Sonnet, and Haiku rows and append one GPT row, launch the GPT-routed Claude Code process with these variables:
620624

621625
```bash
@@ -686,7 +690,7 @@ The provider handles:
686690
## Release & package publishing
687691

688692
- Bump versions in `pyproject.toml`, `src/codex_as_api/__init__.py`, `src/codex_as_api/server.py`, `ts/package.json`, `ts/package-lock.json`, `rust/Cargo.toml`, and `rust/Cargo.lock`.
689-
- Publish a GitHub Release such as `v0.6.2` from the matching commit.
693+
- Publish a GitHub Release such as `v0.6.3` from the matching commit.
690694
- The manually-dispatched `Publish npm packages` workflow builds/tests the TypeScript package, runs `npm pack --dry-run`, publishes `codex-as-api` to npmjs when `NPM_TOKEN` is configured, and publishes `@eunho-j/codex-as-api` to GitHub Packages with `GITHUB_TOKEN`.
691695

692696
Publishing to npmjs requires an authenticated npm session (`npm login` beforehand; `npm whoami` should succeed). From the repository root, the publish itself is one command:
@@ -729,6 +733,14 @@ npm test
729733

730734
## Release Notes
731735

736+
### v0.6.3
737+
738+
- Accept Claude Code auxiliary requests that combine ambient `output_config.effort` with call-level `thinking.disabled`.
739+
- Give explicit disabled thinking precedence and send Codex reasoning effort `none` instead of returning HTTP 400.
740+
- Document and test `CODEX_AS_API_RESPONSES_LITE=off` for Claude Code hosted WebSearch on GPT-5.6; WebFetch needs no Responses Lite override.
741+
- Preserve fail-loudly validation for invalid effort values and unsupported `output_config` fields.
742+
- Add Python, TypeScript, and Rust adapter regressions, streamed `/v1/messages` coverage, and a `/count_tokens` regression for the WebSearch/WebFetch request shape.
743+
732744
### v0.6.2
733745

734746
- Fix `/v1/messages/count_tokens` overcounting that could trigger Claude Code autocompaction on every turn.

RELEASES.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Release Notes
22

3+
## v0.6.3
4+
5+
### Claude Code WebSearch/WebFetch auxiliary calls
6+
7+
- Fix HTTP 400 responses when a Claude Code session with ambient effort sends a WebSearch/WebFetch auxiliary request containing both `output_config.effort` and call-level `thinking: {"type":"disabled"}`.
8+
- Give explicit disabled thinking precedence over valid ambient effort and forward Codex reasoning effort `none` across Python, TypeScript, and Rust.
9+
- Require and document `CODEX_AS_API_RESPONSES_LITE=off` for Claude Code hosted WebSearch on GPT-5.6 because official Responses Lite relies on a standalone `web.run` executor; WebFetch does not require this override.
10+
- Continue rejecting empty, non-string, or unsupported effort values and unsupported `output_config` fields rather than silently discarding malformed requests.
11+
12+
### Validation
13+
14+
- Adapter tests cover every valid Claude Code effort value paired with disabled thinking while retaining invalid-value failures.
15+
- Streamed `/v1/messages` tests across all three runtimes and a local `/count_tokens` test reproduce the Claude Code 2.1.209 request shape and prove provider-backed requests reach the classic Codex wire with reasoning effort `none`.
16+
- A real GPT-5.6 Sol Codex OAuth WebSearch completed with HTTP 200 and structured `server_tool_use`, `web_search_tool_result`, and terminal SSE events.
17+
318
## v0.6.2
419

520
### Claude Code count_tokens autocompact fix

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "codex-as-api"
7-
version = "0.6.2"
7+
version = "0.6.3"
88
description = "Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server."
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }

rust/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "codex-as-api"
3-
version = "0.6.2"
3+
version = "0.6.3"
44
edition = "2021"
55

66
[[bin]]

rust/src/anthropic_adapter.rs

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -523,13 +523,14 @@ fn convert_reasoning_effort(body: &Value) -> Result<Option<String>, String> {
523523
Some(Value::String(effort)) if effort.trim().is_empty() => {
524524
Err("output_config.effort must be a non-empty string when provided".to_string())
525525
}
526-
Some(Value::String(_)) if thinking_effort.as_deref() == Some("none") => {
527-
Err("output_config.effort cannot be used when thinking.type is disabled".to_string())
528-
}
529526
Some(Value::String(effort))
530527
if matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max") =>
531528
{
532-
Ok(Some(effort.clone()))
529+
if thinking_effort.as_deref() == Some("none") {
530+
Ok(Some("none".to_string()))
531+
} else {
532+
Ok(Some(effort.clone()))
533+
}
533534
}
534535
Some(Value::String(_)) => {
535536
Err("output_config.effort must be one of: low, medium, high, xhigh, max".to_string())
@@ -1895,16 +1896,45 @@ mod tests {
18951896
}
18961897

18971898
#[test]
1898-
fn test_output_config_effort_rejects_disabled_thinking() {
1899-
let body = json!({
1900-
"messages": [],
1901-
"thinking": {"type": "disabled"},
1902-
"output_config": {"effort": "high"},
1903-
});
1904-
assert_eq!(
1905-
anthropic_request_to_internal(&body).unwrap_err(),
1906-
"output_config.effort cannot be used when thinking.type is disabled"
1907-
);
1899+
fn test_disabled_thinking_precedes_output_config_effort() {
1900+
for effort in ["low", "medium", "high", "xhigh", "max"] {
1901+
let body = json!({
1902+
"messages": [],
1903+
"thinking": {"type": "disabled"},
1904+
"output_config": {"effort": effort},
1905+
});
1906+
let (_, _, _, _, converted, _) = anthropic_request_to_internal(&body).unwrap();
1907+
assert_eq!(converted, Some("none".to_string()));
1908+
}
1909+
}
1910+
1911+
#[test]
1912+
fn test_output_config_effort_is_validated_before_disabled_thinking_precedence() {
1913+
let invalid_efforts = [
1914+
(
1915+
json!(""),
1916+
"output_config.effort must be a non-empty string when provided",
1917+
),
1918+
(
1919+
json!(42),
1920+
"output_config.effort must be a non-empty string when provided",
1921+
),
1922+
(
1923+
json!("ultra"),
1924+
"output_config.effort must be one of: low, medium, high, xhigh, max",
1925+
),
1926+
];
1927+
for (effort, expected_error) in invalid_efforts {
1928+
let body = json!({
1929+
"messages": [],
1930+
"thinking": {"type": "disabled"},
1931+
"output_config": {"effort": effort},
1932+
});
1933+
assert_eq!(
1934+
anthropic_request_to_internal(&body).unwrap_err(),
1935+
expected_error
1936+
);
1937+
}
19081938
}
19091939

19101940
#[test]

rust/src/server.rs

Lines changed: 134 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2867,6 +2867,110 @@ mod tests {
28672867
std::fs::remove_file(auth_path).unwrap();
28682868
}
28692869

2870+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2871+
async fn anthropic_stream_web_search_uses_disabled_thinking_over_ambient_effort() {
2872+
let (upstream_url, recording, upstream_handle) = start_recording_upstream().await;
2873+
recording.set_response_output(vec![
2874+
json!({
2875+
"type": "web_search_call",
2876+
"id": "search-1",
2877+
"status": "completed",
2878+
"action": {
2879+
"type": "search",
2880+
"query": "Codex release notes",
2881+
"sources": [{
2882+
"type": "url",
2883+
"url": "https://example.com/codex",
2884+
"title": "Codex release notes"
2885+
}]
2886+
}
2887+
}),
2888+
json!({
2889+
"type": "message",
2890+
"role": "assistant",
2891+
"content": [{"type": "output_text", "text": "Search complete."}]
2892+
}),
2893+
]);
2894+
let (api_url, auth_path, api_handle) = start_api_server(
2895+
&upstream_url,
2896+
"gpt-5.6-sol",
2897+
test_codex_config(Some("high"), None, None),
2898+
)
2899+
.await;
2900+
2901+
let response = reqwest::Client::new()
2902+
.post(format!("{api_url}/v1/messages"))
2903+
.json(&json!({
2904+
"model": "claude-sonnet-4-5",
2905+
"max_tokens": 100,
2906+
"system": "Use the available tools.",
2907+
"messages": [{"role": "user", "content": "Search the web."}],
2908+
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
2909+
"stream": true,
2910+
"thinking": {"type": "disabled"},
2911+
"output_config": {"effort": "high"},
2912+
"responses_lite": false
2913+
}))
2914+
.send()
2915+
.await
2916+
.unwrap();
2917+
assert_eq!(response.status(), reqwest::StatusCode::OK);
2918+
assert_eq!(
2919+
response
2920+
.headers()
2921+
.get(CONTENT_TYPE)
2922+
.unwrap()
2923+
.to_str()
2924+
.unwrap(),
2925+
"text/event-stream"
2926+
);
2927+
let mut pending = response.text().await.unwrap();
2928+
let mut events = Vec::new();
2929+
parse_anthropic_sse_blocks(&mut pending, &mut events);
2930+
assert!(pending.is_empty());
2931+
assert_eq!(events.first().unwrap().0, "message_start");
2932+
assert_eq!(events.last().unwrap().0, "message_stop");
2933+
assert_eq!(events.last().unwrap().1["type"], json!("message_stop"));
2934+
2935+
let server_tool_use = events
2936+
.iter()
2937+
.find(|(event, data)| {
2938+
event == "content_block_start"
2939+
&& data["content_block"]["type"] == json!("server_tool_use")
2940+
})
2941+
.unwrap();
2942+
assert_eq!(server_tool_use.1["content_block"]["name"], "web_search");
2943+
let search_result = events
2944+
.iter()
2945+
.find(|(event, data)| {
2946+
event == "content_block_start"
2947+
&& data["content_block"]["type"] == json!("web_search_tool_result")
2948+
})
2949+
.unwrap();
2950+
assert_eq!(
2951+
search_result.1["content_block"]["tool_use_id"],
2952+
server_tool_use.1["content_block"]["id"]
2953+
);
2954+
2955+
let requests = recording.requests();
2956+
assert_eq!(requests.len(), 1);
2957+
assert_eq!(requests[0].endpoint, RecordedEndpoint::Responses);
2958+
assert_eq!(requests[0].body["model"], json!("gpt-5.6-sol"));
2959+
assert_eq!(requests[0].body["reasoning"]["effort"], json!("none"));
2960+
assert_eq!(
2961+
requests[0].body["tools"],
2962+
json!([{"type": "web_search", "external_web_access": true}])
2963+
);
2964+
assert!(requests[0]
2965+
.headers
2966+
.get(crate::model_capabilities::LITE_HEADER_NAME)
2967+
.is_none());
2968+
2969+
api_handle.abort();
2970+
upstream_handle.abort();
2971+
std::fs::remove_file(auth_path).unwrap();
2972+
}
2973+
28702974
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
28712975
async fn anthropic_route_preserves_url_images_and_tool_error_results_on_provider_wire() {
28722976
let (upstream_url, recording, upstream_handle) = start_recording_upstream().await;
@@ -3025,10 +3129,6 @@ mod tests {
30253129
json!({"output_config": {"effort": "ultra"}}),
30263130
json!({"output_config": {"effort": "low", "experimental": true}}),
30273131
json!({"output_config": {"task_budget": {"type": "tokens", "total": 20_000}}}),
3028-
json!({
3029-
"thinking": {"type": "disabled"},
3030-
"output_config": {"effort": "high"}
3031-
}),
30323132
json!({
30333133
"reasoning_effort": "high",
30343134
"output_config": {"effort": "low"}
@@ -3672,7 +3772,7 @@ mod tests {
36723772
}
36733773

36743774
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
3675-
async fn anthropic_stream_rejects_lite_hosted_web_search_before_sse_headers() {
3775+
async fn anthropic_stream_rejects_auto_and_on_lite_hosted_web_search_before_sse_headers() {
36763776
let (upstream_url, recording, upstream_handle) = start_recording_upstream().await;
36773777
let (api_url, auth_path, api_handle) = start_api_server(
36783778
&upstream_url,
@@ -3681,34 +3781,36 @@ mod tests {
36813781
)
36823782
.await;
36833783

3684-
let response = reqwest::Client::new()
3685-
.post(format!("{api_url}/v1/messages"))
3686-
.json(&json!({
3687-
"model": "claude-sonnet-4-5",
3688-
"system": "Use the available tools.",
3689-
"messages": [{"role": "user", "content": "Search the web."}],
3690-
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
3691-
"stream": true,
3692-
"responses_lite": "auto",
3693-
"max_tokens": 100
3694-
}))
3695-
.send()
3696-
.await
3697-
.unwrap();
3784+
for responses_lite in ["auto", "on"] {
3785+
let response = reqwest::Client::new()
3786+
.post(format!("{api_url}/v1/messages"))
3787+
.json(&json!({
3788+
"model": "claude-sonnet-4-5",
3789+
"system": "Use the available tools.",
3790+
"messages": [{"role": "user", "content": "Search the web."}],
3791+
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
3792+
"stream": true,
3793+
"responses_lite": responses_lite,
3794+
"max_tokens": 100
3795+
}))
3796+
.send()
3797+
.await
3798+
.unwrap();
36983799

3699-
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
3700-
assert_eq!(
3701-
response
3702-
.headers()
3703-
.get(CONTENT_TYPE)
3704-
.unwrap()
3705-
.to_str()
3706-
.unwrap(),
3707-
"application/json"
3708-
);
3709-
let body: Value = response.json().await.unwrap();
3710-
assert_eq!(body["type"], json!("error"));
3711-
assert_eq!(body["error"]["type"], json!("invalid_request_error"));
3800+
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
3801+
assert_eq!(
3802+
response
3803+
.headers()
3804+
.get(CONTENT_TYPE)
3805+
.unwrap()
3806+
.to_str()
3807+
.unwrap(),
3808+
"application/json"
3809+
);
3810+
let body: Value = response.json().await.unwrap();
3811+
assert_eq!(body["type"], json!("error"));
3812+
assert_eq!(body["error"]["type"], json!("invalid_request_error"));
3813+
}
37123814
assert!(recording.requests().is_empty());
37133815

37143816
api_handle.abort();

src/codex_as_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""codex-as-api: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server."""
22

3-
__version__ = "0.6.2"
3+
__version__ = "0.6.3"

src/codex_as_api/anthropic_adapter.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,13 +341,19 @@ def _convert_reasoning_effort(thinking: dict[str, Any] | None, output_config: ob
341341
raise ValueError("output_config.task_budget is not supported by the Codex OAuth backend")
342342
raw_effort = output_config.get("effort")
343343
if raw_effort is not None:
344-
if raw_effort not in {"low", "medium", "high", "xhigh", "max"}:
344+
if not isinstance(raw_effort, str) or raw_effort not in {
345+
"low",
346+
"medium",
347+
"high",
348+
"xhigh",
349+
"max",
350+
}:
345351
raise ValueError("output_config.effort must be one of: low, medium, high, xhigh, max")
346352
output_effort = raw_effort
347353

348354
thinking_effort = _convert_thinking(thinking)
349-
if thinking_effort == "none" and output_effort is not None:
350-
raise ValueError("output_config.effort cannot be represented together with thinking.disabled")
355+
if thinking_effort == "none":
356+
return "none"
351357
return output_effort or thinking_effort
352358

353359

0 commit comments

Comments
 (0)