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