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
10pub mod auth;
12pub mod events;
14mod openai;
15pub mod pricing;
17pub mod responses;
19pub mod session;
21pub mod tools;
23pub mod tower;
25pub 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#[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 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 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 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
112pub const MODEL: &str = "gpt-5.6-sol";
114
115pub const CONTEXT_WINDOW_TOKENS: u64 = 272_000;
117
118#[derive(Clone, Debug, Deserialize, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct Prompt {
143 pub instruction: PromptInput,
145}
146
147impl Prompt {
148 #[must_use]
150 pub fn new(instruction: impl Into<String>) -> Self {
151 Self {
152 instruction: PromptInput::Text(instruction.into()),
153 }
154 }
155
156 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
179#[serde(untagged)]
180pub enum PromptInput {
181 Text(String),
183 Content(Vec<UserInput>),
185}
186
187impl PromptInput {
188 #[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 #[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 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
230#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
231pub enum UserInput {
232 Text {
234 text: String,
236 },
237 Image {
239 image_url: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 detail: Option<ImageDetail>,
244 },
245 LocalImage {
247 path: PathBuf,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 detail: Option<ImageDetail>,
252 },
253 Audio {
255 audio_url: String,
257 },
258 LocalAudio {
260 path: PathBuf,
262 },
263}
264
265#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
267#[serde(rename_all = "lowercase")]
268pub enum ImageDetail {
269 Auto,
271 Low,
273 High,
275 Original,
277}
278
279impl UserInput {
280 #[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 #[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 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
323pub enum ReasoningMode {
324 #[default]
326 Standard,
327 Pro,
329}
330
331impl ReasoningMode {
332 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
371pub enum Thinking {
372 None,
374 Low,
376 Medium,
378 #[default]
380 High,
381 Xhigh,
383 Max,
385}
386
387impl Thinking {
388 #[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}