use super::responses;
use super::*;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum AuthStyle {
None,
#[default]
Bearer,
XApiKey,
Anthropic,
Custom(String),
}
pub struct OpenAiModel {
client: reqwest::Client,
api_key: String,
auth: AuthStyle,
extra_headers: Vec<(String, String)>,
temperature_unsupported: Vec<String>,
temperature_override: Option<f64>,
merge_system_into_user: bool,
named_tool_choice_supported: bool,
json_object_format_supported: bool,
model: String,
provider: String,
base_url: String,
profile: ModelProfile,
default_provider_options: Value,
responses_api_primary: bool,
responses_omit_max_output_tokens: bool,
extra_query_params: Vec<(String, String)>,
user_agent: Option<String>,
reasoning_tags: Option<ReasoningTagExtraction>,
reasoning_tags_overridden: bool,
}
pub(super) fn auth_headers(auth: &AuthStyle, api_key: &str) -> Vec<(String, String)> {
match auth {
AuthStyle::None => Vec::new(),
AuthStyle::Bearer => vec![("Authorization".to_string(), format!("Bearer {api_key}"))],
AuthStyle::XApiKey => vec![("x-api-key".to_string(), api_key.to_string())],
AuthStyle::Anthropic => vec![
("x-api-key".to_string(), api_key.to_string()),
("anthropic-version".to_string(), "2023-06-01".to_string()),
],
AuthStyle::Custom(name) => vec![(name.clone(), api_key.to_string())],
}
}
pub(super) fn glob_match(pattern: &str, value: &str) -> bool {
let pattern = pattern.to_ascii_lowercase();
let value = value.to_ascii_lowercase();
let segments: Vec<&str> = pattern.split('*').collect();
if segments.len() == 1 {
return pattern == value;
}
let mut cursor = 0usize;
for (idx, segment) in segments.iter().enumerate() {
if segment.is_empty() {
continue;
}
if idx == 0 {
if !value[cursor..].starts_with(segment) {
return false;
}
cursor += segment.len();
} else if idx == segments.len() - 1 {
return value[cursor..].ends_with(segment);
} else {
match value[cursor..].find(segment) {
Some(offset) => cursor += offset + segment.len(),
None => return false,
}
}
}
true
}
pub(super) fn effective_temperature(
model: &str,
request_temperature: Option<f64>,
temperature_override: Option<f64>,
temperature_unsupported: &[String],
) -> Option<f64> {
if temperature_unsupported.iter().any(|p| glob_match(p, model)) {
return None;
}
temperature_override.or(request_temperature)
}
fn content_text(content: &[ContentBlock]) -> String {
content
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn merge_system_into_user(messages: &[Message]) -> Vec<Message> {
use crate::harness::message::UserMessage;
let system_text = messages
.iter()
.filter_map(|m| match m {
Message::System(s) => {
let t = content_text(&s.content);
(!t.is_empty()).then_some(t)
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
if system_text.is_empty() {
if messages.iter().any(|m| matches!(m, Message::System(_))) {
return messages
.iter()
.filter(|m| !matches!(m, Message::System(_)))
.cloned()
.collect();
}
return messages.to_vec();
}
let mut merged: Vec<Message> = Vec::with_capacity(messages.len());
let mut folded = false;
for msg in messages {
match msg {
Message::System(_) => {} Message::User(user) if !folded => {
folded = true;
let mut content = Vec::with_capacity(user.content.len() + 1);
match user.content.split_first() {
Some((ContentBlock::Text(first), rest)) => {
content.push(ContentBlock::Text(format!("{system_text}\n\n{first}")));
content.extend(rest.iter().cloned());
}
_ => {
content.push(ContentBlock::Text(format!("{system_text}\n\n")));
content.extend(user.content.iter().cloned());
}
}
merged.push(Message::User(UserMessage { content }));
}
other => merged.push(other.clone()),
}
}
if !folded {
merged.insert(0, Message::user(system_text));
}
merged
}
pub(super) fn is_reasoning_model(model: &str) -> bool {
let lower = model.to_ascii_lowercase();
lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4")
}
pub(super) fn derive_profile(provider: &str, model: &str) -> ModelProfile {
let lower = model.to_ascii_lowercase();
let reasoning = is_reasoning_model(model);
let native_structured = lower.contains("gpt-4o") || lower.contains("gpt-4.1") || reasoning;
ModelProfile {
provider: Some(provider.to_string()),
model: Some(model.to_string()),
status: ModelStatus::Stable,
modalities: Modalities {
image_in: true,
..Modalities::default()
},
tool_calling: true,
parallel_tool_calls: true,
streaming: true,
streaming_tool_chunks: true,
native_structured_output: native_structured,
json_schema: true,
reasoning,
max_input_tokens: crate::harness::model::context_window_for_model_id(model),
..ModelProfile::default()
}
}
impl OpenAiModel {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
client: reqwest::Client::builder()
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.build()
.expect("default reqwest client builds"),
api_key: api_key.into(),
auth: AuthStyle::Bearer,
extra_headers: Vec::new(),
temperature_unsupported: Vec::new(),
temperature_override: None,
merge_system_into_user: false,
named_tool_choice_supported: true,
json_object_format_supported: true,
model: DEFAULT_MODEL.to_string(),
provider: "openai".to_string(),
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,
reasoning_tags: Some(ReasoningTagExtraction::default()),
reasoning_tags_overridden: false,
}
}
pub fn with_responses_api_primary(mut self) -> Self {
self.responses_api_primary = true;
self
}
pub fn with_responses_omit_max_output_tokens(mut self) -> Self {
self.responses_omit_max_output_tokens = true;
self
}
pub fn with_extra_query_param(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.extra_query_params.push((name.into(), value.into()));
self
}
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn with_auth_style(mut self, auth: AuthStyle) -> Self {
self.auth = auth;
self
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
pub fn with_temperature_unsupported_models(
mut self,
patterns: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.temperature_unsupported = patterns.into_iter().map(Into::into).collect();
self
}
pub fn with_temperature_override(mut self, temperature: Option<f64>) -> Self {
self.temperature_override = temperature;
self
}
pub fn with_merge_system_into_user(mut self) -> Self {
self.merge_system_into_user = true;
self
}
pub fn with_named_tool_choice(mut self, supported: bool) -> Self {
self.named_tool_choice_supported = supported;
self
}
pub fn with_json_object_format(mut self, supported: bool) -> Self {
self.json_object_format_supported = supported;
self
}
pub fn with_reasoning_tag_extraction(mut self, config: Option<ReasoningTagExtraction>) -> Self {
self.reasoning_tags = config;
self.reasoning_tags_overridden = true;
self
}
pub(super) fn effective_reasoning_tags(&self) -> Option<&ReasoningTagExtraction> {
if !self.reasoning_tags_overridden && self.base_url == DEFAULT_BASE_URL {
return None;
}
self.reasoning_tags.as_ref()
}
pub fn with_native_tool_calling(mut self, enabled: bool) -> Self {
self.profile.tool_calling = enabled;
if !enabled {
self.profile.parallel_tool_calls = false;
self.profile.streaming_tool_chunks = false;
}
self
}
pub fn with_vision(mut self, enabled: bool) -> Self {
self.profile.modalities.image_in = enabled;
self
}
pub fn with_default_provider_options(mut self, options: Value) -> Self {
self.default_provider_options = options;
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self.profile = derive_profile(&self.provider, &self.model);
self
}
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = provider.into();
self.profile = derive_profile(&self.provider, &self.model);
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
pub fn from_env() -> Result<Self> {
let api_key = std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.ok_or_else(|| {
TinyAgentsError::Validation(
"OPENAI_API_KEY is not set; export it or add it to a .env file".to_string(),
)
})?;
let mut model = Self::new(api_key);
if let Ok(name) = std::env::var("OPENAI_MODEL")
&& !name.trim().is_empty()
{
model = model.with_model(name);
}
if let Ok(url) = std::env::var("OPENAI_BASE_URL")
&& !url.trim().is_empty()
{
model = model.with_base_url(url);
}
Ok(model)
}
pub fn from_spec(spec: ProviderSpec, api_key: impl Into<String>) -> Result<Self> {
if spec.model.trim().is_empty() {
return Err(TinyAgentsError::Validation(
"provider spec model must not be empty".to_string(),
));
}
if spec.base_url.trim().is_empty() {
return Err(TinyAgentsError::Validation(
"provider spec base_url must not be empty".to_string(),
));
}
Ok(Self::compatible_provider(
spec.provider,
api_key,
spec.base_url,
spec.model,
))
}
pub fn from_spec_env(spec: ProviderSpec) -> Result<Self> {
let api_key = if spec.requires_api_key {
let env = spec.api_key_env.as_deref().ok_or_else(|| {
TinyAgentsError::Validation(format!(
"{} requires an api_key_env in ProviderSpec",
spec.provider
))
})?;
std::env::var(env)
.ok()
.filter(|k| !k.trim().is_empty())
.ok_or_else(|| {
TinyAgentsError::Validation(format!(
"{env} is not set; export it or provide an explicit API key"
))
})?
} else {
"local".to_string()
};
Self::from_spec(spec, api_key)
}
pub async fn list_models(&self) -> Result<Vec<ModelListing>> {
let url = format!("{}/models", self.base_url);
let response = self
.send_checked(self.authorized(self.client.get(&url)), "request", &url)
.await?;
let text = response.text().await.map_err(|e| {
TinyAgentsError::Model(format!("openai response body read failed: {e}"))
})?;
let listing: ModelListWire = serde_json::from_str(&text)?;
Ok(listing.data)
}
pub fn compatible(
api_key: impl Into<String>,
base_url: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self::new(api_key).with_base_url(base_url).with_model(model)
}
pub fn compatible_provider(
provider: impl Into<String>,
api_key: impl Into<String>,
base_url: impl Into<String>,
model: impl Into<String>,
) -> Self {
Self::new(api_key)
.with_provider(provider)
.with_base_url(base_url)
.with_model(model)
}
pub fn deepseek(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"deepseek",
api_key,
"https://api.deepseek.com/v1",
"deepseek-chat",
)
}
pub fn anthropic(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"anthropic",
api_key,
"https://api.anthropic.com/v1",
"claude-3-5-sonnet-latest",
)
}
pub fn groq(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"groq",
api_key,
"https://api.groq.com/openai/v1",
"llama-3.3-70b-versatile",
)
}
pub fn xai(api_key: impl Into<String>) -> Self {
Self::compatible_provider("xai", api_key, "https://api.x.ai/v1", "grok-2-latest")
}
pub fn openrouter(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"openrouter",
api_key,
"https://openrouter.ai/api/v1",
"openai/gpt-4o-mini",
)
}
pub fn together(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"together",
api_key,
"https://api.together.xyz/v1",
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
)
}
pub fn mistral(api_key: impl Into<String>) -> Self {
Self::compatible_provider(
"mistral",
api_key,
"https://api.mistral.ai/v1",
"mistral-small-latest",
)
}
pub fn ollama() -> Self {
Self::compatible_provider("ollama", "ollama", "http://localhost:11434/v1", "llama3.2")
}
pub fn model(&self) -> &str {
&self.model
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub(super) fn baseline_degrade(&self) -> Degrade {
Degrade {
named_tool_choice: !self.named_tool_choice_supported,
json_object: !self.json_object_format_supported,
}
}
#[cfg(test)]
pub(super) fn translate_request(
&self,
request: &ModelRequest,
) -> Result<ChatCompletionRequest> {
self.translate_request_with(request, self.baseline_degrade())
}
pub(super) fn translate_request_with(
&self,
request: &ModelRequest,
degrade: Degrade,
) -> Result<ChatCompletionRequest> {
let prompt_guided_tools = !self.profile.tool_calling && !request.tools.is_empty();
let instructed_messages;
let base_messages: &[Message] = if prompt_guided_tools {
instructed_messages =
prompt_tools::with_tool_instructions(&request.messages, &request.tools);
&instructed_messages
} else {
&request.messages
};
let merged_messages;
let source_messages: &[Message] = if self.merge_system_into_user {
merged_messages = merge_system_into_user(base_messages);
&merged_messages
} else {
base_messages
};
let messages = source_messages
.iter()
.map(translate_message)
.collect::<Result<Vec<_>>>()?;
let mut tools: Vec<ToolWire> = if prompt_guided_tools {
Vec::new()
} else {
request
.tools
.iter()
.map(|schema| ToolWire {
kind: "function".to_string(),
function: FunctionSchemaWire {
name: schema.name.clone(),
description: schema.description.clone(),
parameters: schema.parameters.clone(),
},
})
.collect()
};
let tool_choice = if tools.is_empty() {
None
} else if let (true, ToolChoice::Tool(name)) =
(degrade.named_tool_choice, &request.tool_choice)
{
if tools.iter().any(|t| t.function.name == *name) {
tools.retain(|t| t.function.name == *name);
}
Some(json!("required"))
} else {
Some(translate_tool_choice(&request.tool_choice))
};
let response_format = request.response_format.as_ref().and_then(|format| {
if degrade.json_object && matches!(format, ResponseFormat::JsonObject) {
Some(degraded_json_object_format())
} else {
translate_response_format(format)
}
});
let model = request.model.clone().unwrap_or_else(|| self.model.clone());
let (max_tokens, max_completion_tokens) = if is_reasoning_model(&model) {
(None, request.max_tokens)
} else {
(request.max_tokens, None)
};
let temperature = effective_temperature(
&model,
request.temperature,
self.temperature_override,
&self.temperature_unsupported,
);
Ok(ChatCompletionRequest {
model,
messages,
tools,
tool_choice,
response_format,
temperature,
top_p: request.top_p,
max_tokens,
max_completion_tokens,
stop: request.stop_sequences.clone(),
seed: request.seed,
stream: false,
stream_options: None,
extra: provider_extra_options(&merge_provider_options(
&self.default_provider_options,
&request.provider_options,
))?,
})
}
fn authorized(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
for (name, value) in auth_headers(&self.auth, &self.api_key) {
builder = builder.header(name, value);
}
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
}
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")
}
}
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,
}
}
async fn invoke_responses(&self, request: &ModelRequest) -> Result<ModelResponse> {
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") =>
{
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))
}
async fn send_responses(
&self,
body: &responses::ResponsesRequest,
timeout_ms: Option<u64>,
url: &str,
) -> Result<reqwest::Response> {
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
}
async fn send_checked(
&self,
builder: reqwest::RequestBuilder,
what: &str,
url: &str,
) -> Result<reqwest::Response> {
let response = builder.send().await.map_err(|e| {
let error =
self.provider_error(format!("{what} to {url} failed: {e}"), None, None, None);
TinyAgentsError::Model(self.provider_failure_message(&error))
})?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
let error = self.parse_error_body(status.as_u16(), &text);
return Err(TinyAgentsError::Provider(Box::new(error)));
}
Ok(response)
}
async fn post_json(
&self,
body: &ChatCompletionRequest,
timeout_ms: Option<u64>,
streaming: bool,
what: &str,
) -> Result<reqwest::Response> {
let url = format!("{}/chat/completions", self.base_url);
let mut builder = self.authorized(self.client.post(&url)).json(body);
if let Some(timeout) = request_timeout(timeout_ms, streaming) {
builder = builder.timeout(timeout);
}
self.send_checked(builder, what, &url).await
}
fn build_chat_body(
&self,
request: &ModelRequest,
degrade: Degrade,
streaming: bool,
) -> Result<ChatCompletionRequest> {
let mut body = self.translate_request_with(request, degrade)?;
if streaming {
body.stream = true;
body.stream_options = Some(json!({ "include_usage": true }));
}
Ok(body)
}
async fn post_chat_with_degrade(
&self,
request: &ModelRequest,
streaming: bool,
what: &str,
) -> Result<reqwest::Response> {
let baseline = self.baseline_degrade();
let body = self.build_chat_body(request, baseline, streaming)?;
match self
.post_json(&body, request.timeout_ms, streaming, what)
.await
{
Ok(response) => Ok(response),
Err(TinyAgentsError::Provider(err))
if err.status == Some(400)
&& let Some(degrade) = degrade_for_400(&err.message, request, baseline) =>
{
let retry = self.build_chat_body(request, degrade, streaming)?;
self.post_json(&retry, request.timeout_ms, streaming, what)
.await
}
Err(e) => Err(e),
}
}
fn provider_error(
&self,
message: impl Into<String>,
status: Option<u16>,
code: Option<String>,
raw: Option<Value>,
) -> ProviderError {
let message = message.into();
let retryable =
crate::harness::retry::classify_provider_failure(status, code.as_deref(), &message)
.is_retryable();
ProviderError {
provider: self.provider.clone(),
model: Some(self.model.clone()),
status,
code,
message,
retryable,
raw,
}
}
fn provider_failure_message(&self, error: &ProviderError) -> String {
format!(
"{} returned{}{}: {}",
error.provider,
error
.status
.map(|status| format!(" HTTP {status}"))
.unwrap_or_default(),
error
.code
.as_deref()
.map(|code| format!(" ({code})"))
.unwrap_or_default(),
error.message
)
}
pub(super) fn parse_error_body(&self, status: u16, text: &str) -> ProviderError {
let raw = serde_json::from_str::<Value>(text).ok();
let error_obj = raw.as_ref().and_then(|value| value.get("error"));
let message = error_obj
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
.or_else(|| {
raw.as_ref()
.and_then(|value| value.get("message"))
.and_then(Value::as_str)
})
.filter(|message| !message.trim().is_empty())
.unwrap_or(text)
.to_string();
let code = error_obj
.and_then(|error| error.get("code").or_else(|| error.get("type")))
.and_then(Value::as_str)
.map(str::to_string);
self.provider_error(message, Some(status), code, raw)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct Degrade {
pub named_tool_choice: bool,
pub json_object: bool,
}
pub(super) fn degrade_for_400(
message: &str,
request: &ModelRequest,
already: Degrade,
) -> Option<Degrade> {
let lower = message.to_ascii_lowercase();
let mut degrade = already;
if !already.named_tool_choice
&& lower.contains("tool_choice")
&& matches!(request.tool_choice, ToolChoice::Tool(_))
{
degrade.named_tool_choice = true;
}
if !already.json_object
&& lower.contains("response_format")
&& matches!(request.response_format, Some(ResponseFormat::JsonObject))
{
degrade.json_object = true;
}
(degrade != already).then_some(degrade)
}
pub(super) fn request_timeout(timeout_ms: Option<u64>, streaming: bool) -> Option<Duration> {
match timeout_ms {
Some(ms) => Some(Duration::from_millis(ms)),
None if streaming => None,
None => Some(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)),
}
}
pub(super) fn merge_provider_options(defaults: &Value, overrides: &Value) -> Value {
if !overrides.is_null() && !overrides.is_object() {
return overrides.clone();
}
match (defaults.as_object(), overrides.as_object()) {
(None, None) => Value::Null,
(Some(base), None) => Value::Object(base.clone()),
(None, Some(over)) => Value::Object(over.clone()),
(Some(base), Some(over)) => {
let mut merged = base.clone();
for (key, value) in over {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
}
}
pub(super) fn provider_extra_options(options: &Value) -> Result<Map<String, Value>> {
if options.is_null() {
return Ok(Map::new());
}
let Some(object) = options.as_object() else {
return Err(TinyAgentsError::Validation(
"provider_options for OpenAI-compatible providers must be a JSON object".to_string(),
));
};
const RESERVED: &[&str] = &[
"model",
"messages",
"tools",
"tool_choice",
"response_format",
"temperature",
"top_p",
"max_tokens",
"max_completion_tokens",
"stop",
"seed",
"stream",
"stream_options",
];
Ok(object
.iter()
.filter(|(key, _)| !RESERVED.contains(&key.as_str()))
.map(|(key, value)| (key.clone(), value.clone()))
.collect())
}
#[async_trait]
impl<State: Send + Sync> ChatModel<State> for OpenAiModel {
fn profile(&self) -> Option<&ModelProfile> {
Some(&self.profile)
}
async fn invoke(&self, _state: &State, request: ModelRequest) -> Result<ModelResponse> {
if self.responses_api_primary {
return self.invoke_responses(&request).await;
}
let response = self
.post_chat_with_degrade(&request, false, "request")
.await?;
let text = response.text().await.map_err(|e| {
TinyAgentsError::Model(format!("openai response body read failed: {e}"))
})?;
let value: Value = serde_json::from_str(&text)?;
let response = parse_chat_response(value, self.effective_reasoning_tags())?;
if !self.profile.tool_calling && !request.tools.is_empty() {
return Ok(prompt_tools::apply_to_response(response));
}
Ok(response)
}
async fn stream(&self, _state: &State, request: ModelRequest) -> Result<ModelStream> {
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 response = self
.post_chat_with_degrade(&request, true, "stream request")
.await?;
let is_event_stream = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.to_ascii_lowercase().contains("text/event-stream"))
.unwrap_or(false);
if !is_event_stream {
let text = response.text().await.map_err(|e| {
TinyAgentsError::Model(format!("openai non-stream stream-body read failed: {e}"))
})?;
let value: Value = serde_json::from_str(&text)?;
let mut parsed = parse_chat_response(value, self.effective_reasoning_tags())?;
if !self.profile.tool_calling && !request.tools.is_empty() {
parsed = prompt_tools::apply_to_response(parsed);
}
let delta = crate::harness::message::MessageDelta {
text: parsed.text(),
reasoning: String::new(),
tool_call: None,
};
let items = vec![
ModelStreamItem::Started,
ModelStreamItem::MessageDelta(delta),
ModelStreamItem::Completed(parsed),
];
return Ok(Box::pin(futures::stream::iter(items)));
}
let bytes = response.bytes_stream().map(|chunk| {
chunk.map_err(|e| TinyAgentsError::Model(format!("stream chunk failed: {e}")))
});
let state = SseState {
bytes: Box::pin(bytes),
buf: Vec::new(),
pending: VecDeque::new(),
acc: OpenAiStreamAcc::new(self.effective_reasoning_tags().cloned()),
provider: self.provider.clone(),
model: self.model.clone(),
started: false,
finished: false,
terminal_emitted: false,
};
let stream = futures::stream::unfold(state, sse_next);
if !self.profile.tool_calling && !request.tools.is_empty() {
return Ok(Box::pin(stream.map(|item| match item {
ModelStreamItem::Completed(response) => {
ModelStreamItem::Completed(prompt_tools::apply_to_response(response))
}
other => other,
})));
}
Ok(Box::pin(stream))
}
}