use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::core::Secret;
#[cfg(test)]
use super::ModelCall;
use super::strict_schema_problem;
use super::wire::{RESPOND_TOOL, classify_status, classify_transport, structured};
use super::{
Completion, ModelError, ModelId, ModelProvider, Request, SchemaMode, Usage, openai_stream, sse,
};
pub struct OpenAi {
http: reqwest::Client,
key: Secret,
base: String,
retain_responses: bool,
default_schema_mode: SchemaMode,
schema_modes: std::collections::BTreeMap<String, SchemaMode>,
stream: bool,
egress: Option<crate::core::Egress>,
timeout: std::time::Duration,
}
impl std::fmt::Debug for OpenAi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAi")
.field("base", &self.base)
.field("key", &"<redacted>")
.finish_non_exhaustive()
}
}
impl OpenAi {
pub const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(5);
pub fn new(key: impl Into<String>) -> Result<Self, ModelError> {
let http = crate::netguard::guarded_client(crate::netguard::Reach::Configured)
.build()
.map_err(|e| ModelError::Unreachable {
model: ModelId::new("openai", "*"),
detail: format!("could not build an HTTP client: {e}"),
})?;
Ok(Self {
http,
key: Secret::new(key),
base: "https://api.openai.com".to_owned(),
retain_responses: false,
default_schema_mode: SchemaMode::Native,
schema_modes: std::collections::BTreeMap::new(),
stream: true,
egress: None,
timeout: Self::DEFAULT_TIMEOUT,
})
}
#[must_use]
pub const fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn base(mut self, base: impl Into<String>) -> Self {
self.base = base.into();
self
}
#[must_use]
pub const fn retain_responses(mut self) -> Self {
self.retain_responses = true;
self
}
#[must_use]
pub fn structured_via(mut self, mode: SchemaMode) -> Self {
self.default_schema_mode = mode;
self
}
#[must_use]
pub fn structured_via_for(mut self, model: impl Into<String>, mode: SchemaMode) -> Self {
self.schema_modes.insert(model.into(), mode);
self
}
#[must_use]
pub fn egress(mut self, egress: crate::core::Egress) -> Self {
self.egress = Some(egress);
self
}
#[must_use]
pub const fn buffered(mut self) -> Self {
self.stream = false;
self
}
fn check_egress(&self, model: &ModelId) -> Result<(), ModelError> {
let Some(egress) = &self.egress else {
return Ok(());
};
let host = reqwest::Url::parse(&self.base)
.ok()
.and_then(|u| u.host_str().map(ToOwned::to_owned));
egress
.permits(host.as_deref())
.map_err(|e| ModelError::Refused {
model: model.clone(),
detail: e.to_string(),
})
}
fn mode_for(&self, model: &ModelId) -> SchemaMode {
self.schema_modes
.get(&model.model)
.copied()
.unwrap_or(self.default_schema_mode)
}
fn apply_schema(
body: &mut Value,
schema: &Value,
model: &ModelId,
mode: SchemaMode,
) -> Result<(), ModelError> {
if let Some(problem) = strict_schema_problem(schema) {
return Err(ModelError::Refused {
model: model.clone(),
detail: format!(
"the schema cannot be used with strict constrained decoding: {problem}"
),
});
}
match mode {
SchemaMode::Native => {
body["text"] = json!({
"format": {
"type": "json_schema",
"name": RESPOND_TOOL,
"strict": true,
"schema": schema,
}
});
}
SchemaMode::ForcedTool => {
body["tools"] = json!([{
"type": "function",
"name": RESPOND_TOOL,
"description": "Return the answer in the required shape.",
"strict": true,
"parameters": schema,
}]);
body["tool_choice"] = json!({ "type": "function", "name": RESPOND_TOOL });
}
}
Ok(())
}
}
#[derive(Debug, Default, Deserialize)]
struct TokenDetails {
#[serde(default)]
reasoning_tokens: u64,
}
#[derive(Debug, Default, Deserialize)]
struct InputDetails {
#[serde(default)]
cached_tokens: u64,
}
#[derive(Debug, Deserialize)]
struct ApiUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
input_tokens_details: Option<InputDetails>,
#[serde(default)]
output_tokens_details: Option<TokenDetails>,
}
#[derive(Debug, Deserialize)]
struct Incomplete {
#[serde(default)]
reason: String,
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
#[serde(default)]
status: String,
#[serde(default)]
output: Vec<Value>,
#[serde(default)]
usage: Option<ApiUsage>,
#[serde(default)]
incomplete_details: Option<Incomplete>,
#[serde(default)]
error: Option<Value>,
}
impl ApiResponse {
fn usage(&self) -> Usage {
let u = self.usage.as_ref();
Usage {
input_tokens: u.map_or(0, |u| u.input_tokens),
output_tokens: u.map_or(0, |u| u.output_tokens),
cache_write_tokens: 0,
cache_read_tokens: u
.and_then(|u| u.input_tokens_details.as_ref())
.map_or(0, |d| d.cached_tokens),
minor_units: 0,
}
}
fn reasoning_tokens(&self) -> u64 {
self.usage
.as_ref()
.and_then(|u| u.output_tokens_details.as_ref())
.map_or(0, |d| d.reasoning_tokens)
}
fn text(&self) -> String {
self.output
.iter()
.filter(|item| item.get("phase").and_then(Value::as_str) != Some("commentary"))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|part| part.get("type").and_then(Value::as_str) == Some("output_text"))
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
}
fn forced_tool_arguments(&self) -> Option<&str> {
self.output
.iter()
.find(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call")
&& item.get("name").and_then(Value::as_str) == Some(RESPOND_TOOL)
})
.and_then(|item| item.get("arguments").and_then(Value::as_str))
}
fn tool_calls(&self) -> Result<Vec<super::ToolCall>, String> {
self.output
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
.filter(|item| item.get("name").and_then(Value::as_str) != Some(RESPOND_TOOL))
.map(|item| {
let id = item
.get("call_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| "a function call carried no call_id".to_owned())?;
let name = item
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("function call '{id}' carried no name"))?;
let raw = item
.get("arguments")
.and_then(Value::as_str)
.ok_or_else(|| {
format!("function call '{id}' for '{name}' carried no arguments")
})?;
let arguments = serde_json::from_str(raw).map_err(|error| {
format!(
"function call '{id}' for '{name}' carried malformed JSON arguments: {error}"
)
})?;
Ok(super::ToolCall {
id,
name,
arguments,
})
})
.collect()
}
fn refusal(&self) -> Option<&str> {
self.output
.iter()
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.find(|part| part.get("type").and_then(Value::as_str) == Some("refusal"))
.and_then(|part| part.get("refusal").and_then(Value::as_str))
}
fn continuation(&self) -> Value {
Value::Array(self.output.clone())
}
}
fn input(prompt: &Value) -> Value {
match prompt {
Value::String(s) => json!(s),
Value::Array(_) => prompt.clone(),
other => crate::model::prompt_envelope(other, "input")
.cloned()
.unwrap_or_else(|| {
let mut rest = other.clone();
if let Some(map) = rest.as_object_mut() {
map.remove("system");
}
json!(rest.to_string())
}),
}
}
fn continue_with(
input: Value,
exchanges: &[super::ToolExchange],
continuation: Option<&super::ProviderContinuation>,
) -> Value {
if exchanges.is_empty() {
return input;
}
let mut out = match input {
Value::Array(v) => v,
other => vec![json!({ "role": "user", "content": other })],
};
if let Some(state) = continuation.and_then(|state| state.state.as_array()) {
out.extend(state.iter().cloned());
} else {
out.extend(exchanges.iter().map(|e| {
json!({
"type": "function_call",
"call_id": e.call.id,
"name": e.call.name,
"arguments": e.call.arguments.to_string(),
})
}));
}
for e in exchanges {
out.push(json!({
"type": "function_call_output",
"call_id": e.call.id,
"output": match &e.output {
Value::String(s) => s.clone(),
other => other.to_string(),
},
}));
}
Value::Array(out)
}
fn function_outputs(exchanges: &[super::ToolExchange]) -> impl Iterator<Item = Value> + '_ {
exchanges.iter().map(|exchange| {
json!({
"type": "function_call_output",
"call_id": exchange.call.id,
"output": match &exchange.output {
Value::String(value) => value.clone(),
other => other.to_string(),
},
})
})
}
fn accumulate_continuation(
completion: &mut Completion,
prior: Option<&super::ProviderContinuation>,
exchanges: &[super::ToolExchange],
) {
let Some(current) = completion.continuation.as_mut() else {
return;
};
let mut state = prior
.and_then(|value| value.state.as_array())
.cloned()
.unwrap_or_default();
state.extend(function_outputs(exchanges));
if let Some(items) = current.state.as_array() {
state.extend(items.iter().cloned());
}
current.state = Value::Array(state);
}
fn instructions(prompt: &Value) -> Option<Value> {
prompt.get("system").cloned().filter(|s| !s.is_null())
}
impl OpenAi {
#[cfg(test)]
fn body(
&self,
model: &ModelId,
prompt: &Value,
schema: Option<&Value>,
tools: &[super::ToolDeclaration],
exchanges: &[super::ToolExchange],
) -> Result<Value, ModelError> {
self.body_with_max(
model,
prompt,
ModelCall::DEFAULT_MAX_OUTPUT_TOKENS,
None,
schema,
tools,
exchanges,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn body_with_max(
&self,
model: &ModelId,
prompt: &Value,
max_output_tokens: u32,
reasoning_effort: Option<super::ReasoningEffort>,
schema: Option<&Value>,
tools: &[super::ToolDeclaration],
exchanges: &[super::ToolExchange],
continuation: Option<&super::ProviderContinuation>,
) -> Result<Value, ModelError> {
if let Some(state) = continuation
&& (state.provider != "openai" || !state.state.is_array())
{
return Err(ModelError::Refused {
model: model.clone(),
detail: "the continuation was not an OpenAI output-item array".to_owned(),
});
}
if reasoning_effort.is_some() && !exchanges.is_empty() && continuation.is_none() {
return Err(ModelError::Refused {
model: model.clone(),
detail: "reasoning-enabled tool continuation requires the complete opaque \
output items from the prior OpenAI response"
.to_owned(),
});
}
super::refuse_dangling_continuation(continuation, exchanges, model)?;
let mut body = json!({
"model": model.model,
"max_output_tokens": max_output_tokens,
"input": continue_with(input(prompt), exchanges, continuation),
"store": self.retain_responses,
});
if !self.retain_responses {
body["include"] = json!(["reasoning.encrypted_content"]);
}
if let Some(effort) = reasoning_effort {
body["reasoning"] = json!({ "effort": effort.as_str() });
}
if let Some(system) = instructions(prompt) {
body["instructions"] = system;
}
if let Some(schema) = schema {
if self.mode_for(model) == SchemaMode::ForcedTool && !tools.is_empty() {
return Err(ModelError::Refused {
model: model.clone(),
detail: format!(
"model '{}' has no native structured output here, so a declared \
response schema is obtained by forcing a synthetic tool — which \
cannot be combined with the {} tool(s) this request declares. \
Use a model with native structured output, or drop the schema \
and validate the answer yourself",
model.model,
tools.len()
),
});
}
Self::apply_schema(&mut body, schema, model, self.mode_for(model))?;
}
if self.stream {
body["stream"] = json!(true);
}
if !tools.is_empty() {
body["tools"] = Value::Array(
tools
.iter()
.map(|t| {
let strict = strict_schema_problem(&t.parameters).is_none();
json!({
"type": "function",
"name": t.name,
"description": t.description,
"parameters": t.parameters,
"strict": strict,
})
})
.collect(),
);
}
Ok(body)
}
fn interpret(
&self,
parsed: &ApiResponse,
model: &ModelId,
schema: Option<&Value>,
) -> Result<Completion, ModelError> {
let usage = parsed.usage();
if let Some(why) = parsed.refusal() {
return Err(ModelError::Unusable {
model: model.clone(),
usage,
detail: format!("the model declined to answer: {why}"),
});
}
if parsed.status == "failed" {
return Err(ModelError::Unusable {
model: model.clone(),
usage,
detail: parsed
.error
.as_ref()
.map_or_else(|| "the response failed".to_owned(), ToString::to_string),
});
}
let text = parsed.text();
let truncated = parsed.status == "incomplete";
let emulating = schema.is_some() && self.mode_for(model) == SchemaMode::ForcedTool;
let calls = parsed.tool_calls().map_err(|detail| ModelError::Unusable {
model: model.clone(),
usage,
detail,
})?;
if text.is_empty() && calls.is_empty() && !truncated && !emulating {
return Err(ModelError::Unusable {
model: model.clone(),
usage,
detail: format!(
"the answer carried no text content (status '{}', {} reasoning token(s))",
parsed.status,
parsed.reasoning_tokens()
),
});
}
let (text, structured_value) = if emulating {
let Some(raw) = parsed.forced_tool_arguments() else {
return Err(ModelError::Unusable {
model: model.clone(),
usage,
detail: "a tool call was forced and the answer carried none — \
the model did not honour `tool_choice`"
.to_owned(),
});
};
(raw.to_owned(), structured(schema, raw, &[], model, usage)?)
} else {
let parsed_schema = structured(schema, &text, &calls, model, usage)?;
(text, parsed_schema)
};
let continuation = (!calls.is_empty())
.then(|| super::ProviderContinuation::new("openai", parsed.continuation()));
Ok(Completion {
structured: structured_value,
tool_calls: calls,
text,
usage,
stop_reason: Some(parsed.incomplete_details.as_ref().map_or_else(
|| parsed.status.clone(),
|i| format!("incomplete:{}", i.reason),
)),
truncated,
continuation,
})
}
async fn read_buffered(
&self,
response: reqwest::Response,
model: &ModelId,
schema: Option<&Value>,
) -> Result<Completion, ModelError> {
let body = crate::netguard::intake::read(response, crate::netguard::intake::ANSWER)
.await
.map_err(|e| super::wire::classify_intake(model, Usage::default(), &e))?;
let parsed: ApiResponse =
serde_json::from_slice(&body).map_err(|e| ModelError::Unusable {
model: model.clone(),
usage: Usage::default(),
detail: format!("the response body did not parse: {e}"),
})?;
self.interpret(&parsed, model, schema)
}
async fn read_streamed(
&self,
response: reqwest::Response,
model: &ModelId,
schema: Option<&Value>,
observer: Option<(&dyn super::ModelStreamObserver, &crate::core::Label)>,
) -> Result<Completion, ModelError> {
use futures_util::StreamExt;
let mut decoder = sse::Decoder::new();
let mut acc = openai_stream::Accumulator::new();
let mut body = response.bytes_stream();
let mut meter = crate::netguard::intake::Meter::new(crate::netguard::intake::ANSWER);
while let Some(chunk) = body.next().await {
let chunk = match chunk {
Ok(chunk) => chunk,
Err(e) => return Err(severed(model, &acc, &e.to_string())),
};
if let Err(e) = meter.charge(chunk.len()) {
return Err(super::wire::classify_intake(model, Usage::default(), &e));
}
let events = match decoder.push(&chunk) {
Ok(events) => events,
Err(error) => return Err(severed(model, &acc, &error.to_string())),
};
for event in events {
let delta = acc.event(&event.name, &event.data);
if let Some(text) = delta
&& let Some((observer, label)) = observer
{
observer.event(crate::core::Tainted::with_label(
super::ModelStreamEvent::TextDelta(text),
label.clone(),
));
}
}
if let Some(message) = acc.error() {
return Err(severed(model, &acc, message));
}
if acc.outcome().is_some() {
break;
}
}
let Some(terminal) = acc.terminal() else {
return Err(severed(
model,
&acc,
"the stream ended before a terminal `response.*` event",
));
};
let parsed: ApiResponse =
serde_json::from_value(terminal.clone()).map_err(|e| ModelError::Unusable {
model: model.clone(),
usage: Usage::default(),
detail: format!("the terminal stream event did not parse: {e}"),
})?;
let completion = self.interpret(&parsed, model, schema)?;
if let Some((observer, label)) = observer {
observer.event(crate::core::Tainted::with_label(
super::ModelStreamEvent::Usage(completion.usage),
label.clone(),
));
}
Ok(completion)
}
}
fn severed(model: &ModelId, acc: &openai_stream::Accumulator, detail: &str) -> ModelError {
if acc.generated() {
return ModelError::Unaccounted {
model: model.clone(),
detail: match acc.id() {
Some(id) => format!("{detail} (provider response id: '{id}')"),
None => detail.to_owned(),
},
};
}
ModelError::Unavailable {
model: model.clone(),
detail: format!("the stream ended before it generated: {detail}"),
}
}
#[async_trait]
impl ModelProvider for OpenAi {
fn request_profile(&self, model: &ModelId) -> Value {
let schema_mode = match self.mode_for(model) {
SchemaMode::Native => "native",
SchemaMode::ForcedTool => "forced-tool",
};
json!({
"driver": "openai-responses/v1",
"base": self.base,
"store": self.retain_responses,
"schema_mode": schema_mode,
"stream": self.stream,
"timeout_ms": self.timeout.as_millis(),
})
}
async fn complete(&self, request: Request<'_>) -> Result<Completion, ModelError> {
let Request {
model,
prompt,
max_output_tokens,
reasoning_effort,
schema,
tools,
exchanges,
continuation,
stream,
} = request;
super::refuse_provider_side_media(prompt, model)?;
super::refuse_in_thread_instructions(prompt, model)?;
self.check_egress(model)?;
let response = self
.http
.post(format!("{}/v1/responses", self.base))
.timeout(self.timeout)
.bearer_auth(self.key.expose())
.json(&self.body_with_max(
model,
prompt,
max_output_tokens,
reasoning_effort,
schema,
tools,
exchanges,
continuation,
)?)
.send()
.await
.map_err(|e| classify_transport(model, &e))?;
let status = response.status();
if !status.is_success() {
let headers = response.headers().clone();
let detail =
crate::netguard::intake::read_text(response, crate::netguard::intake::METADATA)
.await
.unwrap_or_default();
return Err(classify_status(model, status.as_u16(), &headers, &detail));
}
let mut completion = if self.stream {
self.read_streamed(response, model, schema, stream).await?
} else {
self.read_buffered(response, model, schema).await?
};
if !self.stream
&& let Some((observer, label)) = stream
{
observer.event(crate::core::Tainted::with_label(
super::ModelStreamEvent::Usage(completion.usage),
label.clone(),
));
}
accumulate_continuation(&mut completion, continuation, exchanges);
Ok(completion)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Effect as _;
fn driver() -> OpenAi {
OpenAi::new("test-key").expect("build the driver")
}
#[test]
fn provider_wire_profile_is_part_of_effect_identity() {
let model = ModelId::new("openai", "gpt-x");
let native = ModelCall::new(
std::sync::Arc::new(driver()),
model.clone(),
json!("answer"),
)
.expecting(json!({"type": "object"}));
let forced = ModelCall::new(
std::sync::Arc::new(driver().structured_via(SchemaMode::ForcedTool)),
model,
json!("answer"),
)
.expecting(json!({"type": "object"}));
assert_ne!(
native.descriptor(),
forced.descriptor(),
"native constrained output and forced-tool output reused one effect identity"
);
}
#[test]
fn provider_retention_is_private_by_default_and_replay_visible_when_enabled() {
let model = ModelId::new("openai", "gpt-x");
let private = driver()
.body(&model, &json!("sensitive"), None, &[], &[])
.expect("private body");
assert_eq!(
private["store"],
json!(false),
"omitting `store: false` opts into OpenAI's provider-side retention default"
);
assert_eq!(
private["include"],
json!(["reasoning.encrypted_content"]),
"an unstored request must ask for the encrypted reasoning payload"
);
let retained_driver = driver().retain_responses();
let retained = retained_driver
.body(&model, &json!("sensitive"), None, &[], &[])
.expect("retained body");
assert_eq!(retained["store"], json!(true));
assert!(
retained.get("include").is_none(),
"a retained response resolves reasoning ids provider-side"
);
let private_call = ModelCall::new(
std::sync::Arc::new(driver()),
model.clone(),
json!("sensitive"),
);
let retained_call = ModelCall::new(
std::sync::Arc::new(retained_driver),
model,
json!("sensitive"),
);
assert_ne!(
private_call.descriptor(),
retained_call.descriptor(),
"provider retention changed without changing effect identity"
);
}
#[test]
fn a_system_instruction_becomes_the_instructions_field() {
let body = driver()
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "system": "answer only in French", "input": "hi" }),
None,
&[],
&[],
)
.expect("body");
assert_eq!(
body["instructions"], "answer only in French",
"the system instruction must become `instructions`: {body}"
);
assert_eq!(body["input"], "hi", "the question must survive: {body}");
}
#[test]
fn a_prompt_carrying_more_than_the_envelope_is_asked_whole() {
let body = driver()
.body(
&ModelId::new("openai", "gpt-x"),
&json!({
"system": "plan it",
"input": { "customer": "AC-1" },
"tools": [{ "tool": "crm__lookup" }],
}),
None,
&[],
&[],
)
.expect("body");
let asked = body["input"].as_str().expect("a question, not an envelope");
assert!(
asked.contains("AC-1") && asked.contains("crm__lookup"),
"a field of the question was dropped on the way to the wire: {asked}"
);
assert!(
!asked.contains("plan it"),
"the instruction leaked into the question: {asked}"
);
}
#[test]
fn a_system_instruction_is_not_shown_as_the_question() {
let body = driver()
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "system": "be terse", "ticket": "printer on fire" }),
None,
&[],
&[],
)
.expect("body");
let asked = body["input"].as_str().unwrap_or_default();
assert!(
!asked.contains("be terse"),
"the instruction leaked into the question: {asked}"
);
assert!(
asked.contains("printer on fire"),
"the actual content went missing: {asked}"
);
}
#[test]
fn a_prompt_without_a_system_sends_no_instructions() {
let body = driver()
.body(
&ModelId::new("openai", "gpt-x"),
&json!("hi"),
None,
&[],
&[],
)
.expect("body");
assert!(
body.get("instructions").is_none(),
"an unset instruction must not become an empty one: {body}"
);
}
}
#[cfg(test)]
mod tool_tests {
use super::*;
use crate::model::ToolDeclaration;
#[test]
fn a_tool_call_with_no_text_is_a_usable_answer() {
let response: ApiResponse = serde_json::from_value(json!({
"status": "completed",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "weather_lookup",
"arguments": "{\"city\":\"Berlin\"}"
}],
"usage": { "input_tokens": 55, "output_tokens": 15 }
}))
.expect("a Responses payload carrying only a function call");
let completion = OpenAi::new("test-key")
.expect("driver")
.interpret(&response, &ModelId::new("openai", "gpt-x"), None)
.expect(
"a tool call with no text was rejected as an empty answer, so \
every declared-tool loop against OpenAI fails on a response \
that worked",
);
assert_eq!(completion.tool_calls.len(), 1);
assert_eq!(completion.tool_calls[0].name, "weather_lookup");
assert_eq!(completion.tool_calls[0].arguments["city"], "Berlin");
}
#[test]
fn an_answer_with_neither_text_nor_a_tool_call_is_unusable() {
let response: ApiResponse = serde_json::from_value(json!({
"status": "completed",
"output": [],
"usage": { "input_tokens": 10, "output_tokens": 0 }
}))
.expect("an empty Responses payload");
assert!(
OpenAi::new("test-key")
.expect("driver")
.interpret(&response, &ModelId::new("openai", "gpt-x"), None)
.is_err(),
"an answer carrying nothing at all was accepted, so the emptiness \
check was removed rather than corrected"
);
}
#[test]
fn malformed_tool_arguments_are_a_metered_provider_failure() {
let response: ApiResponse = serde_json::from_value(json!({
"status": "completed",
"output": [{
"type": "function_call",
"call_id": "call_1",
"name": "ledger.transfer",
"arguments": "{\"amount\":"
}],
"usage": { "input_tokens": 55, "output_tokens": 15 }
}))
.expect("response");
let error = OpenAi::new("test-key")
.expect("driver")
.interpret(&response, &ModelId::new("openai", "gpt-x"), None)
.expect_err("malformed arguments disappeared as if no call was emitted");
match error {
ModelError::Unusable { usage, detail, .. } => {
assert_eq!(usage.input_tokens, 55);
assert_eq!(usage.output_tokens, 15);
assert!(detail.contains("malformed JSON arguments"), "{detail}");
}
other => panic!("malformed generated output was not a metered failure: {other:?}"),
}
}
#[test]
fn a_declared_tool_is_rendered_in_openais_shape() {
let body = OpenAi::new("test-key")
.expect("driver")
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "input": "hi" }),
None,
&[ToolDeclaration::new(
"ledger.read",
"Read a ledger entry.",
json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false,
}),
)],
&[],
)
.expect("a body with tools");
let f = &body["tools"][0];
assert_eq!(f["type"], "function", "{body}");
assert_eq!(
f["name"], "ledger.read",
"the name must be at the top level — Responses answers `Missing \
required parameter: tools[0].name` to the nested Chat Completions \
shape, and the call never reaches a model: {body}"
);
assert!(
f["function"].is_null(),
"the declaration is nested under `function`, which is the Chat \
Completions shape and is rejected by Responses: {body}"
);
assert_eq!(
f["parameters"]["type"], "object",
"OpenAI names the argument schema `parameters`; `input_schema` is \
Anthropic's spelling: {body}"
);
assert_eq!(
f["strict"], true,
"strict mode enforces the argument schema during generation rather \
than checking after the tokens are paid for: {body}"
);
let optional = OpenAi::new("test-key")
.expect("driver")
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "input": "hi" }),
None,
&[ToolDeclaration::new(
"ledger.search",
"Search ledger entries.",
json!({
"type": "object",
"properties": { "cursor": { "type": "string" } },
"required": [],
"additionalProperties": false,
}),
)],
&[],
)
.expect("a valid non-strict tool schema remains usable");
assert_eq!(
optional["tools"][0]["strict"], false,
"an optional field was advertised as strict even though OpenAI rejects that schema subset"
);
let forced = OpenAi::new("test-key")
.expect("driver")
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "input": "hi" }),
Some(&json!({ "type": "object", "additionalProperties": false })),
&[],
&[],
)
.expect("a body with a schema");
if let Some(tool) = forced["tools"].get(0) {
assert!(
tool["name"].is_string(),
"the two tool-rendering paths in this file disagree about where \
the name goes: {forced}"
);
}
}
}
#[cfg(test)]
mod continuation_tests {
use super::*;
use crate::model::{
ProviderContinuation, ReasoningEffort, ToolCall as ModelToolCall, ToolExchange,
};
#[test]
fn a_continuation_pairs_the_call_with_its_output() {
let body = OpenAi::new("test-key")
.expect("driver")
.body(
&ModelId::new("openai", "gpt-x"),
&json!({ "input": "balance?" }),
None,
&[],
&[ToolExchange::ok(
ModelToolCall {
id: "call_01".to_owned(),
name: "ledger.read".to_owned(),
arguments: json!({ "id": "AC-1" }),
},
json!({ "balance": 42 }),
)],
)
.expect("a continuation body");
let items = body["input"].as_array().expect("input items");
let call = items
.iter()
.find(|i| i["type"] == "function_call")
.expect("the call");
let out = items
.iter()
.find(|i| i["type"] == "function_call_output")
.expect("the output");
assert_eq!(
call["call_id"], out["call_id"],
"an output without its call is rejected by the API: {body}"
);
assert!(
call["arguments"].is_string(),
"Responses carries arguments as a JSON string, unlike Anthropic's \
object: {body}"
);
}
#[test]
fn commentary_phase_text_stays_out_of_the_answer() {
let parsed: ApiResponse = serde_json::from_value(json!({
"status": "completed",
"output": [
{ "type": "message", "role": "assistant", "phase": "commentary",
"content": [{ "type": "output_text", "text": "Let me check the ledger. " }] },
{ "type": "message", "role": "assistant", "phase": "final_answer",
"content": [{ "type": "output_text", "text": "{\"balance\":42}" }] },
],
}))
.expect("parse");
assert_eq!(
parsed.text(),
"{\"balance\":42}",
"commentary narration joined the final answer"
);
}
#[test]
fn encrypted_reasoning_and_assistant_phase_round_trip_unchanged() {
let opaque = json!([
{
"id": "rs_1",
"type": "reasoning",
"encrypted_content": "opaque-ciphertext",
"summary": []
},
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"phase": "commentary",
"status": "completed",
"content": [{"type": "output_text", "text": "checking"}]
},
{
"id": "fc_1",
"type": "function_call",
"call_id": "call_01",
"name": "ledger.read",
"arguments": "{\"id\":\"AC-1\"}",
"status": "completed"
}
]);
let state = ProviderContinuation::new("openai", opaque.clone());
let body = OpenAi::new("test-key")
.expect("driver")
.body_with_max(
&ModelId::new("openai", "gpt-x"),
&json!({"input": "balance?"}),
4096,
Some(ReasoningEffort::High),
None,
&[],
&[ToolExchange::ok(
ModelToolCall {
id: "call_01".to_owned(),
name: "ledger.read".to_owned(),
arguments: json!({"id": "AC-1"}),
},
json!({"balance": 42}),
)],
Some(&state),
)
.expect("lossless reasoning continuation");
assert_eq!(
&body["input"].as_array().unwrap()[1..4],
opaque.as_array().unwrap()
);
assert_eq!(body["input"][4]["type"], "function_call_output");
}
#[test]
fn continuation_accumulates_every_prior_tool_turn() {
let prior = ProviderContinuation::new(
"openai",
json!([{"type": "reasoning", "encrypted_content": "first"}]),
);
let exchange = ToolExchange::ok(
ModelToolCall {
id: "call_1".to_owned(),
name: "lookup".to_owned(),
arguments: json!({}),
},
json!({"value": 1}),
);
let mut completion = Completion {
text: String::new(),
tool_calls: vec![ModelToolCall {
id: "call_2".to_owned(),
name: "lookup".to_owned(),
arguments: json!({}),
}],
usage: Usage::default(),
stop_reason: Some("completed".to_owned()),
truncated: false,
structured: None,
continuation: Some(ProviderContinuation::new(
"openai",
json!([{"type": "function_call", "call_id": "call_2"}]),
)),
};
accumulate_continuation(&mut completion, Some(&prior), &[exchange]);
let state = completion.continuation.unwrap().state;
assert_eq!(state[0]["encrypted_content"], "first");
assert_eq!(state[1]["type"], "function_call_output");
assert_eq!(state[2]["call_id"], "call_2");
}
}