use super::config::{OpenAIResponsesConfig, ReasoningEffort, ReasoningSummary};
use super::responses_convert;
use crate::retry::{RetryConfig, execute_with_retry, is_retryable_model_error};
use adk_core::{
AdkError, Content, ErrorCategory, ErrorComponent, Llm, LlmRequest, LlmResponse,
LlmResponseStream, Part,
};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::StreamExt;
pub struct OpenAIResponsesClient {
client: async_openai::Client<async_openai::config::OpenAIConfig>,
model: String,
reasoning_effort: Option<ReasoningEffort>,
reasoning_summary: Option<ReasoningSummary>,
retry_config: RetryConfig,
http: reqwest::Client,
api_key: String,
base_url: String,
open_responses_mode: bool,
}
impl OpenAIResponsesClient {
pub fn new(config: OpenAIResponsesConfig) -> Result<Self, AdkError> {
let open_responses_mode = config.open_responses_mode.unwrap_or(false);
let has_custom_base_url = config.base_url.is_some();
if config.api_key.is_empty() && !(open_responses_mode && has_custom_base_url) {
return Err(AdkError::new(
ErrorComponent::Model,
ErrorCategory::InvalidInput,
"model.openai_responses.invalid_config",
"OpenAI Responses API key must not be empty",
)
.with_provider("openai-responses"));
}
let effective_api_key = if config.api_key.is_empty() && open_responses_mode {
"open-responses-no-key".to_string()
} else {
config.api_key.clone()
};
let mut openai_config =
async_openai::config::OpenAIConfig::new().with_api_key(&effective_api_key);
if let Some(org_id) = &config.organization_id {
openai_config = openai_config.with_org_id(org_id);
}
if let Some(base_url) = &config.base_url {
openai_config = openai_config.with_api_base(base_url);
}
let client = async_openai::Client::with_config(openai_config);
let base_url =
config.base_url.clone().unwrap_or_else(|| "https://api.openai.com/v1".to_string());
Ok(Self {
client,
model: config.model,
reasoning_effort: config.reasoning_effort,
reasoning_summary: config.reasoning_summary,
retry_config: RetryConfig::default(),
http: reqwest::Client::new(),
api_key: config.api_key,
base_url,
open_responses_mode,
})
}
#[must_use]
pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
self.retry_config = retry_config;
self
}
pub fn set_retry_config(&mut self, retry_config: RetryConfig) {
self.retry_config = retry_config;
}
pub fn retry_config(&self) -> &RetryConfig {
&self.retry_config
}
pub(crate) fn openai_client(
&self,
) -> &async_openai::Client<async_openai::config::OpenAIConfig> {
&self.client
}
pub(crate) fn http_client(&self) -> &reqwest::Client {
&self.http
}
pub(crate) fn api_key(&self) -> &str {
&self.api_key
}
pub(crate) fn base_url(&self) -> &str {
&self.base_url
}
pub fn is_open_responses_mode(&self) -> bool {
self.open_responses_mode
}
}
pub(super) fn map_openai_error(e: async_openai::error::OpenAIError) -> AdkError {
let error_string = e.to_string();
if let async_openai::error::OpenAIError::ApiError(ref api_err) = e {
let status_code = api_err.status_code.as_u16();
let (category, code) = match status_code {
401 => (ErrorCategory::Unauthorized, "model.openai_responses.unauthorized"),
403 => (ErrorCategory::Forbidden, "model.openai_responses.forbidden"),
404 => (ErrorCategory::NotFound, "model.openai_responses.not_found"),
408 => (ErrorCategory::Timeout, "model.openai_responses.timeout"),
429 => (ErrorCategory::RateLimited, "model.openai_responses.rate_limited"),
500 | 502 | 503 | 504 | 529 => {
(ErrorCategory::Unavailable, "model.openai_responses.unavailable")
}
400..=499 => (ErrorCategory::InvalidInput, "model.openai_responses.invalid_request"),
_ => (ErrorCategory::Internal, "model.openai_responses.api_error"),
};
return AdkError::new(
ErrorComponent::Model,
category,
code,
format!("OpenAI Responses API error: {api_err}"),
)
.with_provider("openai-responses")
.with_upstream_status(status_code);
}
if let async_openai::error::OpenAIError::Reqwest(_) = e {
return AdkError::new(
ErrorComponent::Model,
ErrorCategory::Unavailable,
"model.openai_responses.request",
format!("OpenAI Responses API network error: {error_string}"),
)
.with_provider("openai-responses");
}
if let async_openai::error::OpenAIError::StreamError(_) = e {
return AdkError::new(
ErrorComponent::Model,
ErrorCategory::Unavailable,
"model.openai_responses.stream",
format!("OpenAI Responses API stream error: {error_string}"),
)
.with_provider("openai-responses");
}
if let async_openai::error::OpenAIError::JSONDeserialize(_, _) = e {
return AdkError::new(
ErrorComponent::Model,
ErrorCategory::Internal,
"model.openai_responses.parse",
format!("OpenAI Responses API parse error: {error_string}"),
)
.with_provider("openai-responses");
}
AdkError::new(
ErrorComponent::Model,
ErrorCategory::Internal,
"model.openai_responses.unknown",
format!("OpenAI Responses API error: {error_string}"),
)
.with_provider("openai-responses")
}
#[async_trait]
impl Llm for OpenAIResponsesClient {
fn name(&self) -> &str {
&self.model
}
#[tracing::instrument(
name = "model.generate_content",
skip_all,
fields(
model.name = %self.name(),
stream = %stream,
request.contents_count = %request.contents.len(),
request.tools_count = %request.tools.len()
)
)]
async fn generate_content(
&self,
request: LlmRequest,
stream: bool,
) -> Result<LlmResponseStream, AdkError> {
let usage_span = adk_telemetry::llm_generate_span("openai-responses", &self.model, stream);
let create_request = responses_convert::build_create_response(
&self.model,
&request,
self.reasoning_effort,
self.reasoning_summary,
)?;
let uses_native_tools = responses_convert::request_uses_native_tools(&request);
if stream && !uses_native_tools {
let mut create_request = create_request;
create_request.stream = Some(true);
let event_stream = self
.client
.responses()
.create_stream(create_request)
.await
.map_err(map_openai_error)?;
let response_stream = event_stream.filter_map(|event_result| async {
match event_result {
Ok(event) => {
use async_openai::types::responses::ResponseStreamEvent;
match event {
ResponseStreamEvent::ResponseOutputTextDelta(evt) => {
Some(Ok(LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![Part::Text { text: evt.delta }],
}),
partial: true,
turn_complete: false,
..Default::default()
}))
}
ResponseStreamEvent::ResponseReasoningSummaryTextDelta(evt) => {
Some(Ok(LlmResponse {
content: Some(Content {
role: "model".to_string(),
parts: vec![Part::Thinking {
thinking: evt.delta,
signature: None,
}],
}),
partial: true,
turn_complete: false,
..Default::default()
}))
}
ResponseStreamEvent::ResponseCompleted(evt) => {
let full = responses_convert::from_response(&evt.response);
let trailing_parts: Vec<Part> = full
.content
.as_ref()
.map(|c| {
c.parts
.iter()
.filter(|part| {
!matches!(
part,
Part::Text { .. } | Part::Thinking { .. }
)
})
.cloned()
.collect()
})
.unwrap_or_default();
let content = if trailing_parts.is_empty() {
None
} else {
Some(Content {
role: "model".to_string(),
parts: trailing_parts,
})
};
Some(Ok(LlmResponse {
content,
usage_metadata: full.usage_metadata,
finish_reason: full.finish_reason,
provider_metadata: full.provider_metadata,
partial: false,
turn_complete: true,
..Default::default()
}))
}
ResponseStreamEvent::ResponseFailed(evt) => {
let (error_code, error_message) =
if let Some(err) = &evt.response.error {
(Some(err.code.clone()), Some(err.message.clone()))
} else {
(
Some("unknown".to_string()),
Some("Response failed".to_string()),
)
};
Some(Ok(LlmResponse {
error_code,
error_message,
turn_complete: true,
..Default::default()
}))
}
ResponseStreamEvent::ResponseError(evt) => Some(Ok(LlmResponse {
error_code: evt.code.or_else(|| Some("error".to_string())),
error_message: Some(evt.message),
turn_complete: true,
..Default::default()
})),
_ => None,
}
}
Err(e) => Some(Err(map_openai_error(e))),
}
});
Ok(crate::usage_tracking::with_usage_tracking(Box::pin(response_stream), usage_span))
} else {
if stream && uses_native_tools {
adk_telemetry::debug!(
"OpenAI native tools detected; using non-streaming responses path to avoid upstream SSE item parsing drift"
);
}
let client = self.client.clone();
let retry_config = self.retry_config.clone();
let response_stream = try_stream! {
let response = execute_with_retry(
&retry_config,
is_retryable_model_error,
|| {
let client = client.clone();
let req = create_request.clone();
async move {
client
.responses()
.create(req)
.await
.map_err(map_openai_error)
}
},
)
.await?;
let mut adk_response = responses_convert::from_response(&response);
adk_response.turn_complete = true;
adk_response.partial = false;
yield adk_response;
};
Ok(crate::usage_tracking::with_usage_tracking(Box::pin(response_stream), usage_span))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openai::config::OpenAIResponsesConfig;
use async_openai::error::{ApiError, ApiErrorResponse, OpenAIError};
fn api_error(status_code: u16) -> OpenAIError {
OpenAIError::ApiError(ApiErrorResponse {
status_code: reqwest::StatusCode::from_u16(status_code)
.expect("status code should be valid"),
api_error: ApiError {
message: "upstream error".to_string(),
r#type: None,
param: None,
code: None,
},
})
}
#[test]
fn maps_api_error_statuses() {
let cases = [
(400, ErrorCategory::InvalidInput, "model.openai_responses.invalid_request", false),
(401, ErrorCategory::Unauthorized, "model.openai_responses.unauthorized", false),
(403, ErrorCategory::Forbidden, "model.openai_responses.forbidden", false),
(404, ErrorCategory::NotFound, "model.openai_responses.not_found", false),
(408, ErrorCategory::Timeout, "model.openai_responses.timeout", true),
(418, ErrorCategory::InvalidInput, "model.openai_responses.invalid_request", false),
(429, ErrorCategory::RateLimited, "model.openai_responses.rate_limited", true),
(500, ErrorCategory::Unavailable, "model.openai_responses.unavailable", true),
(503, ErrorCategory::Unavailable, "model.openai_responses.unavailable", true),
(529, ErrorCategory::Unavailable, "model.openai_responses.unavailable", true),
(599, ErrorCategory::Internal, "model.openai_responses.api_error", false),
];
for (status, category, code, retryable) in cases {
let error = map_openai_error(api_error(status));
assert_eq!(
(
error.category,
error.code,
error.is_retryable(),
error.details.upstream_status_code,
),
(category, code, retryable, Some(status))
);
}
}
#[test]
fn test_new_rejects_empty_api_key_without_open_responses_mode() {
let config = OpenAIResponsesConfig::new("", "gpt-4o");
let result = OpenAIResponsesClient::new(config);
match result {
Err(err) => assert_eq!(err.code, "model.openai_responses.invalid_config"),
Ok(_) => panic!("expected error for empty API key"),
}
}
#[test]
fn test_new_rejects_empty_api_key_with_open_responses_mode_but_no_base_url() {
let config = OpenAIResponsesConfig::new("", "local-model").with_open_responses_mode(true);
let result = OpenAIResponsesClient::new(config);
match result {
Err(err) => assert_eq!(err.code, "model.openai_responses.invalid_config"),
Ok(_) => panic!("expected error for empty API key without base URL"),
}
}
#[test]
fn test_new_allows_empty_api_key_with_open_responses_mode_and_base_url() {
let config = OpenAIResponsesConfig::new("", "local-model")
.with_open_responses_mode(true)
.with_base_url("http://localhost:1234/v1");
let result = OpenAIResponsesClient::new(config);
assert!(result.is_ok());
}
#[test]
fn test_open_responses_mode_stored_on_client() {
let config = OpenAIResponsesConfig::new("", "local-model")
.with_open_responses_mode(true)
.with_base_url("http://localhost:1234/v1");
let client = OpenAIResponsesClient::new(config).unwrap();
assert!(client.is_open_responses_mode());
assert_eq!(client.base_url(), "http://localhost:1234/v1");
}
#[test]
fn test_new_with_api_key_and_open_responses_mode() {
let config = OpenAIResponsesConfig::new("sk-test", "local-model")
.with_open_responses_mode(true)
.with_base_url("http://localhost:1234/v1");
let client = OpenAIResponsesClient::new(config).unwrap();
assert!(client.is_open_responses_mode());
assert_eq!(client.api_key(), "sk-test");
}
#[test]
fn test_new_without_open_responses_mode_stores_false() {
let config = OpenAIResponsesConfig::new("sk-test", "gpt-4o");
let client = OpenAIResponsesClient::new(config).unwrap();
assert!(!client.is_open_responses_mode());
}
#[test]
fn test_custom_base_url_without_open_responses_mode_still_requires_api_key() {
let config =
OpenAIResponsesConfig::new("", "local-model").with_base_url("http://localhost:1234/v1");
let result = OpenAIResponsesClient::new(config);
assert!(result.is_err());
}
#[test]
fn test_custom_base_url_with_api_key_works_without_open_responses_mode() {
let config = OpenAIResponsesConfig::new("sk-test", "local-model")
.with_base_url("http://localhost:1234/v1");
let client = OpenAIResponsesClient::new(config).unwrap();
assert!(!client.is_open_responses_mode());
assert_eq!(client.base_url(), "http://localhost:1234/v1");
}
}