myco_model/
openai_common.rs1use super::*;
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct OpenAIBackendConfig {
15 pub base_url: String,
18 pub auth_token: String,
19 pub max_output_tokens: Option<usize>,
20 pub debug_dump_api_requests: bool,
21 #[serde(default = "default_max_request_bytes")]
23 pub max_request_bytes: usize,
24 #[serde(default)]
26 pub retry: RetryPolicy,
27 pub effort: Option<Effort>,
34}
35
36impl Default for OpenAIBackendConfig {
37 fn default() -> Self {
38 Self {
39 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#[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 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
85pub(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
101pub(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
113pub(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
137pub(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
148pub(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
161pub(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 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}