Skip to main content

agent_sdk_provider/
openai.rs

1use std::sync::Arc;
2
3use agent_sdk_core::{
4    AgentError, ProviderAdapter, ProviderCapabilities, ProviderRequest, ProviderResponse,
5    ProviderStreamChunk,
6};
7use serde_json::{Value, json};
8
9use crate::{
10    ProviderApiKey,
11    error::unsupported_response,
12    http::{CurlJsonHttpTransport, JsonHttpRequest, JsonHttpTransport},
13    openai_compatible::{
14        OpenAiCompatibleResponsesAdapter, OpenAiResponsesConfig, OpenAiResponsesRequest,
15        OpenAiResponsesResponse, OpenAiResponsesTransport, OpenAiToolArgumentSink,
16    },
17};
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20/// Configuration for the live OpenAI Responses adapter.
21pub struct OpenAiLiveResponsesConfig {
22    /// Stable provider ref exposed through `ProviderCapabilities`.
23    pub provider_ref: String,
24    /// OpenAI model id.
25    pub model: String,
26    /// Absolute Responses API endpoint.
27    pub endpoint_url: String,
28    /// Whether this route advertises streaming support.
29    pub supports_streaming: bool,
30    /// Maximum input tokens advertised by this route.
31    pub max_input_tokens: Option<u32>,
32}
33
34impl OpenAiLiveResponsesConfig {
35    /// Creates a config for OpenAI's hosted Responses API.
36    pub fn new(model: impl Into<String>) -> Self {
37        Self {
38            provider_ref: "provider.openai.responses".to_string(),
39            model: model.into(),
40            endpoint_url: "https://api.openai.com/v1/responses".to_string(),
41            supports_streaming: false,
42            max_input_tokens: None,
43        }
44    }
45
46    /// Sets the stable provider ref used in SDK capability metadata.
47    pub fn provider_ref(mut self, provider_ref: impl Into<String>) -> Self {
48        self.provider_ref = provider_ref.into();
49        self
50    }
51
52    /// Sets a custom endpoint URL for hosted-compatible OpenAI deployments.
53    pub fn endpoint_url(mut self, endpoint_url: impl Into<String>) -> Self {
54        self.endpoint_url = endpoint_url.into();
55        self
56    }
57
58    /// Sets the maximum input token limit advertised for this route.
59    pub fn max_input_tokens(mut self, max_input_tokens: u32) -> Self {
60        self.max_input_tokens = Some(max_input_tokens);
61        self
62    }
63}
64
65#[derive(Clone)]
66/// Live OpenAI Responses API adapter.
67///
68/// It implements `ProviderAdapter` and delegates all runtime policy, journaling,
69/// event publication, approval, and tool execution back to `agent-sdk-core`.
70pub struct OpenAiResponsesAdapter {
71    inner: OpenAiCompatibleResponsesAdapter,
72}
73
74impl OpenAiResponsesAdapter {
75    /// Creates a live adapter using `OPENAI_API_KEY`.
76    pub fn from_env(model: impl Into<String>) -> Result<Self, AgentError> {
77        Self::new(
78            OpenAiLiveResponsesConfig::new(model),
79            ProviderApiKey::from_env("OPENAI_API_KEY")?,
80        )
81    }
82
83    /// Creates a live adapter with a host-resolved API key.
84    pub fn new(
85        config: OpenAiLiveResponsesConfig,
86        api_key: ProviderApiKey,
87    ) -> Result<Self, AgentError> {
88        Self::with_transport(config, api_key, Arc::new(CurlJsonHttpTransport::new()))
89    }
90
91    /// Creates an adapter with an injected JSON transport for deterministic
92    /// tests or host-managed HTTP stacks.
93    pub fn with_transport(
94        config: OpenAiLiveResponsesConfig,
95        api_key: ProviderApiKey,
96        http: Arc<dyn JsonHttpTransport>,
97    ) -> Result<Self, AgentError> {
98        let compatible_config =
99            OpenAiResponsesConfig::new(config.provider_ref.clone(), config.model.clone())
100                .endpoint_ref(config.endpoint_url.clone())
101                .supports_streaming(config.supports_streaming);
102        let compatible_config = match config.max_input_tokens {
103            Some(max_input_tokens) => compatible_config.max_input_tokens(max_input_tokens),
104            None => compatible_config,
105        };
106        let transport = Arc::new(OpenAiLiveResponsesTransport {
107            endpoint_url: config.endpoint_url,
108            api_key,
109            http,
110        });
111        Ok(Self {
112            inner: OpenAiCompatibleResponsesAdapter::new(compatible_config, transport),
113        })
114    }
115
116    /// Adds an optional host-owned sink for raw tool-call arguments.
117    pub fn with_argument_sink(mut self, sink: Arc<dyn OpenAiToolArgumentSink>) -> Self {
118        self.inner = self.inner.with_argument_sink(sink);
119        self
120    }
121}
122
123impl ProviderAdapter for OpenAiResponsesAdapter {
124    fn capabilities(&self) -> ProviderCapabilities {
125        self.inner.capabilities()
126    }
127
128    fn project_request(
129        &self,
130        projection: &agent_sdk_core::ContextProjection,
131        policy: &agent_sdk_core::ProviderProjectionPolicy,
132    ) -> Result<ProviderRequest, AgentError> {
133        self.inner.project_request(projection, policy)
134    }
135
136    fn complete(&self, request: &ProviderRequest) -> Result<ProviderResponse, AgentError> {
137        self.inner.complete(request)
138    }
139
140    fn stream(&self, request: &ProviderRequest) -> Result<Vec<ProviderStreamChunk>, AgentError> {
141        self.inner.stream(request)
142    }
143
144    fn extract_usage(&self, response: &ProviderResponse) -> agent_sdk_core::ProviderUsage {
145        self.inner.extract_usage(response)
146    }
147}
148
149struct OpenAiLiveResponsesTransport {
150    endpoint_url: String,
151    api_key: ProviderApiKey,
152    http: Arc<dyn JsonHttpTransport>,
153}
154
155impl OpenAiResponsesTransport for OpenAiLiveResponsesTransport {
156    fn complete(
157        &self,
158        request: OpenAiResponsesRequest,
159    ) -> Result<OpenAiResponsesResponse, AgentError> {
160        let body = openai_responses_body(request);
161        let http_request = JsonHttpRequest::new(self.endpoint_url.clone(), body)
162            .header(
163                "Authorization",
164                format!("Bearer {}", self.api_key.expose_secret()),
165            )
166            .header("Content-Type", "application/json");
167        let response = self.http.post_json(http_request)?;
168        serde_json::from_value(response.body)
169            .map_err(|error| unsupported_response("OpenAI Responses", error.to_string()))
170    }
171}
172
173fn openai_responses_body(request: OpenAiResponsesRequest) -> Value {
174    let mut instructions = Vec::new();
175    let mut input = Vec::new();
176    for message in request.input {
177        match message.role.as_str() {
178            "system" | "developer" => instructions.push(message.content),
179            "assistant" => input.push(json!({
180                "role": "assistant",
181                "content": message.content,
182            })),
183            "tool" => input.push(json!({
184                "role": "user",
185                "content": format!("Tool result:\n{}", message.content),
186            })),
187            _ => input.push(json!({
188                "role": "user",
189                "content": message.content,
190            })),
191        }
192    }
193
194    let mut body = json!({
195        "model": request.model,
196        "input": input,
197    });
198    if !instructions.is_empty() {
199        body["instructions"] = Value::String(instructions.join("\n\n"));
200    }
201    if let Some(text) = request.text.and_then(openai_text_format) {
202        body["text"] = text;
203    }
204    body
205}
206
207fn openai_text_format(text: crate::OpenAiTextFormatHint) -> Option<Value> {
208    let schema = text.schema?;
209    Some(json!({
210        "format": {
211            "type": "json_schema",
212            "name": text.name,
213            "schema": schema,
214            "strict": true,
215        }
216    }))
217}