From 949748a074fdf1286aabc09ae592e4f3fd8433cf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 11 Jul 2026 03:02:27 +0000 Subject: [PATCH] harness(openai): OpenAI Responses API (/v1/responses) + codex knobs Add a second wire shape to OpenAiModel, selected by with_responses_api_primary(): the OpenAI Responses API (/v1/responses), which the codex OAuth path requires. - responses.rs: ResponsesRequest/Response types + translation. build_responses_input folds system messages into and maps user/assistant/tool turns to items (assistant/tool -> output_text, else input_text, matching the API's role/kind rules). extract_responses_text prefers the terminal , else the first output_text content part. parse_responses_response maps onto ModelResponse (text + usage). Unit-tested. - OpenAiModel: with_responses_api_primary(), with_responses_omit_max_output_tokens() (codex rejects the cap), with_extra_query_param() (codex client_version), with_user_agent() (codex CLI UA). authorized() now applies the UA + query params. - invoke() routes to /responses when responses_api_primary; a 400 implicating max_output_tokens retries once without it. stream() surfaces the unary result as a single terminal Completed (true Responses SSE is a follow-up). Text-in/text-out for now (native tools over /responses are a follow-up; the harness embeds tool specs in the prompt). fmt + clippy -D warnings clean; 65 openai tests pass. NOTE: live per-provider validation deferred to a dedicated tinyagents run. --- src/harness/providers/openai/mod.rs | 1 + src/harness/providers/openai/responses.rs | 293 ++++++++++++++++++++++ src/harness/providers/openai/transport.rs | 156 ++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 src/harness/providers/openai/responses.rs diff --git a/src/harness/providers/openai/mod.rs b/src/harness/providers/openai/mod.rs index 787eddf..04568c7 100644 --- a/src/harness/providers/openai/mod.rs +++ b/src/harness/providers/openai/mod.rs @@ -66,6 +66,7 @@ const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30; const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 600; mod convert; +mod responses; mod sse; mod transport; diff --git a/src/harness/providers/openai/responses.rs b/src/harness/providers/openai/responses.rs new file mode 100644 index 0000000..6187f22 --- /dev/null +++ b/src/harness/providers/openai/responses.rs @@ -0,0 +1,293 @@ +//! OpenAI **Responses API** (`/v1/responses`) request/response translation. +//! +//! A second wire shape [`OpenAiModel`](super::OpenAiModel) can speak, selected +//! by [`with_responses_api_primary`](super::OpenAiModel::with_responses_api_primary). +//! Where Chat Completions uses `messages` / `choices`, the Responses API uses +//! `input` / `instructions` (the system prompt) / `output`. It is the wire the +//! OpenAI Codex OAuth path requires (paired with `with_extra_query_param` + +//! `with_user_agent`). +//! +//! This first port is **text-in / text-out**: system messages fold into +//! `instructions`, user/assistant/tool turns become `input` items, and the +//! terminal `output_text` (or the first `output_text` content part) becomes the +//! assistant reply. Native tool calls over `/responses` and true SSE streaming +//! are follow-ups; the harness embeds tool specs in the prompt for this path +//! (its [`profile`](super::OpenAiModel) advertises the caller's chosen +//! `tool_calling`). + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::harness::message::{AssistantMessage, ContentBlock, Message}; +use crate::harness::model::ModelResponse; +use crate::harness::usage::Usage; + +/// The `/v1/responses` request body. +#[derive(Debug, Serialize)] +pub(super) struct ResponsesRequest { + pub(super) model: String, + pub(super) input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) store: Option, + /// `max_output_tokens` — the Responses-API output cap, carrying the request's + /// `max_tokens`. Omitted for the Codex OAuth backend, which rejects it. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) max_output_tokens: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct ResponsesInput { + pub(super) role: String, + pub(super) content: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct ResponsesContentPart { + #[serde(rename = "type")] + pub(super) kind: String, + pub(super) text: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ResponsesResponse { + #[serde(default)] + pub(super) output: Vec, + #[serde(default)] + pub(super) output_text: Option, + #[serde(default)] + pub(super) usage: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ResponsesOutput { + #[serde(default)] + pub(super) content: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ResponsesContent { + #[serde(rename = "type")] + pub(super) kind: Option, + pub(super) text: Option, +} + +/// Responses-API usage block (`input_tokens` / `output_tokens`). +#[derive(Debug, Deserialize)] +pub(super) struct ResponsesUsage { + #[serde(default)] + pub(super) input_tokens: Option, + #[serde(default)] + pub(super) output_tokens: Option, +} + +/// Concatenates the visible text of a message's content blocks. +fn message_text(content: &[ContentBlock]) -> String { + content + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("") +} + +/// Normalizes a message role for the Responses API: assistant + tool turns fold +/// into `assistant` (which the API keys to `output_text`), everything else to +/// `user` (`input_text`). Mirrors the host `normalize_responses_role`. +fn normalize_role(message: &Message) -> &'static str { + match message { + Message::Assistant(_) | Message::Tool(_) => "assistant", + _ => "user", + } +} + +/// Splits a provider-neutral message list into the Responses `instructions` +/// (concatenated system text) and `input` items. Empty-text turns are skipped; +/// the content-part `kind` tracks the *normalized* role (`output_text` for +/// assistant/tool, `input_text` otherwise) — the API rejects `input_text` on an +/// assistant item. +pub(super) fn build_responses_input(messages: &[Message]) -> (Option, Vec) { + let mut instructions_parts = Vec::new(); + let mut input = Vec::new(); + + for message in messages { + let text = match message { + Message::System(m) => { + let t = message_text(&m.content); + if !t.trim().is_empty() { + instructions_parts.push(t); + } + continue; + } + Message::User(m) => message_text(&m.content), + Message::Assistant(m) => message_text(&m.content), + Message::Tool(m) => message_text(&m.content), + }; + if text.trim().is_empty() { + continue; + } + let role = normalize_role(message); + input.push(ResponsesInput { + role: role.to_string(), + content: vec![ResponsesContentPart { + kind: if role == "assistant" { + "output_text".to_string() + } else { + "input_text".to_string() + }, + text, + }], + }); + } + + let instructions = (!instructions_parts.is_empty()).then(|| instructions_parts.join("\n\n")); + (instructions, input) +} + +/// Extracts the assistant text from a Responses body: the convenience +/// `output_text` field first, else the first `output_text` content part. +pub(super) fn extract_responses_text(response: &ResponsesResponse) -> Option { + if let Some(text) = response + .output_text + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return Some(text.to_string()); + } + for item in &response.output { + for content in &item.content { + if content.kind.as_deref() == Some("output_text") + && let Some(text) = content + .text + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return Some(text.to_string()); + } + } + } + None +} + +/// Parses a raw `/v1/responses` JSON body into a [`ModelResponse`] (text reply). +pub(super) fn parse_responses_response(value: Value) -> ModelResponse { + let parsed: ResponsesResponse = + serde_json::from_value(value.clone()).unwrap_or(ResponsesResponse { + output: Vec::new(), + output_text: None, + usage: None, + }); + let text = extract_responses_text(&parsed).unwrap_or_default(); + let usage = parsed.usage.as_ref().map(|u| Usage { + input_tokens: u.input_tokens.unwrap_or(0), + output_tokens: u.output_tokens.unwrap_or(0), + total_tokens: u.input_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0), + ..Usage::default() + }); + ModelResponse { + message: AssistantMessage { + id: None, + content: vec![ContentBlock::Text(text)], + tool_calls: Vec::new(), + usage, + }, + usage, + finish_reason: Some("stop".to_string()), + raw: Some(value), + resolved_model: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::harness::message::Message; + use serde_json::json; + + #[test] + fn build_input_folds_system_into_instructions_and_keys_roles() { + let messages = vec![ + Message::system("be terse"), + Message::system("and correct"), + Message::user("hi"), + Message::assistant("hello"), + Message::user(" "), // empty → skipped + ]; + let (instructions, input) = build_responses_input(&messages); + assert_eq!(instructions.as_deref(), Some("be terse\n\nand correct")); + assert_eq!(input.len(), 2); + assert_eq!(input[0].role, "user"); + assert_eq!(input[0].content[0].kind, "input_text"); + assert_eq!(input[0].content[0].text, "hi"); + // Assistant items must use `output_text`, not `input_text`. + assert_eq!(input[1].role, "assistant"); + assert_eq!(input[1].content[0].kind, "output_text"); + assert_eq!(input[1].content[0].text, "hello"); + } + + #[test] + fn extract_text_prefers_output_text_then_scans_content() { + let with_convenience = ResponsesResponse { + output: Vec::new(), + output_text: Some(" final ".to_string()), + usage: None, + }; + assert_eq!( + extract_responses_text(&with_convenience).as_deref(), + Some("final") + ); + + let via_content = ResponsesResponse { + output: vec![ResponsesOutput { + content: vec![ + ResponsesContent { + kind: Some("reasoning".into()), + text: Some("...".into()), + }, + ResponsesContent { + kind: Some("output_text".into()), + text: Some("answer".into()), + }, + ], + }], + output_text: None, + usage: None, + }; + assert_eq!( + extract_responses_text(&via_content).as_deref(), + Some("answer") + ); + + let empty = ResponsesResponse { + output: Vec::new(), + output_text: None, + usage: None, + }; + assert_eq!(extract_responses_text(&empty), None); + } + + #[test] + fn parse_maps_text_and_usage_onto_model_response() { + let body = json!({ + "output_text": "the answer", + "usage": { "input_tokens": 12, "output_tokens": 5 } + }); + let resp = parse_responses_response(body); + assert_eq!(resp.text(), "the answer"); + assert_eq!(resp.finish_reason.as_deref(), Some("stop")); + let usage = resp.usage.expect("usage mapped"); + assert_eq!(usage.input_tokens, 12); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.total_tokens, 17); + } + + #[test] + fn parse_tolerates_a_body_without_output() { + let resp = parse_responses_response(json!({ "id": "resp_1" })); + assert_eq!(resp.text(), ""); + } +} diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index 10cdb90..92a569f 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -4,6 +4,7 @@ //! Split out of `openai/mod.rs`; see that module's doc comment for the //! full provider overview. +use super::responses; use super::*; /// How the provider expects the API credential to be sent on each request. @@ -67,6 +68,19 @@ pub struct OpenAiModel { /// `provider_options`, which win on key conflicts. `Value::Null` by default /// (no baked options). See [`Self::with_default_provider_options`]. default_provider_options: Value, + /// When `true`, calls go to the OpenAI **Responses API** (`/v1/responses`) + /// instead of Chat Completions. See [`Self::with_responses_api_primary`]. + responses_api_primary: bool, + /// When `true` (Responses path only), omit `max_output_tokens` from the wire + /// body — the OpenAI Codex OAuth backend rejects it. See + /// [`Self::with_responses_omit_max_output_tokens`]. + responses_omit_max_output_tokens: bool, + /// Static query parameters appended to every request URL (e.g. the Codex + /// `client_version`). See [`Self::with_extra_query_param`]. + extra_query_params: Vec<(String, String)>, + /// A `User-Agent` header override (e.g. the Codex CLI UA). `None` uses + /// reqwest's default. See [`Self::with_user_agent`]. + user_agent: Option, } /// The auth headers `(name, value)` for a given [`AuthStyle`] + credential. @@ -279,9 +293,47 @@ impl OpenAiModel { base_url: DEFAULT_BASE_URL.to_string(), profile: derive_profile("openai", DEFAULT_MODEL), default_provider_options: Value::Null, + responses_api_primary: false, + responses_omit_max_output_tokens: false, + extra_query_params: Vec::new(), + user_agent: None, } } + /// Routes calls to the OpenAI **Responses API** (`/v1/responses`) instead of + /// Chat Completions. Required for the OpenAI Codex OAuth backend; pair with + /// [`with_extra_query_param`](Self::with_extra_query_param) + + /// [`with_user_agent`](Self::with_user_agent) for Codex. + pub fn with_responses_api_primary(mut self) -> Self { + self.responses_api_primary = true; + self + } + + /// Omits `max_output_tokens` from Responses requests (the Codex OAuth backend + /// rejects it). No effect on the Chat Completions path. + pub fn with_responses_omit_max_output_tokens(mut self) -> Self { + self.responses_omit_max_output_tokens = true; + self + } + + /// Appends a static query parameter to every request URL (repeatable) — e.g. + /// the Codex `client_version`. + pub fn with_extra_query_param( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.extra_query_params.push((name.into(), value.into())); + self + } + + /// Overrides the `User-Agent` header sent on every request (e.g. the Codex + /// CLI user agent). + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + /// Overrides how the API credential is sent (default [`AuthStyle::Bearer`]). /// /// Use this for OpenAI-compatible endpoints that authenticate with @@ -724,9 +776,92 @@ impl OpenAiModel { for (name, value) in &self.extra_headers { builder = builder.header(name.as_str(), value.as_str()); } + if let Some(user_agent) = &self.user_agent { + builder = builder.header(reqwest::header::USER_AGENT, user_agent.as_str()); + } + if !self.extra_query_params.is_empty() { + builder = builder.query(&self.extra_query_params); + } builder } + /// The `/responses` endpoint URL — a sibling of `/chat/completions` under the + /// same base URL, tolerating a base that already ends in `/responses` or a + /// `…/v1` chat base. + fn responses_url(&self) -> String { + let base = self.base_url.trim_end_matches('/'); + if base.ends_with("/responses") { + base.to_string() + } else { + format!("{base}/responses") + } + } + + /// Builds the `/v1/responses` request body from a provider-neutral request. + fn translate_responses_request(&self, request: &ModelRequest) -> responses::ResponsesRequest { + let model = request.model.clone().unwrap_or_else(|| self.model.clone()); + let (instructions, input) = responses::build_responses_input(&request.messages); + let max_output_tokens = if self.responses_omit_max_output_tokens { + None + } else { + request.max_tokens + }; + responses::ResponsesRequest { + model, + input, + instructions, + stream: None, + store: Some(false), + max_output_tokens, + } + } + + /// Issues a `POST` to the Responses endpoint and maps the body onto a + /// [`ModelResponse`]. On a 400 whose body implicates `max_output_tokens`, the + /// request is retried once without that field (some `/responses` backends + /// reject the cap outright). + async fn invoke_responses(&self, request: &ModelRequest) -> Result { + let url = self.responses_url(); + let body = self.translate_responses_request(request); + let response = match self.send_responses(&body, request.timeout_ms, &url).await { + Ok(r) => r, + Err(TinyAgentsError::Provider(err)) + if err.status == Some(400) + && body.max_output_tokens.is_some() + && err.message.contains("max_output_tokens") => + { + // Retry once without the cap. + let retry = responses::ResponsesRequest { + max_output_tokens: None, + ..body + }; + self.send_responses(&retry, request.timeout_ms, &url) + .await? + } + Err(e) => return Err(e), + }; + let text = response.text().await.map_err(|e| { + TinyAgentsError::Model(format!("openai responses body read failed: {e}")) + })?; + let value: Value = serde_json::from_str(&text)?; + Ok(responses::parse_responses_response(value)) + } + + /// Shared `POST {responses_url}` with auth, query params, and timeout, mapped + /// through the same checked-transport tail as chat calls. + async fn send_responses( + &self, + body: &responses::ResponsesRequest, + timeout_ms: Option, + url: &str, + ) -> Result { + let mut builder = self.authorized(self.client.post(url)).json(body); + if let Some(timeout) = request_timeout(timeout_ms, false) { + builder = builder.timeout(timeout); + } + self.send_checked(builder, "responses request", url).await + } + /// Sends `builder` and returns the checked (2xx) [`reqwest::Response`]. /// /// The shared transport tail for every OpenAI call: a send/transport failure @@ -935,6 +1070,9 @@ impl ChatModel for OpenAiModel { /// status (the message includes the status code and response body), and /// [`TinyAgentsError::Serialization`] when the response cannot be decoded. async fn invoke(&self, _state: &State, request: ModelRequest) -> Result { + if self.responses_api_primary { + return self.invoke_responses(&request).await; + } let body = self.translate_request(&request)?; let response = self @@ -969,6 +1107,24 @@ impl ChatModel for OpenAiModel { /// surfaced as [`ModelStreamItem::ProviderFailed`] inside the stream /// instead). async fn stream(&self, _state: &State, request: ModelRequest) -> Result { + // The Responses path is text-in/text-out in this port: do the unary call + // and surface it as a single terminal `Completed` (a leading `Started` + + // one `MessageDelta` carrying the text so a UI still renders it). True + // Responses SSE is a follow-up. + if self.responses_api_primary { + let response = self.invoke_responses(&request).await?; + let delta = crate::harness::message::MessageDelta { + text: response.text(), + reasoning: String::new(), + tool_call: None, + }; + let items = vec![ + ModelStreamItem::Started, + ModelStreamItem::MessageDelta(delta), + ModelStreamItem::Completed(response), + ]; + return Ok(Box::pin(futures::stream::iter(items))); + } let mut body = self.translate_request(&request)?; body.stream = true; body.stream_options = Some(json!({ "include_usage": true }));