From d20c41f313db53c91c794baa3458adb42b21be6b Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Thu, 22 Oct 2026 21:02:44 +0200 Subject: [PATCH 1/3] fix: reject unsupported message threads at ingress Co-Authored-By: GPT-6.1 Sol --- src/server.rs | 14 ++++ tests/server.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/src/server.rs b/src/server.rs index da3c842b..e1bdc970 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1535,6 +1535,20 @@ async fn dispatch_request( } }; + if let Some(thread) = body.extra.remove("thread") + && thread.get("type").and_then(Value::as_str) != Some("create") + { + let message = "capability_rejected: beta_header:message-threads-2026-08-12"; + let response = json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message); + monitor_failed( + state.monitor.as_ref(), + &req_id, + Some(response.status()), + message, + ); + return with_request_id(response, &req_id); + } + if let Some(project) = project::name_from_request( body.extra.get("system"), body.messages.iter().rev().map(|message| &message.content), diff --git a/tests/server.rs b/tests/server.rs index ed44d862..4d26076f 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -196,6 +196,42 @@ struct IdentityCaptureProvider { captured: Arc>>, } +struct RequestCaptureProvider { + captured: Arc>>, +} + +#[async_trait] +impl Provider for RequestCaptureProvider { + fn name(&self) -> &'static str { + "codex" + } + + fn supported_models(&self) -> Vec { + vec!["gpt-5.5".to_string()] + } + + fn cli(&self) -> &'static dyn CliHandlers { + &FAKE_CLI + } + + async fn handle_messages( + &self, + body: MessagesRequest, + _ctx: RequestContext, + ) -> axum::response::Response { + self.captured.lock().unwrap().push(body); + (StatusCode::OK, "captured").into_response() + } + + async fn handle_count_tokens( + &self, + body: MessagesRequest, + ctx: RequestContext, + ) -> axum::response::Response { + self.handle_messages(body, ctx).await + } +} + #[async_trait] impl Provider for IdentityCaptureProvider { fn name(&self) -> &'static str { @@ -473,6 +509,154 @@ async fn invalid_json_request_is_json_error() { assert_eq!(error_type, "invalid_request_error"); } +async fn message_thread_response(body: Value) -> (axum::response::Response, Vec) { + let captured = Arc::new(Mutex::new(Vec::new())); + let registry = Arc::new(Registry::from_providers( + AliasProvider::Codex, + [Arc::new(RequestCaptureProvider { + captured: captured.clone(), + }) as Arc], + )); + let response = app(registry) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/messages") + .header("content-type", "application/json") + .header("anthropic-beta", "message-threads-2026-08-12") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let requests = captured.lock().unwrap().clone(); + (response, requests) +} + +async fn assert_message_thread_rejected(body: Value) { + let (response, captured) = message_thread_response(body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(captured.len(), 0); + let body: Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(body["type"], "error"); + assert_eq!(body["error"]["type"], "invalid_request_error"); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("capability_rejected: beta_header:message-threads-2026-08-12") + ); +} + +async fn assert_message_thread_dispatched(mut body: Value) { + let (response, captured) = message_thread_response(body.clone()).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(captured.len(), 1); + assert!(!captured[0].extra.contains_key("thread")); + body.as_object_mut().unwrap().remove("thread"); + let expected: MessagesRequest = serde_json::from_value(body).unwrap(); + assert_eq!( + serde_json::to_value(&captured[0]).unwrap(), + serde_json::to_value(expected).unwrap() + ); +} + +#[tokio::test] +async fn message_thread_continue_tool_result_is_rejected_before_dispatch() { + assert_message_thread_rejected(json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "call_test", "content": "done" + }]}], + "thread": {"type": "continue", "previous_message_id": "msg_test"} + })) + .await; +} + +#[tokio::test] +async fn message_thread_continue_text_is_rejected_before_dispatch() { + assert_message_thread_rejected(json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "next question"}], + "thread": {"type": "continue", "previous_message_id": "msg_test"} + })) + .await; +} + +#[tokio::test] +async fn message_thread_unknown_type_is_rejected_before_dispatch() { + assert_message_thread_rejected(json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "hello"}], + "thread": {"type": "fork"} + })) + .await; +} + +#[tokio::test] +async fn message_thread_create_tool_result_dispatches_without_thread() { + assert_message_thread_dispatched(json!({ + "model": "gpt-5.5", + "max_tokens": 32, + "system": "Keep the full history.", + "messages": [ + {"role": "user", "content": "run the tool"}, + {"role": "assistant", "content": [{ + "type": "tool_use", "id": "call_test", "name": "test", "input": {} + }]}, + {"role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "call_test", "content": "done" + }]} + ], + "thread": {"type": "create"} + })) + .await; +} + +#[tokio::test] +async fn message_thread_create_text_dispatches_without_thread() { + assert_message_thread_dispatched(json!({ + "model": "gpt-5.5", + "max_tokens": 32, + "system": "Keep the full history.", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "next question"} + ], + "thread": {"type": "create"} + })) + .await; +} + +#[tokio::test] +async fn message_thread_absent_text_dispatches_unchanged() { + assert_message_thread_dispatched(json!({ + "model": "gpt-5.5", + "max_tokens": 32, + "system": "Keep the full history.", + "messages": [{"role": "user", "content": "hello"}] + })) + .await; +} + +#[tokio::test] +async fn message_thread_absent_tool_result_dispatches_unchanged() { + assert_message_thread_dispatched(json!({ + "model": "gpt-5.5", + "max_tokens": 32, + "messages": [{"role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "call_test", "content": "done" + }]}] + })) + .await; +} + #[tokio::test] async fn empty_body_is_invalid_json() { let app = app(Arc::new(Registry::with_default_alias())); From cf4bdc6d9a3fa8058860afdb1bb11624aa99646c Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Thu, 22 Oct 2026 21:13:49 +0200 Subject: [PATCH 2/3] test: reject thread metadata without a type Co-Authored-By: GPT-6.1 Sol --- tests/server.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/server.rs b/tests/server.rs index 4d26076f..ce82dd99 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -598,6 +598,16 @@ async fn message_thread_unknown_type_is_rejected_before_dispatch() { .await; } +#[tokio::test] +async fn message_thread_missing_type_is_rejected_before_dispatch() { + assert_message_thread_rejected(json!({ + "model": "gpt-5.5", + "messages": [{"role": "user", "content": "hello"}], + "thread": {} + })) + .await; +} + #[tokio::test] async fn message_thread_create_tool_result_dispatches_without_thread() { assert_message_thread_dispatched(json!({ From af6ebd0e621230ad07ae327646477490c76ff4ce Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Thu, 22 Oct 2026 21:24:55 +0200 Subject: [PATCH 3/3] fix: complete logging for thread rejections Co-Authored-By: GPT-6.1 Sol --- src/server.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/server.rs b/src/server.rs index e1bdc970..07a7c266 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1540,11 +1540,37 @@ async fn dispatch_request( { let message = "capability_rejected: beta_header:message-threads-2026-08-12"; let response = json_error(StatusCode::BAD_REQUEST, "invalid_request_error", message); + log_request_completed( + &log, + RequestLogContext { + req_id: &req_id, + provider: None, + model: None, + count_tokens, + status: response.status(), + started_at, + }, + ); + let (response, details) = record_failed_response( + &log, + FailedResponseLogContext { + req_id: &req_id, + provider: None, + model: None, + count_tokens, + started_at, + }, + response, + ) + .await; monitor_failed( state.monitor.as_ref(), &req_id, Some(response.status()), - message, + details + .as_ref() + .map(|details| details.message.as_str()) + .unwrap_or(message), ); return with_request_id(response, &req_id); }