Skip to main content

myco_model/
openai_common.rs

1//! Settings and history conversion shared by the two OpenAI dialect drivers,
2//! Responses and Chat Completions.
3//!
4//! Cross-*provider* scaffolding (client, SSE loop, slot remapping) lives in
5//! [`super::driver_core`]; this is the layer above it that only the OpenAI
6//! dialects share: identical backend settings, and the same rules for
7//! rendering history text, image sources, and tool results.
8
9use super::*;
10
11/// Only explicit transient codes permit replay; prose and unknown codes never do.
12/// https://developers.openai.com/api/docs/guides/error-codes
13/// https://developers.openai.com/api/reference/resources/responses/methods/retrieve
14pub(super) fn stream_failure(code: Option<&str>, message: String) -> GenerationFailure {
15    GenerationFailure {
16        cause: GenerateError::ExecutionError(message),
17        retryable: matches!(
18            code,
19            Some("server_error" | "rate_limit_exceeded" | "slow_down" | "server_is_overloaded")
20        ),
21        retry_after: None,
22    }
23}
24
25/// Settings for either OpenAI dialect ([`BackendConfig::OpenAIResponses`] /
26/// [`BackendConfig::OpenAICompletions`]).
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct OpenAIBackendConfig {
29    /// Base URL including any path prefix, e.g. `https://api.x.ai/v1` or
30    /// `http://localhost:11434/v1`.
31    pub base_url: String,
32    pub auth_token: String,
33    pub max_output_tokens: Option<usize>,
34    pub debug_dump_api_requests: bool,
35    /// Maximum serialized request body, including history, images, tools, and prompts.
36    #[serde(default = "default_max_request_bytes")]
37    pub max_request_bytes: usize,
38    /// Transient-failure retry for this endpoint (`[gateways.NAME.retry]`).
39    #[serde(default)]
40    pub retry: RetryPolicy,
41    /// When set, request provider reasoning at this effort.
42    ///
43    /// Sent as `reasoning.effort` (Responses) or `reasoning_effort` (Chat
44    /// Completions). Servers that predate the field ignore it; OpenAI rejects
45    /// it on non-reasoning models, so those need `thinking = "none"` in the
46    /// catalog. Defaults to [`Effort::DEFAULT`] so reasoning is always requested.
47    pub effort: Option<Effort>,
48}
49
50impl Default for OpenAIBackendConfig {
51    fn default() -> Self {
52        Self {
53            // No built-in gateway: the catalog (config.toml) supplies base_url.
54            base_url: String::new(),
55            auth_token: String::new(),
56            max_output_tokens: Some(8192),
57            debug_dump_api_requests: false,
58            max_request_bytes: MAX_REQUEST_BYTES,
59            retry: RetryPolicy::default(),
60            effort: Some(Effort::DEFAULT),
61        }
62    }
63}
64
65/// Usage block shared by both OpenAI dialects. The wire spellings differ —
66/// Responses reports `input_tokens` / `output_tokens` / `input_tokens_details`,
67/// Chat Completions `prompt_tokens` / `completion_tokens` /
68/// `prompt_tokens_details` — but the shape and semantics are identical.
69#[derive(Debug, Clone, serde::Deserialize)]
70pub(super) struct OpenAIUsage {
71    #[serde(default, alias = "prompt_tokens")]
72    input_tokens: u64,
73    #[serde(default, alias = "completion_tokens")]
74    output_tokens: u64,
75    #[serde(default, alias = "prompt_tokens_details")]
76    input_tokens_details: Option<OpenAIInputTokensDetails>,
77}
78
79#[derive(Debug, Clone, serde::Deserialize)]
80pub(super) struct OpenAIInputTokensDetails {
81    #[serde(default)]
82    cached_tokens: Option<u64>,
83}
84
85impl OpenAIUsage {
86    pub(super) fn into_token_usage(self) -> TokenUsage {
87        // input_tokens is already the full prompt; cached_tokens is a subset.
88        TokenUsage {
89            input_tokens: self.input_tokens,
90            output_tokens: self.output_tokens,
91            cached_input_tokens: self
92                .input_tokens_details
93                .and_then(|d| d.cached_tokens)
94                .unwrap_or(0),
95        }
96    }
97}
98
99/// History text: `Text` blocks joined by newlines.
100///
101/// Assistant turns carry no images; thinking is never echoed back to the
102/// provider. User images and tool-result images take the image-part paths in
103/// each dialect.
104pub(super) fn text_of(content: &[Content]) -> String {
105    content
106        .iter()
107        .filter_map(|c| match c {
108            Content::Text { text } | Content::System { text, .. } => Some(text.as_str()),
109            Content::Image { .. } | Content::Thinking { .. } => None,
110        })
111        .collect::<Vec<_>>()
112        .join("\n")
113}
114
115/// Image URL fields accept http(s) and `data:` URLs. Same source policy as the
116/// Anthropic driver: pass URLs through, treat anything else as raw base64 PNG.
117pub(super) fn image_url(source: &str) -> String {
118    if source.starts_with("http://")
119        || source.starts_with("https://")
120        || source.starts_with("data:")
121    {
122        return source.to_string();
123    }
124    format!("data:image/png;base64,{source}")
125}
126
127/// Shared algorithm for a user message's content in either dialect: `None`
128/// when the message is text-only (callers then use the plain-string wire
129/// form), or the text/image parts in order, built with the dialect's part
130/// constructors. Thinking never appears in user messages' wire content.
131pub(super) fn user_content_parts<P>(
132    content: &[Content],
133    text: impl Fn(&str) -> P,
134    image: impl Fn(&str) -> P,
135) -> Option<Vec<P>> {
136    if !content.iter().any(|c| matches!(c, Content::Image { .. })) {
137        return None;
138    }
139    Some(
140        content
141            .iter()
142            .filter_map(|c| match c {
143                Content::Text { text: t } | Content::System { text: t, .. } => Some(text(t)),
144                Content::Image { source } => Some(image(source)),
145                Content::Thinking { .. } => None,
146            })
147            .collect(),
148    )
149}
150
151/// Image sources in arrival order (e.g. a `view_image` tool result).
152pub(super) fn images_of(content: &[Content]) -> Vec<&str> {
153    content
154        .iter()
155        .filter_map(|c| match c {
156            Content::Image { source } => Some(source.as_str()),
157            Content::Text { .. } | Content::System { .. } | Content::Thinking { .. } => None,
158        })
159        .collect()
160}
161
162/// The text half of a tool result, error-prefixed. Images are carried
163/// separately by each dialect ([`images_of`]).
164pub(super) fn tool_result_text(result: &ToolResult) -> String {
165    let text = text_of(&result.content);
166    if result.is_error && !text.is_empty() {
167        format!("Error: {text}")
168    } else if result.is_error {
169        "Error".into()
170    } else {
171        text
172    }
173}
174
175/// Wire value for `reasoning.effort` (Responses) / `reasoning_effort` (Chat
176/// Completions), or `None` when the catalog opted out (`thinking = "none"`).
177///
178/// Unknown *fields* are typically ignored by OpenAI-compatible servers, but an
179/// unknown *value* is a 400: `max` is Anthropic-only (both dialects take
180/// minimal|low|medium|high), so clamp it.
181pub(super) fn reasoning_effort(
182    model: &ModelSpec,
183    backend: &OpenAIBackendConfig,
184) -> Option<&'static str> {
185    if model.thinking != ThinkingMode::Effort {
186        return None;
187    }
188    backend.effort.map(|effort| match effort {
189        Effort::Max => Effort::High.as_str(),
190        other => other.as_str(),
191    })
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn usage_reports_cached_input_as_subset() {
200        // One shared struct decodes both dialects' spellings of the same block.
201        for json in [
202            serde_json::json!({
203                "input_tokens": 10_000,
204                "output_tokens": 200,
205                "input_tokens_details": {"cached_tokens": 8_000}
206            }),
207            serde_json::json!({
208                "prompt_tokens": 10_000,
209                "completion_tokens": 200,
210                "prompt_tokens_details": {"cached_tokens": 8_000}
211            }),
212        ] {
213            let usage: OpenAIUsage = serde_json::from_value(json).expect("decode usage");
214            let usage = usage.into_token_usage();
215            assert_eq!(usage.input_tokens, 10_000);
216            assert_eq!(usage.output_tokens, 200);
217            assert_eq!(usage.cached_input_tokens, 8_000);
218            assert_eq!(usage.context_tokens(), 10_000);
219        }
220    }
221
222    #[test]
223    fn image_url_passes_urls_and_wraps_raw_base64() {
224        assert_eq!(image_url("https://x.test/a.png"), "https://x.test/a.png");
225        assert_eq!(
226            image_url("data:image/jpeg;base64,AA"),
227            "data:image/jpeg;base64,AA"
228        );
229        assert_eq!(image_url("iVBOR"), "data:image/png;base64,iVBOR");
230    }
231
232    #[test]
233    fn text_and_images_split_by_kind() {
234        let content = [
235            Content::Thinking {
236                text: "hidden".into(),
237                signature: None,
238                redacted: false,
239            },
240            Content::Text { text: "one".into() },
241            Content::Image {
242                source: "iVBOR".into(),
243            },
244            Content::Text { text: "two".into() },
245        ];
246        assert_eq!(text_of(&content), "one\ntwo");
247        assert_eq!(images_of(&content), ["iVBOR"]);
248    }
249
250    #[test]
251    fn tool_result_errors_are_prefixed() {
252        assert_eq!(tool_result_text(&ToolResult::text("ok")), "ok");
253        assert_eq!(tool_result_text(&ToolResult::err("boom")), "Error: boom");
254        assert_eq!(tool_result_text(&ToolResult::err("")), "Error");
255    }
256}