use super::{OpenAIRespStreamer, RespResponse};
use crate::adapter::adapters::openai::OpenAIAdapter;
use crate::adapter::adapters::openai::cache_policy::{
OpenAiPromptCachePolicy, OpenAiProtocol, is_gpt_5_6_or_later, openai_prompt_cache_policy,
supports_openai_responses_prompt_cache_options,
};
use crate::adapter::adapters::openai::schema::{
OpenAiResponseFormatPlan, response_format_plan, tool_parameters_schema,
};
use crate::adapter::adapters::support::get_api_key;
use crate::adapter::{Adapter, AdapterDispatcher, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{
CacheControl, ChatOptionsSet, ChatRequest, ChatResponse, ChatRole, ChatStream, ChatStreamResponse, ContentPart,
MessageContent, ReasoningEffort, StopReason, Tool, ToolChoice, ToolConfig, ToolName, Usage,
};
use crate::resolver::{AuthData, Endpoint};
use crate::webc::{EventSourceStream, WebClient, WebResponse};
use crate::{Error, Headers, Result};
use crate::{ModelIden, ServiceTarget};
use reqwest::RequestBuilder;
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
use value_ext::JsonValueExt;
pub struct OpenAIRespAdapter;
fn openai_resp_tool_choice(tool_choice: Option<&ToolChoice>) -> Option<Value> {
match tool_choice? {
ToolChoice::Auto => Some(json!("auto")),
ToolChoice::None => Some(json!("none")),
ToolChoice::Required => Some(json!("required")),
ToolChoice::Tool { name } => Some(json!({
"type": "function",
"name": name
})),
}
}
impl OpenAIRespAdapter {
pub const API_KEY_DEFAULT_ENV_NAME: &str = "OPENAI_API_KEY";
}
impl Adapter for OpenAIRespAdapter {
const DEFAULT_API_KEY_ENV_NAME: Option<&'static str> = Some(Self::API_KEY_DEFAULT_ENV_NAME);
fn default_auth(_kind: AdapterKind) -> AuthData {
match Self::DEFAULT_API_KEY_ENV_NAME {
Some(env_name) => AuthData::from_env(env_name),
None => AuthData::None,
}
}
fn default_endpoint(_kind: AdapterKind) -> Endpoint {
const BASE_URL: &str = "https://api.openai.com/v1/";
Endpoint::from_static(BASE_URL)
}
async fn all_model_names(
kind: AdapterKind,
endpoint: Endpoint,
auth: AuthData,
web_client: &WebClient,
) -> Result<Vec<String>> {
OpenAIAdapter::list_model_names_for_end_target(kind, endpoint, auth, web_client).await
}
fn get_service_url(model: &ModelIden, service_type: ServiceType, endpoint: Endpoint) -> Result<String> {
Self::util_get_service_url(model, service_type, endpoint)
}
fn to_web_request_data(
target: ServiceTarget,
service_type: ServiceType,
chat_req: ChatRequest,
chat_options: ChatOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
let ServiceTarget { model, auth, endpoint } = target;
let (_, model_name) = model.model_name.namespace_and_name();
let adapter_kind = model.adapter_kind;
let protocol = OpenAiProtocol::Responses;
let prompt_cache_policy = if supports_openai_responses_prompt_cache_options(&endpoint) {
openai_prompt_cache_policy(adapter_kind, model_name, &chat_req, &chat_options, protocol)
} else {
None
};
let response_format_plan = response_format_plan(&chat_options);
let api_key = get_api_key(auth, &model)?;
let url = AdapterDispatcher::get_service_url(&model, service_type, endpoint)?;
let headers = Headers::from(("Authorization".to_string(), format!("Bearer {api_key}")));
let stream = matches!(service_type, ServiceType::ChatStream);
let (reasoning_effort, model_name): (Option<ReasoningEffort>, &str) =
if matches!(adapter_kind, AdapterKind::OpenAIResp) {
let (reasoning_effort, model_name) = chat_options
.reasoning_effort()
.cloned()
.map(|v| (Some(v), model_name))
.unwrap_or_else(|| ReasoningEffort::from_model_name(model_name));
(reasoning_effort, model_name)
} else {
(None, model_name)
};
let instructions = chat_req.system.clone();
let mut chat_req = chat_req;
chat_req.system = None;
let previous_response_id = chat_req.previous_response_id.clone();
let explicit_store = chat_req.store;
let OpenAIRespRequestParts {
input_items: messages,
tools,
} = Self::into_openai_request_parts(&model, chat_req, prompt_cache_policy.as_ref())?;
let store = explicit_store.unwrap_or(false);
if previous_response_id.is_some() && explicit_store != Some(true) {
tracing::warn!(
"previous_response_id is set but store is not explicitly true — \
stateful session requires store=true to work. Set `store: Some(true)` explicitly."
);
}
let mut payload = json!({
"store": store,
"model": model_name,
"stream": stream,
});
if let Some(policy) = prompt_cache_policy.as_ref() {
let mut prompt_cache_options = json!({"mode": "explicit"});
if let Some(ttl) = policy.ttl {
prompt_cache_options.x_insert("ttl", ttl)?;
}
payload.x_insert("prompt_cache_options", prompt_cache_options)?;
}
if let Some(instructions) = &instructions {
payload.x_insert("instructions", instructions.as_str())?;
}
if let Some(prev_id) = &previous_response_id {
payload.x_insert("previous_response_id", prev_id.as_str())?;
}
let capture_reasoning = chat_options.capture_reasoning_content() == Some(true);
let effort_keyword = reasoning_effort.and_then(|effort| match effort {
ReasoningEffort::Zero => Some("none"),
_ => effort.as_keyword(),
});
if effort_keyword.is_some() || capture_reasoning {
let mut reasoning_obj = json!({});
if let Some(keyword) = effort_keyword {
reasoning_obj
.x_insert("effort", keyword)
.map_err(|e| Error::Internal(format!("reasoning effort insert: {e}")))?;
}
if capture_reasoning {
reasoning_obj
.x_insert("summary", "detailed")
.map_err(|e| Error::Internal(format!("reasoning summary insert: {e}")))?;
}
payload.x_insert("reasoning", reasoning_obj)?;
}
if chat_options.capture_reasoning_content() == Some(true) {
payload.x_insert("include", json!(["reasoning.encrypted_content"]))?;
}
if let Some(tools) = tools {
payload.x_insert("/tools", tools)?;
}
if let Some(tool_choice) = openai_resp_tool_choice(chat_options.tool_choice()) {
payload.x_insert("tool_choice", tool_choice)?;
}
payload.x_insert("input", messages)?;
let response_format = match response_format_plan {
OpenAiResponseFormatPlan::None => None,
OpenAiResponseFormatPlan::JsonMode => Some(json!({"type": "json_object"})),
OpenAiResponseFormatPlan::JsonSchema { name, schema } => Some(json!({
"type": "json_schema",
"name": name,
"strict": true,
"schema": schema,
})),
};
let verbosity = chat_options.verbosity().and_then(|v| v.as_keyword());
if response_format.is_some() || verbosity.is_some() {
let mut value_map = Map::new();
if let Some(verbosity) = verbosity {
value_map.insert("verbosity".into(), verbosity.into());
}
if let Some(response_format) = response_format {
value_map.insert("format".into(), response_format);
}
payload.x_insert("text", value_map)?;
}
if let Some(temperature) = chat_options.temperature() {
payload.x_insert("temperature", temperature)?;
}
if !chat_options.stop_sequences().is_empty() {
payload.x_insert("stop", chat_options.stop_sequences())?;
}
if let Some(max_tokens) = chat_options.max_tokens() {
payload.x_insert("max_output_tokens", max_tokens)?;
}
if let Some(top_p) = chat_options.top_p() {
payload.x_insert("top_p", top_p)?;
}
if let Some(seed) = chat_options.seed() {
payload.x_insert("seed", seed)?;
}
if let Some(prompt_cache_key) = chat_options.prompt_cache_key() {
payload.x_insert("prompt_cache_key", prompt_cache_key)?;
}
if !is_gpt_5_6_or_later(model_name)
&& let Some(cache_control) = chat_options.cache_control()
{
let prompt_cache_retention = match cache_control {
CacheControl::Memory | CacheControl::Ephemeral => Some("in_memory"),
CacheControl::Ephemeral24h => Some("24h"),
CacheControl::Ephemeral5m | CacheControl::Ephemeral1h => None,
};
if let Some(prompt_cache_retention) = prompt_cache_retention {
payload.x_insert("prompt_cache_retention", prompt_cache_retention)?;
}
}
if let Some(extra_body) = chat_options.extra_body() {
payload.x_merge(extra_body.clone())?;
}
Ok(WebRequestData { url, headers, payload })
}
fn to_chat_response(
model_iden: ModelIden,
web_response: WebResponse,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatResponse> {
let WebResponse { body, .. } = web_response;
let captured_raw_body = options_set.capture_raw_body().unwrap_or_default().then(|| body.clone());
let resp: RespResponse = serde_json::from_value(body)?;
let provider_model_iden = model_iden.from_name(&resp.model);
let usage = resp.usage.map(Usage::from).unwrap_or_default();
let mut content: MessageContent = MessageContent::default();
let reasoning_content: Option<String> = None;
for output_item in resp.output {
let parts = ContentPart::from_resp_output_item(output_item)?;
content.extend(parts);
}
Ok(ChatResponse {
content,
reasoning_content,
model_iden,
provider_model_iden,
stop_reason: Some(StopReason::from(resp.status)),
usage,
captured_raw_body,
response_id: Some(resp.id),
})
}
fn to_chat_stream(
model_iden: ModelIden,
reqwest_builder: RequestBuilder,
options_sets: ChatOptionsSet<'_, '_>,
) -> Result<ChatStreamResponse> {
let event_source = EventSourceStream::new(reqwest_builder);
let openai_stream = OpenAIRespStreamer::new(event_source, model_iden.clone(), options_sets);
let chat_stream = ChatStream::from_inter_stream(openai_stream);
Ok(ChatStreamResponse {
model_iden,
stream: chat_stream,
})
}
fn to_embed_request_data(
_service_target: ServiceTarget,
_embed_req: crate::embed::EmbedRequest,
_options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
Err(crate::Error::AdapterNotSupported {
adapter_kind: crate::adapter::AdapterKind::OpenAIResp,
feature: "embeddings".to_string(),
})
}
fn to_embed_response(
_model_iden: ModelIden,
_web_response: WebResponse,
_options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<crate::embed::EmbedResponse> {
Err(crate::Error::AdapterNotSupported {
adapter_kind: crate::adapter::AdapterKind::OpenAIResp,
feature: "embeddings".to_string(),
})
}
}
impl OpenAIRespAdapter {
pub(in crate::adapter::adapters) fn util_get_service_url(
_model: &ModelIden,
service_type: ServiceType,
default_endpoint: Endpoint,
) -> Result<String> {
let base_url = default_endpoint.base_url();
let base_url = reqwest::Url::parse(base_url)
.map_err(|err| Error::Internal(format!("Cannot parse url: {base_url}. Cause:\n{err}")))?;
let original_query_params = base_url.query().to_owned();
let suffix = match service_type {
ServiceType::Chat | ServiceType::ChatStream => "responses",
ServiceType::Embed => "embeddings", };
let mut full_url = base_url.join(suffix).map_err(|err| {
Error::Internal(format!(
"Cannot joing url suffix '{suffix}' for base_url '{base_url}'. Cause:\n{err}"
))
})?;
full_url.set_query(original_query_params);
Ok(full_url.to_string())
}
fn into_openai_request_parts(
model_iden: &ModelIden,
chat_req: ChatRequest,
cache_policy: Option<&OpenAiPromptCachePolicy>,
) -> Result<OpenAIRespRequestParts> {
let mut input_items: Vec<Value> = Vec::new();
let custom_tool_names = chat_req
.tools
.as_ref()
.into_iter()
.flatten()
.filter(|tool| tool.custom_format.is_some())
.map(|tool| tool.name.as_str().to_string())
.collect::<BTreeSet<_>>();
let mut custom_call_ids = BTreeSet::new();
if let Some(system_msg) = chat_req.system {
input_items.push(json!({"role": "system", "content": system_msg}));
}
let mut unamed_file_count = 0;
for msg in chat_req.messages {
let cache_controlled = cache_policy.is_some()
&& msg
.options
.as_ref()
.and_then(|options| options.cache_control.as_ref())
.is_some();
match msg.role {
ChatRole::System => {
if let Some(content) = msg.content.into_joined_texts() {
if cache_controlled {
let mut values = vec![json!({"type": "input_text", "text": content})];
apply_resp_cache_breakpoint(model_iden, &mut values, "message")?;
input_items.push(json!({"role": "system", "content": values}));
} else {
input_items.push(json!({"role": "system", "content": content}))
}
}
}
ChatRole::User => {
if msg.content.is_text_only() && !cache_controlled {
let content = json!(msg.content.joined_texts().unwrap_or_else(String::new));
input_items.push(json! ({"role": "user", "content": content}));
} else {
let mut values: Vec<Value> = Vec::new();
for part in msg.content {
match part {
ContentPart::Text(content) => {
values.push(json!({"type": "input_text", "text": content}))
}
ContentPart::Binary(mut binary) => {
let is_image = binary.is_image();
if is_image {
let image_url = binary.into_url();
let input_image = json!({
"type": "input_image",
"detail": "auto",
"image_url": image_url
});
values.push(input_image);
}
else {
let mut input_file = Map::new();
input_file.insert("type".into(), "input_file".into());
if let Some(file_name) = binary.name.take() {
input_file.insert("filename".into(), file_name.into());
} else {
unamed_file_count += 1;
input_file
.insert("filename".into(), format!("file-{unamed_file_count}").into());
}
let file_url = binary.into_url();
if file_url.starts_with("data") {
input_file.insert("file_data".into(), file_url.into());
} else {
input_file.insert("file_url".into(), file_url.into());
}
let input_file: Value = input_file.into();
values.push(input_file);
}
}
ContentPart::ToolCall(_) => (),
ContentPart::ToolResponse(_) => (),
ContentPart::ThoughtSignature(_) => (),
ContentPart::ReasoningContent(_) => (),
ContentPart::Custom(_) => {}
}
}
if cache_controlled {
apply_resp_cache_breakpoint(model_iden, &mut values, "message")?;
}
input_items.push(json! ({"role": "user", "content": values}));
}
}
ChatRole::Assistant => {
let mut item_message_content: Vec<Value> = Vec::new();
for part in msg.content.iter() {
if let ContentPart::ThoughtSignature(blob) = part {
input_items.push(json!({
"type": "reasoning",
"encrypted_content": blob,
"summary": [],
}));
}
}
for part in msg.content.iter() {
if let ContentPart::ToolCall(tool_call) = part
&& let Some(sigs) = tool_call.thought_signatures.as_ref()
{
for blob in sigs {
input_items.push(json!({
"type": "reasoning",
"encrypted_content": blob,
"summary": [],
}));
}
}
}
for part in msg.content {
match part {
ContentPart::Text(text) => {
item_message_content.push(json!({
"type": "output_text",
"text": text
}));
}
ContentPart::ToolCall(tool_call) => {
if !item_message_content.is_empty() {
input_items.push(json!({
"type": "message",
"role": "assistant",
"content": item_message_content
}));
item_message_content = Vec::new();
}
if custom_tool_names.contains(&tool_call.fn_name) {
let input = tool_call
.fn_arguments
.as_str()
.map_or_else(|| tool_call.fn_arguments.to_string(), str::to_string);
custom_call_ids.insert(tool_call.call_id.clone());
input_items.push(json!({
"type": "custom_tool_call",
"call_id": tool_call.call_id,
"name": tool_call.fn_name,
"input": input,
}));
} else {
input_items.push(json!({
"type": "function_call",
"call_id": tool_call.call_id,
"name": tool_call.fn_name,
"arguments": tool_call.fn_arguments.to_string(),
}));
}
}
ContentPart::Binary(_) => {}
ContentPart::ToolResponse(_) => {}
ContentPart::ThoughtSignature(_) => {}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(_) => {}
}
}
if !item_message_content.is_empty() {
input_items.push(json!({
"type": "message",
"role": "assistant",
"content": item_message_content
}));
}
}
ChatRole::Tool => {
for part in msg.content {
if let ContentPart::ToolResponse(tool_response) = part {
let response_type = if custom_call_ids.contains(&tool_response.call_id) {
"custom_tool_call_output"
} else {
"function_call_output"
};
input_items.push(json!({
"type": response_type,
"call_id": tool_response.call_id,
"output": tool_response.content,
}));
}
}
}
}
}
let tools = chat_req
.tools
.map(|tools| tools.into_iter().map(Self::tool_to_openai_tool).collect::<Result<Vec<Value>>>())
.transpose()?;
Ok(OpenAIRespRequestParts { input_items, tools })
}
fn tool_to_openai_tool(tool: Tool) -> Result<Value> {
let Tool {
name,
description,
schema,
custom_format,
strict,
config,
..
} = tool;
let name = match name {
ToolName::WebSearch => "web_search".to_string(),
ToolName::Custom(name) => name,
};
let tool_value = if let Some(format) = custom_format {
json!({
"type": "custom",
"name": name,
"description": description,
"format": format,
})
} else {
match name.as_ref() {
"web_search" => {
let mut tool_value = json!({"type": "web_search"});
match config {
Some(ToolConfig::WebSearch(_ws_config)) => {
}
Some(ToolConfig::Custom(config_value)) => {
tool_value.x_merge(config_value)?;
}
None => (),
};
tool_value
}
name => {
let strict = strict.unwrap_or(false);
let parameters = tool_parameters_schema(schema, strict);
json!({
"type": "function",
"name": name,
"description": description,
"parameters": parameters,
"strict": strict,
})
}
}
};
Ok(tool_value)
}
}
struct OpenAIRespRequestParts {
input_items: Vec<Value>,
tools: Option<Vec<Value>>,
}
fn apply_resp_cache_breakpoint(_model_iden: &ModelIden, content: &mut [Value], _scope: &'static str) -> Result<()> {
let Some(content_block) = content.iter_mut().rev().find(|value| {
matches!(
value.get("type").and_then(Value::as_str),
Some("input_text" | "input_image" | "input_file")
)
}) else {
return Ok(());
};
content_block.x_insert("prompt_cache_breakpoint", json!({"mode": "explicit"}))?;
Ok(())
}
#[cfg(test)]
mod tests {
type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>;
use super::*;
use crate::adapter::AdapterKind;
use crate::chat::{ChatMessage, ChatOptions, JsonSpec, Tool, ToolCall, ToolChoice, ToolResponse};
#[test]
fn test_cache_control_without_eligible_content_does_not_fail_response_request() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let assistant_msg = ChatMessage::assistant(MessageContent::from_parts(vec![ContentPart::ToolCall(ToolCall {
call_id: "call_1".to_string(),
fn_name: "get_weather".to_string(),
fn_arguments: json!({}),
thought_signatures: None,
})]))
.with_options(CacheControl::Ephemeral);
let chat_req = ChatRequest::new(vec![ChatMessage::user("hello"), assistant_msg]);
let web_req =
OpenAIRespAdapter::to_web_request_data(target, ServiceType::Chat, chat_req, ChatOptionsSet::default())
.expect("unsupported breakpoint placement should be ignored");
assert_eq!(web_req.payload["prompt_cache_options"]["mode"], "explicit");
}
#[test]
fn custom_grammar_tool_and_roundtrip_use_responses_native_items() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let patch = "*** Begin Patch\n*** Update File: source.c\n@@\n-old\n+new\n*** End Patch\n";
let assistant = ChatMessage::assistant(vec![ToolCall {
call_id: "call_patch".to_string(),
fn_name: "apply_patch".to_string(),
fn_arguments: Value::String(patch.to_string()),
thought_signatures: None,
}]);
let response = ChatMessage::from(ToolResponse::new("call_patch", "Done!"));
let format = json!({
"type": "grammar",
"syntax": "lark",
"definition": "start: PATCH",
});
let request = ChatRequest::new(vec![ChatMessage::user("patch it"), assistant, response]).with_tools(vec![
Tool::new("apply_patch")
.with_description("Apply a patch")
.with_custom_format(format.clone()),
]);
let web_req =
OpenAIRespAdapter::to_web_request_data(target, ServiceType::Chat, request, ChatOptionsSet::default())
.unwrap();
assert_eq!(
web_req.payload["tools"][0],
json!({
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch",
"format": format,
})
);
let input = web_req.payload["input"].as_array().unwrap();
assert!(input.iter().any(|item| {
item["type"] == "custom_tool_call" && item["call_id"] == "call_patch" && item["input"] == patch
}));
assert!(input.iter().any(|item| {
item["type"] == "custom_tool_call_output" && item["call_id"] == "call_patch" && item["output"] == "Done!"
}));
}
#[test]
fn test_extra_body_merged_into_response_payload() {
let chat_options = ChatOptions::default()
.with_top_p(0.3)
.with_extra_body(json!({"top_p": 0.9, "metadata": {"source": "test"}}));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&chat_options));
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5-mini"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello"),
options_set,
)
.expect("to_web_request_data should succeed");
assert_eq!(web_req.payload["top_p"], 0.9);
assert_eq!(web_req.payload["metadata"]["source"], "test");
}
#[test]
fn pydantic_union_schema_is_sanitized_for_responses() {
let schema = json!({
"type": "object",
"properties": {
"animal": {
"discriminator": {"propertyName": "kind"},
"oneOf": [{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}]
}
},
"$defs": {
"Cat": {
"type": "object",
"properties": {"kind": {"const": "cat"}},
"required": ["kind"]
},
"Dog": {
"type": "object",
"properties": {"kind": {"const": "dog"}},
"required": ["kind"]
}
}
});
let options = ChatOptions::default().with_response_format(JsonSpec::new("union", schema));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&options));
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5-mini"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("return an animal"),
options_set,
)
.unwrap();
let animal = &web_req.payload["text"]["format"]["schema"]["properties"]["animal"];
assert!(animal.get("oneOf").is_none());
assert_eq!(animal["discriminator"], json!({"propertyName": "kind"}));
assert_eq!(
animal["anyOf"],
json!([{"$ref": "#/$defs/Cat"}, {"$ref": "#/$defs/Dog"}])
);
}
#[test]
fn dynamic_map_schema_is_sent_to_backend_for_validation() {
let schema = json!({
"type": "object",
"properties": {
"lookup": {"type": "object", "additionalProperties": {"type": "integer"}}
}
});
let options = ChatOptions::default().with_response_format(JsonSpec::new("mapping", schema));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&options));
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5-mini"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("return a mapping"),
options_set,
)
.unwrap();
assert_eq!(
web_req.payload["text"]["format"]["schema"]["properties"]["lookup"]["additionalProperties"],
json!({"type": "integer"})
);
}
#[test]
fn test_tool_choice_specific_tool_serialized_on_response_payload() {
let chat_options = ChatOptions::default().with_tool_choice(ToolChoice::tool("get_weather"));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&chat_options));
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5-mini"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let chat_req = ChatRequest::from_user("weather").with_tools(vec![Tool::new("get_weather")]);
let web_req = OpenAIRespAdapter::to_web_request_data(target, ServiceType::Chat, chat_req, options_set)
.expect("to_web_request_data should succeed");
assert_eq!(
web_req.payload["tool_choice"],
json!({
"type": "function",
"name": "get_weather"
})
);
}
#[test]
fn test_assistant_message_uses_output_text_content_type() {
let model_iden = ModelIden::new(AdapterKind::OpenAIResp, "gpt-5-codex");
let chat_req = ChatRequest::default()
.with_system("You are a helpful assistant.")
.append_message(ChatMessage::user("What's the weather?"))
.append_message(ChatMessage::assistant("The weather is sunny."));
let parts = OpenAIRespAdapter::into_openai_request_parts(&model_iden, chat_req, None)
.expect("Should serialize successfully");
let assistant_msg = parts
.input_items
.iter()
.find(|item| {
item.get("type").and_then(|t| t.as_str()) == Some("message")
&& item.get("role").and_then(|r| r.as_str()) == Some("assistant")
})
.expect("Should have an assistant message");
let content = assistant_msg
.get("content")
.and_then(|c| c.as_array())
.expect("Assistant message should have content array");
assert!(!content.is_empty(), "Content should not be empty");
let first_content = &content[0];
let content_type = first_content
.get("type")
.and_then(|t| t.as_str())
.expect("Content should have a type");
assert_eq!(
content_type, "output_text",
"Assistant message content should use 'output_text' type, not 'input_text'"
);
}
#[test]
fn test_gpt_5_6_responses_defaults_to_explicit_cache_mode() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello"),
ChatOptionsSet::default(),
)
.expect("to_web_request_data should succeed");
assert_eq!(web_req.payload["prompt_cache_options"]["mode"], "explicit");
assert!(web_req.payload["prompt_cache_options"].get("ttl").is_none());
assert!(
web_req.payload["input"][0]["content"][0]
.get("prompt_cache_breakpoint")
.is_none()
);
}
#[test]
fn test_gpt_5_6_codex_responses_endpoint_omits_prompt_cache_options() -> Result<()> {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: Endpoint::from_static("https://chatgpt.com/backend-api/codex/"),
};
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello"),
ChatOptionsSet::default(),
)?;
assert!(web_req.payload.get("prompt_cache_options").is_none());
Ok(())
}
#[test]
fn test_gpt_5_6_responses_cache_key_uses_api_default_cache_mode() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6-mini"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let chat_options = ChatOptions::default().with_prompt_cache_key("stable-key");
let options_set = ChatOptionsSet::default().with_chat_options(Some(&chat_options));
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello"),
options_set,
)
.expect("to_web_request_data should succeed");
assert!(web_req.payload.get("prompt_cache_options").is_none());
assert!(
web_req.payload["input"][0]["content"][0]
.get("prompt_cache_breakpoint")
.is_none()
);
}
#[test]
fn test_gpt_5_6_responses_places_breakpoint_on_last_eligible_content_block() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let chat_req = ChatRequest::new(vec![
ChatMessage::user(vec![
ContentPart::from_text("stable text"),
ContentPart::from_binary_url("image/png", "https://example.com/image.png", None),
ContentPart::from_text("last text"),
])
.with_options(CacheControl::Ephemeral),
]);
let web_req =
OpenAIRespAdapter::to_web_request_data(target, ServiceType::Chat, chat_req, ChatOptionsSet::default())
.expect("to_web_request_data should succeed");
let blocks = web_req.payload["input"][0]["content"]
.as_array()
.expect("message content should be an array");
assert!(blocks[0].get("prompt_cache_breakpoint").is_none());
assert!(blocks[1].get("prompt_cache_breakpoint").is_none());
assert_eq!(blocks[2]["prompt_cache_breakpoint"]["mode"], "explicit");
}
#[test]
fn test_gpt_5_6_responses_ignores_tool_cache_control() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.6"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let chat_req = ChatRequest::from_user("hello")
.append_tool(Tool::new("get_weather").with_cache_control(CacheControl::Ephemeral));
let web_req =
OpenAIRespAdapter::to_web_request_data(target, ServiceType::Chat, chat_req, ChatOptionsSet::default())
.expect("tool cache control should be ignored");
assert_eq!(web_req.payload["prompt_cache_options"]["mode"], "explicit");
assert!(web_req.payload["tools"][0].get("prompt_cache_breakpoint").is_none());
}
#[test]
fn test_gpt_5_5_responses_keeps_legacy_cache_retention() {
let target = ServiceTarget {
model: ModelIden::new(AdapterKind::OpenAIResp, "gpt-5.5"),
auth: AuthData::from_single("test-key"),
endpoint: OpenAIRespAdapter::default_endpoint(AdapterKind::OpenAIResp),
};
let chat_options = ChatOptions::default().with_cache_control(CacheControl::Ephemeral24h);
let options_set = ChatOptionsSet::default().with_chat_options(Some(&chat_options));
let web_req = OpenAIRespAdapter::to_web_request_data(
target,
ServiceType::Chat,
ChatRequest::from_user("hello"),
options_set,
)
.expect("to_web_request_data should succeed");
assert_eq!(web_req.payload["prompt_cache_retention"], "24h");
assert!(web_req.payload.get("prompt_cache_options").is_none());
}
}