From a1de04caac66b198fc0688b3dbfc6e9899b1d17b Mon Sep 17 00:00:00 2001 From: Paulo Phagula Date: Tue, 8 Sep 2026 15:29:34 +0200 Subject: [PATCH] fix(opencode): send x-opencode-session header to OpenCode Go upstream OpenCode Go now rejects requests missing x-opencode-session ("Request is missing x-opencode-session and cannot be routed efficiently"). Forward the Claude Code session id (already threaded through RequestContext) as that header on every upstream request, falling back to a generated UUID when no session id is available. Fixes #137 --- src/providers/opencode/client.rs | 29 ++++++-- src/providers/opencode/mod.rs | 113 +++++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/providers/opencode/client.rs b/src/providers/opencode/client.rs index b28ca990..dd2b59ec 100644 --- a/src/providers/opencode/client.rs +++ b/src/providers/opencode/client.rs @@ -78,6 +78,7 @@ impl OpenCodeClient { body: &T, stream: bool, traffic: Option>, + session_id: Option<&str>, ) -> Result { let Some(api_key) = self.api_key.as_deref().filter(|key| !key.is_empty()) else { return Err(OpenCodeError { @@ -93,6 +94,11 @@ impl OpenCodeClient { "application/json" }; + let session_header_value = session_id + .filter(|value| http::HeaderValue::from_str(value).is_ok()) + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + if let Some(capture) = traffic.as_ref() { let value = serde_json::to_value(body).unwrap_or(serde_json::Value::Null); capture.write_json("020-upstream-request", &value); @@ -110,7 +116,8 @@ impl OpenCodeClient { "headers": { "accept": accept, auth_header: "[redacted]", - "content-type": "application/json" + "content-type": "application/json", + "x-opencode-session": session_header_value } }), ); @@ -121,6 +128,7 @@ impl OpenCodeClient { .post(url) .header(http::header::ACCEPT, accept) .header(http::header::CONTENT_TYPE, "application/json") + .header("x-opencode-session", &session_header_value) .json(body); match endpoint { EndpointKind::ChatCompletions | EndpointKind::Responses => { @@ -239,6 +247,7 @@ mod tests { authorization: String, x_api_key: String, anthropic_version: String, + x_opencode_session: String, body: serde_json::Value, } @@ -267,6 +276,11 @@ mod tests { .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_string(), + x_opencode_session: headers + .get("x-opencode-session") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(), body, }); Json(serde_json::json!({"ok": true})) @@ -308,10 +322,10 @@ mod tests { let client = OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) .unwrap(); - for (endpoint, model) in [ - (EndpointKind::ChatCompletions, "glm-5.2"), - (EndpointKind::Messages, "minimax-m3"), - (EndpointKind::Responses, "gpt-5.6-luna"), + for (endpoint, model, session_id) in [ + (EndpointKind::ChatCompletions, "glm-5.2", Some("sess-abc")), + (EndpointKind::Messages, "minimax-m3", None), + (EndpointKind::Responses, "gpt-5.6-luna", Some("sess-xyz")), ] { client .post( @@ -319,6 +333,7 @@ mod tests { &serde_json::json!({"model": model, "messages": []}), false, None, + session_id, ) .await .unwrap() @@ -345,5 +360,9 @@ mod tests { assert_eq!(seen[0].body["model"], "glm-5.2"); assert_eq!(seen[1].body["model"], "minimax-m3"); assert_eq!(seen[2].body["model"], "gpt-5.6-luna"); + assert_eq!(seen[0].x_opencode_session, "sess-abc"); + assert!(!seen[1].x_opencode_session.is_empty()); + assert_ne!(seen[1].x_opencode_session, "sess-abc"); + assert_eq!(seen[2].x_opencode_session, "sess-xyz"); } } diff --git a/src/providers/opencode/mod.rs b/src/providers/opencode/mod.rs index cdc1b56d..bc984e9a 100644 --- a/src/providers/opencode/mod.rs +++ b/src/providers/opencode/mod.rs @@ -88,7 +88,13 @@ impl OpenCodeProvider { }; mark_upstream_started(&ctx); let upstream = match client - .post(spec.endpoint, &translated, true, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + true, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await { Ok(upstream) => upstream, @@ -111,7 +117,13 @@ impl OpenCodeProvider { }; mark_upstream_started(&ctx); let upstream = match client - .post(spec.endpoint, &translated, false, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + false, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await { Ok(upstream) => upstream, @@ -135,7 +147,13 @@ impl OpenCodeProvider { }; mark_upstream_started(&ctx); let upstream = match client - .post(spec.endpoint, &translated, true, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + true, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await { Ok(upstream) => upstream, @@ -248,7 +266,13 @@ impl Provider for OpenCodeProvider { let translated = chat::prepare_request(&body, spec.id) .map_err(invalid_request_provider_error)?; let upstream = client - .post(spec.endpoint, &translated, true, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + true, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await .map_err(opencode_provider_error)?; chat::stream_body( @@ -264,7 +288,13 @@ impl Provider for OpenCodeProvider { let translated = messages::prepare_request(&body, spec.id) .map_err(invalid_request_provider_error)?; let upstream = client - .post(spec.endpoint, &translated, true, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + true, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await .map_err(opencode_provider_error)?; messages::stream_body( @@ -278,7 +308,13 @@ impl Provider for OpenCodeProvider { let translated = responses::prepare_request(&body, spec.id, ctx.session_id.clone()) .map_err(invalid_request_provider_error)?; let upstream = client - .post(spec.endpoint, &translated, true, ctx.traffic.clone()) + .post( + spec.endpoint, + &translated, + true, + ctx.traffic.clone(), + ctx.session_id.as_deref(), + ) .await .map_err(opencode_provider_error)?; responses::stream_body( @@ -448,7 +484,7 @@ mod tests { use axum::{ Json, Router, body::Body, - extract::OriginalUri, + extract::{OriginalUri, State}, http::HeaderMap, response::{IntoResponse, Response}, routing::post, @@ -457,6 +493,7 @@ mod tests { use futures_util::StreamExt; use serde_json::json; use std::convert::Infallible; + use std::sync::Mutex; use super::*; @@ -471,6 +508,68 @@ mod tests { } } + fn context_with_session(session_id: &str) -> RequestContext { + RequestContext { + session_id: Some(session_id.to_string()), + ..context() + } + } + + #[tokio::test] + async fn claude_code_session_id_is_forwarded_as_opencode_session_header() { + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + + async fn capture_session_header( + State(seen): State>>>, + headers: HeaderMap, + Json(_body): Json, + ) -> Response { + seen.lock().unwrap().push( + headers + .get("x-opencode-session") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(), + ); + Json(json!({ + "id": "msg_native", + "type": "message", + "role": "assistant", + "model": "minimax-m3", + "content": [{"type":"text","text":"hello"}], + "stop_reason": "end_turn", + "usage": {"input_tokens":1,"output_tokens":1} + })) + .into_response() + } + + let app = Router::new() + .route("/v1/messages", post(capture_session_header)) + .with_state(seen.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let provider = OpenCodeProvider::with_client( + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(), + ); + + let body: MessagesRequest = serde_json::from_value(json!({ + "model": "opencode-go/minimax-m3", + "stream": false, + "messages": [{"role":"user","content":"hello"}] + })) + .unwrap(); + let response = provider + .handle_messages(body, context_with_session("claude-session-42")) + .await; + assert_eq!(response.status(), StatusCode::OK); + server.abort(); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.as_slice(), ["claude-session-42"]); + } + #[tokio::test] async fn missing_key_is_actionable() { let client = OpenCodeClient::new("https://example.com/v1".into(), None).unwrap();