1use 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)]
18pub struct OpenAiResponsesConfig {
21 pub provider_ref: String,
23 pub model: String,
25 pub endpoint_ref: String,
27 pub supports_streaming: bool,
29 pub max_input_tokens: Option<u32>,
31}
32
33impl OpenAiResponsesConfig {
34 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 pub fn endpoint_ref(mut self, endpoint_ref: impl Into<String>) -> Self {
47 self.endpoint_ref = endpoint_ref.into();
48 self
49 }
50
51 pub fn supports_streaming(mut self, supports_streaming: bool) -> Self {
53 self.supports_streaming = supports_streaming;
54 self
55 }
56
57 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
64pub trait OpenAiResponsesTransport: Send + Sync {
68 fn complete(
70 &self,
71 request: OpenAiResponsesRequest,
72 ) -> Result<OpenAiResponsesResponse, AgentError>;
73}
74
75pub trait OpenAiToolArgumentSink: Send + Sync {
78 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)]
89pub struct OpenAiCompatibleResponsesAdapter {
91 config: OpenAiResponsesConfig,
92 transport: Arc<dyn OpenAiResponsesTransport>,
93 argument_sink: Option<Arc<dyn OpenAiToolArgumentSink>>,
94}
95
96impl OpenAiCompatibleResponsesAdapter {
97 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 pub fn with_argument_sink(mut self, sink: Arc<dyn OpenAiToolArgumentSink>) -> Self {
111 self.argument_sink = Some(sink);
112 self
113 }
114
115 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)]
193pub struct OpenAiResponsesRequest {
195 pub model: String,
197 pub input: Vec<OpenAiInputMessage>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub text: Option<OpenAiTextFormatHint>,
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub tools: Vec<OpenAiToolDeclaration>,
205 pub endpoint_ref: String,
207}
208
209impl OpenAiResponsesRequest {
210 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)]
252pub struct OpenAiInputMessage {
254 pub role: String,
256 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)]
281pub struct OpenAiTextFormatHint {
283 #[serde(rename = "type")]
284 pub kind: String,
286 pub name: String,
288 pub schema_version: String,
290 pub schema_fingerprint: String,
292 pub include_schema_ref: bool,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 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)]
331pub struct OpenAiToolDeclaration {
333 #[serde(rename = "type")]
334 pub kind: String,
336 pub name: String,
338 pub description: String,
340 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)]
370pub struct OpenAiResponsesResponse {
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub id: Option<String>,
375 #[serde(skip_serializing_if = "Option::is_none")]
376 pub status: Option<String>,
378 #[serde(default, skip_serializing_if = "String::is_empty")]
379 pub output_text: String,
381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub output: Vec<OpenAiWireOutputItem>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub usage: Option<OpenAiResponsesUsage>,
387}
388
389impl OpenAiResponsesResponse {
390 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 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)]
460pub struct OpenAiWireOutputItem {
462 #[serde(rename = "type")]
463 pub kind: String,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
466 pub content: Vec<OpenAiContentPart>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub call_id: Option<String>,
471 #[serde(skip_serializing_if = "Option::is_none")]
472 pub name: Option<String>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub arguments: Option<String>,
477}
478
479impl OpenAiWireOutputItem {
480 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 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)]
527pub struct OpenAiContentPart {
529 #[serde(rename = "type")]
530 pub kind: String,
532 #[serde(skip_serializing_if = "Option::is_none")]
533 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)]
552pub struct OpenAiResponsesUsage {
554 pub input_tokens: Option<u32>,
556 pub output_tokens: Option<u32>,
558 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}