myco_model/
openai_common.rs1use super::*;
10
11pub(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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct OpenAIBackendConfig {
29 pub base_url: String,
32 pub auth_token: String,
33 pub max_output_tokens: Option<usize>,
34 pub debug_dump_api_requests: bool,
35 #[serde(default = "default_max_request_bytes")]
37 pub max_request_bytes: usize,
38 #[serde(default)]
40 pub retry: RetryPolicy,
41 pub effort: Option<Effort>,
48}
49
50impl Default for OpenAIBackendConfig {
51 fn default() -> Self {
52 Self {
53 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#[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 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
99pub(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
115pub(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
127pub(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
151pub(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
162pub(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
175pub(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 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}