@pheem49/mint 1.5.5 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.codex +0 -0
  2. package/.github/FUNDING.yml +2 -0
  3. package/.github/workflows/ci.yml +45 -0
  4. package/.github/workflows/release.yml +79 -0
  5. package/Cargo.lock +5792 -0
  6. package/Cargo.toml +32 -0
  7. package/README.md +387 -353
  8. package/assets/icon.png +0 -0
  9. package/bin/mint +0 -0
  10. package/crates/mint-cli/Cargo.toml +23 -0
  11. package/crates/mint-cli/src/agent.rs +851 -0
  12. package/crates/mint-cli/src/gmail.rs +216 -0
  13. package/crates/mint-cli/src/image.rs +142 -0
  14. package/crates/mint-cli/src/main.rs +2837 -0
  15. package/crates/mint-cli/src/mcp.rs +63 -0
  16. package/crates/mint-cli/src/onboard.rs +1149 -0
  17. package/crates/mint-cli/src/setup.rs +390 -0
  18. package/crates/mint-cli/src/skills.rs +8 -0
  19. package/crates/mint-cli/src/updater.rs +279 -0
  20. package/crates/mint-core/Cargo.toml +22 -0
  21. package/crates/mint-core/src/agent_loop.rs +94 -0
  22. package/crates/mint-core/src/api_server.rs +991 -0
  23. package/crates/mint-core/src/channels.rs +248 -0
  24. package/crates/mint-core/src/chat.rs +895 -0
  25. package/crates/mint-core/src/code_tools.rs +729 -0
  26. package/crates/mint-core/src/config.rs +368 -0
  27. package/crates/mint-core/src/files.rs +159 -0
  28. package/crates/mint-core/src/knowledge.rs +541 -0
  29. package/crates/mint-core/src/lib.rs +84 -0
  30. package/crates/mint-core/src/mcp.rs +273 -0
  31. package/crates/mint-core/src/memory.rs +673 -0
  32. package/crates/mint-core/src/orchestration.rs +2157 -0
  33. package/crates/mint-core/src/pictures.rs +314 -0
  34. package/crates/mint-core/src/plugins.rs +727 -0
  35. package/crates/mint-core/src/safety.rs +416 -0
  36. package/crates/mint-core/src/semantic.rs +254 -0
  37. package/crates/mint-core/src/shell.rs +317 -0
  38. package/crates/mint-core/src/skills.rs +71 -0
  39. package/crates/mint-core/src/symbols.rs +157 -0
  40. package/crates/mint-core/src/tasks.rs +308 -0
  41. package/crates/mint-core/src/tts.rs +92 -0
  42. package/crates/mint-core/src/weather.rs +93 -0
  43. package/crates/mint-core/src/web_search.rs +200 -0
  44. package/crates/mint-core/src/workflows.rs +81 -0
  45. package/crates/mint-core/tests/mcp_stdio.rs +45 -0
  46. package/crates/mint-core/tests/memory_persistence.rs +172 -0
  47. package/crates/mint-core/tests/pictures_storage.rs +14 -0
  48. package/crates/mint-core/tests/task_lifecycle.rs +87 -0
  49. package/package.json +35 -99
  50. package/src/bin/index.js +16 -0
  51. package/src/renderer/index-web.html +17 -0
  52. package/src/renderer/index.html +17 -0
  53. package/src/renderer/public/Live2DCubismCore.js +9 -0
  54. package/src/renderer/public/assets/icon.png +0 -0
  55. package/src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.model3.json +36 -0
  56. package/src/renderer/src/App.tsx +33 -0
  57. package/src/renderer/src/calculator.ts +47 -0
  58. package/src/renderer/src/components/ChatPanel.tsx +1598 -0
  59. package/src/renderer/src/components/DashboardSidebar.tsx +358 -0
  60. package/src/renderer/src/components/Live2DStage.tsx +374 -0
  61. package/src/renderer/src/components/MintDashboard.tsx +950 -0
  62. package/src/renderer/src/components/ModelPanel.tsx +154 -0
  63. package/src/renderer/src/components/PicturesLibrary.tsx +46 -0
  64. package/src/renderer/src/components/ProactiveGlow.tsx +19 -0
  65. package/src/renderer/src/components/ScreenPicker.tsx +579 -0
  66. package/src/renderer/src/components/SettingsWindow.tsx +1467 -0
  67. package/src/renderer/src/components/SpotlightWindow.tsx +280 -0
  68. package/src/renderer/src/components/WidgetWindow.tsx +36 -0
  69. package/src/renderer/src/components/WorkspacePanel.tsx +268 -0
  70. package/src/{UI → renderer/src/css}/settings.css +69 -16
  71. package/src/renderer/src/css/spotlight.css +113 -0
  72. package/src/renderer/src/css/styles.css +3722 -0
  73. package/src/renderer/src/css/widget.css +185 -0
  74. package/src/renderer/src/env.d.ts +116 -0
  75. package/src/renderer/src/index.css +379 -0
  76. package/src/renderer/src/main.tsx +13 -0
  77. package/src/renderer/src/tauri.ts +996 -0
  78. package/src/renderer/src-web/App.tsx +25 -0
  79. package/src/renderer/src-web/calculator.ts +47 -0
  80. package/src/renderer/src-web/components/ChatPanel.tsx +1662 -0
  81. package/src/renderer/src-web/components/DashboardSidebar.tsx +242 -0
  82. package/src/renderer/src-web/components/MintDashboard.tsx +763 -0
  83. package/src/renderer/src-web/components/PicturesLibrary.tsx +73 -0
  84. package/src/renderer/src-web/components/SettingsWindow.tsx +1500 -0
  85. package/src/renderer/src-web/css/settings.css +1100 -0
  86. package/src/{UI → renderer/src-web/css}/spotlight.css +4 -4
  87. package/src/{UI → renderer/src-web/css}/styles.css +1055 -159
  88. package/src/{UI → renderer/src-web/css}/widget.css +2 -2
  89. package/src/renderer/src-web/env.d.ts +107 -0
  90. package/src/renderer/src-web/index.css +379 -0
  91. package/src/renderer/src-web/main.tsx +13 -0
  92. package/src/renderer/src-web/tauri.ts +983 -0
  93. package/tsconfig.json +30 -0
  94. package/vite.config.ts +33 -0
  95. package/vite.config.web.ts +51 -0
  96. package/GUIDE_TH.md +0 -125
  97. package/assets/Agent_Mint.png +0 -0
  98. package/assets/CLI_Screen.png +0 -0
  99. package/assets/Settings.png +0 -0
  100. package/benchmark_ai.js +0 -71
  101. package/install.ps1 +0 -64
  102. package/install.sh +0 -54
  103. package/main.js +0 -139
  104. package/mint-cli-logic.js +0 -3
  105. package/mint-cli.js +0 -410
  106. package/models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.model3.json +0 -47
  107. package/models/Shiroko_Model/Shiroko//342/232/241/351/253/230/344/272/256/342/232/241/344/275/277/347/224/250/346/225/231/347/250/213/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.txt +0 -23
  108. package/preload-picker.js +0 -11
  109. package/preload-settings.js +0 -11
  110. package/preload.js +0 -41
  111. package/scripts/install_linux_desktop_entry.js +0 -48
  112. package/src/AI_Brain/Gemini_API.js +0 -813
  113. package/src/AI_Brain/agent_orchestrator.js +0 -73
  114. package/src/AI_Brain/autonomous_brain.js +0 -179
  115. package/src/AI_Brain/behavior_memory.js +0 -135
  116. package/src/AI_Brain/headless_agent.js +0 -143
  117. package/src/AI_Brain/knowledge_base.js +0 -349
  118. package/src/AI_Brain/memory_store.js +0 -662
  119. package/src/AI_Brain/proactive_engine.js +0 -172
  120. package/src/AI_Brain/provider_adapter.js +0 -365
  121. package/src/Automation_Layer/browser_automation.js +0 -149
  122. package/src/Automation_Layer/file_operations.js +0 -286
  123. package/src/Automation_Layer/open_app.js +0 -85
  124. package/src/Automation_Layer/open_website.js +0 -38
  125. package/src/CLI/approval_handler.js +0 -47
  126. package/src/CLI/chat_router.js +0 -247
  127. package/src/CLI/chat_ui.js +0 -1159
  128. package/src/CLI/cli_colors.js +0 -115
  129. package/src/CLI/cli_formatters.js +0 -94
  130. package/src/CLI/code_agent.js +0 -1667
  131. package/src/CLI/code_session_memory.js +0 -62
  132. package/src/CLI/gmail_auth.js +0 -210
  133. package/src/CLI/image_input.js +0 -90
  134. package/src/CLI/intent_detectors.js +0 -181
  135. package/src/CLI/interactive_chat.js +0 -658
  136. package/src/CLI/list_features.js +0 -64
  137. package/src/CLI/onboarding.js +0 -416
  138. package/src/CLI/repo_summarizer.js +0 -282
  139. package/src/CLI/semantic_code_search.js +0 -312
  140. package/src/CLI/skill_manager.js +0 -41
  141. package/src/CLI/slash_command_handler.js +0 -418
  142. package/src/CLI/symbol_indexer.js +0 -231
  143. package/src/CLI/updater.js +0 -230
  144. package/src/CLI/workspace_manager.js +0 -90
  145. package/src/Channels/brave_search_bridge.js +0 -35
  146. package/src/Channels/discord_bridge.js +0 -66
  147. package/src/Channels/google_search_bridge.js +0 -38
  148. package/src/Channels/line_bridge.js +0 -60
  149. package/src/Channels/slack_bridge.js +0 -48
  150. package/src/Channels/telegram_bridge.js +0 -41
  151. package/src/Channels/whatsapp_bridge.js +0 -57
  152. package/src/Command_Parser/parser.js +0 -45
  153. package/src/Plugins/dev_tools.js +0 -41
  154. package/src/Plugins/discord.js +0 -20
  155. package/src/Plugins/docker.js +0 -47
  156. package/src/Plugins/gmail.js +0 -251
  157. package/src/Plugins/google_calendar.js +0 -252
  158. package/src/Plugins/mcp_manager.js +0 -95
  159. package/src/Plugins/notion.js +0 -256
  160. package/src/Plugins/obsidian.js +0 -54
  161. package/src/Plugins/plugin_manager.js +0 -81
  162. package/src/Plugins/spotify.js +0 -173
  163. package/src/Plugins/system_metrics.js +0 -31
  164. package/src/Plugins/system_monitor.js +0 -72
  165. package/src/System/action_executor.js +0 -178
  166. package/src/System/bridge_manager.js +0 -76
  167. package/src/System/chat_history_manager.js +0 -83
  168. package/src/System/config_manager.js +0 -194
  169. package/src/System/custom_workflows.js +0 -163
  170. package/src/System/daemon_manager.js +0 -67
  171. package/src/System/google_tts_urls.js +0 -51
  172. package/src/System/granular_automation.js +0 -157
  173. package/src/System/ipc_handlers.js +0 -332
  174. package/src/System/notifications.js +0 -23
  175. package/src/System/optional_require.js +0 -23
  176. package/src/System/picture_store.js +0 -109
  177. package/src/System/proactive_loop.js +0 -153
  178. package/src/System/safety_manager.js +0 -273
  179. package/src/System/sandbox_runner.js +0 -182
  180. package/src/System/screen_capture.js +0 -175
  181. package/src/System/smart_context.js +0 -227
  182. package/src/System/system_automation.js +0 -162
  183. package/src/System/system_events.js +0 -79
  184. package/src/System/system_info.js +0 -125
  185. package/src/System/task_manager.js +0 -222
  186. package/src/System/tool_registry.js +0 -293
  187. package/src/System/window_manager.js +0 -220
  188. package/src/UI/floating.css +0 -80
  189. package/src/UI/floating.html +0 -17
  190. package/src/UI/floating.js +0 -67
  191. package/src/UI/live2d_manager.js +0 -600
  192. package/src/UI/preload-floating.js +0 -7
  193. package/src/UI/preload-spotlight.js +0 -11
  194. package/src/UI/preload-widget.js +0 -5
  195. package/src/UI/proactive-glow.html +0 -42
  196. package/src/UI/renderer.js +0 -2127
  197. package/src/UI/screenPicker.html +0 -214
  198. package/src/UI/screenPicker.js +0 -262
  199. package/src/UI/settings.html +0 -577
  200. package/src/UI/settings.js +0 -770
  201. package/src/UI/spotlight.html +0 -23
  202. package/src/UI/spotlight.js +0 -185
  203. package/src/UI/widget.html +0 -29
  204. package/src/UI/widget.js +0 -10
  205. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/72d86db84cfa9730b894c241fd24c0db.png +0 -0
  206. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/233/264/350/243/231.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/apron.exp3.json} +0 -0
  207. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/214/253/345/222/252/346/273/244/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/catfilter.exp3.json} +0 -0
  208. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/202/271/344/270/200/344/270/213.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/click.exp3.json} +0 -0
  209. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazed.exp3.json} +0 -0
  210. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//345/221/206/347/214/253/347/234/274/347/217/240/346/221/207/346/231/203.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/dazedeyes.exp3.json} +0 -0
  211. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//347/234/274/351/225/234.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/glasses.exp3.json} +0 -0
  212. /package/{models → src/renderer/public/models}/Shiroko_Model/Shiroko/Shiroko_Core/items_pinned_to_model.json +0 -0
  213. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/277/347/254/224.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/pen.exp3.json} +0 -0
  214. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//346/213/215/347/205/247.exp3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/photo.exp3.json} +0 -0
  215. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_00.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_00.png} +0 -0
  216. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_01.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_01.png} +0 -0
  217. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_02.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_02.png} +0 -0
  218. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.4096/texture_03.png" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.4096/texture_03.png} +0 -0
  219. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.cdi3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.cdi3.json} +0 -0
  220. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.moc3" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.moc3} +0 -0
  221. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.physics3.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.physics3.json} +0 -0
  222. /package/{models/Shiroko_Model/Shiroko/Shiroko_Core//351/235/242/351/245/2740.vtube.json" → src/renderer/public/models/Shiroko_Model/Shiroko/Shiroko_Core/shiroko.vtube.json} +0 -0
@@ -0,0 +1,895 @@
1
+ use futures_util::StreamExt;
2
+ use reqwest::Client;
3
+ use serde::{Deserialize, Serialize};
4
+ use serde_json::{Value, json};
5
+ use thiserror::Error;
6
+
7
+ use crate::MintConfig;
8
+
9
+ /// Send a chat request, automatically falling back to other configured providers
10
+ /// if the primary one returns a recoverable error.
11
+ /// Returns `(response, Option<fallback_provider>)`.
12
+ pub async fn send_chat_with_fallback(
13
+ config: &MintConfig,
14
+ request: &ChatRequest,
15
+ ) -> Result<(ChatResponse, Option<String>), ChatError> {
16
+ match send_chat(config, request).await {
17
+ Ok(r) => return Ok((r, None)),
18
+ Err(e) if !is_recoverable(&e) => return Err(e),
19
+ Err(_) => {}
20
+ }
21
+ for provider in config.available_providers() {
22
+ if provider == config.ai_provider.as_str() {
23
+ continue;
24
+ }
25
+ let alt = config_for_provider(config, provider);
26
+ if let Ok(mut r) = send_chat(&alt, request).await {
27
+ r.fallback_provider = Some(config.ai_provider.clone());
28
+ return Ok((r, Some(provider.to_owned())));
29
+ }
30
+ }
31
+ // all fallbacks failed — retry primary to surface original error
32
+ send_chat(config, request).await.map(|r| (r, None))
33
+ }
34
+
35
+ /// Stream a chat request with automatic provider fallback.
36
+ /// Returns `(response, Option<fallback_provider>)`.
37
+ pub async fn stream_chat_with_fallback<F>(
38
+ config: &MintConfig,
39
+ request: &ChatRequest,
40
+ mut on_chunk: F,
41
+ ) -> Result<(ChatResponse, Option<String>), ChatError>
42
+ where
43
+ F: FnMut(String),
44
+ {
45
+ match stream_chat(config, request, &mut on_chunk).await {
46
+ Ok(r) => return Ok((r, None)),
47
+ Err(e) if !is_recoverable(&e) => return Err(e),
48
+ Err(e) => {
49
+ eprintln!("Ollama stream chat failed, falling back: {:?}", e);
50
+ }
51
+ }
52
+ for provider in config.available_providers() {
53
+ if provider == config.ai_provider.as_str() {
54
+ continue;
55
+ }
56
+ let alt = config_for_provider(config, provider);
57
+ if let Ok(mut r) = stream_chat(&alt, request, &mut on_chunk).await {
58
+ r.fallback_provider = Some(config.ai_provider.clone());
59
+ return Ok((r, Some(provider.to_owned())));
60
+ }
61
+ }
62
+ stream_chat(config, request, &mut on_chunk)
63
+ .await
64
+ .map(|r| (r, None))
65
+ }
66
+
67
+ /// Whether an error warrants trying another provider.
68
+ fn is_recoverable(e: &ChatError) -> bool {
69
+ matches!(
70
+ e,
71
+ ChatError::MissingApiKey(_)
72
+ | ChatError::Request(_)
73
+ | ChatError::MissingResponseText
74
+ | ChatError::UnsupportedAttachments(_)
75
+ )
76
+ }
77
+
78
+ /// Clone the config with a different active provider.
79
+ fn config_for_provider(config: &MintConfig, provider: &str) -> MintConfig {
80
+ MintConfig {
81
+ ai_provider: provider.to_owned(),
82
+ ..config.clone()
83
+ }
84
+ }
85
+
86
+ #[derive(Debug, Clone, Deserialize)]
87
+ #[serde(rename_all = "camelCase")]
88
+ pub struct ChatRequest {
89
+ pub message: String,
90
+ #[serde(default)]
91
+ pub system_instruction: String,
92
+ #[serde(default)]
93
+ pub chat_id: Option<String>,
94
+ #[serde(default)]
95
+ pub image_data_uri: Option<String>,
96
+ #[serde(default)]
97
+ pub audio_data_uri: Option<String>,
98
+ #[serde(default)]
99
+ pub document_attachment: Option<DocumentAttachment>,
100
+ #[serde(default)]
101
+ pub workspace_path: Option<String>,
102
+ }
103
+
104
+ #[derive(Debug, Clone, Deserialize)]
105
+ #[serde(rename_all = "camelCase")]
106
+ pub struct DocumentAttachment {
107
+ pub filename: String,
108
+ pub data_uri: String,
109
+ }
110
+
111
+ #[derive(Debug, Clone, Serialize)]
112
+ #[serde(rename_all = "camelCase")]
113
+ pub struct ChatResponse {
114
+ pub provider: String,
115
+ pub model: String,
116
+ pub text: String,
117
+ #[serde(skip_serializing_if = "Option::is_none")]
118
+ pub fallback_provider: Option<String>,
119
+ }
120
+
121
+ #[derive(Debug, Error)]
122
+ pub enum ChatError {
123
+ #[error("provider '{0}' is not implemented in the Rust backend yet")]
124
+ UnsupportedProvider(String),
125
+ #[error("missing API key for provider '{0}'")]
126
+ MissingApiKey(String),
127
+ #[error("provider request failed: {0}")]
128
+ Request(#[from] reqwest::Error),
129
+ #[error("provider response did not include assistant text")]
130
+ MissingResponseText,
131
+ #[error("provider '{0}' does not support Mint multimodal attachments yet")]
132
+ UnsupportedAttachments(String),
133
+ #[error("invalid multimodal data URI")]
134
+ InvalidAttachment,
135
+ }
136
+
137
+ pub async fn send_chat(
138
+ config: &MintConfig,
139
+ request: &ChatRequest,
140
+ ) -> Result<ChatResponse, ChatError> {
141
+ let client = crate::HTTP_CLIENT.clone();
142
+ let provider = config.ai_provider.as_str();
143
+ require_supported_attachments(provider, request)?;
144
+ let (model, text) = match provider {
145
+ "gemini" => call_gemini(&client, config, request).await?,
146
+ "openai" | "local_openai" | "openrouter" | "deepseek" => {
147
+ call_openai(&client, config, request).await?
148
+ }
149
+ "ollama" => call_ollama(&client, config, request).await?,
150
+ "anthropic" => call_anthropic(&client, config, request).await?,
151
+ "huggingface" => call_huggingface(&client, config, request).await?,
152
+ other => return Err(ChatError::UnsupportedProvider(other.into())),
153
+ };
154
+ Ok(ChatResponse {
155
+ provider: provider.into(),
156
+ model,
157
+ text,
158
+ fallback_provider: None,
159
+ })
160
+ }
161
+
162
+ pub async fn stream_chat<F>(
163
+ config: &MintConfig,
164
+ request: &ChatRequest,
165
+ mut on_chunk: F,
166
+ ) -> Result<ChatResponse, ChatError>
167
+ where
168
+ F: FnMut(String),
169
+ {
170
+ let client = crate::HTTP_CLIENT.clone();
171
+ let provider = config.ai_provider.as_str();
172
+ require_supported_attachments(provider, request)?;
173
+ let (model, text) = match provider {
174
+ "gemini" => stream_gemini(&client, config, request, &mut on_chunk).await?,
175
+ "openai" | "local_openai" | "openrouter" | "deepseek" => {
176
+ stream_openai(&client, config, request, &mut on_chunk).await?
177
+ }
178
+ "ollama" => stream_ollama(&client, config, request, &mut on_chunk).await?,
179
+ "anthropic" => stream_anthropic(&client, config, request, &mut on_chunk).await?,
180
+ "huggingface" => stream_huggingface(&client, config, request, &mut on_chunk).await?,
181
+ other => return Err(ChatError::UnsupportedProvider(other.into())),
182
+ };
183
+ Ok(ChatResponse {
184
+ provider: provider.into(),
185
+ model,
186
+ text,
187
+ fallback_provider: None,
188
+ })
189
+ }
190
+
191
+ async fn call_gemini(
192
+ client: &Client,
193
+ config: &MintConfig,
194
+ request: &ChatRequest,
195
+ ) -> Result<(String, String), ChatError> {
196
+ let api_key = provider_key(&config.api_key, "GEMINI_API_KEY");
197
+ required_key("gemini", &api_key)?;
198
+ let model = config.gemini_model.clone();
199
+ let url =
200
+ format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");
201
+
202
+ let response: Value = client
203
+ .post(url)
204
+ .header("x-goog-api-key", api_key)
205
+ .json(&gemini_chat_payload(config, request)?)
206
+ .send()
207
+ .await?
208
+ .error_for_status()?
209
+ .json()
210
+ .await?;
211
+ Ok((
212
+ model,
213
+ response["candidates"][0]["content"]["parts"][0]["text"]
214
+ .as_str()
215
+ .ok_or(ChatError::MissingResponseText)?
216
+ .into(),
217
+ ))
218
+ }
219
+
220
+ async fn call_openai(
221
+ client: &Client,
222
+ config: &MintConfig,
223
+ request: &ChatRequest,
224
+ ) -> Result<(String, String), ChatError> {
225
+ let local = config.ai_provider == "local_openai";
226
+ let openrouter = config.ai_provider == "openrouter";
227
+ let deepseek = config.ai_provider == "deepseek";
228
+ let api_key = if openrouter {
229
+ provider_key(&config.openrouter_api_key, "OPENROUTER_API_KEY")
230
+ } else if deepseek {
231
+ provider_key(&config.deepseek_api_key, "DEEPSEEK_API_KEY")
232
+ } else {
233
+ provider_key(&config.openai_api_key, "OPENAI_API_KEY")
234
+ };
235
+ if openrouter {
236
+ required_key("openrouter", &api_key)?;
237
+ } else if deepseek {
238
+ required_key("deepseek", &api_key)?;
239
+ } else if !local {
240
+ required_key("openai", &api_key)?;
241
+ }
242
+ let base_url = if openrouter {
243
+ "https://openrouter.ai/api/v1"
244
+ } else if deepseek {
245
+ "https://api.deepseek.com"
246
+ } else if local {
247
+ config.local_api_base_url.trim_end_matches('/')
248
+ } else {
249
+ "https://api.openai.com/v1"
250
+ };
251
+ let model = if openrouter {
252
+ config.openrouter_model.clone()
253
+ } else if deepseek {
254
+ config.deepseek_model.clone()
255
+ } else if local {
256
+ config.local_model_name.clone()
257
+ } else {
258
+ config.openai_model.clone()
259
+ };
260
+ let response: Value = client
261
+ .post(format!("{base_url}/chat/completions"))
262
+ .bearer_auth(if local { "not-needed" } else { &api_key })
263
+ .json(&openai_chat_payload(&model, request, false))
264
+ .send()
265
+ .await?
266
+ .error_for_status()?
267
+ .json()
268
+ .await?;
269
+ Ok((
270
+ model,
271
+ response["choices"][0]["message"]["content"]
272
+ .as_str()
273
+ .ok_or(ChatError::MissingResponseText)?
274
+ .into(),
275
+ ))
276
+ }
277
+
278
+ async fn call_ollama(
279
+ client: &Client,
280
+ config: &MintConfig,
281
+ request: &ChatRequest,
282
+ ) -> Result<(String, String), ChatError> {
283
+ let host = if config.ollama_host.trim().is_empty() {
284
+ "http://localhost:11434"
285
+ } else {
286
+ config.ollama_host.trim_end_matches('/')
287
+ };
288
+ let model = config.ollama_model.clone();
289
+ let mut user_message = json!({ "role": "user", "content": request.message });
290
+ if let Some(images) = ollama_images(request) {
291
+ user_message["images"] = json!(images);
292
+ }
293
+ let response: Value = client
294
+ .post(format!("{host}/api/chat"))
295
+ .json(&json!({
296
+ "model": model,
297
+ "stream": false,
298
+ "messages": [
299
+ { "role": "system", "content": request.system_instruction },
300
+ user_message
301
+ ]
302
+ }))
303
+ .send()
304
+ .await?
305
+ .error_for_status()?
306
+ .json()
307
+ .await?;
308
+ Ok((
309
+ model,
310
+ response["message"]["content"]
311
+ .as_str()
312
+ .ok_or(ChatError::MissingResponseText)?
313
+ .into(),
314
+ ))
315
+ }
316
+
317
+ async fn call_anthropic(
318
+ client: &Client,
319
+ config: &MintConfig,
320
+ request: &ChatRequest,
321
+ ) -> Result<(String, String), ChatError> {
322
+ let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
323
+ required_key("anthropic", &api_key)?;
324
+ let model = config.anthropic_model.clone();
325
+ let response: Value = client
326
+ .post("https://api.anthropic.com/v1/messages")
327
+ .header("x-api-key", api_key)
328
+ .header("anthropic-version", "2023-06-01")
329
+ .json(&json!({
330
+ "model": model,
331
+ "max_tokens": 8192,
332
+ "system": request.system_instruction,
333
+ "messages": [{ "role": "user", "content": request.message }]
334
+ }))
335
+ .send()
336
+ .await?
337
+ .error_for_status()?
338
+ .json()
339
+ .await?;
340
+ Ok((
341
+ model,
342
+ response["content"][0]["text"]
343
+ .as_str()
344
+ .ok_or(ChatError::MissingResponseText)?
345
+ .into(),
346
+ ))
347
+ }
348
+
349
+ async fn call_huggingface(
350
+ client: &Client,
351
+ config: &MintConfig,
352
+ request: &ChatRequest,
353
+ ) -> Result<(String, String), ChatError> {
354
+ let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
355
+ required_key("huggingface", &api_key)?;
356
+ let model = config.hf_model.clone();
357
+ let response: Value = client
358
+ .post("https://router.huggingface.co/v1/chat/completions")
359
+ .bearer_auth(api_key)
360
+ .json(&json!({
361
+ "model": model,
362
+ "messages": [
363
+ { "role": "system", "content": request.system_instruction },
364
+ { "role": "user", "content": request.message }
365
+ ]
366
+ }))
367
+ .send()
368
+ .await?
369
+ .error_for_status()?
370
+ .json()
371
+ .await?;
372
+ Ok((
373
+ model,
374
+ response["choices"][0]["message"]["content"]
375
+ .as_str()
376
+ .ok_or(ChatError::MissingResponseText)?
377
+ .into(),
378
+ ))
379
+ }
380
+
381
+ async fn stream_gemini<F>(
382
+ client: &Client,
383
+ config: &MintConfig,
384
+ request: &ChatRequest,
385
+ on_chunk: &mut F,
386
+ ) -> Result<(String, String), ChatError>
387
+ where
388
+ F: FnMut(String),
389
+ {
390
+ let api_key = provider_key(&config.api_key, "GEMINI_API_KEY");
391
+ required_key("gemini", &api_key)?;
392
+ let model = config.gemini_model.clone();
393
+
394
+ let response = client
395
+ .post(format!(
396
+ "https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse"
397
+ ))
398
+ .header("x-goog-api-key", api_key)
399
+ .json(&gemini_chat_payload(config, request)?)
400
+ .send()
401
+ .await?
402
+ .error_for_status()?;
403
+ collect_stream(response, StreamFormat::Gemini, on_chunk)
404
+ .await
405
+ .map(|text| (model, text))
406
+ }
407
+
408
+ async fn stream_openai<F>(
409
+ client: &Client,
410
+ config: &MintConfig,
411
+ request: &ChatRequest,
412
+ on_chunk: &mut F,
413
+ ) -> Result<(String, String), ChatError>
414
+ where
415
+ F: FnMut(String),
416
+ {
417
+ let local = config.ai_provider == "local_openai";
418
+ let openrouter = config.ai_provider == "openrouter";
419
+ let deepseek = config.ai_provider == "deepseek";
420
+ let api_key = if openrouter {
421
+ provider_key(&config.openrouter_api_key, "OPENROUTER_API_KEY")
422
+ } else if deepseek {
423
+ provider_key(&config.deepseek_api_key, "DEEPSEEK_API_KEY")
424
+ } else {
425
+ provider_key(&config.openai_api_key, "OPENAI_API_KEY")
426
+ };
427
+ if openrouter {
428
+ required_key("openrouter", &api_key)?;
429
+ } else if deepseek {
430
+ required_key("deepseek", &api_key)?;
431
+ } else if !local {
432
+ required_key("openai", &api_key)?;
433
+ }
434
+ let base_url = if openrouter {
435
+ "https://openrouter.ai/api/v1"
436
+ } else if deepseek {
437
+ "https://api.deepseek.com"
438
+ } else if local {
439
+ config.local_api_base_url.trim_end_matches('/')
440
+ } else {
441
+ "https://api.openai.com/v1"
442
+ };
443
+ let model = if openrouter {
444
+ config.openrouter_model.clone()
445
+ } else if deepseek {
446
+ config.deepseek_model.clone()
447
+ } else if local {
448
+ config.local_model_name.clone()
449
+ } else {
450
+ config.openai_model.clone()
451
+ };
452
+ let response = client
453
+ .post(format!("{base_url}/chat/completions"))
454
+ .bearer_auth(if local { "not-needed" } else { &api_key })
455
+ .json(&openai_chat_payload(&model, request, true))
456
+ .send()
457
+ .await?
458
+ .error_for_status()?;
459
+ collect_stream(response, StreamFormat::OpenAi, on_chunk)
460
+ .await
461
+ .map(|text| (model, text))
462
+ }
463
+
464
+ async fn stream_ollama<F>(
465
+ client: &Client,
466
+ config: &MintConfig,
467
+ request: &ChatRequest,
468
+ on_chunk: &mut F,
469
+ ) -> Result<(String, String), ChatError>
470
+ where
471
+ F: FnMut(String),
472
+ {
473
+ let host = if config.ollama_host.trim().is_empty() {
474
+ "http://localhost:11434"
475
+ } else {
476
+ config.ollama_host.trim_end_matches('/')
477
+ };
478
+ let model = config.ollama_model.clone();
479
+ let mut user_message = json!({ "role": "user", "content": request.message });
480
+ if let Some(images) = ollama_images(request) {
481
+ user_message["images"] = json!(images);
482
+ }
483
+ let response = client
484
+ .post(format!("{host}/api/chat"))
485
+ .json(&json!({
486
+ "model": model,
487
+ "stream": true,
488
+ "messages": [
489
+ { "role": "system", "content": request.system_instruction },
490
+ user_message
491
+ ]
492
+ }))
493
+ .send()
494
+ .await?
495
+ .error_for_status()?;
496
+ collect_stream(response, StreamFormat::Ollama, on_chunk)
497
+ .await
498
+ .map(|text| (model, text))
499
+ }
500
+
501
+ async fn stream_anthropic<F>(
502
+ client: &Client,
503
+ config: &MintConfig,
504
+ request: &ChatRequest,
505
+ on_chunk: &mut F,
506
+ ) -> Result<(String, String), ChatError>
507
+ where
508
+ F: FnMut(String),
509
+ {
510
+ let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
511
+ required_key("anthropic", &api_key)?;
512
+ let model = config.anthropic_model.clone();
513
+ let response = client
514
+ .post("https://api.anthropic.com/v1/messages")
515
+ .header("x-api-key", api_key)
516
+ .header("anthropic-version", "2023-06-01")
517
+ .json(&json!({
518
+ "model": model,
519
+ "max_tokens": 8192,
520
+ "stream": true,
521
+ "system": request.system_instruction,
522
+ "messages": [{ "role": "user", "content": request.message }]
523
+ }))
524
+ .send()
525
+ .await?
526
+ .error_for_status()?;
527
+ collect_stream(response, StreamFormat::Anthropic, on_chunk)
528
+ .await
529
+ .map(|text| (model, text))
530
+ }
531
+
532
+ async fn stream_huggingface<F>(
533
+ client: &Client,
534
+ config: &MintConfig,
535
+ request: &ChatRequest,
536
+ on_chunk: &mut F,
537
+ ) -> Result<(String, String), ChatError>
538
+ where
539
+ F: FnMut(String),
540
+ {
541
+ let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
542
+ required_key("huggingface", &api_key)?;
543
+ let model = config.hf_model.clone();
544
+ let response = client
545
+ .post("https://router.huggingface.co/v1/chat/completions")
546
+ .bearer_auth(api_key)
547
+ .json(&json!({
548
+ "model": model,
549
+ "stream": true,
550
+ "messages": [
551
+ { "role": "system", "content": request.system_instruction },
552
+ { "role": "user", "content": request.message }
553
+ ]
554
+ }))
555
+ .send()
556
+ .await?
557
+ .error_for_status()?;
558
+ collect_stream(response, StreamFormat::OpenAi, on_chunk)
559
+ .await
560
+ .map(|text| (model, text))
561
+ }
562
+
563
+ #[derive(Clone, Copy)]
564
+ enum StreamFormat {
565
+ Gemini,
566
+ OpenAi,
567
+ Ollama,
568
+ Anthropic,
569
+ }
570
+
571
+ async fn collect_stream<F>(
572
+ response: reqwest::Response,
573
+ format: StreamFormat,
574
+ on_chunk: &mut F,
575
+ ) -> Result<String, ChatError>
576
+ where
577
+ F: FnMut(String),
578
+ {
579
+ let mut bytes = response.bytes_stream();
580
+ let mut buffer = String::new();
581
+ let mut text = String::new();
582
+ while let Some(chunk) = bytes.next().await {
583
+ buffer.push_str(&String::from_utf8_lossy(&chunk?));
584
+ while let Some(index) = buffer.find('\n') {
585
+ let line = buffer[..index].trim().to_owned();
586
+ buffer.drain(..=index);
587
+ if let Some(chunk) = parse_stream_line(format, &line) {
588
+ on_chunk(chunk.clone());
589
+ text.push_str(&chunk);
590
+ }
591
+ }
592
+ }
593
+ if let Some(chunk) = parse_stream_line(format, buffer.trim()) {
594
+ on_chunk(chunk.clone());
595
+ text.push_str(&chunk);
596
+ }
597
+ if text.is_empty() {
598
+ Err(ChatError::MissingResponseText)
599
+ } else {
600
+ Ok(text)
601
+ }
602
+ }
603
+
604
+ fn parse_stream_line(format: StreamFormat, line: &str) -> Option<String> {
605
+ let payload = match format {
606
+ StreamFormat::Ollama => line,
607
+ _ => line.strip_prefix("data: ")?.trim(),
608
+ };
609
+ if payload.is_empty() || payload == "[DONE]" {
610
+ return None;
611
+ }
612
+ let value: Value = serde_json::from_str(payload).ok()?;
613
+ let text = match format {
614
+ StreamFormat::Gemini => value["candidates"][0]["content"]["parts"][0]["text"].as_str(),
615
+ StreamFormat::OpenAi => value["choices"][0]["delta"]["content"].as_str(),
616
+ StreamFormat::Ollama => value["message"]["content"].as_str(),
617
+ StreamFormat::Anthropic => value["delta"]["text"].as_str(),
618
+ }?;
619
+ (!text.is_empty()).then(|| text.to_owned())
620
+ }
621
+
622
+ fn required_key(provider: &str, key: &str) -> Result<(), ChatError> {
623
+ if key.trim().is_empty() {
624
+ Err(ChatError::MissingApiKey(provider.into()))
625
+ } else {
626
+ Ok(())
627
+ }
628
+ }
629
+
630
+ fn provider_key(configured: &str, environment_variable: &str) -> String {
631
+ if configured.trim().is_empty() {
632
+ std::env::var(environment_variable).unwrap_or_default()
633
+ } else {
634
+ configured.into()
635
+ }
636
+ }
637
+
638
+ fn require_supported_attachments(provider: &str, request: &ChatRequest) -> Result<(), ChatError> {
639
+ let has_image = request
640
+ .image_data_uri
641
+ .as_ref()
642
+ .map_or(false, |s| !s.trim().is_empty());
643
+ let has_audio = request
644
+ .audio_data_uri
645
+ .as_ref()
646
+ .map_or(false, |s| !s.trim().is_empty());
647
+
648
+ match provider {
649
+ "gemini" => Ok(()),
650
+ "ollama" if has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
651
+ "ollama" => Ok(()),
652
+ _ if has_image || has_audio => Err(ChatError::UnsupportedAttachments(provider.into())),
653
+ _ => Ok(()),
654
+ }
655
+ }
656
+
657
+ /// Extract base64 image data from the request for Ollama's `images` field.
658
+ /// Ollama expects a plain array of base64 strings (without the data URI prefix).
659
+ fn ollama_images(request: &ChatRequest) -> Option<Vec<String>> {
660
+ let image_data = request.image_data_uri.as_ref()?;
661
+ let images: Vec<String> = image_data
662
+ .split_whitespace()
663
+ .filter_map(|img| {
664
+ img.strip_prefix("data:")
665
+ .and_then(|payload| payload.split_once(";base64,"))
666
+ .filter(|(mime, data)| mime.starts_with("image/") && !data.is_empty())
667
+ .map(|(_, data)| data.to_owned())
668
+ })
669
+ .collect();
670
+ if images.is_empty() {
671
+ None
672
+ } else {
673
+ Some(images)
674
+ }
675
+ }
676
+
677
+ fn wants_agent_json(request: &ChatRequest) -> bool {
678
+ let instruction = request.system_instruction.as_str();
679
+ instruction.contains("Return only JSON")
680
+ || (instruction.contains("Return exactly one JSON object per response")
681
+ && instruction.contains("Input formats:")
682
+ && instruction.contains("- finish:"))
683
+ }
684
+
685
+ fn gemini_chat_payload(config: &MintConfig, request: &ChatRequest) -> Result<Value, ChatError> {
686
+ let mut payload = json!({
687
+ "systemInstruction": { "parts": [{ "text": request.system_instruction }] },
688
+ "contents": [{ "role": "user", "parts": gemini_parts(request)? }]
689
+ });
690
+ if wants_agent_json(request) {
691
+ payload["generationConfig"] = gemini_agent_generation_config(config);
692
+ }
693
+ Ok(payload)
694
+ }
695
+
696
+ fn gemini_agent_generation_config(config: &MintConfig) -> Value {
697
+ let mut allowed_actions = vec![
698
+ "list_files",
699
+ "read_file",
700
+ "search_code",
701
+ "symbols",
702
+ "semantic_index",
703
+ "semantic_search",
704
+ "knowledge_search",
705
+ "web_search",
706
+ "memory_recall",
707
+ "git_status",
708
+ "git_diff",
709
+ "git_log",
710
+ "git_branch",
711
+ "create_plan",
712
+ "update_plan",
713
+ "request_user_approval",
714
+ "ask_user",
715
+ "detect_project",
716
+ "list_tests",
717
+ "read_diagnostics",
718
+ "view_image",
719
+ "note_write",
720
+ "run_plugin",
721
+ "mcp_tool",
722
+ "run_shell",
723
+ "verify",
724
+ "apply_patch",
725
+ "write_file",
726
+ ];
727
+ allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
728
+ allowed_actions.push("finish");
729
+
730
+ json!({
731
+ "responseMimeType": "application/json",
732
+ "responseSchema": {
733
+ "type": "OBJECT",
734
+ "properties": {
735
+ "thought": { "type": "STRING" },
736
+ "action": {
737
+ "type": "STRING",
738
+ "enum": allowed_actions
739
+ },
740
+ "input": {
741
+ "type": "OBJECT",
742
+ "properties": {
743
+ "path": { "type": "STRING", "description": "The target file or directory path (required for list_files, read_file, write_file, apply_patch, symbols, semantic_index, semantic_search)" },
744
+ "query": { "type": "STRING", "description": "The search query string (required for search_code, semantic_search, knowledge_search, web_search, memory_recall)" },
745
+ "command": { "type": "STRING", "description": "The local read-only or test shell command to run (required for run_shell)" },
746
+ "commands": {
747
+ "type": "ARRAY",
748
+ "items": { "type": "STRING" },
749
+ "description": "List of verification commands (required for verify)"
750
+ },
751
+ "steps": {
752
+ "type": "ARRAY",
753
+ "items": { "type": "STRING" },
754
+ "description": "Plan steps (for create_plan/update_plan)"
755
+ },
756
+ "fileContent": { "type": "STRING", "description": "The complete new content of a new file (required for write_file, note_write)" },
757
+ "summary": { "type": "STRING", "description": "The final detailed answer, explanation, or response to the user's query (required for finish)" },
758
+ "verification": { "type": "STRING", "description": "The description of checks to run before finishing" },
759
+ "title": { "type": "STRING", "description": "Short approval or plan title" },
760
+ "status": { "type": "STRING", "description": "Plan item status or project status" },
761
+ "startLine": { "type": "INTEGER", "description": "First line to read (1-indexed, for read_file)" },
762
+ "endLine": { "type": "INTEGER", "description": "Last line to read (for read_file)" },
763
+ "limit": { "type": "INTEGER", "description": "Max number of items/lines/files to return" },
764
+ "server": { "type": "STRING", "description": "MCP server name (for mcp_tool)" },
765
+ "tool": { "type": "STRING", "description": "MCP tool name (for mcp_tool)" },
766
+ "notePath": { "type": "STRING", "description": "The file path for note writing (for note_write)" },
767
+ "name": { "type": "STRING", "description": "Plugin name (for run_plugin)" },
768
+ "instruction": { "type": "STRING", "description": "Instruction to run the plugin (for run_plugin)" },
769
+ "patch": {
770
+ "type": "OBJECT",
771
+ "properties": {
772
+ "path": { "type": "STRING", "description": "The target file path (required for apply_patch)" },
773
+ "hunks": {
774
+ "type": "ARRAY",
775
+ "items": {
776
+ "type": "OBJECT",
777
+ "properties": {
778
+ "oldText": { "type": "STRING", "description": "The exact block of code to replace" },
779
+ "newText": { "type": "STRING", "description": "The replacement block of code" }
780
+ }
781
+ }
782
+ }
783
+ }
784
+ },
785
+ "arguments": { "type": "OBJECT", "description": "MCP tool arguments (for mcp_tool)" }
786
+ },
787
+ "required": ["path", "query", "command", "fileContent", "summary"]
788
+ }
789
+ },
790
+ "required": ["thought", "action", "input"]
791
+ }
792
+ })
793
+ }
794
+
795
+ fn openai_chat_payload(model: &str, request: &ChatRequest, stream: bool) -> Value {
796
+ let mut payload = json!({
797
+ "model": model,
798
+ "stream": stream,
799
+ "messages": [
800
+ { "role": "system", "content": request.system_instruction },
801
+ { "role": "user", "content": request.message }
802
+ ]
803
+ });
804
+ if wants_agent_json(request) {
805
+ payload["response_format"] = json!({ "type": "json_object" });
806
+ }
807
+ payload
808
+ }
809
+
810
+ fn gemini_parts(request: &ChatRequest) -> Result<Vec<Value>, ChatError> {
811
+ let mut parts = vec![json!({ "text": request.message })];
812
+ if let Some(ref image_data) = request.image_data_uri {
813
+ for img in image_data.split_whitespace() {
814
+ let payload = img
815
+ .strip_prefix("data:")
816
+ .and_then(|payload| payload.split_once(";base64,"))
817
+ .filter(|(mime_type, data)| mime_type.starts_with("image/") && !data.is_empty())
818
+ .ok_or(ChatError::InvalidAttachment)?;
819
+ parts.push(json!({
820
+ "inlineData": {
821
+ "mimeType": payload.0,
822
+ "data": payload.1
823
+ }
824
+ }));
825
+ }
826
+ }
827
+ if let Some(ref audio_data) = request.audio_data_uri {
828
+ for aud in audio_data.split_whitespace() {
829
+ let payload = aud
830
+ .strip_prefix("data:")
831
+ .and_then(|payload| payload.split_once(";base64,"))
832
+ .filter(|(mime_type, data)| mime_type.starts_with("audio/") && !data.is_empty())
833
+ .ok_or(ChatError::InvalidAttachment)?;
834
+ parts.push(json!({
835
+ "inlineData": {
836
+ "mimeType": payload.0,
837
+ "data": payload.1
838
+ }
839
+ }));
840
+ }
841
+ }
842
+ Ok(parts)
843
+ }
844
+
845
+ #[cfg(test)]
846
+ mod tests {
847
+ use super::*;
848
+
849
+ #[test]
850
+ fn rejects_missing_api_key() {
851
+ let error = required_key("gemini", "").unwrap_err();
852
+ assert!(matches!(error, ChatError::MissingApiKey(provider) if provider == "gemini"));
853
+ }
854
+
855
+ #[test]
856
+ fn parses_stream_provider_formats() {
857
+ assert_eq!(
858
+ parse_stream_line(
859
+ StreamFormat::OpenAi,
860
+ r#"data: {"choices":[{"delta":{"content":"hello"}}]}"#
861
+ )
862
+ .as_deref(),
863
+ Some("hello")
864
+ );
865
+ assert_eq!(
866
+ parse_stream_line(StreamFormat::Ollama, r#"{"message":{"content":"hi"}}"#).as_deref(),
867
+ Some("hi")
868
+ );
869
+ assert_eq!(
870
+ parse_stream_line(
871
+ StreamFormat::Anthropic,
872
+ r#"data: {"delta":{"type":"text_delta","text":"hey"}}"#
873
+ )
874
+ .as_deref(),
875
+ Some("hey")
876
+ );
877
+ }
878
+
879
+ #[test]
880
+ fn builds_gemini_multimodal_parts() {
881
+ let parts = gemini_parts(&ChatRequest {
882
+ message: "describe".into(),
883
+ system_instruction: String::new(),
884
+ chat_id: None,
885
+ image_data_uri: Some("data:image/png;base64,aGk= data:image/jpeg;base64,Ynl5".into()),
886
+ audio_data_uri: None,
887
+ document_attachment: None,
888
+ workspace_path: None,
889
+ })
890
+ .unwrap();
891
+ assert_eq!(parts.len(), 3);
892
+ assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
893
+ assert_eq!(parts[2]["inlineData"]["mimeType"], "image/jpeg");
894
+ }
895
+ }