Skip to main content

agent_sdk_provider/
openai_compatible.rs

1//! OpenAI-compatible Responses-style provider adapter.
2//! The adapter is transport-injected for compatibility testing and non-OpenAI
3//! Responses-style endpoints. Use `OpenAiResponsesAdapter` for the default live
4//! OpenAI endpoint.
5//!
6use std::{fmt, sync::Arc};
7
8use agent_sdk_core::{
9    AgentError, AgentErrorKind, ProviderAdapter, ProviderCapabilities, ProviderMessageRole,
10    ProviderRequest, ProviderResponse, ProviderStopReason, ProviderToolCall, ProviderToolSpec,
11    ProviderUsage, RetryClassification, ToolCallId, domain::ContentRef as ContentRefId,
12    tool_records::CanonicalToolName,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
18/// Configuration for one OpenAI-compatible Responses adapter route.
19/// It is data-only and does not contain credentials or live endpoint handles.
20pub struct OpenAiResponsesConfig {
21    /// Stable provider ref exposed through `ProviderCapabilities`.
22    pub provider_ref: String,
23    /// Provider-native model id.
24    pub model: String,
25    /// Host-owned endpoint ref or profile label.
26    pub endpoint_ref: String,
27    /// Whether the injected transport supports streaming.
28    pub supports_streaming: bool,
29    /// Maximum input token limit advertised by this route.
30    pub max_input_tokens: Option<u32>,
31}
32
33impl OpenAiResponsesConfig {
34    /// Creates a configuration for an OpenAI-compatible Responses route.
35    pub fn new(provider_ref: impl Into<String>, model: impl Into<String>) -> Self {
36        Self {
37            provider_ref: provider_ref.into(),
38            model: model.into(),
39            endpoint_ref: "endpoint.host_configured.openai_compatible".to_string(),
40            supports_streaming: false,
41            max_input_tokens: None,
42        }
43    }
44
45    /// Sets the host-owned endpoint ref.
46    pub fn endpoint_ref(mut self, endpoint_ref: impl Into<String>) -> Self {
47        self.endpoint_ref = endpoint_ref.into();
48        self
49    }
50
51    /// Marks whether streaming is supported by the injected transport.
52    pub fn supports_streaming(mut self, supports_streaming: bool) -> Self {
53        self.supports_streaming = supports_streaming;
54        self
55    }
56
57    /// Sets the maximum input token limit advertised for this route.
58    pub fn max_input_tokens(mut self, max_input_tokens: u32) -> Self {
59        self.max_input_tokens = Some(max_input_tokens);
60        self
61    }
62}
63
64/// Transport boundary for an OpenAI-compatible Responses request.
65/// Implementations may perform network I/O; the adapter itself only maps
66/// SDK DTOs to and from the transport contract.
67pub trait OpenAiResponsesTransport: Send + Sync {
68    /// Sends one Responses-style request and returns a decoded response.
69    fn complete(
70        &self,
71        request: OpenAiResponsesRequest,
72    ) -> Result<OpenAiResponsesResponse, AgentError>;
73}
74
75/// Optional host-owned sink for raw provider tool-call arguments.
76/// The adapter never places raw arguments in the `ProviderToolCall` summary.
77pub trait OpenAiToolArgumentSink: Send + Sync {
78    /// Stores raw function-call arguments, returning a content ref when the
79    /// host wants executors to resolve arguments through normal content policy.
80    fn store_tool_arguments(
81        &self,
82        call_id: &str,
83        canonical_tool_name: &CanonicalToolName,
84        raw_arguments: &str,
85    ) -> Result<Option<ContentRefId>, AgentError>;
86}
87
88#[derive(Clone)]
89/// Provider adapter for OpenAI-compatible Responses-style transports.
90pub struct OpenAiCompatibleResponsesAdapter {
91    config: OpenAiResponsesConfig,
92    transport: Arc<dyn OpenAiResponsesTransport>,
93    argument_sink: Option<Arc<dyn OpenAiToolArgumentSink>>,
94}
95
96impl OpenAiCompatibleResponsesAdapter {
97    /// Creates an adapter over a host-supplied transport.
98    pub fn new(
99        config: OpenAiResponsesConfig,
100        transport: Arc<dyn OpenAiResponsesTransport>,
101    ) -> Self {
102        Self {
103            config,
104            transport,
105            argument_sink: None,
106        }
107    }
108
109    /// Adds an optional host-owned sink for raw tool-call arguments.
110    pub fn with_argument_sink(mut self, sink: Arc<dyn OpenAiToolArgumentSink>) -> Self {
111        self.argument_sink = Some(sink);
112        self
113    }
114
115    /// Returns the adapter config.
116    pub fn config(&self) -> &OpenAiResponsesConfig {
117        &self.config
118    }
119
120    fn map_response(
121        &self,
122        response: OpenAiResponsesResponse,
123    ) -> Result<ProviderResponse, AgentError> {
124        let usage = response.usage.clone().map(ProviderUsage::from);
125        let tool_calls = self.tool_calls_from_response(&response)?;
126        if !tool_calls.is_empty() {
127            let mut mapped = ProviderResponse::tool_use(tool_calls);
128            mapped.usage = usage;
129            return Ok(mapped);
130        }
131
132        Ok(ProviderResponse {
133            schema_version: ProviderResponse::SCHEMA_VERSION,
134            output_text: response.output_text(),
135            stop_reason: response.stop_reason_without_tools(),
136            tool_calls: Vec::new(),
137            usage,
138        })
139    }
140
141    fn tool_calls_from_response(
142        &self,
143        response: &OpenAiResponsesResponse,
144    ) -> Result<Vec<ProviderToolCall>, AgentError> {
145        let mut calls = Vec::new();
146        for item in &response.output {
147            if item.kind != "function_call" {
148                continue;
149            }
150            let call_id = item.call_id.as_deref().ok_or_else(|| {
151                provider_failure("OpenAI-compatible function_call item missing call_id")
152            })?;
153            let name = item.name.as_deref().ok_or_else(|| {
154                provider_failure("OpenAI-compatible function_call item missing name")
155            })?;
156            let canonical_tool_name = CanonicalToolName::new(name);
157            let mut call = ProviderToolCall::new(
158                ToolCallId::new(call_id),
159                canonical_tool_name.clone(),
160                format!("provider requested tool {name} with arguments stored as content refs"),
161            );
162            if let (Some(sink), Some(raw_arguments)) =
163                (self.argument_sink.as_ref(), item.arguments.as_deref())
164            {
165                if let Some(args_ref) =
166                    sink.store_tool_arguments(call_id, &canonical_tool_name, raw_arguments)?
167                {
168                    call = call.with_args_ref(args_ref);
169                }
170            }
171            calls.push(call);
172        }
173        Ok(calls)
174    }
175}
176
177impl ProviderAdapter for OpenAiCompatibleResponsesAdapter {
178    fn capabilities(&self) -> ProviderCapabilities {
179        let mut capabilities = ProviderCapabilities::text_only(self.config.provider_ref.clone());
180        capabilities.supports_streaming = self.config.supports_streaming;
181        capabilities.max_input_tokens = self.config.max_input_tokens;
182        capabilities
183    }
184
185    fn complete(&self, request: &ProviderRequest) -> Result<ProviderResponse, AgentError> {
186        let wire_request = OpenAiResponsesRequest::from_provider_request(&self.config, request);
187        let response = self.transport.complete(wire_request)?;
188        self.map_response(response)
189    }
190}
191
192#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
193/// Minimal Responses-style request sent to an injected transport.
194pub struct OpenAiResponsesRequest {
195    /// Provider-native model id.
196    pub model: String,
197    /// Provider message input.
198    pub input: Vec<OpenAiInputMessage>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    /// Optional structured-output text format hint.
201    pub text: Option<OpenAiTextFormatHint>,
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    /// Provider-native function tool declarations.
204    pub tools: Vec<OpenAiToolDeclaration>,
205    /// Host endpoint/profile label, not a credential or raw client.
206    pub endpoint_ref: String,
207}
208
209impl OpenAiResponsesRequest {
210    /// Builds a Responses-style request from the canonical provider request.
211    pub fn from_provider_request(
212        config: &OpenAiResponsesConfig,
213        request: &ProviderRequest,
214    ) -> Self {
215        Self {
216            model: config.model.clone(),
217            input: request
218                .messages
219                .iter()
220                .map(OpenAiInputMessage::from_provider_message)
221                .collect(),
222            text: request
223                .structured_output_hint
224                .as_ref()
225                .map(OpenAiTextFormatHint::from_provider_hint),
226            tools: request
227                .tools
228                .iter()
229                .map(OpenAiToolDeclaration::from_provider_tool)
230                .collect(),
231            endpoint_ref: config.endpoint_ref.clone(),
232        }
233    }
234}
235
236impl fmt::Debug for OpenAiResponsesRequest {
237    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238        formatter
239            .debug_struct("OpenAiResponsesRequest")
240            .field("model", &self.model)
241            .field("input_count", &self.input.len())
242            .field("input", &"<redacted>")
243            .field("text", &self.text)
244            .field("tool_count", &self.tools.len())
245            .field("tools", &"<redacted>")
246            .field("endpoint_ref", &self.endpoint_ref)
247            .finish()
248    }
249}
250
251#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
252/// Minimal OpenAI-compatible message input.
253pub struct OpenAiInputMessage {
254    /// Provider role string.
255    pub role: String,
256    /// Redacted provider-visible content.
257    pub content: String,
258}
259
260impl OpenAiInputMessage {
261    fn from_provider_message(message: &agent_sdk_core::ProviderMessage) -> Self {
262        Self {
263            role: role_name(&message.role).to_string(),
264            content: message.content.clone(),
265        }
266    }
267}
268
269impl fmt::Debug for OpenAiInputMessage {
270    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271        formatter
272            .debug_struct("OpenAiInputMessage")
273            .field("role", &self.role)
274            .field("content", &"<redacted>")
275            .field("content_chars", &self.content.chars().count())
276            .finish()
277    }
278}
279
280#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
281/// Structured-output text format hint for Responses-compatible providers.
282pub struct OpenAiTextFormatHint {
283    #[serde(rename = "type")]
284    /// Provider text format type.
285    pub kind: String,
286    /// Stable schema id.
287    pub name: String,
288    /// Schema semantic version.
289    pub schema_version: String,
290    /// SDK-owned schema fingerprint.
291    pub schema_fingerprint: String,
292    /// Whether the host should include the schema ref in the provider request.
293    pub include_schema_ref: bool,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    /// Redacted inline schema body when the SDK output contract made one
296    /// available for provider-native structured-output hints.
297    pub schema: Option<Value>,
298}
299
300impl OpenAiTextFormatHint {
301    fn from_provider_hint(hint: &agent_sdk_core::ProviderStructuredOutputHint) -> Self {
302        Self {
303            kind: "json_schema".to_string(),
304            name: hint.schema_id.as_str().to_string(),
305            schema_version: format!(
306                "{}.{}.{}",
307                hint.schema_version.major, hint.schema_version.minor, hint.schema_version.patch
308            ),
309            schema_fingerprint: hint.schema_fingerprint.as_str().to_string(),
310            include_schema_ref: hint.include_schema_ref,
311            schema: hint.redacted_schema.clone(),
312        }
313    }
314}
315
316impl fmt::Debug for OpenAiTextFormatHint {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        formatter
319            .debug_struct("OpenAiTextFormatHint")
320            .field("kind", &self.kind)
321            .field("name", &self.name)
322            .field("schema_version", &self.schema_version)
323            .field("schema_fingerprint", &self.schema_fingerprint)
324            .field("include_schema_ref", &self.include_schema_ref)
325            .field("schema_present", &self.schema.is_some())
326            .finish()
327    }
328}
329
330#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
331/// OpenAI-compatible Responses function declaration.
332pub struct OpenAiToolDeclaration {
333    #[serde(rename = "type")]
334    /// Provider tool type.
335    pub kind: String,
336    /// Provider-visible function name.
337    pub name: String,
338    /// Bounded provider-visible description.
339    pub description: String,
340    /// Provider-visible JSON schema for function arguments.
341    pub parameters: Value,
342}
343
344impl OpenAiToolDeclaration {
345    fn from_provider_tool(tool: &ProviderToolSpec) -> Self {
346        Self {
347            kind: "function".to_string(),
348            name: tool.name.clone(),
349            description: tool.description.clone().unwrap_or_else(|| {
350                format!("SDK tool {} governed by package policy refs", tool.name)
351            }),
352            parameters: tool.provider_schema(),
353        }
354    }
355}
356
357impl fmt::Debug for OpenAiToolDeclaration {
358    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
359        formatter
360            .debug_struct("OpenAiToolDeclaration")
361            .field("kind", &self.kind)
362            .field("name", &self.name)
363            .field("description", &self.description)
364            .field("parameters", &"<redacted>")
365            .finish()
366    }
367}
368
369#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
370/// Minimal Responses-style response accepted by this adapter.
371pub struct OpenAiResponsesResponse {
372    #[serde(skip_serializing_if = "Option::is_none")]
373    /// Provider response id.
374    pub id: Option<String>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    /// Provider status.
377    pub status: Option<String>,
378    #[serde(default, skip_serializing_if = "String::is_empty")]
379    /// Convenience output text field.
380    pub output_text: String,
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    /// Provider output items.
383    pub output: Vec<OpenAiWireOutputItem>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    /// Provider usage accounting.
386    pub usage: Option<OpenAiResponsesUsage>,
387}
388
389impl OpenAiResponsesResponse {
390    /// Creates a text response fixture.
391    pub fn text(output_text: impl Into<String>) -> Self {
392        Self {
393            status: Some("completed".to_string()),
394            output_text: output_text.into(),
395            ..Self::default()
396        }
397    }
398
399    /// Creates a function-call response fixture.
400    pub fn function_call(
401        call_id: impl Into<String>,
402        name: impl Into<String>,
403        arguments: impl Into<String>,
404    ) -> Self {
405        Self {
406            status: Some("completed".to_string()),
407            output: vec![OpenAiWireOutputItem::function_call(
408                call_id, name, arguments,
409            )],
410            ..Self::default()
411        }
412    }
413
414    fn output_text(&self) -> String {
415        if !self.output_text.is_empty() {
416            return self.output_text.clone();
417        }
418        self.output
419            .iter()
420            .filter(|item| item.kind == "message")
421            .flat_map(|item| item.content.iter())
422            .filter_map(|part| {
423                if part.kind == "output_text" {
424                    part.text.clone()
425                } else {
426                    None
427                }
428            })
429            .collect::<Vec<_>>()
430            .join("")
431    }
432
433    fn stop_reason_without_tools(&self) -> ProviderStopReason {
434        match self.status.as_deref().unwrap_or("completed") {
435            "completed" => ProviderStopReason::EndTurn,
436            "cancelled" => ProviderStopReason::Cancelled,
437            "incomplete" => ProviderStopReason::MaxTokens,
438            "failed" => ProviderStopReason::ProviderError,
439            _ => ProviderStopReason::Unknown,
440        }
441    }
442}
443
444impl fmt::Debug for OpenAiResponsesResponse {
445    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
446        formatter
447            .debug_struct("OpenAiResponsesResponse")
448            .field("id", &self.id)
449            .field("status", &self.status)
450            .field("output_text", &"<redacted>")
451            .field("output_text_chars", &self.output_text.chars().count())
452            .field("output_count", &self.output.len())
453            .field("output", &self.output)
454            .field("usage", &self.usage)
455            .finish()
456    }
457}
458
459#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
460/// Minimal Responses output item shape.
461pub struct OpenAiWireOutputItem {
462    #[serde(rename = "type")]
463    /// Provider item type.
464    pub kind: String,
465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
466    /// Message content parts.
467    pub content: Vec<OpenAiContentPart>,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    /// Function-call id.
470    pub call_id: Option<String>,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    /// Function/tool name.
473    pub name: Option<String>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    /// Raw provider arguments. The adapter never puts this in summaries.
476    pub arguments: Option<String>,
477}
478
479impl OpenAiWireOutputItem {
480    /// Creates a function-call output item fixture.
481    pub fn function_call(
482        call_id: impl Into<String>,
483        name: impl Into<String>,
484        arguments: impl Into<String>,
485    ) -> Self {
486        Self {
487            kind: "function_call".to_string(),
488            call_id: Some(call_id.into()),
489            name: Some(name.into()),
490            arguments: Some(arguments.into()),
491            ..Self::default()
492        }
493    }
494
495    /// Creates a message output item fixture.
496    pub fn message(text: impl Into<String>) -> Self {
497        Self {
498            kind: "message".to_string(),
499            content: vec![OpenAiContentPart {
500                kind: "output_text".to_string(),
501                text: Some(text.into()),
502            }],
503            ..Self::default()
504        }
505    }
506}
507
508impl fmt::Debug for OpenAiWireOutputItem {
509    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
510        formatter
511            .debug_struct("OpenAiWireOutputItem")
512            .field("kind", &self.kind)
513            .field("content_count", &self.content.len())
514            .field("content", &self.content)
515            .field("call_id", &self.call_id)
516            .field("name", &self.name)
517            .field("arguments", &"<redacted>")
518            .field(
519                "arguments_chars",
520                &self.arguments.as_ref().map(|value| value.chars().count()),
521            )
522            .finish()
523    }
524}
525
526#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
527/// Minimal Responses content part.
528pub struct OpenAiContentPart {
529    #[serde(rename = "type")]
530    /// Provider content part type.
531    pub kind: String,
532    #[serde(skip_serializing_if = "Option::is_none")]
533    /// Text payload.
534    pub text: Option<String>,
535}
536
537impl fmt::Debug for OpenAiContentPart {
538    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
539        formatter
540            .debug_struct("OpenAiContentPart")
541            .field("kind", &self.kind)
542            .field("text", &"<redacted>")
543            .field(
544                "text_chars",
545                &self.text.as_ref().map(|value| value.chars().count()),
546            )
547            .finish()
548    }
549}
550
551#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
552/// Minimal Responses usage shape.
553pub struct OpenAiResponsesUsage {
554    /// Provider input tokens.
555    pub input_tokens: Option<u32>,
556    /// Provider output tokens.
557    pub output_tokens: Option<u32>,
558    /// Provider total tokens.
559    pub total_tokens: Option<u32>,
560}
561
562impl From<OpenAiResponsesUsage> for ProviderUsage {
563    fn from(value: OpenAiResponsesUsage) -> Self {
564        Self {
565            input_tokens: value.input_tokens,
566            output_tokens: value.output_tokens,
567            total_tokens: value.total_tokens,
568        }
569    }
570}
571
572fn role_name(role: &ProviderMessageRole) -> &'static str {
573    match role {
574        ProviderMessageRole::System => "system",
575        ProviderMessageRole::Developer => "developer",
576        ProviderMessageRole::User => "user",
577        ProviderMessageRole::Assistant => "assistant",
578        ProviderMessageRole::Tool => "tool",
579        ProviderMessageRole::Context => "user",
580    }
581}
582
583fn provider_failure(message: impl Into<String>) -> AgentError {
584    AgentError::new(
585        AgentErrorKind::ProviderFailure,
586        RetryClassification::RepairNeeded,
587        message,
588    )
589}