use crate::backend::{ChatReply, ChatRequest, InferenceBackend};
use crate::retry::{with_backoff, RetryPolicy};
use async_trait::async_trait;
use newt_core::router::Tier;
use std::sync::Arc;
pub struct ResponsesBackend {
endpoint: String,
model: String,
client: reqwest::Client,
api_key: Option<String>,
retry: RetryPolicy,
}
impl ResponsesBackend {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
client: reqwest::Client::new(),
api_key: None,
retry: RetryPolicy::from_env(),
}
}
pub fn with_api_key(mut self, api_key: Option<String>) -> Self {
self.api_key = api_key.filter(|k| !k.is_empty());
self
}
pub fn from_config(cfg: &newt_core::BackendConfig) -> Self {
let model = cfg.effective_model().unwrap_or_default().to_string();
Self::new(cfg.endpoint.clone(), model).with_api_key(cfg.resolve_api_key())
}
fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.api_key {
Some(key) => rb.bearer_auth(key),
None => rb,
}
}
async fn try_complete(&self, req: &ChatRequest) -> anyhow::Result<ChatReply> {
let msgs: Vec<serde_json::Value> = req
.messages
.iter()
.map(|m| serde_json::json!({ "role": &m.role, "content": &m.content }))
.collect();
let (instructions, input) = newt_core::responses_wire::build_responses_input(&msgs);
let mut body = serde_json::json!({
"model": self.model,
"input": input,
"stream": false,
"store": newt_core::responses_wire::STORE_RESPONSE_SERVER_SIDE,
});
if let Some(instructions) = instructions {
body["instructions"] = serde_json::json!(instructions);
}
if let Some(max) = req.max_tokens {
body["max_output_tokens"] = serde_json::json!(max);
}
let url = format!("{}/v1/responses", self.endpoint.trim_end_matches('/'));
let resp = self
.authed(self.client.post(&url).json(&body))
.send()
.await
.map_err(|e| anyhow::anyhow!("Responses request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Responses API returned {status}: {text}");
}
let json: serde_json::Value = resp.json().await?;
let decoded = newt_core::responses_wire::decode_response(&json)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let model_id = decoded.model.unwrap_or_else(|| self.model.clone());
Ok(ChatReply {
content: decoded.text,
model_id,
usage: decoded.usage,
})
}
}
#[async_trait]
impl InferenceBackend for ResponsesBackend {
fn name(&self) -> &str {
"openai-responses"
}
fn model_id(&self) -> &str {
&self.model
}
fn supports_tier(&self, _tier: Tier) -> bool {
true
}
fn endpoint(&self) -> Option<&str> {
Some(&self.endpoint)
}
async fn complete(&self, req: ChatRequest) -> anyhow::Result<ChatReply> {
with_backoff(&self.retry, || self.try_complete(&req)).await
}
}
pub fn openai_inference_backend(cfg: &newt_core::BackendConfig) -> Arc<dyn InferenceBackend> {
use newt_core::OpenAiApi;
match cfg.api.unwrap_or_default() {
OpenAiApi::Responses => Arc::new(ResponsesBackend::from_config(cfg)),
OpenAiApi::ChatCompletions => Arc::new(crate::local::LocalVllmBackend::from_config(cfg)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use newt_core::{BackendConfig, BackendKind, OpenAiApi};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn backend_cfg(endpoint: &str, api: OpenAiApi) -> BackendConfig {
BackendConfig {
name: "sol".into(),
kind: Some(BackendKind::Openai),
api: Some(api),
endpoint: endpoint.into(),
model: Some("gpt-5.6-sol".into()),
..Default::default()
}
}
#[tokio::test]
async fn responses_backend_posts_to_v1_responses_and_extracts_output_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"model": "gpt-5.6-sol",
"output": [{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "the answer"}]
}],
"usage": {"input_tokens": 11, "output_tokens": 3}
})))
.expect(1)
.mount(&server)
.await;
let backend =
ResponsesBackend::from_config(&backend_cfg(&server.uri(), OpenAiApi::Responses));
let reply = backend
.complete(ChatRequest::new().system("sys").user("hi"))
.await
.expect("responses completion");
assert_eq!(reply.content, "the answer");
assert_eq!(reply.usage.unwrap().input_tokens, 11);
}
#[tokio::test]
async fn responses_backend_never_calls_chat_completions() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"output_text": "ok"
})))
.mount(&server)
.await;
let backend =
ResponsesBackend::from_config(&backend_cfg(&server.uri(), OpenAiApi::Responses));
let reply = backend
.complete(ChatRequest::new().user("hi"))
.await
.unwrap();
assert_eq!(reply.content, "ok");
}
#[tokio::test]
async fn transport_sets_store_false_for_stateless_no_retention() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"output": [{"type": "message",
"content": [{"type": "output_text", "text": "ok"}]}]
})))
.mount(&server)
.await;
let backend =
ResponsesBackend::from_config(&backend_cfg(&server.uri(), OpenAiApi::Responses));
backend
.complete(ChatRequest::new().user("hi"))
.await
.expect("completion");
let requests = server.received_requests().await.expect("journal");
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(
body.get("store"),
Some(&serde_json::Value::Bool(false)),
"store:false must be explicit on the wire"
);
}
#[tokio::test]
async fn incomplete_status_on_a_200_is_an_error_not_a_silent_reply() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "incomplete",
"incomplete_details": {"reason": "max_output_tokens"},
"output": [{
"type": "message",
"content": [{"type": "output_text", "text": "partial…"}]
}]
})))
.expect(1) .mount(&server)
.await;
let backend =
ResponsesBackend::from_config(&backend_cfg(&server.uri(), OpenAiApi::Responses));
let err = backend
.complete(ChatRequest::new().user("hi"))
.await
.expect_err("incomplete 200 must be an error");
let msg = err.to_string();
assert!(msg.contains("did not complete"), "got: {msg}");
assert!(msg.contains("max_output_tokens"), "names the reason: {msg}");
}
#[tokio::test]
async fn failed_status_on_a_200_is_an_error_not_a_silent_reply() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "failed",
"error": {"message": "model overloaded mid-turn"}
})))
.expect(1)
.mount(&server)
.await;
let backend =
ResponsesBackend::from_config(&backend_cfg(&server.uri(), OpenAiApi::Responses));
let err = backend
.complete(ChatRequest::new().user("hi"))
.await
.expect_err("failed 200 must be an error");
assert!(
err.to_string().contains("model overloaded mid-turn"),
"surfaces the server's failure message: {err}"
);
}
#[test]
fn factory_selects_transport_by_api() {
let responses = openai_inference_backend(&backend_cfg("http://x", OpenAiApi::Responses));
assert_eq!(responses.name(), "openai-responses");
let chat = openai_inference_backend(&backend_cfg("http://x", OpenAiApi::ChatCompletions));
assert_eq!(chat.name(), "vllm-local");
let mut no_api = backend_cfg("http://x", OpenAiApi::ChatCompletions);
no_api.api = None;
assert_eq!(openai_inference_backend(&no_api).name(), "vllm-local");
}
}