Remove ApiPrompt (#11265)

Keep things simple and build a full Responses API request request right
in the model client
This commit is contained in:
pakrym-oai
2026-02-10 16:12:31 +00:00
committed by GitHub
parent 59c625458b
commit 3322b99900
9 changed files with 237 additions and 467 deletions
+5 -9
View File
@@ -2,7 +2,7 @@
Typed clients for Codex/OpenAI APIs built on top of the generic transport in `codex-client`.
- Hosts the request/response models and prompt helpers for Responses and Compact APIs.
- Hosts the request/response models and request builders for Responses and Compact APIs.
- Owns provider configuration (base URLs, headers, query params), auth header injection, retry tuning, and stream idle settings.
- Parses SSE streams into `ResponseEvent`/`ResponseStream`, including rate-limit snapshots and API-specific error mapping.
- Serves as the wire-level layer consumed by `codex-core`; higher layers handle auth refresh and business logic.
@@ -11,14 +11,10 @@ Typed clients for Codex/OpenAI APIs built on top of the generic transport in `co
The public interface of this crate is intentionally small and uniform:
- **Prompted endpoints (Responses)**
- Input: a single `Prompt` plus endpoint-specific options.
- `Prompt` (re-exported as `codex_api::Prompt`) carries:
- `instructions: String` the fully-resolved system prompt for this turn.
- `input: Vec<ResponseItem>` conversation history and user/tool messages.
- `tools: Vec<serde_json::Value>` JSON tools compatible with the target API.
- `parallel_tool_calls: bool`.
- `output_schema: Option<Value>` used to build `text.format` when present.
- **Responses endpoint**
- Input:
- `ResponsesApiRequest` for the request body (`model`, `instructions`, `input`, `tools`, `parallel_tool_calls`, reasoning/text controls).
- `ResponsesOptions` for transport/header concerns (`conversation_id`, `session_source`, `extra_headers`, `compression`, `turn_state`).
- Output: a `ResponseStream` of `ResponseEvent` (both re-exported from `common`).
- **Compaction endpoint**
+27 -23
View File
@@ -14,22 +14,6 @@ use std::task::Context;
use std::task::Poll;
use tokio::sync::mpsc;
/// Canonical prompt input for Responses endpoints.
#[derive(Debug, Clone)]
pub struct Prompt {
/// Fully-resolved system instructions for this turn.
pub instructions: String,
/// Conversation history and user/tool messages.
pub input: Vec<ResponseItem>,
/// JSON-encoded tool definitions compatible with the target API.
// TODO(jif) have a proper type here
pub tools: Vec<Value>,
/// Whether parallel tool calls are permitted.
pub parallel_tool_calls: bool,
/// Optional output schema used to build the `text.format` controls.
pub output_schema: Option<Value>,
}
/// Canonical input payload for the compaction endpoint.
#[derive(Debug, Clone, Serialize)]
pub struct CompactionInput<'a> {
@@ -152,13 +136,13 @@ impl From<VerbosityConfig> for OpenAiVerbosity {
}
}
#[derive(Debug, Serialize)]
pub struct ResponsesApiRequest<'a> {
pub model: &'a str,
pub instructions: &'a str,
pub input: &'a [ResponseItem],
pub tools: &'a [serde_json::Value],
pub tool_choice: &'static str,
#[derive(Debug, Serialize, Clone)]
pub struct ResponsesApiRequest {
pub model: String,
pub instructions: String,
pub input: Vec<ResponseItem>,
pub tools: Vec<serde_json::Value>,
pub tool_choice: String,
pub parallel_tool_calls: bool,
pub reasoning: Option<Reasoning>,
pub store: bool,
@@ -170,6 +154,26 @@ pub struct ResponsesApiRequest<'a> {
pub text: Option<TextControls>,
}
impl From<&ResponsesApiRequest> for ResponseCreateWsRequest {
fn from(request: &ResponsesApiRequest) -> Self {
Self {
model: request.model.clone(),
instructions: request.instructions.clone(),
previous_response_id: None,
input: request.input.clone(),
tools: request.tools.clone(),
tool_choice: request.tool_choice.clone(),
parallel_tool_calls: request.parallel_tool_calls,
reasoning: request.reasoning.clone(),
store: request.store,
stream: request.stream,
include: request.include.clone(),
prompt_cache_key: request.prompt_cache_key.clone(),
text: request.text.clone(),
}
}
}
#[derive(Debug, Serialize)]
pub struct ResponseCreateWsRequest {
pub model: String,
+18 -47
View File
@@ -1,14 +1,14 @@
use crate::auth::AuthProvider;
use crate::common::Prompt as ApiPrompt;
use crate::common::Reasoning;
use crate::common::ResponseStream;
use crate::common::TextControls;
use crate::common::ResponsesApiRequest;
use crate::endpoint::session::EndpointSession;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::requests::ResponsesRequest;
use crate::requests::ResponsesRequestBuilder;
use crate::requests::headers::build_conversation_headers;
use crate::requests::headers::insert_header;
use crate::requests::headers::subagent_header;
use crate::requests::responses::Compression;
use crate::requests::responses::attach_item_ids;
use crate::sse::spawn_response_stream;
use crate::telemetry::SseTelemetry;
use codex_client::HttpTransport;
@@ -21,7 +21,6 @@ use http::Method;
use serde_json::Value;
use std::sync::Arc;
use std::sync::OnceLock;
use tracing::instrument;
pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> {
session: EndpointSession<T, A>,
@@ -30,11 +29,6 @@ pub struct ResponsesClient<T: HttpTransport, A: AuthProvider> {
#[derive(Default)]
pub struct ResponsesOptions {
pub reasoning: Option<Reasoning>,
pub include: Vec<String>,
pub prompt_cache_key: Option<String>,
pub text: Option<TextControls>,
pub store_override: Option<bool>,
pub conversation_id: Option<String>,
pub session_source: Option<SessionSource>,
pub extra_headers: HeaderMap,
@@ -63,31 +57,10 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
pub async fn stream_request(
&self,
request: ResponsesRequest,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<ResponseStream, ApiError> {
self.stream(
request.body,
request.headers,
request.compression,
turn_state,
)
.await
}
#[instrument(level = "trace", skip_all, err)]
pub async fn stream_prompt(
&self,
model: &str,
prompt: &ApiPrompt,
request: ResponsesApiRequest,
options: ResponsesOptions,
) -> Result<ResponseStream, ApiError> {
let ResponsesOptions {
reasoning,
include,
prompt_cache_key,
text,
store_override,
conversation_id,
session_source,
extra_headers,
@@ -95,21 +68,19 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
turn_state,
} = options;
let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input)
.tools(&prompt.tools)
.parallel_tool_calls(prompt.parallel_tool_calls)
.reasoning(reasoning)
.include(include)
.prompt_cache_key(prompt_cache_key)
.text(text)
.conversation(conversation_id)
.session_source(session_source)
.store_override(store_override)
.extra_headers(extra_headers)
.compression(compression)
.build(self.session.provider())?;
let mut body = serde_json::to_value(&request)
.map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?;
if request.store && self.session.provider().is_azure_responses_endpoint() {
attach_item_ids(&mut body, &request.input);
}
self.stream_request(request, turn_state).await
let mut headers = extra_headers;
headers.extend(build_conversation_headers(conversation_id));
if let Some(subagent) = subagent_header(&session_source) {
insert_header(&mut headers, "x-openai-subagent", &subagent);
}
self.stream(body, headers, compression, turn_state).await
}
fn path() -> &'static str {
-3
View File
@@ -17,7 +17,6 @@ pub use crate::auth::AuthProvider;
pub use crate::common::CompactionInput;
pub use crate::common::MemorySummarizeInput;
pub use crate::common::MemorySummarizeOutput;
pub use crate::common::Prompt;
pub use crate::common::RawMemory;
pub use crate::common::RawMemoryMetadata;
pub use crate::common::ResponseAppendWsRequest;
@@ -37,8 +36,6 @@ pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection;
pub use crate::error::ApiError;
pub use crate::provider::Provider;
pub use crate::provider::is_azure_responses_wire_base_url;
pub use crate::requests::ResponsesRequest;
pub use crate::requests::ResponsesRequestBuilder;
pub use crate::sse::stream_from_fixture;
pub use crate::telemetry::SseTelemetry;
pub use crate::telemetry::WebsocketTelemetry;
-3
View File
@@ -1,5 +1,2 @@
pub(crate) mod headers;
pub mod responses;
pub use responses::ResponsesRequest;
pub use responses::ResponsesRequestBuilder;
+1 -228
View File
@@ -1,14 +1,4 @@
use crate::common::Reasoning;
use crate::common::ResponsesApiRequest;
use crate::common::TextControls;
use crate::error::ApiError;
use crate::provider::Provider;
use crate::requests::headers::build_conversation_headers;
use crate::requests::headers::insert_header;
use crate::requests::headers::subagent_header;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
use http::HeaderMap;
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -18,149 +8,7 @@ pub enum Compression {
Zstd,
}
/// Assembled request body plus headers for a Responses stream request.
pub struct ResponsesRequest {
pub body: Value,
pub headers: HeaderMap,
pub compression: Compression,
}
#[derive(Default)]
pub struct ResponsesRequestBuilder<'a> {
model: Option<&'a str>,
instructions: Option<&'a str>,
input: Option<&'a [ResponseItem]>,
tools: Option<&'a [Value]>,
parallel_tool_calls: bool,
reasoning: Option<Reasoning>,
include: Vec<String>,
prompt_cache_key: Option<String>,
text: Option<TextControls>,
conversation_id: Option<String>,
session_source: Option<SessionSource>,
store_override: Option<bool>,
headers: HeaderMap,
compression: Compression,
}
impl<'a> ResponsesRequestBuilder<'a> {
pub fn new(model: &'a str, instructions: &'a str, input: &'a [ResponseItem]) -> Self {
Self {
model: Some(model),
instructions: Some(instructions),
input: Some(input),
..Default::default()
}
}
pub fn tools(mut self, tools: &'a [Value]) -> Self {
self.tools = Some(tools);
self
}
pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
self.parallel_tool_calls = enabled;
self
}
pub fn reasoning(mut self, reasoning: Option<Reasoning>) -> Self {
self.reasoning = reasoning;
self
}
pub fn include(mut self, include: Vec<String>) -> Self {
self.include = include;
self
}
pub fn prompt_cache_key(mut self, key: Option<String>) -> Self {
self.prompt_cache_key = key;
self
}
pub fn text(mut self, text: Option<TextControls>) -> Self {
self.text = text;
self
}
pub fn conversation(mut self, conversation_id: Option<String>) -> Self {
self.conversation_id = conversation_id;
self
}
pub fn session_source(mut self, source: Option<SessionSource>) -> Self {
self.session_source = source;
self
}
pub fn store_override(mut self, store: Option<bool>) -> Self {
self.store_override = store;
self
}
pub fn extra_headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn compression(mut self, compression: Compression) -> Self {
self.compression = compression;
self
}
pub fn build(self, provider: &Provider) -> Result<ResponsesRequest, ApiError> {
let model = self
.model
.ok_or_else(|| ApiError::Stream("missing model for responses request".into()))?;
let instructions = self
.instructions
.ok_or_else(|| ApiError::Stream("missing instructions for responses request".into()))?;
let input = self
.input
.ok_or_else(|| ApiError::Stream("missing input for responses request".into()))?;
let tools = self.tools.unwrap_or_default();
let store = self
.store_override
.unwrap_or_else(|| provider.is_azure_responses_endpoint());
let req = ResponsesApiRequest {
model,
instructions,
input,
tools,
tool_choice: "auto",
parallel_tool_calls: self.parallel_tool_calls,
reasoning: self.reasoning,
store,
stream: true,
include: self.include,
prompt_cache_key: self.prompt_cache_key,
text: self.text,
};
let mut body = serde_json::to_value(&req)
.map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?;
if store && provider.is_azure_responses_endpoint() {
attach_item_ids(&mut body, input);
}
let mut headers = self.headers;
headers.extend(build_conversation_headers(self.conversation_id));
if let Some(subagent) = subagent_header(&self.session_source) {
insert_header(&mut headers, "x-openai-subagent", &subagent);
}
Ok(ResponsesRequest {
body,
headers,
compression: self.compression,
})
}
}
fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
let Some(input_value) = payload_json.get_mut("input") else {
return;
};
@@ -186,78 +34,3 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::RetryConfig;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderValue;
use pretty_assertions::assert_eq;
use std::time::Duration;
fn provider(name: &str, base_url: &str) -> Provider {
Provider {
name: name.to_string(),
base_url: base_url.to_string(),
query_params: None,
headers: HeaderMap::new(),
retry: RetryConfig {
max_attempts: 1,
base_delay: Duration::from_millis(50),
retry_429: false,
retry_5xx: true,
retry_transport: true,
},
stream_idle_timeout: Duration::from_secs(5),
}
}
#[test]
fn azure_default_store_attaches_ids_and_headers() {
let provider = provider("azure", "https://example.openai.azure.com/v1");
let input = vec![
ResponseItem::Message {
id: Some("m1".into()),
role: "assistant".into(),
content: Vec::new(),
end_turn: None,
phase: None,
},
ResponseItem::Message {
id: None,
role: "assistant".into(),
content: Vec::new(),
end_turn: None,
phase: None,
},
];
let request = ResponsesRequestBuilder::new("gpt-test", "inst", &input)
.conversation(Some("conv-1".into()))
.session_source(Some(SessionSource::SubAgent(SubAgentSource::Review)))
.build(&provider)
.expect("request");
assert_eq!(request.body.get("store"), Some(&Value::Bool(true)));
let ids: Vec<Option<String>> = request
.body
.get("input")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.map(|item| item.get("id").and_then(|v| v.as_str().map(str::to_string)))
.collect();
assert_eq!(ids, vec![Some("m1".to_string()), None]);
assert_eq!(
request.headers.get("session_id"),
Some(&HeaderValue::from_static("conv-1"))
);
assert_eq!(
request.headers.get("x-openai-subagent"),
Some(&HeaderValue::from_static("review"))
);
}
}
+102 -20
View File
@@ -7,6 +7,7 @@ use async_trait::async_trait;
use bytes::Bytes;
use codex_api::AuthProvider;
use codex_api::Provider;
use codex_api::ResponsesApiRequest;
use codex_api::ResponsesClient;
use codex_api::ResponsesOptions;
use codex_api::requests::responses::Compression;
@@ -17,10 +18,12 @@ use codex_client::StreamResponse;
use codex_client::TransportError;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use http::HeaderMap;
use http::HeaderValue;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::Value;
fn assert_path_ends_with(requests: &[Request], suffix: &str) {
assert_eq!(requests.len(), 1);
@@ -251,27 +254,106 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
let mut provider = provider("openai");
provider.retry.max_attempts = 2;
let request = ResponsesApiRequest {
model: "gpt-test".into(),
instructions: "Say hi".into(),
input: Vec::new(),
tools: Vec::new(),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
store: false,
stream: true,
include: Vec::new(),
prompt_cache_key: None,
text: None,
};
let client = ResponsesClient::new(transport.clone(), provider, NoAuth);
let prompt = codex_api::Prompt {
instructions: "Say hi".to_string(),
input: vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
phase: None,
}],
tools: Vec::<Value>::new(),
parallel_tool_calls: false,
output_schema: None,
};
let options = ResponsesOptions::default();
let _stream = client.stream_prompt("gpt-test", &prompt, options).await?;
let _stream = client
.stream_request(
request,
ResponsesOptions {
compression: Compression::None,
..Default::default()
},
)
.await?;
assert_eq!(transport.attempts(), 2);
Ok(())
}
#[tokio::test]
async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
let state = RecordingState::default();
let transport = RecordingTransport::new(state.clone());
let client = ResponsesClient::new(transport, provider("azure"), NoAuth);
let request = ResponsesApiRequest {
model: "gpt-test".into(),
instructions: "Say hi".into(),
input: vec![ResponseItem::Message {
id: Some("msg_1".into()),
role: "user".into(),
content: vec![ContentItem::InputText { text: "hi".into() }],
end_turn: None,
phase: None,
}],
tools: Vec::new(),
tool_choice: "auto".into(),
parallel_tool_calls: false,
reasoning: None,
store: true,
stream: true,
include: Vec::new(),
prompt_cache_key: None,
text: None,
};
let mut extra_headers = HeaderMap::new();
extra_headers.insert("x-test-header", HeaderValue::from_static("present"));
let _stream = client
.stream_request(
request,
ResponsesOptions {
conversation_id: Some("sess_123".into()),
session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)),
extra_headers,
compression: Compression::None,
turn_state: None,
},
)
.await?;
let requests = state.take_stream_requests();
assert_eq!(requests.len(), 1);
let req = &requests[0];
assert_eq!(
req.headers.get("session_id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers
.get("x-openai-subagent")
.and_then(|v| v.to_str().ok()),
Some("review")
);
assert_eq!(
req.headers
.get("x-test-header")
.and_then(|v| v.to_str().ok()),
Some("present")
);
let input_id = req
.body
.as_ref()
.and_then(|body| body.get("input"))
.and_then(|input| input.get(0))
.and_then(|item| item.get("id"))
.and_then(|id| id.as_str());
assert_eq!(input_id, Some("msg_1"));
Ok(())
}