Skip to main content

nanocodex_oai_api/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs, rustdoc::broken_intra_doc_links)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4
5#[cfg(all(target_family = "wasm", not(target_os = "unknown")))]
6compile_error!(
7    "nanocodex-oai-api supports native targets and hosted wasm*-unknown-unknown targets; WASI is not yet supported"
8);
9
10/// Authentication sources and managed credential snapshots.
11pub mod auth;
12/// Complete typed lifecycle events emitted around Responses operations.
13pub mod events;
14mod openai;
15/// Automatic `gpt-5.6-sol` USD estimates from provider token usage.
16pub mod pricing;
17/// Complete typed request, event, and item model for the Responses protocol.
18pub mod responses;
19/// Managed session identities, inputs, and compaction results.
20pub mod session;
21/// Tool contracts shared by agent loops and concrete tool runtimes.
22pub mod tools;
23/// Generic Tower attempt, service, retry, and streamed-output contracts.
24pub mod tower;
25/// Responses transport policy, errors, and connection statistics.
26pub mod transport;
27
28use std::{fmt, path::PathBuf, str::FromStr};
29
30use serde::{Deserialize, Serialize};
31
32pub(crate) use auth::{OpenAiAuth, OpenAiAuthError, OpenAiAuthMode, OpenAiAuthSnapshot};
33pub(crate) use events::stream::EventSink;
34pub(crate) use events::{
35    AgentEventData, AgentEventKind, AssistantEvent, ContextEvent, EventError, ModelEvent,
36    ReasoningEvent, RunEvent, ToolEvent, TransportEvent, monotonic_now_ns,
37};
38pub(crate) use openai::ModelConfig;
39pub use openai::{OpenAi, OpenAiBuilder, OpenAiError};
40pub(crate) use pricing::{CostStatus, EstimatedUsdCost};
41pub use responses::ResponseEvent;
42pub(crate) use responses::{
43    ContentItem, FunctionOutputBody, FunctionOutputContent, MessagePhase, MessageRole,
44    ResponseItem, ResponseItemId, ToolDefinition, Usage,
45};
46pub use session::{
47    CompletedResponse, Response, ResponseError, ResponseErrorKind, ResponseTurn, Session,
48    SessionBuildError, SessionBuilder,
49};
50pub(crate) use tools::ToolOutputBody;
51pub(crate) use tower::attempt::{
52    ResponsesAttempt, ResponsesAttemptFactory, ResponsesOutput, ResponsesServiceResponse,
53    TransportStats,
54};
55pub(crate) use tower::service::ResponsesService;
56pub(crate) use tower::{
57    DefaultResponsesService, ResponsesClient, ResponsesRetryPolicy, ResponsesServiceError,
58};
59pub(crate) use transport::EncodedRequest;
60pub(crate) use transport::{ResponsesError, ResponsesHistory, ResponsesTransport, RetryAdvice};
61
62pub(crate) use tower::{attempt, middleware, service, service_error, stream};
63#[cfg(not(target_family = "wasm"))]
64pub(crate) use transport::{connector, http};
65pub(crate) use transport::{socket, telemetry};
66
67/// Internal bridge for the higher-level `nanocodex-agent` crate.
68///
69/// This namespace is not a supported caller API. It keeps mutable model
70/// configuration, event emission authority, and attempt construction out of
71/// the normal rustdoc surface while allowing the separately versioned agent
72/// crate to compose this crate without duplicating those mechanics.
73#[doc(hidden)]
74pub mod __private {
75    pub use crate::{
76        events::stream::EventSink,
77        openai::{
78            CallerServiceFactory, LayeredServiceFactory, ModelConfig, ResponsesServiceFactory,
79        },
80        session::{
81            context::{ContextManager, assign_missing_response_item_id},
82            state::{ManagedSessionState, ManagedSessionStateError},
83        },
84        tower::attempt::ResponsesAttemptFactory,
85    };
86
87    /// Agent-owned context accounting and compaction policy primitives.
88    pub mod compaction {
89        pub use crate::session::compaction::{
90            auto_compact_token_limit, install_history, trigger,
91            trim_tool_outputs_to_fit_context_window,
92        };
93    }
94
95    /// Decomposes a validated client recipe for the higher-level agent driver.
96    pub fn into_openai_parts<F>(openai: crate::OpenAi<F>) -> (ModelConfig, F)
97    where
98        F: ResponsesServiceFactory,
99    {
100        openai.into_parts()
101    }
102
103    /// Installs the server-visible mapping for tools nested under Code Mode.
104    pub fn with_code_mode_tool_names(
105        profile: crate::responses::RequestProfile,
106        names: Vec<(String, String)>,
107    ) -> crate::responses::RequestProfile {
108        profile.with_code_mode_tool_names(names)
109    }
110}
111
112/// The single Responses model contract supported by this SDK.
113pub const MODEL: &str = "gpt-5.6-sol";
114
115/// Context-window size of the supported Responses model contract.
116pub const CONTEXT_WINDOW_TOKENS: u64 = 272_000;
117
118/// User input for one agent turn.
119///
120/// Session policy such as the filesystem workspace belongs to the agent
121/// builder rather than an individual prompt.
122///
123/// # Examples
124///
125/// ```
126/// use nanocodex_oai_api::{ImageDetail, Prompt, UserInput};
127///
128/// let prompt = Prompt::content([
129///     UserInput::Text {
130///         text: "Describe the deployment diagram.".to_owned(),
131///     },
132///     UserInput::Image {
133///         image_url: "https://example.com/deployment.png".to_owned(),
134///         detail: Some(ImageDetail::High),
135///     },
136/// ]);
137///
138/// assert!(!prompt.instruction.is_empty());
139/// ```
140#[derive(Clone, Debug, Deserialize, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct Prompt {
143    /// Ordered text and multimodal content for this turn.
144    pub instruction: PromptInput,
145}
146
147impl Prompt {
148    /// Creates a text-only prompt.
149    #[must_use]
150    pub fn new(instruction: impl Into<String>) -> Self {
151        Self {
152            instruction: PromptInput::Text(instruction.into()),
153        }
154    }
155
156    /// Creates a prompt from ordered content items.
157    #[must_use]
158    pub fn content(input: impl IntoIterator<Item = UserInput>) -> Self {
159        Self {
160            instruction: PromptInput::Content(input.into_iter().collect()),
161        }
162    }
163}
164
165impl From<String> for Prompt {
166    fn from(instruction: String) -> Self {
167        Self::new(instruction)
168    }
169}
170
171impl From<&str> for Prompt {
172    fn from(instruction: &str) -> Self {
173        Self::new(instruction)
174    }
175}
176
177/// Ordered input for one agent turn.
178#[derive(Clone, Debug, Deserialize, Serialize)]
179#[serde(untagged)]
180pub enum PromptInput {
181    /// A text-only instruction.
182    Text(String),
183    /// Ordered text and multimodal input items.
184    Content(Vec<UserInput>),
185}
186
187impl PromptInput {
188    /// Returns the total UTF-8 byte length of text items.
189    #[must_use]
190    pub fn text_bytes(&self) -> usize {
191        match self {
192            Self::Text(text) => text.len(),
193            Self::Content(items) => items.iter().map(UserInput::text_bytes).sum(),
194        }
195    }
196
197    /// Returns the total Unicode scalar-value count of text items.
198    #[must_use]
199    pub fn text_chars(&self) -> usize {
200        match self {
201            Self::Text(text) => text.chars().count(),
202            Self::Content(items) => items.iter().map(UserInput::text_chars).sum(),
203        }
204    }
205
206    /// Returns whether this input contains no non-whitespace text or media.
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        match self {
210            Self::Text(text) => text.trim().is_empty(),
211            Self::Content(items) => items.is_empty() || items.iter().all(UserInput::is_empty),
212        }
213    }
214}
215
216impl From<String> for PromptInput {
217    fn from(value: String) -> Self {
218        Self::Text(value)
219    }
220}
221
222impl From<&str> for PromptInput {
223    fn from(value: &str) -> Self {
224        Self::Text(value.to_owned())
225    }
226}
227
228/// One ordered user-supplied prompt item.
229#[derive(Clone, Debug, Deserialize, Serialize)]
230#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
231pub enum UserInput {
232    /// Model-visible text.
233    Text {
234        /// Text supplied by the user.
235        text: String,
236    },
237    /// An image supplied as a URL or data URL.
238    Image {
239        /// Image URL visible to the model.
240        image_url: String,
241        /// Optional image-detail policy.
242        #[serde(default, skip_serializing_if = "Option::is_none")]
243        detail: Option<ImageDetail>,
244    },
245    /// An image loaded from the local filesystem by a native runtime.
246    LocalImage {
247        /// Path to the local image.
248        path: PathBuf,
249        /// Optional image-detail policy.
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        detail: Option<ImageDetail>,
252    },
253    /// A reserved remote audio input.
254    Audio {
255        /// Audio URL retained by the input contract.
256        audio_url: String,
257    },
258    /// A reserved local audio input.
259    LocalAudio {
260        /// Path retained by the input contract.
261        path: PathBuf,
262    },
263}
264
265/// Image fidelity requested from the model.
266#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
267#[serde(rename_all = "lowercase")]
268pub enum ImageDetail {
269    /// Lets the provider select the detail level.
270    Auto,
271    /// Requests lower-resolution image processing.
272    Low,
273    /// Requests high-resolution image processing.
274    High,
275    /// Requests original-resolution image processing.
276    Original,
277}
278
279impl UserInput {
280    /// Returns the UTF-8 byte length when this is text, or zero for media.
281    #[must_use]
282    pub const fn text_bytes(&self) -> usize {
283        match self {
284            Self::Text { text } => text.len(),
285            Self::Image { .. }
286            | Self::LocalImage { .. }
287            | Self::Audio { .. }
288            | Self::LocalAudio { .. } => 0,
289        }
290    }
291
292    /// Returns the Unicode scalar-value count when this is text, or zero for media.
293    #[must_use]
294    pub fn text_chars(&self) -> usize {
295        match self {
296            Self::Text { text } => text.chars().count(),
297            Self::Image { .. }
298            | Self::LocalImage { .. }
299            | Self::Audio { .. }
300            | Self::LocalAudio { .. } => 0,
301        }
302    }
303
304    /// Returns whether this item contains neither non-whitespace text nor media.
305    #[must_use]
306    pub fn is_empty(&self) -> bool {
307        match self {
308            Self::Text { text } => text.trim().is_empty(),
309            Self::Image { .. }
310            | Self::LocalImage { .. }
311            | Self::Audio { .. }
312            | Self::LocalAudio { .. } => false,
313        }
314    }
315}
316
317/// Responses reasoning execution mode for the supported GPT-5.6 model family.
318///
319/// Standard mode preserves the default request behavior. Pro mode performs
320/// additional model work before returning one final answer and can increase
321/// latency and token usage independently of [`Thinking`].
322#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
323pub enum ReasoningMode {
324    /// Standard reasoning behavior.
325    #[default]
326    Standard,
327    /// Pro reasoning behavior.
328    Pro,
329}
330
331impl ReasoningMode {
332    /// Returns the request value used by the Responses API.
333    #[must_use]
334    pub const fn as_str(self) -> &'static str {
335        match self {
336            Self::Standard => "standard",
337            Self::Pro => "pro",
338        }
339    }
340
341    pub(crate) const fn request_value(self) -> Option<&'static str> {
342        match self {
343            Self::Standard => None,
344            Self::Pro => Some("pro"),
345        }
346    }
347}
348
349impl fmt::Display for ReasoningMode {
350    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351        formatter.write_str(self.as_str())
352    }
353}
354
355impl FromStr for ReasoningMode {
356    type Err = String;
357
358    fn from_str(value: &str) -> Result<Self, Self::Err> {
359        match value {
360            "standard" => Ok(Self::Standard),
361            "pro" => Ok(Self::Pro),
362            _ => Err(format!(
363                "invalid reasoning mode {value:?}; expected standard or pro"
364            )),
365        }
366    }
367}
368
369/// Requested model reasoning effort.
370#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
371pub enum Thinking {
372    /// Disable reasoning when supported.
373    None,
374    /// Low reasoning effort.
375    Low,
376    /// Medium reasoning effort.
377    Medium,
378    /// High reasoning effort.
379    #[default]
380    High,
381    /// Extra-high reasoning effort.
382    Xhigh,
383    /// Maximum reasoning effort.
384    Max,
385}
386
387impl Thinking {
388    /// Returns the request value used by the Responses API.
389    #[must_use]
390    pub const fn as_str(self) -> &'static str {
391        match self {
392            Self::None => "none",
393            Self::Low => "low",
394            Self::Medium => "medium",
395            Self::High => "high",
396            Self::Xhigh => "xhigh",
397            Self::Max => "max",
398        }
399    }
400}
401
402impl fmt::Display for Thinking {
403    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
404        formatter.write_str(self.as_str())
405    }
406}
407
408impl FromStr for Thinking {
409    type Err = String;
410
411    fn from_str(value: &str) -> Result<Self, Self::Err> {
412        match value {
413            "none" => Ok(Self::None),
414            "low" => Ok(Self::Low),
415            "medium" => Ok(Self::Medium),
416            "high" => Ok(Self::High),
417            "xhigh" => Ok(Self::Xhigh),
418            "max" => Ok(Self::Max),
419            _ => Err(format!(
420                "invalid reasoning effort {value:?}; expected none, low, medium, high, xhigh, or max"
421            )),
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use serde_json::json;
429
430    use super::{Prompt, ReasoningMode, Thinking};
431
432    #[test]
433    fn reasoning_configuration_parses_every_public_value() {
434        assert_eq!("standard".parse(), Ok(ReasoningMode::Standard));
435        assert_eq!("pro".parse(), Ok(ReasoningMode::Pro));
436
437        for (value, expected) in [
438            ("none", Thinking::None),
439            ("low", Thinking::Low),
440            ("medium", Thinking::Medium),
441            ("high", Thinking::High),
442            ("xhigh", Thinking::Xhigh),
443            ("max", Thinking::Max),
444        ] {
445            assert_eq!(value.parse(), Ok(expected));
446        }
447    }
448
449    #[test]
450    fn prompt_serialization_contains_only_user_input() {
451        let prompt = Prompt::new("inspect the repository");
452        assert_eq!(
453            serde_json::to_value(prompt).unwrap(),
454            json!({ "instruction": "inspect the repository" })
455        );
456    }
457
458    #[test]
459    fn prompt_deserialization_rejects_session_policy() {
460        let error = serde_json::from_value::<Prompt>(json!({
461            "instruction": "inspect the repository",
462            "workspace": "/work/project"
463        }))
464        .unwrap_err();
465        assert!(error.to_string().contains("unknown field `workspace`"));
466    }
467}