@pheem49/mint 1.16.0 → 1.18.0

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 (984) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +2 -2
  3. package/README.md +8 -2
  4. package/Release_Note.md +222 -72
  5. package/apps/mint-companion/src-tauri/src/compact.rs +69 -0
  6. package/apps/mint-companion/src-tauri/src/main.rs +218 -11
  7. package/apps/mint-companion/vite.config.ts +2 -1
  8. package/crates/mint-cli/src/agent/diff_render.rs +66 -44
  9. package/crates/mint-cli/src/agent/live_status.rs +64 -6
  10. package/crates/mint-cli/src/agent/markdown_render.rs +342 -5
  11. package/crates/mint-cli/src/agent.rs +202 -47
  12. package/crates/mint-cli/src/commands/agent.rs +223 -117
  13. package/crates/mint-cli/src/commands/browser.rs +114 -0
  14. package/crates/mint-cli/src/commands/config.rs +14 -1
  15. package/crates/mint-cli/src/commands/integrations.rs +3 -3
  16. package/crates/mint-cli/src/commands/knowledge.rs +16 -2
  17. package/crates/mint-cli/src/commands/mod.rs +15 -4
  18. package/crates/mint-cli/src/commands/tailnet_web.rs +3 -7
  19. package/crates/mint-cli/src/companion_navigation.rs +268 -0
  20. package/crates/mint-cli/src/interactive/chat_tui/dialog.rs +572 -0
  21. package/crates/mint-cli/src/interactive/chat_tui/mod.rs +1427 -0
  22. package/crates/mint-cli/src/interactive/chat_tui/render.rs +1524 -0
  23. package/crates/mint-cli/src/interactive/chat_tui/selection.rs +401 -0
  24. package/crates/mint-cli/src/interactive/chat_tui/state.rs +1833 -0
  25. package/crates/mint-cli/src/interactive/chat_tui/tests.rs +3414 -0
  26. package/crates/mint-cli/src/interactive/chat_tui/thought.rs +202 -0
  27. package/crates/mint-cli/src/interactive/chat_tui/transcript.rs +728 -0
  28. package/crates/mint-cli/src/interactive/chat_tui/watermark.rs +148 -0
  29. package/crates/mint-cli/src/interactive/command_ui.rs +92 -5
  30. package/crates/mint-cli/src/interactive/composer.rs +186 -0
  31. package/crates/mint-cli/src/interactive/format.rs +78 -3
  32. package/crates/mint-cli/src/interactive/input_box.rs +61 -1
  33. package/crates/mint-cli/src/interactive/resume_picker.rs +367 -25
  34. package/crates/mint-cli/src/interactive/slash_commands.rs +758 -373
  35. package/crates/mint-cli/src/interactive/tool_activity.rs +468 -56
  36. package/crates/mint-cli/src/interactive.rs +74 -5
  37. package/crates/mint-cli/src/main.rs +26 -2
  38. package/crates/mint-cli/src/markdown.rs +2 -37
  39. package/crates/mint-cli/src/onboard.rs +3897 -1321
  40. package/crates/mint-cli/src/setup.rs +1342 -624
  41. package/crates/mint-cli/src/terminal_theme.rs +159 -35
  42. package/crates/mint-cli/tests/library_intake.rs +1 -0
  43. package/crates/mint-cli/tests/mods_testing.rs +5 -1
  44. package/crates/mint-companion-protocol/src/lib.rs +113 -0
  45. package/crates/mint-core/src/agent/chat/anthropic.rs +318 -0
  46. package/crates/mint-core/src/agent/chat/custom.rs +149 -0
  47. package/crates/mint-core/src/agent/chat/gemini.rs +518 -0
  48. package/crates/mint-core/src/agent/chat/mod.rs +857 -0
  49. package/crates/mint-core/src/agent/chat/ollama.rs +321 -0
  50. package/crates/mint-core/src/agent/chat/openai.rs +582 -0
  51. package/crates/mint-core/src/agent/chat/streaming.rs +469 -0
  52. package/crates/mint-core/src/agent/chat/tests.rs +1479 -0
  53. package/crates/mint-core/src/agent/memory.rs +129 -8
  54. package/crates/mint-core/src/agent/safety.rs +56 -4
  55. package/crates/mint-core/src/agent/skill_inventory.rs +3 -1
  56. package/crates/mint-core/src/agent/skills.rs +1 -0
  57. package/crates/mint-core/src/agent/tasks.rs +2 -2
  58. package/crates/mint-core/src/api_server/routes/auth.rs +1 -4
  59. package/crates/mint-core/src/api_server/routes/chat.rs +53 -17
  60. package/crates/mint-core/src/api_server/routes/gemini_live.rs +14 -1
  61. package/crates/mint-core/src/api_server/routes/html_preview.rs +1 -8
  62. package/crates/mint-core/src/api_server/routes/misc.rs +116 -0
  63. package/crates/mint-core/src/api_server/routes/profile_oauth.rs +43 -26
  64. package/crates/mint-core/src/api_server/routes/sessions.rs +114 -76
  65. package/crates/mint-core/src/api_server/routes/skills_subagents.rs +11 -7
  66. package/crates/mint-core/src/api_server/routes/status_health.rs +18 -8
  67. package/crates/mint-core/src/api_server.rs +211 -65
  68. package/crates/mint-core/src/avatar_bridge.rs +5 -24
  69. package/crates/mint-core/src/browser/cdp.rs +92 -30
  70. package/crates/mint-core/src/browser/headless.rs +582 -0
  71. package/crates/mint-core/src/browser/input.rs +7 -62
  72. package/crates/mint-core/src/browser/interact.rs +0 -14
  73. package/crates/mint-core/src/browser/mod.rs +10 -30
  74. package/crates/mint-core/src/browser/navigate.rs +83 -44
  75. package/crates/mint-core/src/browser/runtime.rs +282 -0
  76. package/crates/mint-core/src/browser/session.rs +240 -400
  77. package/crates/mint-core/src/browser/workflow.rs +496 -0
  78. package/crates/mint-core/src/companion/navigation.rs +115 -0
  79. package/crates/mint-core/src/companion.rs +109 -1
  80. package/crates/mint-core/src/cron/store.rs +2 -2
  81. package/crates/mint-core/src/integrations/mcp.rs +6 -8
  82. package/crates/mint-core/src/integrations/mcp_result.rs +45 -0
  83. package/crates/mint-core/src/lib.rs +10 -11
  84. package/crates/mint-core/src/media/gemini_live.rs +636 -86
  85. package/crates/mint-core/src/media/image_proxy.rs +563 -0
  86. package/crates/mint-core/src/media/library/migration.rs +26 -0
  87. package/crates/mint-core/src/media/library/tests.rs +1 -1
  88. package/crates/mint-core/src/media/library/thumbnails.rs +8 -2
  89. package/crates/mint-core/src/media/pictures.rs +1 -1
  90. package/crates/mint-core/src/media/tool_images.rs +287 -0
  91. package/crates/mint-core/src/media/tts/playback.rs +609 -0
  92. package/crates/mint-core/src/media/tts.rs +1 -0
  93. package/crates/mint-core/src/media.rs +2 -0
  94. package/crates/mint-core/src/mods/manager.rs +101 -35
  95. package/crates/mint-core/src/orchestration/context_render.rs +878 -44
  96. package/crates/mint-core/src/orchestration/decision_parsing.rs +536 -39
  97. package/crates/mint-core/src/orchestration/diagnostics.rs +307 -0
  98. package/crates/mint-core/src/orchestration/full_skill_result_tests.rs +12 -0
  99. package/crates/mint-core/src/orchestration/image_regressions.rs +379 -0
  100. package/crates/mint-core/src/orchestration/loop_guard.rs +479 -0
  101. package/crates/mint-core/src/orchestration/mcp_outcome_tests.rs +864 -0
  102. package/crates/mint-core/src/orchestration/memory_skill.rs +88 -28
  103. package/crates/mint-core/src/orchestration/mod.rs +859 -4244
  104. package/crates/mint-core/src/orchestration/reliability.rs +581 -0
  105. package/crates/mint-core/src/orchestration/repeated_failures.rs +8 -0
  106. package/crates/mint-core/src/orchestration/review_regressions.rs +179 -28
  107. package/crates/mint-core/src/orchestration/tests.rs +1665 -0
  108. package/crates/mint-core/src/orchestration/tools/dispatch.rs +967 -0
  109. package/crates/mint-core/src/orchestration/tools/files.rs +32 -3
  110. package/crates/mint-core/src/orchestration/tools/mod.rs +5 -1
  111. package/crates/mint-core/src/orchestration/tools/planning.rs +102 -42
  112. package/crates/mint-core/src/orchestration/tools/plugins_mcp.rs +19 -4
  113. package/crates/mint-core/src/orchestration/tools/project.rs +1 -1
  114. package/crates/mint-core/src/orchestration/tools/shell.rs +157 -62
  115. package/crates/mint-core/src/orchestration/tools/web.rs +5 -1
  116. package/crates/mint-core/src/orchestration/trajectory_schema.rs +353 -0
  117. package/crates/mint-core/src/orchestration/truncation.rs +368 -0
  118. package/crates/mint-core/src/orchestration/types.rs +648 -0
  119. package/crates/mint-core/src/orchestration/verification.rs +36 -9
  120. package/crates/mint-core/src/orchestration/workspace_helpers.rs +76 -27
  121. package/crates/mint-core/src/prompts/agent.rs +69 -102
  122. package/crates/mint-core/src/prompts/tool_catalog.rs +104 -153
  123. package/crates/mint-core/src/search/files.rs +20 -0
  124. package/crates/mint-core/src/search/web_search.rs +44 -3
  125. package/crates/mint-core/src/slash/mod.rs +481 -55
  126. package/crates/mint-core/src/slash/models.rs +6 -1
  127. package/crates/mint-core/src/swarm/batch.rs +643 -0
  128. package/crates/mint-core/src/swarm/dossier.rs +152 -0
  129. package/crates/mint-core/src/swarm/labels.rs +100 -0
  130. package/crates/mint-core/src/swarm/mod.rs +158 -0
  131. package/crates/mint-core/src/swarm/permissions.rs +167 -0
  132. package/crates/mint-core/src/system/auth.rs +156 -7
  133. package/crates/mint-core/src/system/bg_shell.rs +78 -9
  134. package/crates/mint-core/src/system/config.rs +266 -35
  135. package/crates/mint-core/src/system/mint_web.rs +33 -12
  136. package/crates/mint-core/src/system/shell.rs +179 -10
  137. package/crates/mint-core/src/system/workspace.rs +204 -0
  138. package/crates/mint-core/tests/background_retention.rs +1 -1
  139. package/crates/mint-core/tests/memory_persistence.rs +27 -0
  140. package/crates/mint-mod-runtime/src/lib.rs +37 -23
  141. package/out/web/assets/ArtifactPreviewPanel-Cx6BVOOF.js +2 -0
  142. package/out/web/assets/ArtifactPreviewPanel-DAT_Y7i0.css +1 -0
  143. package/out/web/assets/CalculationCard-D2E74u5q.js +1 -0
  144. package/out/web/assets/CliSessionsView-ZbdtSjvK.css +1 -0
  145. package/out/web/assets/CliSessionsView-z5i6qGyb.js +1 -0
  146. package/out/web/assets/CodeReviewPage-BK2bOScd.css +1 -0
  147. package/out/web/assets/CodeReviewPage-Dp6zHuHu.js +2 -0
  148. package/out/web/assets/CommandPalette-DaKlzbKs.js +1 -0
  149. package/out/web/assets/ImageGenCard-BMCJHP9O.js +1 -0
  150. package/out/web/assets/ImageSearchCard-Cld_SOUg.js +1 -0
  151. package/out/web/assets/ImageStudioPanel-B5N7TjeB.js +1 -0
  152. package/out/web/assets/LibraryPage-CMB89rJ0.css +1 -0
  153. package/out/web/assets/LibraryPage-DbZEi4W_.js +2 -0
  154. package/out/web/assets/LinkedFoldersView-9B9DIPoZ.js +1 -0
  155. package/out/web/assets/McpRegistryPicker-DnXPlzI7.js +1 -0
  156. package/out/web/assets/McpServersView-CiNBNUl4.js +1 -0
  157. package/out/web/assets/MermaidCard-BBDNEXbY.js +2 -0
  158. package/out/web/assets/MintDashboard-C_UYPoNV.js +119 -0
  159. package/out/web/assets/MintDashboard-DTd8C_c8.css +1 -0
  160. package/out/web/assets/PlanCard-C2x5pajq.js +1 -0
  161. package/out/web/assets/PluginsView-DoTg9MVa.js +1 -0
  162. package/out/web/assets/RewindModal-B5oowrR0.js +1 -0
  163. package/out/web/assets/ScheduledTasksView-BD5DVPkZ.js +1 -0
  164. package/out/web/assets/SelectField-Cy6CghrU.js +1 -0
  165. package/out/web/assets/SelectField-lFEkkWCU.css +1 -0
  166. package/out/web/assets/SettingsWindow-BYl_oCFh.js +4 -0
  167. package/out/web/assets/SettingsWindow-Ckcgnjt8.css +1 -0
  168. package/out/web/assets/SkillsView-CIeWwq5z.js +2 -0
  169. package/out/web/assets/StockCard-DxUciKyp.js +1 -0
  170. package/out/web/assets/ToolSurfacePage-qBgs_VHq.css +1 -0
  171. package/out/web/assets/ToolSurfacePage-saVbVrkE.js +38 -0
  172. package/out/web/assets/UiFeatureCard-CdY0hGWy.js +1 -0
  173. package/out/web/assets/UiGridCard-BBK3I5hC.js +1 -0
  174. package/out/web/assets/UiMockupWidget-CpR9EDuc.js +1 -0
  175. package/out/web/assets/{VeoStudioPanel-DOj7_WR3.css → VeoStudioPanel-9kU9YLEN.css} +1 -1
  176. package/out/web/assets/VeoStudioPanel-Co6-wEWM.js +4 -0
  177. package/out/web/assets/WeatherCard-Buzi7Vfu.js +1 -0
  178. package/out/web/assets/WorkspacePanel-BfHKkulU.js +1 -0
  179. package/out/web/assets/WorkspacePdfPreview-Bi8Zzyir.js +2 -0
  180. package/out/web/assets/abnfDiagram-VCTEODGH-CMfR95N2.js +1 -0
  181. package/out/web/assets/architecture-7GRP2DOG-DhAM4HyO.js +1 -0
  182. package/out/web/assets/architectureDiagram-5GKGNRK7-Yk3TUJ_F.js +36 -0
  183. package/out/web/assets/{blockDiagram-NRAW4CY4-BrrqzHW5.js → blockDiagram-I7D4REHJ-X9fBgJik.js} +4 -4
  184. package/out/web/assets/c4Diagram-7LVT6UL2-Bk9x7N-8.js +38 -0
  185. package/out/web/assets/chunk-2Q5K7J3B-D2JLPQzF.js +1 -0
  186. package/out/web/assets/{chunk-TEH6E4GO-G4Cdlyrt.js → chunk-4HAMMTFA-Dx5fk1_0.js} +3 -3
  187. package/out/web/assets/{chunk-5VM5RSS4-BiPuE0io.js → chunk-5VM5RSS4-BZ2oBPYQ.js} +1 -1
  188. package/out/web/assets/chunk-75Z2AOVW-BszTQoM4.js +2 -0
  189. package/out/web/assets/chunk-DU6HZSFF-DK-As3rk.js +128 -0
  190. package/out/web/assets/chunk-F27PBJKO-LmncZZIS.js +1 -0
  191. package/out/web/assets/chunk-FOHPRMQF-JXEeeJ9r.js +161 -0
  192. package/out/web/assets/{chunk-GMAD6QVW-BuhEcgHq.js → chunk-GMAD6QVW-DSQ_sDRJ.js} +12 -12
  193. package/out/web/assets/chunk-GVQU2GXP-Cno3ulje.js +1 -0
  194. package/out/web/assets/chunk-IMKFNOWR-bIzfY9Jc.js +231 -0
  195. package/out/web/assets/chunk-JWPE2WC7-BeEMUFDw.js +1 -0
  196. package/out/web/assets/chunk-L3NEJ4N5-Dl6eOa0D.js +1 -0
  197. package/out/web/assets/chunk-OSK3NFVY-BaUGJ-cM.js +10 -0
  198. package/out/web/assets/chunk-P2QGCYS3-_066ndhH.js +1 -0
  199. package/out/web/assets/chunk-POPQ4Y6H-YT1AKhTv.js +1 -0
  200. package/out/web/assets/chunk-PWAF6VOD-CrEgDsxh.js +1 -0
  201. package/out/web/assets/chunk-SHT3W25Y-_KwaRLOl.js +168 -0
  202. package/out/web/assets/{chunk-SVP7TREG-Bu_yYb-V.js → chunk-SVP7TREG-BQ1qb32L.js} +1 -1
  203. package/out/web/assets/{chunk-LCL6LL3I-BhXV9zyH.js → chunk-TICWLB2K-BDyehCtg.js} +4 -4
  204. package/out/web/assets/chunk-XXDRQBXY-B9YlhOkE.js +1 -0
  205. package/out/web/assets/chunk-Y2CYZVJY-BxCWjNVG.js +1 -0
  206. package/out/web/assets/classDiagram-ZZMXUADV-BoSpzRCJ.js +1 -0
  207. package/out/web/assets/classDiagram-v2-VYDZK3BY-BoSpzRCJ.js +1 -0
  208. package/out/web/assets/config-2suVFPZg.css +1 -0
  209. package/out/web/assets/config-CkRCdkxn.js +2 -0
  210. package/out/web/assets/core-Mi3s5D4_.js +1 -0
  211. package/out/web/assets/cose-bilkent-JH36ORCC-qlna31ty.js +1 -0
  212. package/out/web/assets/cynefin-OW5HDTMX-Dl_31aaN.js +1 -0
  213. package/out/web/assets/cynefinDiagram-5FMLGOSQ-Bpir8jha.js +62 -0
  214. package/out/web/assets/cytoscape.esm-DWtAUG0r.js +321 -0
  215. package/out/web/assets/dagre-CnWJJM5f.js +1 -0
  216. package/out/web/assets/dagre-GXQ25YYZ-D9xu3nsq.js +4 -0
  217. package/out/web/assets/diagram-S7CK7UJ4-DeveoEeB.js +30 -0
  218. package/out/web/assets/diagram-UQ7AKVKN-p4xWRESV.js +41 -0
  219. package/out/web/assets/{diagram-VSXAHHWV-BJ86Ov2Y.js → diagram-VSXAHHWV-C7qz9NDF.js} +1 -1
  220. package/out/web/assets/diagram-VX7I27RA-DRiCa3Vd.js +24 -0
  221. package/out/web/assets/diagram-Z3DM3KII-Bt7zduQX.js +24 -0
  222. package/out/web/assets/dist-9ISJ7r-g.js +1 -0
  223. package/out/web/assets/dist-js-BV3mI7uw.js +1 -0
  224. package/out/web/assets/ebnfDiagram-PWID7BFC-DV9WpwdR.js +1 -0
  225. package/out/web/assets/{erDiagram-SSCWMZ5O-CzzmnZWT.js → erDiagram-RLTQ6QDP-BJ_nwG2m.js} +3 -3
  226. package/out/web/assets/event-D7D-EBy9.js +1 -0
  227. package/out/web/assets/eventmodeling-NTZA5JFV-BTBMrfVa.js +1 -0
  228. package/out/web/assets/flowDiagram-HODETNUW-Cdlq3cJL.js +1 -0
  229. package/out/web/assets/ganttDiagram-EL5Y4UJY-DQLdsXrC.js +292 -0
  230. package/out/web/assets/gitGraph-4MIJSDKK-CL1OEOQ_.js +1 -0
  231. package/out/web/assets/gitGraphDiagram-WWUBYQGX-8cSi42H7.js +106 -0
  232. package/out/web/assets/graphlib-Bh9gHply.js +1 -0
  233. package/out/web/assets/index-BocxnrDG.js +168 -0
  234. package/out/web/assets/index-yeh_r55a.css +1 -0
  235. package/out/web/assets/info-A6RAGUB7-C1zsX5sc.js +1 -0
  236. package/out/web/assets/infoDiagram-27XIBGKW-DsRYtIyI.js +2 -0
  237. package/out/web/assets/{ishikawaDiagram-5VMMS53U-s38yo28y.js → ishikawaDiagram-5VMMS53U-B2crwIXj.js} +4 -4
  238. package/out/web/assets/journeyDiagram-3NMN7TZE-BTH-GyVR.js +139 -0
  239. package/out/web/assets/jsx-runtime-DmUwWVTj.js +1 -0
  240. package/out/web/assets/{kanban-definition-3QL26DDD-DodjgySL.js → kanban-definition-UXKFOSKX-BeRfCnuA.js} +6 -6
  241. package/out/web/assets/katex-MCdQaEAD.js +257 -0
  242. package/out/web/assets/mermaid-parser.core-BJwgCBBc.js +7 -0
  243. package/out/web/assets/mermaid.core-CsDDodN8.js +44 -0
  244. package/out/web/assets/{mindmap-definition-FBJOCRG2-260WBDxZ.js → mindmap-definition-YA3MSWOX-B42efOQ1.js} +4 -4
  245. package/out/web/assets/modelManager-B3ZFMh8v.js +2 -0
  246. package/out/web/assets/oauthManager-Ch5Du2fF.js +1 -0
  247. package/out/web/assets/packet-AYTQ26CC-B6xUyE6r.js +1 -0
  248. package/out/web/assets/pdf-Bcahc2yC.js +56 -0
  249. package/out/web/assets/pdf.worker.min-Dkey6ZUl.mjs +27 -0
  250. package/out/web/assets/pegDiagram-XKGWAZYB-1LabVycL.js +1 -0
  251. package/out/web/assets/pie-WAS4IAKB-DJwo-nf_.js +1 -0
  252. package/out/web/assets/pieDiagram-E7YTZNPT-icmAqzy4.js +39 -0
  253. package/out/web/assets/{quadrantDiagram-AXDQQJYC-B8D3gU5d.js → quadrantDiagram-AXDQQJYC-CZ-wEx41.js} +4 -4
  254. package/out/web/assets/radar-RG4KPBEZ-D7d-Xriw.js +1 -0
  255. package/out/web/assets/railroad-74A4TZTK-viuhdYbs.js +1 -0
  256. package/out/web/assets/railroad-abnf-HS5TGJTU-DNXiCNea.js +1 -0
  257. package/out/web/assets/railroad-ebnf-LZEXJU2U-nkSu1-0C.js +1 -0
  258. package/out/web/assets/railroad-peg-WCYAUIDC-BhbMeQzz.js +1 -0
  259. package/out/web/assets/railroadDiagram-O6MQD6OU-CLpG1jUX.js +1 -0
  260. package/out/web/assets/{requirementDiagram-EFPCY7ZU-D2X-Ewik.js → requirementDiagram-BXWQKSXE-kRDrvWrc.js} +3 -3
  261. package/out/web/assets/rolldown-runtime-CNOWq4Kh.js +1 -0
  262. package/out/web/assets/rough.esm-CNnFptCR.js +1 -0
  263. package/out/web/assets/sankeyDiagram-P5KCCOFB-DxAwmq4x.js +40 -0
  264. package/out/web/assets/sequenceDiagram-WJ2MYXX4-aLgY-6Wz.js +162 -0
  265. package/out/web/assets/sizeCapture-INFHLROL-C8Q8DbV5.js +1 -0
  266. package/out/web/assets/src-CRDfRlHM.js +1 -0
  267. package/out/web/assets/stateDiagram-D77RDMKH-D5QWfOhp.js +1 -0
  268. package/out/web/assets/stateDiagram-v2-MP3YSRHH-D6OiZoAP.js +1 -0
  269. package/out/web/assets/studio-BBZQ_-QK.js +1 -0
  270. package/out/web/assets/swimlanes-42K2YHIH-CRs5UyA-.js +1 -0
  271. package/out/web/assets/swimlanesDiagram-VR7AAH4N-UgEXIsV0.js +8 -0
  272. package/out/web/assets/syntaxHighlight-C-wAGT84.js +3 -0
  273. package/out/web/assets/timeline-definition-24CTP7MA-Denqd9qb.js +120 -0
  274. package/out/web/assets/treeView-Q6P3EWNA-0my3Z3s4.js +1 -0
  275. package/out/web/assets/treemap-WGGIJYW6-B_EjkLYq.js +1 -0
  276. package/out/web/assets/vennDiagram-4TSXK5OY-DxVg3Ewr.js +34 -0
  277. package/out/web/assets/wardley-WFR3VGLG-CGD-Tj4p.js +1 -0
  278. package/out/web/assets/{wardleyDiagram-VM6X3IG4-DAdXELbQ.js → wardleyDiagram-VM6X3IG4-CmfS39Xm.js} +35 -35
  279. package/out/web/assets/window-CkUOP7t0.js +1 -0
  280. package/out/web/assets/workspaces-SYPrmbgC.js +1 -0
  281. package/out/web/assets/{xychartDiagram-S5SC5T6Z-IZIXf3Vo.js → xychartDiagram-S5SC5T6Z-BVM9bKis.js} +4 -4
  282. package/out/web/index-web.html +4 -4
  283. package/out/web/index.html +4 -4
  284. package/out/web/pdfjs/cmaps/78-EUC-H.bcmap +0 -0
  285. package/out/web/pdfjs/cmaps/78-EUC-V.bcmap +0 -0
  286. package/out/web/pdfjs/cmaps/78-H.bcmap +0 -0
  287. package/out/web/pdfjs/cmaps/78-RKSJ-H.bcmap +0 -0
  288. package/out/web/pdfjs/cmaps/78-RKSJ-V.bcmap +0 -0
  289. package/out/web/pdfjs/cmaps/78-V.bcmap +0 -0
  290. package/out/web/pdfjs/cmaps/78ms-RKSJ-H.bcmap +0 -0
  291. package/out/web/pdfjs/cmaps/78ms-RKSJ-V.bcmap +0 -0
  292. package/out/web/pdfjs/cmaps/83pv-RKSJ-H.bcmap +0 -0
  293. package/out/web/pdfjs/cmaps/90ms-RKSJ-H.bcmap +0 -0
  294. package/out/web/pdfjs/cmaps/90ms-RKSJ-V.bcmap +0 -0
  295. package/out/web/pdfjs/cmaps/90msp-RKSJ-H.bcmap +0 -0
  296. package/out/web/pdfjs/cmaps/90msp-RKSJ-V.bcmap +0 -0
  297. package/out/web/pdfjs/cmaps/90pv-RKSJ-H.bcmap +0 -0
  298. package/out/web/pdfjs/cmaps/90pv-RKSJ-V.bcmap +0 -0
  299. package/out/web/pdfjs/cmaps/Add-H.bcmap +0 -0
  300. package/out/web/pdfjs/cmaps/Add-RKSJ-H.bcmap +0 -0
  301. package/out/web/pdfjs/cmaps/Add-RKSJ-V.bcmap +0 -0
  302. package/out/web/pdfjs/cmaps/Add-V.bcmap +0 -0
  303. package/out/web/pdfjs/cmaps/Adobe-CNS1-0.bcmap +0 -0
  304. package/out/web/pdfjs/cmaps/Adobe-CNS1-1.bcmap +0 -0
  305. package/out/web/pdfjs/cmaps/Adobe-CNS1-2.bcmap +0 -0
  306. package/out/web/pdfjs/cmaps/Adobe-CNS1-3.bcmap +0 -0
  307. package/out/web/pdfjs/cmaps/Adobe-CNS1-4.bcmap +0 -0
  308. package/out/web/pdfjs/cmaps/Adobe-CNS1-5.bcmap +0 -0
  309. package/out/web/pdfjs/cmaps/Adobe-CNS1-6.bcmap +0 -0
  310. package/out/web/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap +0 -0
  311. package/out/web/pdfjs/cmaps/Adobe-GB1-0.bcmap +0 -0
  312. package/out/web/pdfjs/cmaps/Adobe-GB1-1.bcmap +0 -0
  313. package/out/web/pdfjs/cmaps/Adobe-GB1-2.bcmap +0 -0
  314. package/out/web/pdfjs/cmaps/Adobe-GB1-3.bcmap +0 -0
  315. package/out/web/pdfjs/cmaps/Adobe-GB1-4.bcmap +0 -0
  316. package/out/web/pdfjs/cmaps/Adobe-GB1-5.bcmap +0 -0
  317. package/out/web/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap +0 -0
  318. package/out/web/pdfjs/cmaps/Adobe-Japan1-0.bcmap +0 -0
  319. package/out/web/pdfjs/cmaps/Adobe-Japan1-1.bcmap +0 -0
  320. package/out/web/pdfjs/cmaps/Adobe-Japan1-2.bcmap +0 -0
  321. package/out/web/pdfjs/cmaps/Adobe-Japan1-3.bcmap +0 -0
  322. package/out/web/pdfjs/cmaps/Adobe-Japan1-4.bcmap +0 -0
  323. package/out/web/pdfjs/cmaps/Adobe-Japan1-5.bcmap +0 -0
  324. package/out/web/pdfjs/cmaps/Adobe-Japan1-6.bcmap +0 -0
  325. package/out/web/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap +0 -0
  326. package/out/web/pdfjs/cmaps/Adobe-Korea1-0.bcmap +0 -0
  327. package/out/web/pdfjs/cmaps/Adobe-Korea1-1.bcmap +0 -0
  328. package/out/web/pdfjs/cmaps/Adobe-Korea1-2.bcmap +0 -0
  329. package/out/web/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap +0 -0
  330. package/out/web/pdfjs/cmaps/B5-H.bcmap +0 -0
  331. package/out/web/pdfjs/cmaps/B5-V.bcmap +0 -0
  332. package/out/web/pdfjs/cmaps/B5pc-H.bcmap +0 -0
  333. package/out/web/pdfjs/cmaps/B5pc-V.bcmap +0 -0
  334. package/out/web/pdfjs/cmaps/CNS-EUC-H.bcmap +0 -0
  335. package/out/web/pdfjs/cmaps/CNS-EUC-V.bcmap +0 -0
  336. package/out/web/pdfjs/cmaps/CNS1-H.bcmap +0 -0
  337. package/out/web/pdfjs/cmaps/CNS1-V.bcmap +0 -0
  338. package/out/web/pdfjs/cmaps/CNS2-H.bcmap +0 -0
  339. package/out/web/pdfjs/cmaps/CNS2-V.bcmap +3 -0
  340. package/out/web/pdfjs/cmaps/ETHK-B5-H.bcmap +0 -0
  341. package/out/web/pdfjs/cmaps/ETHK-B5-V.bcmap +0 -0
  342. package/out/web/pdfjs/cmaps/ETen-B5-H.bcmap +0 -0
  343. package/out/web/pdfjs/cmaps/ETen-B5-V.bcmap +0 -0
  344. package/out/web/pdfjs/cmaps/ETenms-B5-H.bcmap +3 -0
  345. package/out/web/pdfjs/cmaps/ETenms-B5-V.bcmap +0 -0
  346. package/out/web/pdfjs/cmaps/EUC-H.bcmap +0 -0
  347. package/out/web/pdfjs/cmaps/EUC-V.bcmap +0 -0
  348. package/out/web/pdfjs/cmaps/Ext-H.bcmap +0 -0
  349. package/out/web/pdfjs/cmaps/Ext-RKSJ-H.bcmap +0 -0
  350. package/out/web/pdfjs/cmaps/Ext-RKSJ-V.bcmap +0 -0
  351. package/out/web/pdfjs/cmaps/Ext-V.bcmap +0 -0
  352. package/out/web/pdfjs/cmaps/GB-EUC-H.bcmap +0 -0
  353. package/out/web/pdfjs/cmaps/GB-EUC-V.bcmap +0 -0
  354. package/out/web/pdfjs/cmaps/GB-H.bcmap +4 -0
  355. package/out/web/pdfjs/cmaps/GB-V.bcmap +0 -0
  356. package/out/web/pdfjs/cmaps/GBK-EUC-H.bcmap +0 -0
  357. package/out/web/pdfjs/cmaps/GBK-EUC-V.bcmap +0 -0
  358. package/out/web/pdfjs/cmaps/GBK2K-H.bcmap +0 -0
  359. package/out/web/pdfjs/cmaps/GBK2K-V.bcmap +0 -0
  360. package/out/web/pdfjs/cmaps/GBKp-EUC-H.bcmap +0 -0
  361. package/out/web/pdfjs/cmaps/GBKp-EUC-V.bcmap +0 -0
  362. package/out/web/pdfjs/cmaps/GBT-EUC-H.bcmap +0 -0
  363. package/out/web/pdfjs/cmaps/GBT-EUC-V.bcmap +0 -0
  364. package/out/web/pdfjs/cmaps/GBT-H.bcmap +0 -0
  365. package/out/web/pdfjs/cmaps/GBT-V.bcmap +0 -0
  366. package/out/web/pdfjs/cmaps/GBTpc-EUC-H.bcmap +0 -0
  367. package/out/web/pdfjs/cmaps/GBTpc-EUC-V.bcmap +0 -0
  368. package/out/web/pdfjs/cmaps/GBpc-EUC-H.bcmap +0 -0
  369. package/out/web/pdfjs/cmaps/GBpc-EUC-V.bcmap +0 -0
  370. package/out/web/pdfjs/cmaps/H.bcmap +0 -0
  371. package/out/web/pdfjs/cmaps/HKdla-B5-H.bcmap +0 -0
  372. package/out/web/pdfjs/cmaps/HKdla-B5-V.bcmap +0 -0
  373. package/out/web/pdfjs/cmaps/HKdlb-B5-H.bcmap +0 -0
  374. package/out/web/pdfjs/cmaps/HKdlb-B5-V.bcmap +0 -0
  375. package/out/web/pdfjs/cmaps/HKgccs-B5-H.bcmap +0 -0
  376. package/out/web/pdfjs/cmaps/HKgccs-B5-V.bcmap +0 -0
  377. package/out/web/pdfjs/cmaps/HKm314-B5-H.bcmap +0 -0
  378. package/out/web/pdfjs/cmaps/HKm314-B5-V.bcmap +0 -0
  379. package/out/web/pdfjs/cmaps/HKm471-B5-H.bcmap +0 -0
  380. package/out/web/pdfjs/cmaps/HKm471-B5-V.bcmap +0 -0
  381. package/out/web/pdfjs/cmaps/HKscs-B5-H.bcmap +0 -0
  382. package/out/web/pdfjs/cmaps/HKscs-B5-V.bcmap +0 -0
  383. package/out/web/pdfjs/cmaps/Hankaku.bcmap +0 -0
  384. package/out/web/pdfjs/cmaps/Hiragana.bcmap +0 -0
  385. package/out/web/pdfjs/cmaps/KSC-EUC-H.bcmap +0 -0
  386. package/out/web/pdfjs/cmaps/KSC-EUC-V.bcmap +0 -0
  387. package/out/web/pdfjs/cmaps/KSC-H.bcmap +0 -0
  388. package/out/web/pdfjs/cmaps/KSC-Johab-H.bcmap +0 -0
  389. package/out/web/pdfjs/cmaps/KSC-Johab-V.bcmap +0 -0
  390. package/out/web/pdfjs/cmaps/KSC-V.bcmap +0 -0
  391. package/out/web/pdfjs/cmaps/KSCms-UHC-H.bcmap +0 -0
  392. package/out/web/pdfjs/cmaps/KSCms-UHC-HW-H.bcmap +0 -0
  393. package/out/web/pdfjs/cmaps/KSCms-UHC-HW-V.bcmap +0 -0
  394. package/out/web/pdfjs/cmaps/KSCms-UHC-V.bcmap +0 -0
  395. package/out/web/pdfjs/cmaps/KSCpc-EUC-H.bcmap +0 -0
  396. package/out/web/pdfjs/cmaps/KSCpc-EUC-V.bcmap +0 -0
  397. package/out/web/pdfjs/cmaps/Katakana.bcmap +0 -0
  398. package/out/web/pdfjs/cmaps/LICENSE +36 -0
  399. package/out/web/pdfjs/cmaps/NWP-H.bcmap +0 -0
  400. package/out/web/pdfjs/cmaps/NWP-V.bcmap +0 -0
  401. package/out/web/pdfjs/cmaps/RKSJ-H.bcmap +0 -0
  402. package/out/web/pdfjs/cmaps/RKSJ-V.bcmap +0 -0
  403. package/out/web/pdfjs/cmaps/Roman.bcmap +0 -0
  404. package/out/web/pdfjs/cmaps/UniCNS-UCS2-H.bcmap +0 -0
  405. package/out/web/pdfjs/cmaps/UniCNS-UCS2-V.bcmap +0 -0
  406. package/out/web/pdfjs/cmaps/UniCNS-UTF16-H.bcmap +0 -0
  407. package/out/web/pdfjs/cmaps/UniCNS-UTF16-V.bcmap +0 -0
  408. package/out/web/pdfjs/cmaps/UniCNS-UTF32-H.bcmap +0 -0
  409. package/out/web/pdfjs/cmaps/UniCNS-UTF32-V.bcmap +0 -0
  410. package/out/web/pdfjs/cmaps/UniCNS-UTF8-H.bcmap +0 -0
  411. package/out/web/pdfjs/cmaps/UniCNS-UTF8-V.bcmap +0 -0
  412. package/out/web/pdfjs/cmaps/UniGB-UCS2-H.bcmap +0 -0
  413. package/out/web/pdfjs/cmaps/UniGB-UCS2-V.bcmap +0 -0
  414. package/out/web/pdfjs/cmaps/UniGB-UTF16-H.bcmap +0 -0
  415. package/out/web/pdfjs/cmaps/UniGB-UTF16-V.bcmap +0 -0
  416. package/out/web/pdfjs/cmaps/UniGB-UTF32-H.bcmap +0 -0
  417. package/out/web/pdfjs/cmaps/UniGB-UTF32-V.bcmap +0 -0
  418. package/out/web/pdfjs/cmaps/UniGB-UTF8-H.bcmap +0 -0
  419. package/out/web/pdfjs/cmaps/UniGB-UTF8-V.bcmap +0 -0
  420. package/out/web/pdfjs/cmaps/UniJIS-UCS2-H.bcmap +0 -0
  421. package/out/web/pdfjs/cmaps/UniJIS-UCS2-HW-H.bcmap +0 -0
  422. package/out/web/pdfjs/cmaps/UniJIS-UCS2-HW-V.bcmap +0 -0
  423. package/out/web/pdfjs/cmaps/UniJIS-UCS2-V.bcmap +0 -0
  424. package/out/web/pdfjs/cmaps/UniJIS-UTF16-H.bcmap +0 -0
  425. package/out/web/pdfjs/cmaps/UniJIS-UTF16-V.bcmap +0 -0
  426. package/out/web/pdfjs/cmaps/UniJIS-UTF32-H.bcmap +0 -0
  427. package/out/web/pdfjs/cmaps/UniJIS-UTF32-V.bcmap +0 -0
  428. package/out/web/pdfjs/cmaps/UniJIS-UTF8-H.bcmap +0 -0
  429. package/out/web/pdfjs/cmaps/UniJIS-UTF8-V.bcmap +0 -0
  430. package/out/web/pdfjs/cmaps/UniJIS2004-UTF16-H.bcmap +0 -0
  431. package/out/web/pdfjs/cmaps/UniJIS2004-UTF16-V.bcmap +0 -0
  432. package/out/web/pdfjs/cmaps/UniJIS2004-UTF32-H.bcmap +0 -0
  433. package/out/web/pdfjs/cmaps/UniJIS2004-UTF32-V.bcmap +0 -0
  434. package/out/web/pdfjs/cmaps/UniJIS2004-UTF8-H.bcmap +0 -0
  435. package/out/web/pdfjs/cmaps/UniJIS2004-UTF8-V.bcmap +0 -0
  436. package/out/web/pdfjs/cmaps/UniJISPro-UCS2-HW-V.bcmap +0 -0
  437. package/out/web/pdfjs/cmaps/UniJISPro-UCS2-V.bcmap +0 -0
  438. package/out/web/pdfjs/cmaps/UniJISPro-UTF8-V.bcmap +0 -0
  439. package/out/web/pdfjs/cmaps/UniJISX0213-UTF32-H.bcmap +0 -0
  440. package/out/web/pdfjs/cmaps/UniJISX0213-UTF32-V.bcmap +0 -0
  441. package/out/web/pdfjs/cmaps/UniJISX02132004-UTF32-H.bcmap +0 -0
  442. package/out/web/pdfjs/cmaps/UniJISX02132004-UTF32-V.bcmap +0 -0
  443. package/out/web/pdfjs/cmaps/UniKS-UCS2-H.bcmap +0 -0
  444. package/out/web/pdfjs/cmaps/UniKS-UCS2-V.bcmap +0 -0
  445. package/out/web/pdfjs/cmaps/UniKS-UTF16-H.bcmap +0 -0
  446. package/out/web/pdfjs/cmaps/UniKS-UTF16-V.bcmap +0 -0
  447. package/out/web/pdfjs/cmaps/UniKS-UTF32-H.bcmap +0 -0
  448. package/out/web/pdfjs/cmaps/UniKS-UTF32-V.bcmap +0 -0
  449. package/out/web/pdfjs/cmaps/UniKS-UTF8-H.bcmap +0 -0
  450. package/out/web/pdfjs/cmaps/UniKS-UTF8-V.bcmap +0 -0
  451. package/out/web/pdfjs/cmaps/V.bcmap +0 -0
  452. package/out/web/pdfjs/cmaps/WP-Symbol.bcmap +0 -0
  453. package/out/web/pdfjs/standard_fonts/FoxitDingbats.pfb +0 -0
  454. package/out/web/pdfjs/standard_fonts/FoxitFixed.pfb +0 -0
  455. package/out/web/pdfjs/standard_fonts/FoxitFixedBold.pfb +0 -0
  456. package/out/web/pdfjs/standard_fonts/FoxitFixedBoldItalic.pfb +0 -0
  457. package/out/web/pdfjs/standard_fonts/FoxitFixedItalic.pfb +0 -0
  458. package/out/web/pdfjs/standard_fonts/FoxitSerif.pfb +0 -0
  459. package/out/web/pdfjs/standard_fonts/FoxitSerifBold.pfb +0 -0
  460. package/out/web/pdfjs/standard_fonts/FoxitSerifBoldItalic.pfb +0 -0
  461. package/out/web/pdfjs/standard_fonts/FoxitSerifItalic.pfb +0 -0
  462. package/out/web/pdfjs/standard_fonts/FoxitSymbol.pfb +0 -0
  463. package/out/web/pdfjs/standard_fonts/LICENSE_FOXIT +27 -0
  464. package/out/web/pdfjs/standard_fonts/LICENSE_LIBERATION +363 -0
  465. package/out/web/pdfjs/standard_fonts/LiberationSans-Bold.ttf +0 -0
  466. package/out/web/pdfjs/standard_fonts/LiberationSans-BoldItalic.ttf +0 -0
  467. package/out/web/pdfjs/standard_fonts/LiberationSans-Italic.ttf +0 -0
  468. package/out/web/pdfjs/standard_fonts/LiberationSans-Regular.ttf +0 -0
  469. package/out/web/pdfjs/wasm/LICENSE_JBIG2 +196 -0
  470. package/out/web/pdfjs/wasm/LICENSE_OPENJPEG +39 -0
  471. package/out/web/pdfjs/wasm/LICENSE_PDFJS_JBIG2 +13 -0
  472. package/out/web/pdfjs/wasm/LICENSE_PDFJS_OPENJPEG +22 -0
  473. package/out/web/pdfjs/wasm/LICENSE_PDFJS_QCMS +19 -0
  474. package/out/web/pdfjs/wasm/LICENSE_QCMS +21 -0
  475. package/out/web/pdfjs/wasm/jbig2.wasm +0 -0
  476. package/out/web/pdfjs/wasm/jbig2_nowasm_fallback.js +15 -0
  477. package/out/web/pdfjs/wasm/openjpeg.wasm +0 -0
  478. package/out/web/pdfjs/wasm/openjpeg_nowasm_fallback.js +17 -0
  479. package/out/web/pdfjs/wasm/qcms_bg.wasm +0 -0
  480. package/out/web/pdfjs/wasm/quickjs-eval.js +16 -0
  481. package/out/web/pdfjs/wasm/quickjs-eval.wasm +0 -0
  482. package/package.json +6 -4
  483. package/shared-modules/CODEOWNERS +3 -3
  484. package/shared-modules/SDL2/SDL2_mixer.json +27 -0
  485. package/shared-modules/appstream/README.md +13 -0
  486. package/shared-modules/appstream/appstream-compose.json +111 -0
  487. package/shared-modules/dbus-glib/dbus-glib.json +2 -2
  488. package/shared-modules/libportal/libportal.json +23 -0
  489. package/shared-modules/linux-audio/fluidsynth2-static.json +2 -2
  490. package/shared-modules/linux-audio/fluidsynth2.json +2 -2
  491. package/shared-modules/linux-audio/libinstpatch.json +2 -2
  492. package/shared-modules/linux-audio/liblo-static.json +3 -0
  493. package/shared-modules/luajit/luajit.json +2 -2
  494. package/shared-modules/squashfs-tools/squashfs-tools.json +0 -3
  495. package/slash-commands.json +14 -3
  496. package/src/bin/index.js +4 -1
  497. package/src/bin/pdfjs-assets.mjs +31 -0
  498. package/src/renderer/node_modules/.vite/vitest/CACHEDIR.TAG +3 -0
  499. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  500. package/src/renderer/shared/agentProgress.ts +62 -5
  501. package/src/renderer/shared/components/Settings/AgentsTab.tsx +70 -6
  502. package/src/renderer/shared/components/Settings/AudioTab.tsx +1 -1
  503. package/src/renderer/shared/components/Settings/AutomationTab.tsx +32 -29
  504. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +32 -12
  505. package/src/renderer/shared/components/Settings/CustomProviderLogoPicker.test.tsx +65 -0
  506. package/src/renderer/shared/components/Settings/CustomProviderLogoPicker.tsx +456 -0
  507. package/src/renderer/shared/components/Settings/GeneralTab.tsx +65 -94
  508. package/src/renderer/shared/components/Settings/PluginsTab.tsx +101 -24
  509. package/src/renderer/shared/components/Settings/ProfileTab.tsx +1 -1
  510. package/src/renderer/shared/components/Settings/SettingsWindow.tsx +796 -0
  511. package/src/renderer/shared/components/Settings/ThemeTab.test.tsx +45 -0
  512. package/src/renderer/shared/components/Settings/ThemeTab.tsx +119 -4
  513. package/src/renderer/shared/components/activity/AgentActivityDrawer.tsx +270 -0
  514. package/src/renderer/shared/components/activity/AgentActivityTable.tsx +582 -0
  515. package/src/renderer/shared/components/activity/AskUserActivityCard.test.tsx +122 -0
  516. package/src/renderer/shared/components/activity/AskUserActivityCard.tsx +70 -0
  517. package/src/renderer/shared/components/{McpToolResults.tsx → activity/McpToolResults.tsx} +69 -13
  518. package/src/renderer/shared/components/{PlanChecklistWidget.tsx → activity/PlanChecklistWidget.tsx} +2 -2
  519. package/src/renderer/shared/components/activity/RunSummaryDashboard.test.tsx +127 -0
  520. package/src/renderer/shared/components/activity/RunSummaryDashboard.tsx +222 -0
  521. package/src/renderer/shared/components/activity/SubagentDagView.test.ts +123 -0
  522. package/src/renderer/shared/components/{SubagentDagView.tsx → activity/SubagentDagView.tsx} +104 -50
  523. package/src/renderer/shared/components/activity/SubagentStatusStrip.test.tsx +82 -0
  524. package/src/renderer/shared/components/activity/SubagentStatusStrip.tsx +107 -0
  525. package/src/renderer/shared/components/activity/ViewedImages.tsx +179 -0
  526. package/src/renderer/shared/components/{AuthGate.tsx → app/AuthGate.tsx} +3 -3
  527. package/src/renderer/shared/components/{ChunkErrorBoundary.tsx → app/ChunkErrorBoundary.tsx} +13 -13
  528. package/src/renderer/shared/components/{CommandPalette.tsx → app/CommandPalette.tsx} +30 -30
  529. package/src/renderer/shared/components/{DashboardSidebar.tsx → app/DashboardSidebar.tsx} +7 -5
  530. package/src/renderer/shared/components/{DesktopTitlebar.tsx → app/DesktopTitlebar.tsx} +9 -26
  531. package/src/renderer/shared/components/{ErrorNotice.tsx → app/ErrorNotice.tsx} +1 -1
  532. package/src/renderer/shared/components/{MintDashboard.tsx → app/MintDashboard.tsx} +217 -228
  533. package/src/renderer/shared/components/{ModalErrorBoundary.tsx → app/ModalErrorBoundary.tsx} +8 -8
  534. package/src/renderer/shared/components/{CalculationCard.tsx → cards/CalculationCard.tsx} +16 -30
  535. package/src/renderer/shared/components/{ImageGenCard.tsx → cards/ImageGenCard.tsx} +11 -24
  536. package/src/renderer/shared/components/{ImageSearchCard.tsx → cards/ImageSearchCard.tsx} +11 -24
  537. package/src/renderer/shared/components/{MermaidCard.tsx → cards/MermaidCard.tsx} +38 -22
  538. package/src/renderer/shared/components/cards/PlanCard.test.tsx +41 -0
  539. package/src/renderer/shared/components/cards/PlanCard.tsx +32 -0
  540. package/src/renderer/shared/components/cards/ReleaseNotesCard.test.tsx +29 -0
  541. package/src/renderer/shared/components/cards/ReleaseNotesCard.tsx +137 -0
  542. package/src/renderer/shared/components/{StockCard.tsx → cards/StockCard.tsx} +22 -38
  543. package/src/renderer/shared/components/cards/ToolResultCards.tsx +94 -0
  544. package/src/renderer/shared/components/{UiFeatureCard.tsx → cards/UiFeatureCard.tsx} +6 -6
  545. package/src/renderer/shared/components/{UiGridCard.tsx → cards/UiGridCard.tsx} +3 -3
  546. package/src/renderer/shared/components/{UiMockupWidget.tsx → cards/UiMockupWidget.tsx} +4 -4
  547. package/src/renderer/shared/components/{WeatherCard.tsx → cards/WeatherCard.tsx} +6 -6
  548. package/src/renderer/shared/components/{ApprovalCard.test.tsx → chat/ApprovalCard.test.tsx} +12 -1
  549. package/src/renderer/shared/components/{ApprovalCard.tsx → chat/ApprovalCard.tsx} +26 -7
  550. package/src/renderer/shared/components/chat/ArtifactCards.test.tsx +69 -0
  551. package/src/renderer/shared/components/chat/ArtifactCards.tsx +238 -0
  552. package/src/renderer/shared/components/chat/AttachedDocumentCard.test.tsx +39 -0
  553. package/src/renderer/shared/components/chat/AttachedDocumentCard.tsx +220 -0
  554. package/src/renderer/shared/components/chat/ChatCodeBlock.test.tsx +33 -0
  555. package/src/renderer/shared/components/{ChatCodeBlock.tsx → chat/ChatCodeBlock.tsx} +16 -2
  556. package/src/renderer/shared/components/chat/ChatMessageItem.test.tsx +183 -0
  557. package/src/renderer/shared/components/chat/ChatMessageItem.tsx +388 -0
  558. package/src/renderer/shared/components/chat/ChatModeSelector.css +227 -0
  559. package/src/renderer/shared/components/chat/ChatModeSelector.test.tsx +45 -0
  560. package/src/renderer/shared/components/chat/ChatModeSelector.tsx +198 -0
  561. package/src/renderer/shared/components/chat/ChatPanel.test.tsx +53 -0
  562. package/src/renderer/shared/components/{ChatPanel.tsx → chat/ChatPanel.tsx} +462 -537
  563. package/src/renderer/shared/components/{CollapsiblePasteInput.tsx → chat/CollapsiblePasteInput.tsx} +2 -2
  564. package/src/renderer/shared/components/{DiffReviewModal.tsx → chat/DiffReviewModal.tsx} +44 -44
  565. package/src/renderer/shared/components/chat/GeminiLiveOverlay.test.tsx +165 -0
  566. package/src/renderer/shared/components/{GeminiLiveOverlay.tsx → chat/GeminiLiveOverlay.tsx} +47 -5
  567. package/src/renderer/shared/components/{ModelSelectorPopover.tsx → chat/ModelSelectorPopover.tsx} +44 -16
  568. package/src/renderer/shared/components/chat/QueuedMessagesBanner.test.tsx +89 -0
  569. package/src/renderer/shared/components/chat/QueuedMessagesBanner.tsx +201 -0
  570. package/src/renderer/shared/components/{RewindModal.tsx → chat/RewindModal.tsx} +14 -14
  571. package/src/renderer/shared/components/{SlashChoicePanel.tsx → chat/SlashChoicePanel.tsx} +2 -2
  572. package/src/renderer/shared/components/{SlashSuggestions.test.tsx → chat/SlashSuggestions.test.tsx} +14 -0
  573. package/src/renderer/shared/components/{SlashSuggestions.tsx → chat/SlashSuggestions.tsx} +4 -4
  574. package/src/renderer/shared/components/chat/SourcesBlock.test.tsx +53 -0
  575. package/src/renderer/shared/components/{SourcesBlock.tsx → chat/SourcesBlock.tsx} +262 -123
  576. package/src/renderer/shared/components/{ThinkingBlock.tsx → chat/ThinkingBlock.tsx} +1 -1
  577. package/src/renderer/shared/components/chat/TurnFileChanges.test.tsx +130 -0
  578. package/src/renderer/shared/components/chat/TurnFileChanges.tsx +463 -0
  579. package/src/renderer/shared/components/{VoicePill.css → chat/VoicePill.css} +1 -1
  580. package/src/renderer/shared/components/{VoicePill.jsx → chat/VoicePill.jsx} +3 -2
  581. package/src/renderer/shared/components/{CliSessionsView.test.tsx → extensions/CliSessionsView.test.tsx} +2 -2
  582. package/src/renderer/shared/components/{CliSessionsView.tsx → extensions/CliSessionsView.tsx} +19 -16
  583. package/src/renderer/shared/components/{LinkedFoldersView.tsx → extensions/LinkedFoldersView.tsx} +3 -3
  584. package/src/renderer/shared/components/{McpRegistryPicker.tsx → extensions/McpRegistryPicker.tsx} +6 -6
  585. package/src/renderer/shared/components/{McpServersView.tsx → extensions/McpServersView.tsx} +105 -36
  586. package/src/renderer/shared/components/{McpToolAllowlist.tsx → extensions/McpToolAllowlist.tsx} +2 -2
  587. package/src/renderer/shared/components/{ModUi.test.tsx → extensions/ModUi.test.tsx} +9 -4
  588. package/src/renderer/shared/components/{ModsPanel.tsx → extensions/ModsPanel.tsx} +59 -7
  589. package/src/renderer/shared/components/{PluginsView.tsx → extensions/PluginsView.tsx} +6 -6
  590. package/src/renderer/shared/components/{ScheduledTasksView.tsx → extensions/ScheduledTasksView.tsx} +5 -5
  591. package/src/renderer/shared/components/extensions/SkillsView.test.tsx +31 -0
  592. package/src/renderer/shared/components/{SkillsView.tsx → extensions/SkillsView.tsx} +20 -4
  593. package/src/renderer/shared/components/{ImageStudioPanel.tsx → studio/ImageStudioPanel.tsx} +6 -6
  594. package/src/renderer/shared/components/{LibraryItemCard.tsx → studio/LibraryItemCard.tsx} +2 -2
  595. package/src/renderer/shared/components/{LibraryPage.tsx → studio/LibraryPage.tsx} +53 -10
  596. package/src/renderer/shared/components/{LibraryPreview.tsx → studio/LibraryPreview.tsx} +2 -2
  597. package/src/renderer/shared/components/{VeoStudioPanel.tsx → studio/VeoStudioPanel.tsx} +7 -7
  598. package/src/renderer/shared/components/{GlideSelect.css → ui/GlideSelect.css} +7 -6
  599. package/src/renderer/shared/components/ui/ProviderBrandIcon.test.tsx +67 -0
  600. package/src/renderer/shared/components/ui/ProviderBrandIcon.tsx +167 -0
  601. package/src/renderer/shared/components/ui/SelectField.tsx +506 -0
  602. package/src/renderer/shared/components/ui/error-recovery.css +10 -10
  603. package/src/renderer/shared/components/ui/error-recovery.tsx +1 -1
  604. package/src/renderer/shared/components/{ArtifactPreviewPanel.tsx → workspace/ArtifactPreviewPanel.tsx} +374 -118
  605. package/src/renderer/shared/components/workspace/BackgroundTerminals.test.tsx +63 -0
  606. package/src/renderer/shared/components/{BackgroundTerminals.tsx → workspace/BackgroundTerminals.tsx} +40 -9
  607. package/src/renderer/shared/components/workspace/CodeReviewPage.test.tsx +50 -0
  608. package/src/renderer/shared/components/{CodeReviewPage.tsx → workspace/CodeReviewPage.tsx} +124 -24
  609. package/src/renderer/shared/components/{GitBranchSelector.tsx → workspace/GitBranchSelector.tsx} +80 -24
  610. package/src/renderer/shared/components/workspace/SessionSources.tsx +113 -0
  611. package/src/renderer/shared/components/{TerminalDock.tsx → workspace/TerminalDock.tsx} +5 -2
  612. package/src/renderer/shared/components/workspace/ToolSurfacePage.test.tsx +81 -0
  613. package/src/renderer/shared/components/{ToolSurfacePage.tsx → workspace/ToolSurfacePage.tsx} +361 -90
  614. package/src/renderer/shared/components/{ToolsPanelToggle.tsx → workspace/ToolsPanelToggle.tsx} +1 -1
  615. package/src/renderer/shared/components/workspace/WorkspaceFileInfo.tsx +28 -0
  616. package/src/renderer/shared/components/workspace/WorkspaceMediaPreview.tsx +17 -0
  617. package/src/renderer/shared/components/{WorkspacePaneResizer.tsx → workspace/WorkspacePaneResizer.tsx} +1 -1
  618. package/src/renderer/shared/components/workspace/WorkspacePdfPreview.tsx +308 -0
  619. package/src/renderer/shared/components/workspace/WorkspacePreviewActions.test.tsx +101 -0
  620. package/src/renderer/shared/components/{WorkspacePreviewActions.tsx → workspace/WorkspacePreviewActions.tsx} +3 -3
  621. package/src/renderer/shared/components/{WorkspaceSelector.tsx → workspace/WorkspaceSelector.tsx} +99 -7
  622. package/src/renderer/shared/components/{background-terminals.css → workspace/background-terminals.css} +11 -11
  623. package/src/renderer/shared/components/{workspace-selector.css → workspace/workspace-selector.css} +84 -2
  624. package/src/renderer/shared/constants/config.ts +6 -3
  625. package/src/renderer/shared/constants/models.test.ts +51 -0
  626. package/src/renderer/shared/constants/models.ts +139 -32
  627. package/src/renderer/shared/conversation/coordinator.test.ts +46 -0
  628. package/src/renderer/shared/conversation/coordinator.ts +50 -2
  629. package/src/renderer/shared/conversation/lifecycle.test.ts +254 -4
  630. package/src/renderer/shared/conversation/lifecycle.ts +268 -12
  631. package/src/renderer/shared/conversation/progressiveStream.test.ts +83 -0
  632. package/src/renderer/shared/conversation/progressiveStream.ts +219 -0
  633. package/src/renderer/shared/conversation/projectNavigation.test.ts +18 -0
  634. package/src/renderer/shared/conversation/projectNavigation.ts +11 -0
  635. package/src/renderer/shared/conversation/transport.test.ts +20 -0
  636. package/src/renderer/shared/conversation/transport.ts +51 -9
  637. package/src/renderer/shared/conversation/useConversationCoordinator.ts +42 -12
  638. package/src/renderer/shared/css/agent-observability.css +205 -11
  639. package/src/renderer/shared/css/chat-ui-cards.css +48 -48
  640. package/src/renderer/shared/css/code-review.css +139 -110
  641. package/src/renderer/shared/css/code-sessions.css +56 -27
  642. package/src/renderer/shared/css/collapsed-paste.css +1 -1
  643. package/src/renderer/shared/css/error-notice.css +1 -1
  644. package/src/renderer/shared/css/file-changes.css +298 -51
  645. package/src/renderer/shared/css/library.css +105 -10
  646. package/src/renderer/shared/css/management-views.css +79 -38
  647. package/src/renderer/shared/css/mcp-results.css +48 -5
  648. package/src/renderer/shared/css/model-selector.css +18 -18
  649. package/src/renderer/shared/css/mods.css +7 -7
  650. package/src/renderer/shared/css/notification-notice.css +2 -2
  651. package/src/renderer/shared/css/queued-messages.css +221 -0
  652. package/src/renderer/shared/css/session-sources.css +12 -7
  653. package/src/renderer/shared/css/settings/agents.css +2 -2
  654. package/src/renderer/{src → shared}/css/settings/base.css +101 -107
  655. package/src/renderer/shared/css/settings/general.css +397 -100
  656. package/src/renderer/shared/css/settings/plugins.css +8 -8
  657. package/src/renderer/shared/css/settings/profile.css +5 -5
  658. package/src/renderer/shared/css/settings/theme.css +49 -37
  659. package/src/renderer/shared/css/slash-choice.css +2 -2
  660. package/src/renderer/shared/css/slash-suggestions.css +3 -3
  661. package/src/renderer/shared/css/sources-block.css +41 -2
  662. package/src/renderer/shared/css/tools-panel.css +2 -2
  663. package/src/renderer/shared/css/ui-tailwind.css +10 -2
  664. package/src/renderer/shared/css/veo-studio.css +2 -2
  665. package/src/renderer/shared/css/viewed-images.css +93 -4
  666. package/src/renderer/shared/css/workspace-file-info.css +12 -0
  667. package/src/renderer/shared/css/workspace-media-preview.css +13 -0
  668. package/src/renderer/shared/env.d.ts +9 -0
  669. package/src/renderer/shared/platform.test.ts +143 -0
  670. package/src/renderer/shared/platform.ts +12 -5
  671. package/src/renderer/shared/speech/player.ts +160 -0
  672. package/src/renderer/shared/speech/types.ts +11 -0
  673. package/src/renderer/shared/theme/themeManager.test.ts +37 -0
  674. package/src/renderer/shared/theme/themeManager.ts +24 -5
  675. package/src/renderer/shared/theme/themes.ts +71 -4
  676. package/src/renderer/shared/theme/tokens.css +452 -53
  677. package/src/renderer/shared/types.ts +57 -2
  678. package/src/renderer/shared/utils/agentActivity.test.ts +633 -8
  679. package/src/renderer/shared/utils/agentActivity.ts +776 -59
  680. package/src/renderer/shared/utils/artifactBrowser.test.ts +37 -32
  681. package/src/renderer/shared/utils/artifactBrowser.ts +18 -20
  682. package/src/renderer/shared/utils/artifacts.ts +55 -0
  683. package/src/renderer/shared/utils/attachedDocument.test.ts +73 -0
  684. package/src/renderer/shared/utils/attachedDocument.ts +49 -0
  685. package/src/renderer/shared/utils/contextCompaction.test.tsx +1 -1
  686. package/src/renderer/shared/utils/externalLinks.ts +91 -0
  687. package/src/renderer/shared/utils/fileIcons.test.ts +59 -0
  688. package/src/renderer/shared/utils/fileIcons.ts +149 -16
  689. package/src/renderer/shared/utils/lazyWithRetry.ts +13 -0
  690. package/src/renderer/shared/utils/markdown.tsx +238 -51
  691. package/src/renderer/shared/utils/markdown.webImages.test.tsx +140 -2
  692. package/src/renderer/shared/utils/mcpResults.test.tsx +53 -1
  693. package/src/renderer/shared/utils/pdfRange.ts +16 -0
  694. package/src/renderer/shared/utils/previewObjectUrls.ts +20 -0
  695. package/src/renderer/shared/utils/sessionSources.test.ts +41 -1
  696. package/src/renderer/shared/utils/sessionSources.ts +2 -0
  697. package/src/renderer/shared/utils/slashCommandProcessor.ts +7 -1
  698. package/src/renderer/shared/utils/speech.ts +18 -4
  699. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +11 -1
  700. package/src/renderer/shared/utils/syntaxHighlight.tsx +51 -3
  701. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +324 -51
  702. package/src/renderer/shared/utils/useNativeVoiceInput.ts +19 -5
  703. package/src/renderer/shared/utils/useSessionSources.ts +4 -0
  704. package/src/renderer/shared/utils/viewedImages.test.ts +129 -0
  705. package/src/renderer/shared/utils/viewedImages.ts +81 -10
  706. package/src/renderer/shared/utils/workspaceDrag.test.ts +153 -0
  707. package/src/renderer/shared/utils/workspaceDrag.ts +172 -0
  708. package/src/renderer/shared/utils/workspaceImage.ts +17 -0
  709. package/src/renderer/shared/utils/workspaceListPreferences.test.ts +64 -0
  710. package/src/renderer/shared/utils/workspaceListPreferences.ts +54 -0
  711. package/src/renderer/shared/utils/workspaceMedia.ts +49 -0
  712. package/src/renderer/shared/utils/workspacePreview.test.ts +147 -2
  713. package/src/renderer/shared/utils/workspacePreview.ts +34 -3
  714. package/src/renderer/shared/utils/workspaces.ts +3 -2
  715. package/src/renderer/src/App.tsx +20 -16
  716. package/src/renderer/src/agentProgress.ts +1 -0
  717. package/src/renderer/src/components/ChatPanel.tsx +1 -1
  718. package/src/renderer/src/components/DashboardSidebar.tsx +2 -2
  719. package/src/renderer/src/components/ImageStudioPanel.tsx +1 -1
  720. package/src/renderer/src/components/LiveTranslateControls.tsx +1 -1
  721. package/src/renderer/src/components/MintDashboard.tsx +1 -1
  722. package/src/renderer/src/components/ProactiveGlow.tsx +1 -1
  723. package/src/renderer/src/components/ScreenPicker.css +1 -1
  724. package/src/renderer/src/components/ScreenPicker.tsx +1 -1
  725. package/src/renderer/src/components/SettingsWindow.tsx +6 -830
  726. package/src/renderer/src/components/SpotlightWindow.tsx +4 -32
  727. package/src/renderer/src/components/VeoStudioPanel.tsx +1 -1
  728. package/src/renderer/src/components/WorkspacePanel.tsx +49 -15
  729. package/src/renderer/src/components/browser-suggestions-popup.css +8 -8
  730. package/src/renderer/src/css/app-shell.css +26 -106
  731. package/src/renderer/src/css/chat.css +1499 -292
  732. package/src/renderer/src/css/desktop-tools.css +17 -348
  733. package/src/renderer/src/css/sidebar-workspace.css +22 -2
  734. package/src/renderer/src/css/spotlight.css +2 -2
  735. package/src/renderer/src/css/tool-surfaces.css +335 -65
  736. package/src/renderer/src/css/widget.css +6 -5
  737. package/src/renderer/src/env.d.ts +13 -0
  738. package/src/renderer/src/index.css +53 -83
  739. package/src/renderer/src/tauri.ts +99 -13
  740. package/src/renderer/src/voiceInput.ts +3 -1
  741. package/src/renderer/src-web/App.tsx +20 -8
  742. package/src/renderer/src-web/agentProgress.ts +1 -0
  743. package/src/renderer/src-web/components/ChatPanel.tsx +1 -1
  744. package/src/renderer/src-web/components/DashboardSidebar.tsx +2 -2
  745. package/src/renderer/src-web/components/ImageStudioPanel.tsx +1 -1
  746. package/src/renderer/src-web/components/MintDashboard.tsx +1 -1
  747. package/src/renderer/src-web/components/SettingsWindow.tsx +6 -851
  748. package/src/renderer/src-web/components/VeoStudioPanel.tsx +1 -1
  749. package/src/renderer/src-web/css/app-shell.css +20 -104
  750. package/src/renderer/src-web/css/chat.css +1536 -261
  751. package/src/renderer/src-web/css/mobile.css +29 -13
  752. package/src/renderer/src-web/css/settings/mobile.css +2 -2
  753. package/src/renderer/src-web/css/sidebar-workspace.css +21 -1
  754. package/src/renderer/src-web/env.d.ts +14 -0
  755. package/src/renderer/src-web/index.css +53 -83
  756. package/src/renderer/src-web/tauri.ts +93 -15
  757. package/src/renderer/tests/browserArtifacts.browser.html +1 -0
  758. package/src/renderer/tests/browserArtifacts.browser.tsx +68 -0
  759. package/src/renderer/tests/chatEscape.browser.html +1 -0
  760. package/src/renderer/tests/chatEscape.browser.tsx +25 -0
  761. package/src/renderer/tests/chatPerformance.browser.html +1 -1
  762. package/src/renderer/tests/collapsedPaste.browser.html +1 -1
  763. package/src/renderer/tests/composerMenus.browser.html +3 -3
  764. package/src/renderer/tests/contextCompaction.browser.html +1 -1
  765. package/src/renderer/tests/errorRecovery.browser.html +1 -1
  766. package/src/renderer/tests/fixtures/preview.webm +0 -0
  767. package/src/renderer/tests/glideSelect.browser.html +1 -2
  768. package/src/renderer/tests/glideSelect.preview.html +1 -1
  769. package/src/renderer/tests/library.browser.tsx +4 -1
  770. package/src/renderer/tests/liveOrb.browser.html +1 -1
  771. package/src/renderer/tests/liveVoice.browser.html +1 -0
  772. package/src/renderer/tests/mediaFixtures.ts +32 -0
  773. package/src/renderer/tests/modInput.browser.html +1 -1
  774. package/src/renderer/tests/sessionSources.browser.tsx +76 -15
  775. package/src/renderer/tests/toolsPanel.browser.tsx +5 -5
  776. package/src/renderer/tests/viewedImages.browser.tsx +3 -3
  777. package/src/renderer/tests/voiceDictation.browser.html +101 -0
  778. package/src/renderer/tests/voicePill.browser.html +3 -3
  779. package/src/renderer/tests/voicePill.preview.html +1 -1
  780. package/src/renderer/tests/webSources.browser.tsx +2 -98
  781. package/src/renderer/tests/workspaceDrop.browser.html +1 -0
  782. package/src/renderer/tests/workspaceDrop.browser.tsx +183 -0
  783. package/src/renderer/tests/workspaceExpansion.browser.html +1 -0
  784. package/src/renderer/tests/workspaceExpansion.browser.tsx +66 -0
  785. package/src/renderer/tests/workspaceRemoval.browser.html +66 -0
  786. package/src/renderer/tests/workspaceUndo.browser.html +10 -2
  787. package/src-tauri/src/app/lifecycle.rs +45 -15
  788. package/src-tauri/src/background/headless.rs +14 -8
  789. package/src-tauri/src/commands/conversation.rs +137 -73
  790. package/src-tauri/src/commands/integrations.rs +7 -1
  791. package/src-tauri/src/commands/media.rs +7 -0
  792. package/src-tauri/src/commands/workspace.rs +38 -0
  793. package/src-tauri/src/native/mod.rs +0 -1
  794. package/src-tauri/src/native/system.rs +4 -47
  795. package/tsconfig.json +15 -1
  796. package/vite.config.ts +2 -1
  797. package/vite.config.web.ts +2 -0
  798. package/crates/mint-cli/src/interactive/chat_tui.rs +0 -7853
  799. package/crates/mint-core/src/agent/chat.rs +0 -4155
  800. package/crates/mint-core/src/browser/lifecycle.rs +0 -191
  801. package/crates/mint-core/src/browser/logging.rs +0 -20
  802. package/crates/mint-core/src/orchestration/tools/browser.rs +0 -31
  803. package/out/web/assets/CliSessionsView-CtzUJpK_.css +0 -1
  804. package/out/web/assets/CliSessionsView-D7hw0Mgj.js +0 -1
  805. package/out/web/assets/CodeReviewPage-C7bEOJfd.js +0 -4
  806. package/out/web/assets/CodeReviewPage-Dte9uLiL.css +0 -1
  807. package/out/web/assets/CommandPalette-0RrF-AOT.js +0 -1
  808. package/out/web/assets/ImageStudioPanel-CdxkAf_r.js +0 -1
  809. package/out/web/assets/LibraryPage--Qfisp6D.js +0 -2
  810. package/out/web/assets/LibraryPage-DAxUTAg7.css +0 -1
  811. package/out/web/assets/LinkedFoldersView-2nfZbDgN.js +0 -1
  812. package/out/web/assets/McpRegistryPicker-h9uS8_wT.js +0 -1
  813. package/out/web/assets/McpServersView-slfQJzNB.js +0 -1
  814. package/out/web/assets/MintDashboard-BSR5XpXh.js +0 -25
  815. package/out/web/assets/MintDashboard-CWUcCGhd.css +0 -1
  816. package/out/web/assets/PluginsView-l7LSm503.js +0 -1
  817. package/out/web/assets/ScheduledTasksView-CttmLbq1.js +0 -1
  818. package/out/web/assets/SettingsWindow-BCMLYvEV.js +0 -4
  819. package/out/web/assets/SettingsWindow-DcMVmU9u.css +0 -1
  820. package/out/web/assets/SkillsView-rNs1qr_U.js +0 -2
  821. package/out/web/assets/ToolSurfacePage-B9DzEPWN.js +0 -37
  822. package/out/web/assets/ToolSurfacePage-CTfgggF2.css +0 -1
  823. package/out/web/assets/VeoStudioPanel-DiJs_Cg2.js +0 -4
  824. package/out/web/assets/WorkspacePanel-CbJ1PFoZ.js +0 -1
  825. package/out/web/assets/WorkspaceSelector-B2xIp04D.js +0 -1
  826. package/out/web/assets/WorkspaceSelector-Ca8uJOsS.css +0 -1
  827. package/out/web/assets/abnfDiagram-VCTEODGH-CD0CjoGl.js +0 -1
  828. package/out/web/assets/architecture-7GRP2DOG-RKV83OeZ.js +0 -1
  829. package/out/web/assets/architectureDiagram-5GKGNRK7-nN_HAFct.js +0 -36
  830. package/out/web/assets/c4Diagram-UCG6FXSJ-DTdV8_zE.js +0 -38
  831. package/out/web/assets/chunk-2Q5K7J3B-Dmqrq8Tp.js +0 -1
  832. package/out/web/assets/chunk-75Z2AOVW-_M0z5olV.js +0 -2
  833. package/out/web/assets/chunk-CKBBP62Z-BpQEgeHF.js +0 -2
  834. package/out/web/assets/chunk-CLGD4ZFX-D54yGvGS.js +0 -1
  835. package/out/web/assets/chunk-CacB07dV.js +0 -1
  836. package/out/web/assets/chunk-DSNCTWBM-C-gvT2ru.js +0 -2
  837. package/out/web/assets/chunk-DU6HZSFF-CnFngIhW.js +0 -128
  838. package/out/web/assets/chunk-F27PBJKO-BqtWrnEO.js +0 -1
  839. package/out/web/assets/chunk-FOHPRMQF-BaVC1uA7.js +0 -161
  840. package/out/web/assets/chunk-G27WJ6UU-Ckoq5mi2.js +0 -231
  841. package/out/web/assets/chunk-JQ64N6SF-CF8T7GiZ.js +0 -1
  842. package/out/web/assets/chunk-JWPE2WC7-BwtYOHqU.js +0 -1
  843. package/out/web/assets/chunk-LNGE3PJU-AdBoOWw2.js +0 -34
  844. package/out/web/assets/chunk-OBVCFTLP-CRIkunUB.js +0 -1
  845. package/out/web/assets/chunk-P2QGCYS3-UFEU6BxZ.js +0 -1
  846. package/out/web/assets/chunk-POPQ4Y6H-CQcrNbjr.js +0 -1
  847. package/out/web/assets/chunk-PWAF6VOD-Cc2uVDsG.js +0 -1
  848. package/out/web/assets/chunk-R7TYR2AO-DiCxzdAY.js +0 -10
  849. package/out/web/assets/chunk-RHFEMEQ7-b7hnPmrR.js +0 -168
  850. package/out/web/assets/chunk-XXDRQBXY-DnJkbUaw.js +0 -1
  851. package/out/web/assets/chunk-Y2CYZVJY-C9o-s6OY.js +0 -1
  852. package/out/web/assets/classDiagram-DTDB5LWJ-RnC2mYO4.js +0 -1
  853. package/out/web/assets/classDiagram-v2-JRS7N3AN-RnC2mYO4.js +0 -1
  854. package/out/web/assets/config-CnvCChUM.js +0 -2
  855. package/out/web/assets/config-DM6oIVO3.css +0 -1
  856. package/out/web/assets/core-DsKkQ7ig.js +0 -1
  857. package/out/web/assets/cose-bilkent-JH36ORCC--UN4aw5-.js +0 -1
  858. package/out/web/assets/cynefin-OW5HDTMX-CnzQfOGp.js +0 -1
  859. package/out/web/assets/cynefinDiagram-5FMLGOSQ-DVtQnfpm.js +0 -62
  860. package/out/web/assets/cytoscape.esm-Df1UN4Y1.js +0 -321
  861. package/out/web/assets/dagre-3AP2YEHR-DG76_k5_.js +0 -4
  862. package/out/web/assets/dagre-BWadQu2F.js +0 -1
  863. package/out/web/assets/diagram-S7CK7UJ4-CaouUoze.js +0 -30
  864. package/out/web/assets/diagram-UQ7AKVKN-B4bEc26B.js +0 -41
  865. package/out/web/assets/diagram-VX7I27RA-7hH5IPYf.js +0 -24
  866. package/out/web/assets/diagram-Z3DM3KII-CCM4clo8.js +0 -24
  867. package/out/web/assets/dist-BoTOASLF.js +0 -1
  868. package/out/web/assets/dist-js-BkYNznHX.js +0 -1
  869. package/out/web/assets/ebnfDiagram-PWID7BFC-Da7WAM3k.js +0 -1
  870. package/out/web/assets/event-Cdt7Byyj.js +0 -1
  871. package/out/web/assets/eventmodeling-NTZA5JFV-xcfsA0XD.js +0 -1
  872. package/out/web/assets/flowDiagram-A5DVABFB-JdtH1673.js +0 -1
  873. package/out/web/assets/ganttDiagram-EL5Y4UJY-B3ClFGmL.js +0 -292
  874. package/out/web/assets/gitGraph-4MIJSDKK-BZ_y_cDU.js +0 -1
  875. package/out/web/assets/gitGraphDiagram-WWUBYQGX-drhRSYm3.js +0 -106
  876. package/out/web/assets/graphlib-CTR5ZhLf.js +0 -1
  877. package/out/web/assets/index-BZ9xgmMP.css +0 -1
  878. package/out/web/assets/index-Ca8cCS4h.js +0 -168
  879. package/out/web/assets/info-A6RAGUB7-B7I3K0F1.js +0 -1
  880. package/out/web/assets/infoDiagram-RXCK75RN-deX_1MPh.js +0 -2
  881. package/out/web/assets/journeyDiagram-EYS64GPL-CZn2kB8b.js +0 -139
  882. package/out/web/assets/jsx-runtime-DN4NhQUK.js +0 -1
  883. package/out/web/assets/katex-O3jvqgnU.js +0 -257
  884. package/out/web/assets/markdown-CMhYIcWB.js +0 -39
  885. package/out/web/assets/mermaid-parser.core-Whu2rlJC.js +0 -7
  886. package/out/web/assets/mermaid.core-CKyPo8VH.js +0 -11
  887. package/out/web/assets/modelManager-CUaGWMWu.js +0 -2
  888. package/out/web/assets/oauthManager-DlNhXJr6.js +0 -1
  889. package/out/web/assets/packet-AYTQ26CC-CEeIRKRG.js +0 -1
  890. package/out/web/assets/pegDiagram-XKGWAZYB-BYadI-KO.js +0 -1
  891. package/out/web/assets/pie-WAS4IAKB-D8uf4dh5.js +0 -1
  892. package/out/web/assets/pieDiagram-E7YTZNPT-CjSDy4hO.js +0 -39
  893. package/out/web/assets/radar-RG4KPBEZ-DZJxnokY.js +0 -1
  894. package/out/web/assets/railroad-74A4TZTK-DBysUiUi.js +0 -1
  895. package/out/web/assets/railroad-abnf-HS5TGJTU-CHWxoJYg.js +0 -1
  896. package/out/web/assets/railroad-ebnf-LZEXJU2U-DDv7X6bY.js +0 -1
  897. package/out/web/assets/railroad-peg-WCYAUIDC-DpfcYYNJ.js +0 -1
  898. package/out/web/assets/railroadDiagram-O6MQD6OU-CYtyv0t3.js +0 -1
  899. package/out/web/assets/rough.esm-DFbTUy8a.js +0 -1
  900. package/out/web/assets/sankeyDiagram-P5KCCOFB-Bn2x6C4Y.js +0 -40
  901. package/out/web/assets/sequenceDiagram-WJ2MYXX4-BvFLKtPd.js +0 -162
  902. package/out/web/assets/sizeCapture-X5ZJPWSS-DKeOCaN2.js +0 -1
  903. package/out/web/assets/src-DnfeXQPr.js +0 -1
  904. package/out/web/assets/stateDiagram-HBIQ2CUA-Bx0GWeaY.js +0 -1
  905. package/out/web/assets/stateDiagram-v2-4QOOHH4V--hol1yg_.js +0 -1
  906. package/out/web/assets/studio-B1mk40wD.js +0 -1
  907. package/out/web/assets/swimlanes-XN3QIQJK-CCma0T7L.js +0 -1
  908. package/out/web/assets/swimlanesDiagram-VK2B7HYN-BkohoVsU.js +0 -8
  909. package/out/web/assets/timeline-definition-24CTP7MA-iCRhhJ7C.js +0 -120
  910. package/out/web/assets/treeView-Q6P3EWNA-DZ-y08qp.js +0 -1
  911. package/out/web/assets/treemap-WGGIJYW6-De0CxzMx.js +0 -1
  912. package/out/web/assets/vennDiagram-4TSXK5OY-w_7aZ6xe.js +0 -34
  913. package/out/web/assets/wardley-WFR3VGLG-DT0y0xJ4.js +0 -1
  914. package/out/web/assets/window-BJCVtyET.js +0 -1
  915. package/shared-modules/libsecret/libsecret.json +0 -23
  916. package/shared-modules/libusb/libusb.json +0 -27
  917. package/shared-modules/lzo/lzo.json +0 -27
  918. package/src/renderer/shared/components/AgentActivityDrawer.tsx +0 -204
  919. package/src/renderer/shared/components/AgentActivityTable.tsx +0 -301
  920. package/src/renderer/shared/components/ChatMessageItem.test.tsx +0 -68
  921. package/src/renderer/shared/components/ChatMessageItem.tsx +0 -326
  922. package/src/renderer/shared/components/ChatPanel.test.tsx +0 -21
  923. package/src/renderer/shared/components/FormattedMessage.tsx +0 -11
  924. package/src/renderer/shared/components/GeminiLiveOverlay.test.tsx +0 -24
  925. package/src/renderer/shared/components/GlideSelect.d.ts +0 -31
  926. package/src/renderer/shared/components/GlideSelect.jsx +0 -266
  927. package/src/renderer/shared/components/InlineThoughtNote.tsx +0 -27
  928. package/src/renderer/shared/components/ModSessionControls.tsx +0 -8
  929. package/src/renderer/shared/components/ModUi.tsx +0 -26
  930. package/src/renderer/shared/components/ProgressActivity.tsx +0 -17
  931. package/src/renderer/shared/components/ReviewHistory.tsx +0 -42
  932. package/src/renderer/shared/components/RunSummaryDashboard.tsx +0 -143
  933. package/src/renderer/shared/components/SelectField.tsx +0 -38
  934. package/src/renderer/shared/components/SessionSources.tsx +0 -48
  935. package/src/renderer/shared/components/SessionSourcesPopover.tsx +0 -61
  936. package/src/renderer/shared/components/ToolSurfacePage.test.tsx +0 -32
  937. package/src/renderer/shared/components/ViewedImages.tsx +0 -36
  938. package/src/renderer/shared/components/WorkspaceContextPopover.tsx +0 -138
  939. package/src/renderer/shared/components/WorkspacePreviewActions.test.tsx +0 -44
  940. package/src/renderer/shared/components/ui/demo.tsx +0 -26
  941. package/src/renderer/shared/css/session-sources-popover.css +0 -9
  942. package/src/renderer/src/components/BrowserShell.tsx +0 -108
  943. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +0 -91
  944. package/src/renderer/src/components/CalculationCard.tsx +0 -3
  945. package/src/renderer/src/components/ImageGenCard.tsx +0 -2
  946. package/src/renderer/src/components/ImageSearchCard.tsx +0 -2
  947. package/src/renderer/src/components/MermaidCard.tsx +0 -1
  948. package/src/renderer/src/components/PicturesLibrary.tsx +0 -2
  949. package/src/renderer/src/components/Settings/AgentsTab.tsx +0 -4
  950. package/src/renderer/src/components/Settings/AudioTab.tsx +0 -3
  951. package/src/renderer/src/components/Settings/AutomationTab.tsx +0 -3
  952. package/src/renderer/src/components/Settings/GeneralTab.tsx +0 -3
  953. package/src/renderer/src/components/Settings/MemoryTab.tsx +0 -3
  954. package/src/renderer/src/components/Settings/PluginsTab.tsx +0 -3
  955. package/src/renderer/src/components/Settings/ProfileTab.tsx +0 -3
  956. package/src/renderer/src/components/Settings/ShortcutsTab.tsx +0 -3
  957. package/src/renderer/src/components/Settings/ThemeTab.tsx +0 -3
  958. package/src/renderer/src/components/StockCard.tsx +0 -3
  959. package/src/renderer/src/components/WeatherCard.tsx +0 -3
  960. package/src/renderer/src/components/browser-shell.css +0 -185
  961. package/src/renderer/src/css/pictures.css +0 -2
  962. package/src/renderer/src-web/components/PicturesLibrary.tsx +0 -2
  963. package/src/renderer/src-web/components/Settings/AgentsTab.tsx +0 -4
  964. package/src/renderer/src-web/components/Settings/AudioTab.tsx +0 -3
  965. package/src/renderer/src-web/components/Settings/AutomationTab.tsx +0 -3
  966. package/src/renderer/src-web/components/Settings/GeneralTab.tsx +0 -3
  967. package/src/renderer/src-web/components/Settings/MemoryTab.tsx +0 -3
  968. package/src/renderer/src-web/components/Settings/PluginsTab.tsx +0 -3
  969. package/src/renderer/src-web/components/Settings/ProfileTab.tsx +0 -3
  970. package/src/renderer/src-web/components/Settings/ShortcutsTab.tsx +0 -3
  971. package/src/renderer/src-web/components/Settings/ThemeTab.tsx +0 -3
  972. package/src/renderer/src-web/css/pictures.css +0 -2
  973. package/src/renderer/src-web/css/settings/base.css +0 -1266
  974. package/src/renderer/src-web/css/spotlight.css +0 -102
  975. package/src/renderer/src-web/css/widget.css +0 -213
  976. package/src-tauri/src/native/browser.rs +0 -252
  977. /package/src/renderer/shared/components/{ContextCompactionStatus.tsx → chat/ContextCompactionStatus.tsx} +0 -0
  978. /package/src/renderer/shared/components/{GeminiLiveOverlay.css → chat/GeminiLiveOverlay.css} +0 -0
  979. /package/src/renderer/shared/components/{VirtualChatHistory.tsx → chat/VirtualChatHistory.tsx} +0 -0
  980. /package/src/renderer/shared/components/{VoicePill.d.ts → chat/VoicePill.d.ts} +0 -0
  981. /package/src/renderer/shared/components/{VoicePill.test.tsx → chat/VoicePill.test.tsx} +0 -0
  982. /package/src/renderer/shared/components/{ScreenCaptureDialog.css → studio/ScreenCaptureDialog.css} +0 -0
  983. /package/src/renderer/shared/components/{ScreenCaptureDialog.tsx → studio/ScreenCaptureDialog.tsx} +0 -0
  984. /package/src/renderer/shared/components/{SelectField.test.tsx → ui/SelectField.test.tsx} +0 -0
@@ -1,4155 +0,0 @@
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
- /// Provider-neutral pieces produced while a model response is still arriving.
10
- /// Callers never need to know which vendor-specific field carried the data.
11
- #[derive(Debug, Clone, PartialEq, Eq)]
12
- pub enum ChatStreamEvent {
13
- TextDelta {
14
- delta: String,
15
- },
16
- ReasoningDelta {
17
- delta: String,
18
- },
19
- ToolCallDelta {
20
- index: usize,
21
- name: Option<String>,
22
- arguments: Option<String>,
23
- input: Option<Value>,
24
- },
25
- }
26
-
27
- /// `available_providers()`'s configured-providers list, reordered so
28
- /// `gemini` is tried first among fallbacks (when it's configured and isn't
29
- /// already the primary) — the user's explicit preference, ahead of
30
- /// `available_providers()`'s own anthropic/openai/openrouter/deepseek/
31
- /// gemini/... ordering (which exists for display purposes, e.g. the
32
- /// `/models` picker, and is left alone). Everything else keeps its existing
33
- /// relative order.
34
- fn fallback_provider_order(config: &MintConfig) -> Vec<String> {
35
- let mut providers = config.available_providers();
36
- if let Some(pos) = providers.iter().position(|p| p == "gemini") {
37
- let gemini = providers.remove(pos);
38
- providers.insert(0, gemini);
39
- }
40
- providers
41
- }
42
-
43
- /// Send a chat request, automatically falling back to other configured providers
44
- /// if the primary one returns a recoverable error. Tries `gemini` first among
45
- /// the fallbacks when it's configured (see `fallback_provider_order`), then
46
- /// the rest in their usual order.
47
- /// Returns `(response, Option<fallback_provider>)`.
48
- pub async fn send_chat_with_fallback(
49
- config: &MintConfig,
50
- request: &ChatRequest,
51
- ) -> Result<(ChatResponse, Option<String>), ChatError> {
52
- let primary_error = match send_chat(config, request).await {
53
- Ok(r) => return Ok((r, None)),
54
- Err(e) if !is_recoverable(&e) => return Err(e),
55
- Err(e) => e,
56
- };
57
- // No point cycling through other cloud providers if the network itself is
58
- // unreachable — they all sit behind the same dead link, so every attempt
59
- // would just fail the same way after its own connect timeout. Surface
60
- // this immediately so the caller can retry-and-wait instead.
61
- if matches!(primary_error, ChatError::NetworkUnavailable) {
62
- return Err(primary_error);
63
- }
64
- let fallback_reason = fallback_reason_text(&config.ai_provider, &primary_error);
65
- for provider in fallback_provider_order(config) {
66
- if provider.as_str() == config.ai_provider.as_str() {
67
- continue;
68
- }
69
- let alt = config_for_provider(config, &provider);
70
- if let Ok(mut r) = send_chat(&alt, request).await {
71
- r.fallback_provider = Some(config.ai_provider.clone());
72
- r.fallback_reason = fallback_reason.clone();
73
- return Ok((r, Some(provider)));
74
- }
75
- }
76
- // all fallbacks failed — retry primary to surface original error
77
- send_chat(config, request).await.map(|r| (r, None))
78
- }
79
-
80
- /// Short, human-readable explanation for why `send_chat_with_fallback` swapped
81
- /// away from `provider` — `None` for an ordinary transient failure, so routine
82
- /// hiccups don't get an extra notice while specific, actionable ones do.
83
- fn fallback_reason_text(provider: &str, error: &ChatError) -> Option<String> {
84
- let provider_name = if provider.starts_with("custom:") {
85
- provider.strip_prefix("custom:").unwrap_or(provider)
86
- } else {
87
- provider
88
- };
89
- match error {
90
- ChatError::InsufficientBalance(_) => Some(format!("{provider_name} ran out of balance")),
91
- ChatError::Request(e) => {
92
- if let Some(status) = e.status() {
93
- if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
94
- Some(format!("{provider_name} payload too large (HTTP 413)"))
95
- } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
96
- Some(format!("{provider_name} rate limit exceeded (HTTP 429)"))
97
- } else {
98
- Some(format!("{provider_name} returned HTTP {status}"))
99
- }
100
- } else {
101
- None
102
- }
103
- }
104
- _ => None,
105
- }
106
- }
107
-
108
- /// Stream a chat request with automatic provider fallback.
109
- /// Returns `(response, Option<fallback_provider>)`.
110
- pub async fn stream_chat_with_fallback<F>(
111
- config: &MintConfig,
112
- request: &ChatRequest,
113
- mut on_chunk: F,
114
- ) -> Result<(ChatResponse, Option<String>), ChatError>
115
- where
116
- F: FnMut(String),
117
- {
118
- let primary_error = match stream_chat(config, request, &mut on_chunk).await {
119
- Ok(r) => return Ok((r, None)),
120
- Err(e) if !is_recoverable(&e) => return Err(e),
121
- Err(e) => {
122
- eprintln!("Ollama stream chat failed, falling back: {:?}", e);
123
- e
124
- }
125
- };
126
- if matches!(primary_error, ChatError::NetworkUnavailable) {
127
- return Err(primary_error);
128
- }
129
- let fallback_reason = fallback_reason_text(&config.ai_provider, &primary_error);
130
- for provider in fallback_provider_order(config) {
131
- if provider.as_str() == config.ai_provider.as_str() {
132
- continue;
133
- }
134
- let alt = config_for_provider(config, &provider);
135
- if let Ok(mut r) = stream_chat(&alt, request, &mut on_chunk).await {
136
- r.fallback_provider = Some(config.ai_provider.clone());
137
- r.fallback_reason = fallback_reason.clone();
138
- return Ok((r, Some(provider)));
139
- }
140
- }
141
- stream_chat(config, request, &mut on_chunk)
142
- .await
143
- .map(|r| (r, None))
144
- }
145
-
146
- /// Streaming equivalent of [`send_chat_with_fallback`] that preserves the
147
- /// complete response (tool calls, usage, stop reason, and reasoning) while
148
- /// exposing provider-neutral deltas as they arrive.
149
- pub async fn stream_chat_events_with_fallback<F>(
150
- config: &MintConfig,
151
- request: &ChatRequest,
152
- mut on_event: F,
153
- ) -> Result<(ChatResponse, Option<String>), ChatError>
154
- where
155
- F: FnMut(ChatStreamEvent),
156
- {
157
- let primary_error = match stream_chat_events(config, request, &mut on_event).await {
158
- Ok(r) => return Ok((r, None)),
159
- Err(e) if !is_recoverable(&e) => return Err(e),
160
- Err(e) => e,
161
- };
162
- if matches!(primary_error, ChatError::NetworkUnavailable) {
163
- return Err(primary_error);
164
- }
165
- let fallback_reason = fallback_reason_text(&config.ai_provider, &primary_error);
166
- for provider in fallback_provider_order(config) {
167
- if provider == config.ai_provider {
168
- continue;
169
- }
170
- let alt = config_for_provider(config, &provider);
171
- if let Ok(mut response) = stream_chat_events(&alt, request, &mut on_event).await {
172
- response.fallback_provider = Some(config.ai_provider.clone());
173
- response.fallback_reason = fallback_reason.clone();
174
- return Ok((response, Some(provider)));
175
- }
176
- }
177
- stream_chat_events(config, request, &mut on_event)
178
- .await
179
- .map(|response| (response, None))
180
- }
181
-
182
- /// Whether an error warrants trying another provider.
183
- fn is_recoverable(e: &ChatError) -> bool {
184
- matches!(
185
- e,
186
- ChatError::MissingApiKey(_)
187
- | ChatError::Request(_)
188
- | ChatError::MissingResponseText
189
- | ChatError::UnsupportedAttachments(_)
190
- | ChatError::InsufficientBalance(_)
191
- | ChatError::NetworkUnavailable
192
- )
193
- }
194
-
195
- /// Clone the config with a different active provider.
196
- fn config_for_provider(config: &MintConfig, provider: &str) -> MintConfig {
197
- MintConfig {
198
- ai_provider: provider.to_owned(),
199
- ..config.clone()
200
- }
201
- }
202
-
203
- #[derive(Debug, Clone, Deserialize, Default)]
204
- #[serde(rename_all = "camelCase")]
205
- pub struct ChatRequest {
206
- pub message: String,
207
- #[serde(default)]
208
- pub system_instruction: String,
209
- #[serde(default)]
210
- pub chat_id: Option<String>,
211
- #[serde(default)]
212
- pub image_data_uri: Option<String>,
213
- #[serde(default)]
214
- pub audio_data_uri: Option<String>,
215
- #[serde(default)]
216
- pub video_data_uri: Option<String>,
217
- #[serde(default)]
218
- pub document_attachment: Option<DocumentAttachment>,
219
- #[serde(default)]
220
- pub workspace_path: Option<String>,
221
- #[serde(default)]
222
- pub agent_id: Option<String>,
223
- #[serde(default)]
224
- pub plan_mode: bool,
225
- /// When set, restricts this turn's `mcp_tool`/`mcp_list_tools` calls to this one
226
- /// configured MCP server — set by picking a plugin from the composer's `@` mention
227
- /// dropdown, rather than leaving server selection to the model's own judgment.
228
- #[serde(default)]
229
- pub pinned_mcp_server: Option<String>,
230
- /// Structured conversation history for native tool-calling. When set, provider
231
- /// adapters build the request from this instead of the flat `message` field.
232
- #[serde(default)]
233
- pub messages: Option<Vec<ChatMessage>>,
234
- /// Tool definitions to offer the model via each provider's native
235
- /// function/tool-calling API. Ignored when `messages` is `None`.
236
- #[serde(default)]
237
- pub tools: Option<Vec<ToolSpec>>,
238
- /// Optional per-request temperature override. When `None`, falls back to
239
- /// `config.resolved_temperature()`.
240
- #[serde(default)]
241
- pub temperature: Option<f64>,
242
- /// Optional per-request thinking/reasoning enabled override.
243
- #[serde(default)]
244
- pub thinking_enabled: Option<bool>,
245
- /// Optional per-request thinking effort override ("low", "medium", "high", "extra_high").
246
- #[serde(default)]
247
- pub thinking_effort: Option<String>,
248
- }
249
-
250
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251
- #[serde(rename_all = "camelCase")]
252
- pub enum ChatRole {
253
- System,
254
- User,
255
- Assistant,
256
- Tool,
257
- }
258
-
259
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260
- #[serde(rename_all = "camelCase")]
261
- pub struct ChatMessage {
262
- pub role: ChatRole,
263
- pub content: Vec<ContentBlock>,
264
- }
265
-
266
- impl ChatMessage {
267
- pub fn text(role: ChatRole, text: impl Into<String>) -> Self {
268
- ChatMessage {
269
- role,
270
- content: vec![ContentBlock::Text { text: text.into() }],
271
- }
272
- }
273
- }
274
-
275
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276
- #[serde(tag = "type", rename_all = "camelCase")]
277
- pub enum ContentBlock {
278
- Text {
279
- text: String,
280
- },
281
- ToolUse {
282
- id: String,
283
- name: String,
284
- input: Value,
285
- /// See `ToolCall::thought_signature` — carried on `ToolUse` too so it
286
- /// survives being replayed back into `native_messages` history on the
287
- /// next turn. `None` for every provider but Gemini.
288
- #[serde(default, skip_serializing_if = "Option::is_none")]
289
- thought_signature: Option<String>,
290
- },
291
- ToolResult {
292
- tool_use_id: String,
293
- content: String,
294
- #[serde(default)]
295
- is_error: bool,
296
- },
297
- Image {
298
- data_uri: String,
299
- },
300
- Audio {
301
- data_uri: String,
302
- },
303
- Video {
304
- data_uri: String,
305
- },
306
- }
307
-
308
- /// A tool definition offered to the model via native function/tool-calling.
309
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310
- #[serde(rename_all = "camelCase")]
311
- pub struct ToolSpec {
312
- pub name: String,
313
- pub description: String,
314
- /// JSON schema (draft-7-ish, as accepted by the provider's tool-parameter format).
315
- pub parameters: Value,
316
- }
317
-
318
- /// A tool invocation the model requested via native function/tool-calling.
319
- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320
- #[serde(rename_all = "camelCase")]
321
- pub struct ToolCall {
322
- pub id: String,
323
- pub name: String,
324
- pub input: Value,
325
- /// Gemini 3.x's "thinking" models attach an opaque `thoughtSignature` to
326
- /// each `functionCall` part; it must be echoed back verbatim on the next
327
- /// turn's replayed `functionCall` or the API rejects the request with a
328
- /// 400 (`INVALID_ARGUMENT: Function call is missing a thought_signature`).
329
- /// `None` for every other provider.
330
- #[serde(default, skip_serializing_if = "Option::is_none")]
331
- pub thought_signature: Option<String>,
332
- }
333
-
334
- #[derive(Debug, Clone, Deserialize)]
335
- #[serde(rename_all = "camelCase")]
336
- pub struct DocumentAttachment {
337
- pub filename: String,
338
- pub data_uri: String,
339
- }
340
-
341
- const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024;
342
- const MAX_DOCUMENT_CONTEXT_CHARS: usize = 400_000;
343
- static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
344
-
345
- impl ChatRequest {
346
- pub(crate) fn with_stored_document_context(
347
- &self,
348
- _config: &crate::MintConfig,
349
- path: &std::path::Path,
350
- ) -> Result<ChatRequest, String> {
351
- let Some(document) = &self.document_attachment else {
352
- return Ok(self.clone());
353
- };
354
- let extracted =
355
- crate::knowledge::extract_managed_document_text(path).map_err(|e| e.to_string())?;
356
- self.with_document_text(document, extracted)
357
- }
358
- fn with_document_text(
359
- &self,
360
- document: &DocumentAttachment,
361
- extracted: String,
362
- ) -> Result<ChatRequest, String> {
363
- let mut context: String = extracted.chars().take(MAX_DOCUMENT_CONTEXT_CHARS).collect();
364
- if extracted.chars().count() > MAX_DOCUMENT_CONTEXT_CHARS {
365
- context.push_str("\n\n[Document text truncated because it is long.]");
366
- }
367
-
368
- let filename = document.filename.trim();
369
- let filename = if filename.is_empty() {
370
- "attached document"
371
- } else {
372
- filename
373
- };
374
-
375
- let mut enriched = self.clone();
376
- enriched.document_attachment = None;
377
- enriched.message = format!(
378
- "{}\n\nAttached document: {filename}\nUse the extracted document text below when answering. If the user asks for a summary, summarize this document.\n\n--- Extracted document text ---\n{context}\n--- End extracted document text ---",
379
- self.message
380
- );
381
- Ok(enriched)
382
- }
383
- pub fn with_document_context(&self, config: &crate::MintConfig) -> Result<ChatRequest, String> {
384
- let Some(document) = &self.document_attachment else {
385
- return Ok(self.clone());
386
- };
387
-
388
- let (_mime_type, encoded) = document
389
- .data_uri
390
- .strip_prefix("data:")
391
- .and_then(|payload| payload.split_once(";base64,"))
392
- .ok_or_else(|| "invalid document attachment data URI".to_string())?;
393
-
394
- // Dispatch by the filename's extension, not the data URI's MIME type:
395
- // browsers report an empty or generic `application/octet-stream` type
396
- // for many of these formats (.md, .toml, .yaml, ...), so MIME type
397
- // isn't a reliable signal here. `extract_document_text` below performs
398
- // the real allowlist check and rejects anything it doesn't recognize.
399
- let extension = std::path::Path::new(document.filename.trim())
400
- .extension()
401
- .and_then(|ext| ext.to_str())
402
- .map(|ext| ext.to_ascii_lowercase())
403
- .filter(|ext| !ext.is_empty())
404
- .unwrap_or_else(|| "txt".to_string());
405
-
406
- use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
407
- let bytes = BASE64_STANDARD
408
- .decode(encoded)
409
- .map_err(|error| format!("invalid document attachment encoding: {error}"))?;
410
- if bytes.len() > MAX_DOCUMENT_BYTES {
411
- return Err("document attachment is too large (> 10 MiB)".into());
412
- }
413
-
414
- let directory = crate::config::config_path()
415
- .map_err(|error| error.to_string())?
416
- .with_file_name("Attachments");
417
- std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
418
-
419
- let safe_name: String = document
420
- .filename
421
- .chars()
422
- .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '.' | '-' | '_'))
423
- .take(80)
424
- .collect();
425
- let safe_name = if safe_name.is_empty() {
426
- "document".into()
427
- } else {
428
- safe_name
429
- };
430
-
431
- let path = directory.join(format!(
432
- "mint-doc-{}-{}.{}",
433
- COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
434
- safe_name,
435
- extension
436
- ));
437
- std::fs::write(&path, bytes).map_err(|error| error.to_string())?;
438
-
439
- let extracted = crate::knowledge::extract_document_text(&path, config)
440
- .map_err(|error| error.to_string());
441
- let _ = std::fs::remove_file(&path);
442
- let extracted = extracted?;
443
-
444
- self.with_document_text(document, extracted)
445
- }
446
- }
447
-
448
- #[derive(Debug, Clone, Serialize)]
449
- #[serde(rename_all = "camelCase")]
450
- pub struct ChatResponse {
451
- pub provider: String,
452
- pub model: String,
453
- pub text: String,
454
- #[serde(skip_serializing_if = "Option::is_none")]
455
- pub fallback_provider: Option<String>,
456
- /// Short, human-readable reason `send_chat_with_fallback` swapped providers,
457
- /// when the primary's failure was specific enough to explain (insufficient
458
- /// balance, no network) — `None` for an ordinary transient error, so a
459
- /// ordinary hiccup doesn't get an extra notice while a genuinely actionable
460
- /// one does.
461
- #[serde(skip_serializing_if = "Option::is_none")]
462
- pub fallback_reason: Option<String>,
463
- /// Tool calls the model requested via native function/tool-calling, when the
464
- /// request set `ChatRequest.tools` and the provider honored it.
465
- #[serde(skip_serializing_if = "Option::is_none")]
466
- pub tool_calls: Option<Vec<ToolCall>>,
467
- /// Provider-reported reason generation stopped (e.g. `"tool_use"`, `"end_turn"`,
468
- /// `"stop"`, `"max_tokens"`), when the provider reports one.
469
- #[serde(skip_serializing_if = "Option::is_none")]
470
- pub stop_reason: Option<String>,
471
- /// Total tokens (input + output) the provider billed for this turn, when
472
- /// reported. Used to track how close a long-running conversation is to the
473
- /// model's context window, rather than estimating client-side. Derived from
474
- /// `input_tokens + output_tokens` when both are present.
475
- #[serde(skip_serializing_if = "Option::is_none")]
476
- pub total_tokens: Option<u32>,
477
- /// Prompt/context tokens the provider processed for this turn (the whole
478
- /// resent history + system prompt + tool schemas), when reported — the
479
- /// "sent up to the model" half, tracked separately from `output_tokens` so
480
- /// the UI can show `↑ context · ↓ generated` instead of one opaque sum.
481
- #[serde(skip_serializing_if = "Option::is_none")]
482
- pub input_tokens: Option<u32>,
483
- /// Completion tokens the model generated this turn, when reported — the
484
- /// "came back down from the model" half. See `input_tokens`.
485
- #[serde(skip_serializing_if = "Option::is_none")]
486
- pub output_tokens: Option<u32>,
487
- /// Model reasoning / chain-of-thought tokens, when reported by reasoning models
488
- /// (e.g. DeepSeek Reasoner, Claude 3.7 Thinking, Gemini Thinking, or <think> tags).
489
- #[serde(skip_serializing_if = "Option::is_none")]
490
- pub thought: Option<String>,
491
- }
492
-
493
- /// Internal, provider-agnostic result of a single non-streaming provider call.
494
- /// `send_chat` maps this onto the public `ChatResponse`.
495
- struct ProviderReply {
496
- model: String,
497
- text: String,
498
- thought: Option<String>,
499
- tool_calls: Option<Vec<ToolCall>>,
500
- stop_reason: Option<String>,
501
- /// Prompt/context tokens processed for this call, when the provider reports
502
- /// usage. `send_chat` derives `ChatResponse::total_tokens` from this plus
503
- /// `output_tokens`.
504
- input_tokens: Option<u32>,
505
- /// Completion tokens generated for this call, when reported.
506
- output_tokens: Option<u32>,
507
- }
508
-
509
- #[derive(Debug, Error)]
510
- pub enum ChatError {
511
- #[error("provider '{0}' is not implemented in the Rust backend yet")]
512
- UnsupportedProvider(String),
513
- #[error("missing API key for provider '{0}'")]
514
- MissingApiKey(String),
515
- #[error("provider request failed: {0}")]
516
- Request(#[from] reqwest::Error),
517
- #[error("provider response did not include assistant text")]
518
- MissingResponseText,
519
- #[error("provider '{0}' does not support Mint multimodal attachments yet")]
520
- UnsupportedAttachments(String),
521
- #[error("invalid multimodal data URI")]
522
- InvalidAttachment,
523
- /// Provider returned `402 Payment Required` — distinguished from the generic
524
- /// `Request` case so callers can tell "your account ran out of balance" apart
525
- /// from an ordinary transient failure instead of silently swapping providers
526
- /// with no explanation.
527
- #[error("provider '{0}' rejected the request: insufficient balance")]
528
- InsufficientBalance(String),
529
- /// The request never reached any server at all (DNS failure, connection
530
- /// refused, no route) — as opposed to reaching a server that returned an
531
- /// error. Distinguished so a caller can retry-and-wait instead of pointlessly
532
- /// cycling through other cloud providers that sit behind the same dead
533
- /// network link.
534
- #[error("no network connection to reach the provider")]
535
- NetworkUnavailable,
536
- #[error("provider API error: {0}")]
537
- Api(String),
538
- }
539
-
540
- pub async fn send_chat(
541
- config: &MintConfig,
542
- request: &ChatRequest,
543
- ) -> Result<ChatResponse, ChatError> {
544
- let client = crate::HTTP_CLIENT.clone();
545
- let provider = config.ai_provider.as_str();
546
- require_supported_attachments(provider, request)?;
547
- let reply = match provider {
548
- "gemini" => call_gemini(&client, config, request).await?,
549
- "openai" | "local_openai" | "openrouter" | "deepseek" => {
550
- call_openai(&client, config, request).await?
551
- }
552
- "ollama" => call_ollama(&client, config, request).await?,
553
- "anthropic" => call_anthropic(&client, config, request).await?,
554
- "huggingface" => call_huggingface(&client, config, request).await?,
555
- p if p.starts_with("custom:") => call_custom_provider(&client, config, request).await?,
556
- other => return Err(ChatError::UnsupportedProvider(other.into())),
557
- };
558
- Ok(ChatResponse {
559
- provider: provider.into(),
560
- model: reply.model,
561
- text: reply.text,
562
- thought: reply.thought,
563
- fallback_provider: None,
564
- fallback_reason: None,
565
- tool_calls: reply.tool_calls,
566
- stop_reason: reply.stop_reason,
567
- total_tokens: match (reply.input_tokens, reply.output_tokens) {
568
- (Some(i), Some(o)) => Some(i + o),
569
- (i, o) => i.or(o),
570
- },
571
- input_tokens: reply.input_tokens,
572
- output_tokens: reply.output_tokens,
573
- })
574
- }
575
-
576
- pub async fn stream_chat<F>(
577
- config: &MintConfig,
578
- request: &ChatRequest,
579
- mut on_chunk: F,
580
- ) -> Result<ChatResponse, ChatError>
581
- where
582
- F: FnMut(String),
583
- {
584
- stream_chat_events(config, request, |event| {
585
- if let ChatStreamEvent::TextDelta { delta } = event {
586
- on_chunk(delta);
587
- }
588
- })
589
- .await
590
- }
591
-
592
- pub async fn stream_chat_events<F>(
593
- config: &MintConfig,
594
- request: &ChatRequest,
595
- mut on_event: F,
596
- ) -> Result<ChatResponse, ChatError>
597
- where
598
- F: FnMut(ChatStreamEvent),
599
- {
600
- let client = crate::HTTP_CLIENT.clone();
601
- let provider = config.ai_provider.as_str();
602
- require_supported_attachments(provider, request)?;
603
- let reply = match provider {
604
- "gemini" => stream_gemini_reply(&client, config, request, &mut on_event).await?,
605
- "openai" | "local_openai" | "openrouter" | "deepseek" => {
606
- stream_openai_reply(&client, config, request, &mut on_event).await?
607
- }
608
- "ollama" => stream_ollama_reply(&client, config, request, &mut on_event).await?,
609
- "anthropic" => stream_anthropic_reply(&client, config, request, &mut on_event).await?,
610
- "huggingface" => stream_huggingface_reply(&client, config, request, &mut on_event).await?,
611
- p if p.starts_with("custom:") => {
612
- stream_custom_provider_reply(&client, config, request, &mut on_event).await?
613
- }
614
- other => return Err(ChatError::UnsupportedProvider(other.into())),
615
- };
616
- Ok(ChatResponse {
617
- provider: provider.into(),
618
- model: reply.model,
619
- text: reply.text,
620
- thought: reply.thought,
621
- fallback_provider: None,
622
- fallback_reason: None,
623
- tool_calls: reply.tool_calls,
624
- stop_reason: reply.stop_reason,
625
- total_tokens: match (reply.input_tokens, reply.output_tokens) {
626
- (Some(input), Some(output)) => Some(input + output),
627
- (input, output) => input.or(output),
628
- },
629
- input_tokens: reply.input_tokens,
630
- output_tokens: reply.output_tokens,
631
- })
632
- }
633
-
634
- async fn call_gemini(
635
- client: &Client,
636
- config: &MintConfig,
637
- request: &ChatRequest,
638
- ) -> Result<ProviderReply, ChatError> {
639
- let api_key = provider_key(&config.api_key, "GEMINI_API_KEY");
640
- required_key("gemini", &api_key)?;
641
- let model = config.gemini_model.clone();
642
- let url =
643
- format!("https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent");
644
-
645
- let payload = if request.messages.is_some() {
646
- let mut p = gemini_native_payload(request)?;
647
- let temp = effective_temperature(config, request);
648
- let mut gen_cfg = json!({ "temperature": temp });
649
- gemini_apply_thinking_config(&mut gen_cfg, config, request, &model);
650
- p["generationConfig"] = gen_cfg;
651
- p
652
- } else {
653
- gemini_chat_payload(config, request)?
654
- };
655
-
656
- let response: Value = client
657
- .post(url)
658
- .header("x-goog-api-key", api_key)
659
- .json(&payload)
660
- .send()
661
- .await?
662
- .error_for_status()?
663
- .json()
664
- .await?;
665
- parse_gemini_reply(model, &response)
666
- }
667
-
668
- /// Strips JSON Schema keywords that standard tool-parameter schemas use (built
669
- /// once and shared across OpenAI/Anthropic/Gemini) but that Gemini's function-
670
- /// calling `Schema` proto rejects outright — e.g. `additionalProperties` on an
671
- /// object property, as in `avatar_signal`'s `emotions` map, previously caused
672
- /// every native tool-calling turn to fail with `400 INVALID_ARGUMENT: Unknown
673
- /// name "additionalProperties"`, silently falling back to a weaker provider on
674
- /// every single Gemini turn.
675
- fn gemini_sanitize_schema(schema: &Value) -> Value {
676
- match schema {
677
- Value::Object(map) => Value::Object(
678
- map.iter()
679
- .filter(|(key, _)| key.as_str() != "additionalProperties")
680
- .map(|(key, value)| (key.clone(), gemini_sanitize_schema(value)))
681
- .collect(),
682
- ),
683
- Value::Array(items) => Value::Array(items.iter().map(gemini_sanitize_schema).collect()),
684
- other => other.clone(),
685
- }
686
- }
687
-
688
- /// Builds a Gemini `generateContent` payload from structured history, using
689
- /// native `functionDeclarations`/`functionCall`/`functionResponse` instead of
690
- /// the legacy `responseSchema` JSON-prompt scheme (see `gemini_chat_payload`).
691
- fn gemini_native_payload(request: &ChatRequest) -> Result<Value, ChatError> {
692
- let messages = request.messages.as_deref().unwrap_or(&[]);
693
- let mut payload = json!({
694
- "systemInstruction": { "parts": [{ "text": request.system_instruction }] },
695
- "contents": gemini_native_contents(messages)?,
696
- });
697
- if let Some(tools) = &request.tools {
698
- payload["tools"] = json!([{
699
- "functionDeclarations": tools.iter().map(|tool| json!({
700
- "name": tool.name,
701
- "description": tool.description,
702
- "parameters": gemini_sanitize_schema(&tool.parameters),
703
- })).collect::<Vec<_>>()
704
- }]);
705
- }
706
- Ok(payload)
707
- }
708
-
709
- fn gemini_native_contents(messages: &[ChatMessage]) -> Result<Vec<Value>, ChatError> {
710
- // Gemini's `functionResponse` part is matched by function *name*, not by a
711
- // call id like Anthropic/OpenAI. Build an id -> name lookup from the
712
- // `ToolUse` blocks already in history so `ToolResult` blocks (which only
713
- // carry `tool_use_id`) can be translated correctly.
714
- let mut tool_names: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
715
- for message in messages {
716
- for block in &message.content {
717
- if let ContentBlock::ToolUse { id, name, .. } = block {
718
- tool_names.insert(id.as_str(), name.as_str());
719
- }
720
- }
721
- }
722
- messages
723
- .iter()
724
- .filter(|message| message.role != ChatRole::System)
725
- .map(|message| {
726
- let role = match message.role {
727
- ChatRole::Assistant => "model",
728
- _ => "user",
729
- };
730
- let parts = message
731
- .content
732
- .iter()
733
- .map(|block| gemini_native_part(block, &tool_names))
734
- .collect::<Result<Vec<_>, _>>()?;
735
- Ok(json!({ "role": role, "parts": parts }))
736
- })
737
- .collect()
738
- }
739
-
740
- fn gemini_native_part(
741
- block: &ContentBlock,
742
- tool_names: &std::collections::HashMap<&str, &str>,
743
- ) -> Result<Value, ChatError> {
744
- Ok(match block {
745
- ContentBlock::Text { text } => json!({ "text": text }),
746
- ContentBlock::ToolUse {
747
- name,
748
- input,
749
- thought_signature,
750
- ..
751
- } => {
752
- // A `thoughtSignature` captured off the model's own `functionCall`
753
- // part must be echoed back verbatim here — a sibling key of
754
- // `functionCall`, not nested inside it — or Gemini 3.x rejects the
755
- // request outright (400 INVALID_ARGUMENT: "Function call is
756
- // missing a thought_signature"), which without this looked like a
757
- // recoverable provider error and silently triggered a fallback to
758
- // a different (much weaker) provider on every second tool-calling
759
- // turn. See `ToolCall::thought_signature`.
760
- let mut part = json!({ "functionCall": { "name": name, "args": input } });
761
- if let Some(signature) = thought_signature {
762
- part["thoughtSignature"] = json!(signature);
763
- }
764
- part
765
- }
766
- ContentBlock::ToolResult {
767
- tool_use_id,
768
- content,
769
- ..
770
- } => {
771
- let name = tool_names
772
- .get(tool_use_id.as_str())
773
- .copied()
774
- .unwrap_or(tool_use_id.as_str());
775
- json!({ "functionResponse": { "name": name, "response": { "result": content } } })
776
- }
777
- ContentBlock::Image { data_uri } => {
778
- let (mime_type, data) = split_data_uri(data_uri, "image/")?;
779
- json!({ "inlineData": { "mimeType": mime_type, "data": data } })
780
- }
781
- ContentBlock::Audio { data_uri } => {
782
- let (mime_type, data) = split_data_uri(data_uri, "audio/")?;
783
- json!({ "inlineData": { "mimeType": mime_type, "data": data } })
784
- }
785
- ContentBlock::Video { data_uri } => {
786
- let (mime_type, data) = split_data_uri(data_uri, "video/")?;
787
- json!({ "inlineData": { "mimeType": mime_type, "data": data } })
788
- }
789
- })
790
- }
791
-
792
- /// Extracts `<think>...</think>` chain-of-thought blocks emitted by reasoning models
793
- /// (e.g. DeepSeek-R1, QwQ) when embedded directly into plain text content.
794
- /// Returns `(Option<thought>, remaining_text)`.
795
- fn extract_think_tag(text: &str) -> (Option<String>, String) {
796
- if let Some(start) = text.find("<think>") {
797
- if let Some(end) = text[start + 7..].find("</think>") {
798
- let thought = text[start + 7..start + 7 + end].trim().to_string();
799
- let mut remaining = text[..start].to_string();
800
- remaining.push_str(&text[start + 7 + end + 8..]);
801
- let cleaned = remaining.trim().to_string();
802
- (
803
- if thought.is_empty() {
804
- None
805
- } else {
806
- Some(thought)
807
- },
808
- cleaned,
809
- )
810
- } else {
811
- let thought = text[start + 7..].trim().to_string();
812
- let cleaned = text[..start].trim().to_string();
813
- (
814
- if thought.is_empty() {
815
- None
816
- } else {
817
- Some(thought)
818
- },
819
- cleaned,
820
- )
821
- }
822
- } else {
823
- (None, text.to_string())
824
- }
825
- }
826
-
827
- /// Parses a Gemini `generateContent` response, collecting text parts and any
828
- /// `functionCall` parts into tool calls. Gemini doesn't assign call ids, so a
829
- /// stable per-response index (`call_0`, `call_1`, ...) is synthesized; this is
830
- /// safe because `gemini_native_part` resolves `functionResponse` back to the
831
- /// function *name*, not the id.
832
- fn parse_gemini_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
833
- let parts = response["candidates"][0]["content"]["parts"]
834
- .as_array()
835
- .cloned()
836
- .unwrap_or_default();
837
- let mut text = String::new();
838
- let mut thought_parts: Vec<&str> = Vec::new();
839
- let mut tool_calls = Vec::new();
840
- for (index, part) in parts.iter().enumerate() {
841
- let is_thought = part
842
- .get("thought")
843
- .and_then(Value::as_bool)
844
- .unwrap_or(false);
845
- if let Some(t) = part["text"].as_str() {
846
- if is_thought {
847
- thought_parts.push(t);
848
- } else {
849
- text.push_str(t);
850
- }
851
- }
852
- if let Some(call) = part.get("functionCall") {
853
- tool_calls.push(ToolCall {
854
- id: format!("call_{index}"),
855
- name: call["name"].as_str().unwrap_or_default().to_string(),
856
- input: call["args"].clone(),
857
- // Sibling of `functionCall` within the same `part`, not nested
858
- // inside it — see the module-level doc on `ToolCall::thought_signature`.
859
- thought_signature: part
860
- .get("thoughtSignature")
861
- .and_then(Value::as_str)
862
- .map(str::to_owned),
863
- });
864
- }
865
- }
866
- let direct_thought = if !thought_parts.is_empty() {
867
- Some(thought_parts.join("").trim().to_string()).filter(|s| !s.is_empty())
868
- } else {
869
- None
870
- };
871
- let (thought, text) = if direct_thought.is_none() {
872
- extract_think_tag(&text)
873
- } else {
874
- (direct_thought, text)
875
- };
876
- if text.is_empty() && tool_calls.is_empty() {
877
- if let Some(th) = &thought {
878
- return Ok(ProviderReply {
879
- model,
880
- text: th.clone(),
881
- thought: Some(th.clone()),
882
- tool_calls: None,
883
- stop_reason: response["candidates"][0]["finishReason"]
884
- .as_str()
885
- .map(str::to_owned),
886
- input_tokens: response["usageMetadata"]["promptTokenCount"]
887
- .as_u64()
888
- .map(|n| n as u32),
889
- output_tokens: response["usageMetadata"]["candidatesTokenCount"]
890
- .as_u64()
891
- .map(|n| n as u32),
892
- });
893
- }
894
- return Err(ChatError::MissingResponseText);
895
- }
896
- Ok(ProviderReply {
897
- model,
898
- text,
899
- thought,
900
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
901
- stop_reason: response["candidates"][0]["finishReason"]
902
- .as_str()
903
- .map(str::to_owned),
904
- input_tokens: response["usageMetadata"]["promptTokenCount"]
905
- .as_u64()
906
- .map(|n| n as u32),
907
- output_tokens: response["usageMetadata"]["candidatesTokenCount"]
908
- .as_u64()
909
- .map(|n| n as u32),
910
- })
911
- }
912
-
913
- async fn call_openai(
914
- client: &Client,
915
- config: &MintConfig,
916
- request: &ChatRequest,
917
- ) -> Result<ProviderReply, ChatError> {
918
- let local = config.ai_provider == "local_openai";
919
- let openrouter = config.ai_provider == "openrouter";
920
- let deepseek = config.ai_provider == "deepseek";
921
- let api_key = if openrouter {
922
- provider_key(&config.openrouter_api_key, "OPENROUTER_API_KEY")
923
- } else if deepseek {
924
- provider_key(&config.deepseek_api_key, "DEEPSEEK_API_KEY")
925
- } else {
926
- provider_key(&config.openai_api_key, "OPENAI_API_KEY")
927
- };
928
- if openrouter {
929
- required_key("openrouter", &api_key)?;
930
- } else if deepseek {
931
- required_key("deepseek", &api_key)?;
932
- } else if !local {
933
- required_key("openai", &api_key)?;
934
- }
935
- let base_url = if openrouter {
936
- "https://openrouter.ai/api/v1"
937
- } else if deepseek {
938
- "https://api.deepseek.com"
939
- } else if local {
940
- config.local_api_base_url.trim_end_matches('/')
941
- } else {
942
- "https://api.openai.com/v1"
943
- };
944
- let model = if openrouter {
945
- config.openrouter_model.clone()
946
- } else if deepseek {
947
- config.deepseek_model.clone()
948
- } else if local {
949
- config.local_model_name.clone()
950
- } else {
951
- config.openai_model.clone()
952
- };
953
- let response: Value = send_openai_style_request(
954
- client
955
- .post(format!("{base_url}/chat/completions"))
956
- .bearer_auth(if local { "not-needed" } else { &api_key })
957
- .json(&openai_chat_payload(config, &model, request, false)?),
958
- &config.ai_provider,
959
- )
960
- .await?;
961
- parse_openai_style_reply(model, &response)
962
- }
963
-
964
- /// Sends a built request, classifying the failure modes `.error_for_status()`
965
- /// would otherwise flatten into one opaque `reqwest::Error`: a connect-level
966
- /// failure (DNS, refused, no route — never reached the server at all) becomes
967
- /// `NetworkUnavailable`, and an HTTP `402 Payment Required` becomes
968
- /// `InsufficientBalance(provider)`. Shared by every OpenAI Chat
969
- /// Completions-shaped provider (`call_openai`, `call_huggingface`).
970
- async fn send_openai_style_request(
971
- request: reqwest::RequestBuilder,
972
- provider: &str,
973
- ) -> Result<Value, ChatError> {
974
- let response = request.send().await.map_err(|e| {
975
- if e.is_connect() {
976
- ChatError::NetworkUnavailable
977
- } else {
978
- ChatError::Request(e)
979
- }
980
- })?;
981
- if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
982
- return Err(ChatError::InsufficientBalance(provider.to_string()));
983
- }
984
- if !response.status().is_success() {
985
- let status = response.status();
986
- let body = response.text().await.unwrap_or_default();
987
- return Err(ChatError::Api(format!("HTTP {status}: {body}")));
988
- }
989
- Ok(response.json().await?)
990
- }
991
-
992
- // Preserve malformed arguments so the executor can reject them. Replacing them
993
- // with {} would silently turn a requested directory into the workspace root.
994
- fn decode_tool_arguments(arguments: &Value) -> Value {
995
- match arguments.as_str() {
996
- Some(raw) => serde_json::from_str(raw).unwrap_or_else(|_| arguments.clone()),
997
- None => arguments.clone(),
998
- }
999
- }
1000
-
1001
- /// Parses an OpenAI Chat Completions-shaped response (`choices[0].message`),
1002
- /// extracting both plain text and any native `tool_calls`. Shared by OpenAI,
1003
- /// custom OpenAI-compatible providers, and (for its response shape only) reused
1004
- /// by nothing else — Ollama's native `/api/chat` response is shaped differently
1005
- /// and has its own parser below.
1006
- fn parse_openai_style_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1007
- let message = &response["choices"][0]["message"];
1008
- let raw_text = message["content"].as_str().unwrap_or_default().to_string();
1009
- let direct_thought = message["reasoning_content"]
1010
- .as_str()
1011
- .or_else(|| message["reasoning"].as_str())
1012
- .or_else(|| message["thought"].as_str())
1013
- .or_else(|| message["thinking"].as_str())
1014
- .map(|s| s.trim().to_string())
1015
- .filter(|s| !s.is_empty());
1016
-
1017
- let (thought, text) = if direct_thought.is_none() {
1018
- extract_think_tag(&raw_text)
1019
- } else {
1020
- (direct_thought, raw_text)
1021
- };
1022
-
1023
- let tool_calls: Vec<ToolCall> = message["tool_calls"]
1024
- .as_array()
1025
- .map(|calls| {
1026
- calls
1027
- .iter()
1028
- .filter_map(|call| {
1029
- let name = call["function"]["name"].as_str()?.to_string();
1030
- let input = decode_tool_arguments(&call["function"]["arguments"]);
1031
- Some(ToolCall {
1032
- id: call["id"].as_str().unwrap_or_default().to_string(),
1033
- name,
1034
- input,
1035
- thought_signature: None,
1036
- })
1037
- })
1038
- .collect()
1039
- })
1040
- .unwrap_or_default();
1041
- if text.is_empty() && tool_calls.is_empty() {
1042
- if let Some(th) = &thought {
1043
- return Ok(ProviderReply {
1044
- model,
1045
- text: th.clone(),
1046
- thought: Some(th.clone()),
1047
- tool_calls: None,
1048
- stop_reason: response["choices"][0]["finish_reason"]
1049
- .as_str()
1050
- .map(str::to_owned),
1051
- input_tokens: response["usage"]["prompt_tokens"]
1052
- .as_u64()
1053
- .map(|n| n as u32),
1054
- output_tokens: response["usage"]["completion_tokens"]
1055
- .as_u64()
1056
- .map(|n| n as u32),
1057
- });
1058
- }
1059
- return Err(ChatError::MissingResponseText);
1060
- }
1061
- Ok(ProviderReply {
1062
- model,
1063
- text,
1064
- thought,
1065
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1066
- stop_reason: response["choices"][0]["finish_reason"]
1067
- .as_str()
1068
- .map(str::to_owned),
1069
- input_tokens: response["usage"]["prompt_tokens"]
1070
- .as_u64()
1071
- .map(|n| n as u32),
1072
- output_tokens: response["usage"]["completion_tokens"]
1073
- .as_u64()
1074
- .map(|n| n as u32),
1075
- })
1076
- }
1077
-
1078
- async fn call_ollama(
1079
- client: &Client,
1080
- config: &MintConfig,
1081
- request: &ChatRequest,
1082
- ) -> Result<ProviderReply, ChatError> {
1083
- let host = if config.ollama_host.trim().is_empty() {
1084
- "http://localhost:11434"
1085
- } else {
1086
- config.ollama_host.trim_end_matches('/')
1087
- };
1088
- let model = config.ollama_model.clone();
1089
- let native = request.messages.is_some()
1090
- && config.tool_calling_mode() == crate::config::ToolCallingMode::Native;
1091
- let mut body = if native {
1092
- let mut payload = json!({
1093
- "model": model,
1094
- "stream": false,
1095
- "messages": ollama_request_messages(request),
1096
- });
1097
- if let Some(tools) = &request.tools {
1098
- payload["tools"] = json!(
1099
- tools
1100
- .iter()
1101
- .map(|tool| json!({
1102
- "type": "function",
1103
- "function": {
1104
- "name": tool.name,
1105
- "description": tool.description,
1106
- "parameters": tool.parameters,
1107
- }
1108
- }))
1109
- .collect::<Vec<_>>()
1110
- );
1111
- }
1112
- payload
1113
- } else {
1114
- let mut user_message = json!({ "role": "user", "content": request.message });
1115
- if let Some(images) = ollama_images(request) {
1116
- user_message["images"] = json!(images);
1117
- }
1118
- json!({
1119
- "model": model,
1120
- "stream": false,
1121
- "messages": [
1122
- { "role": "system", "content": request.system_instruction },
1123
- user_message
1124
- ]
1125
- })
1126
- };
1127
- // Force the context window Mint plans against (`context_window_tokens`)
1128
- // instead of leaving Ollama on its tiny ~4K default, which silently
1129
- // truncates anything longer.
1130
- let temp = effective_temperature(config, request);
1131
- body["options"] = json!({
1132
- "num_ctx": config.ollama_num_ctx,
1133
- "temperature": temp,
1134
- });
1135
- let (thinking_enabled, _) = effective_thinking(config, request, &model);
1136
- if MintConfig::is_thinking_supported_for_model("ollama", &model) {
1137
- body["think"] = json!(thinking_enabled);
1138
- }
1139
- let response: Value = client
1140
- .post(format!("{host}/api/chat"))
1141
- .json(&body)
1142
- .send()
1143
- .await?
1144
- .error_for_status()?
1145
- .json()
1146
- .await?;
1147
- parse_ollama_reply(model, &response)
1148
- }
1149
-
1150
- /// Converts structured history into Ollama's native `/api/chat` message shape.
1151
- /// Unlike OpenAI, Ollama expects tool-call `arguments` as a JSON object (not a
1152
- /// stringified JSON blob) and doesn't require a `tool_call_id` on tool-result
1153
- /// messages.
1154
- fn ollama_request_messages(request: &ChatRequest) -> Vec<Value> {
1155
- let history = request.messages.as_deref().unwrap_or(&[]);
1156
- let mut result = ollama_native_messages(history);
1157
- if !request.system_instruction.is_empty() && !history.iter().any(|m| m.role == ChatRole::System)
1158
- {
1159
- result.insert(
1160
- 0,
1161
- json!({"role":"system","content":request.system_instruction}),
1162
- );
1163
- }
1164
- result
1165
- }
1166
-
1167
- fn ollama_native_messages(messages: &[ChatMessage]) -> Vec<Value> {
1168
- let mut result = Vec::new();
1169
- for message in messages {
1170
- match message.role {
1171
- ChatRole::System => {
1172
- result.push(json!({ "role": "system", "content": message_text(message) }));
1173
- }
1174
- ChatRole::User => {
1175
- let mut entry = json!({ "role": "user", "content": message_text(message) });
1176
- let images: Vec<String> = message
1177
- .content
1178
- .iter()
1179
- .filter_map(|block| match block {
1180
- ContentBlock::Image { data_uri } => split_data_uri(data_uri, "image/")
1181
- .ok()
1182
- .map(|(_, data)| data),
1183
- _ => None,
1184
- })
1185
- .collect();
1186
- if !images.is_empty() {
1187
- entry["images"] = json!(images);
1188
- }
1189
- result.push(entry);
1190
- }
1191
- ChatRole::Assistant => {
1192
- let text = message_text(message);
1193
- let tool_calls: Vec<Value> = message
1194
- .content
1195
- .iter()
1196
- .filter_map(|block| match block {
1197
- ContentBlock::ToolUse { name, input, .. } => {
1198
- Some(json!({ "function": { "name": name, "arguments": input } }))
1199
- }
1200
- _ => None,
1201
- })
1202
- .collect();
1203
- let mut entry = json!({ "role": "assistant", "content": text });
1204
- if !tool_calls.is_empty() {
1205
- entry["tool_calls"] = json!(tool_calls);
1206
- }
1207
- result.push(entry);
1208
- }
1209
- ChatRole::Tool => {
1210
- // Ollama's `/api/chat` `tool` role message has no image field —
1211
- // like OpenAI, an `Image` block riding along with a tool result
1212
- // (e.g. a browser screenshot) is emitted as a follow-up `user`
1213
- // message using the same `images` field regular user turns use.
1214
- let mut pending_images: Vec<String> = Vec::new();
1215
- for block in &message.content {
1216
- match block {
1217
- ContentBlock::ToolResult { content, .. } => {
1218
- result.push(json!({ "role": "tool", "content": content }));
1219
- }
1220
- ContentBlock::Image { data_uri } => {
1221
- if let Ok((_, data)) = split_data_uri(data_uri, "image/") {
1222
- pending_images.push(data);
1223
- }
1224
- }
1225
- _ => {}
1226
- }
1227
- }
1228
- if !pending_images.is_empty() {
1229
- result.push(json!({
1230
- "role": "user",
1231
- "content": "",
1232
- "images": pending_images,
1233
- }));
1234
- }
1235
- }
1236
- }
1237
- }
1238
- result
1239
- }
1240
-
1241
- /// Parses Ollama's native `/api/chat` response (`{"message": {...}}`), which is
1242
- /// shaped differently from the OpenAI Chat Completions envelope
1243
- /// (`{"choices": [{"message": {...}}]}`) that `parse_openai_style_reply` handles.
1244
- fn parse_ollama_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1245
- let message = &response["message"];
1246
- let raw_text = message["content"].as_str().unwrap_or_default().to_string();
1247
- let direct_thought = message["thinking"]
1248
- .as_str()
1249
- .or_else(|| message["reasoning_content"].as_str())
1250
- .map(|s| s.trim().to_string())
1251
- .filter(|s| !s.is_empty());
1252
-
1253
- let (thought, text) = if direct_thought.is_none() {
1254
- extract_think_tag(&raw_text)
1255
- } else {
1256
- (direct_thought, raw_text)
1257
- };
1258
-
1259
- let tool_calls: Vec<ToolCall> = message["tool_calls"]
1260
- .as_array()
1261
- .map(|calls| {
1262
- calls
1263
- .iter()
1264
- .enumerate()
1265
- .filter_map(|(index, call)| {
1266
- let name = call["function"]["name"].as_str()?.to_string();
1267
- let arguments = &call["function"]["arguments"];
1268
- let input = decode_tool_arguments(arguments);
1269
- let id = call["id"]
1270
- .as_str()
1271
- .map(str::to_owned)
1272
- .unwrap_or_else(|| format!("call_{index}"));
1273
- Some(ToolCall {
1274
- id,
1275
- name,
1276
- input,
1277
- thought_signature: None,
1278
- })
1279
- })
1280
- .collect()
1281
- })
1282
- .unwrap_or_default();
1283
- if text.is_empty() && tool_calls.is_empty() {
1284
- if let Some(th) = &thought {
1285
- return Ok(ProviderReply {
1286
- model,
1287
- text: th.clone(),
1288
- thought: Some(th.clone()),
1289
- tool_calls: None,
1290
- stop_reason: None,
1291
- input_tokens: response["prompt_eval_count"].as_u64().map(|n| n as u32),
1292
- output_tokens: response["eval_count"].as_u64().map(|n| n as u32),
1293
- });
1294
- }
1295
- return Err(ChatError::MissingResponseText);
1296
- }
1297
- Ok(ProviderReply {
1298
- model,
1299
- text,
1300
- thought,
1301
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1302
- stop_reason: None,
1303
- input_tokens: response["prompt_eval_count"].as_u64().map(|n| n as u32),
1304
- output_tokens: response["eval_count"].as_u64().map(|n| n as u32),
1305
- })
1306
- }
1307
-
1308
- async fn call_anthropic(
1309
- client: &Client,
1310
- config: &MintConfig,
1311
- request: &ChatRequest,
1312
- ) -> Result<ProviderReply, ChatError> {
1313
- let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
1314
- required_key("anthropic", &api_key)?;
1315
- let model = config.anthropic_model.clone();
1316
- let payload = anthropic_chat_payload(config, &model, request, false)?;
1317
- let response: Value = client
1318
- .post("https://api.anthropic.com/v1/messages")
1319
- .header("x-api-key", api_key)
1320
- .header("anthropic-version", "2023-06-01")
1321
- .header("anthropic-beta", "prompt-caching-2024-07-31")
1322
- .json(&payload)
1323
- .send()
1324
- .await?
1325
- .error_for_status()?
1326
- .json()
1327
- .await?;
1328
- parse_anthropic_reply(model, &response)
1329
- }
1330
-
1331
- /// Builds the Anthropic Messages API payload. When `request.messages` is set,
1332
- /// uses the structured history and (if present) `request.tools` for native
1333
- /// tool-calling; otherwise falls back to the single flat `message` field exactly
1334
- /// as before (legacy JSON-prompt callers are unaffected).
1335
- fn anthropic_chat_payload(
1336
- config: &MintConfig,
1337
- model: &str,
1338
- request: &ChatRequest,
1339
- stream: bool,
1340
- ) -> Result<Value, ChatError> {
1341
- let system = anthropic_system_blocks(&request.system_instruction);
1342
- let (thinking_enabled, thinking_effort) = effective_thinking(config, request, model);
1343
- let is_thinking_supported = MintConfig::is_thinking_supported_for_model("anthropic", model);
1344
- let use_thinking = thinking_enabled && is_thinking_supported;
1345
-
1346
- let (budget, max_tokens, temp) = if use_thinking {
1347
- let b = match thinking_effort.as_str() {
1348
- "low" => 1024,
1349
- "medium" => 4096,
1350
- "high" => 16384,
1351
- "extra_high" => 32768,
1352
- _ => 4096,
1353
- };
1354
- (Some(b), b + 8192, 1.0)
1355
- } else {
1356
- (None, 8192, effective_temperature(config, request))
1357
- };
1358
-
1359
- let mut payload = if let Some(messages) = &request.messages {
1360
- json!({
1361
- "model": model,
1362
- "max_tokens": max_tokens,
1363
- "system": system,
1364
- "messages": anthropic_messages(messages)?,
1365
- "temperature": temp,
1366
- })
1367
- } else {
1368
- json!({
1369
- "model": model,
1370
- "max_tokens": max_tokens,
1371
- "system": system,
1372
- "messages": [{
1373
- "role": "user",
1374
- "content": anthropic_message_content(&request.message, request.image_data_uri.as_deref())?,
1375
- }],
1376
- "temperature": temp,
1377
- })
1378
- };
1379
- if let Some(b) = budget {
1380
- payload["thinking"] = json!({
1381
- "type": "enabled",
1382
- "budget_tokens": b,
1383
- });
1384
- }
1385
- if stream {
1386
- payload["stream"] = json!(true);
1387
- }
1388
- if let Some(tools) = &request.tools {
1389
- let last_idx = tools.len().saturating_sub(1);
1390
- payload["tools"] = json!(
1391
- tools
1392
- .iter()
1393
- .enumerate()
1394
- .map(|(i, tool)| {
1395
- let mut value = json!({
1396
- "name": tool.name,
1397
- "description": tool.description,
1398
- "input_schema": tool.parameters,
1399
- });
1400
- // The tool catalog is identical on every step of an agent
1401
- // run — marking the last tool definition as a cache
1402
- // breakpoint caches the whole system+tools prefix (Anthropic
1403
- // caches everything up to and including a marked block), so
1404
- // repeated steps hit cache instead of reprocessing it.
1405
- if i == last_idx {
1406
- value["cache_control"] = json!({ "type": "ephemeral" });
1407
- }
1408
- value
1409
- })
1410
- .collect::<Vec<_>>()
1411
- );
1412
- }
1413
- Ok(payload)
1414
- }
1415
-
1416
- /// Anthropic requires `system` to be an array of text blocks (not a bare
1417
- /// string) to attach a `cache_control` breakpoint to it. The system prompt is
1418
- /// identical on every step of an agent run, so marking it cacheable turns
1419
- /// repeated multi-step turns into a cache hit instead of a full reprocess
1420
- /// each time. Falls back to the old bare-string shape when there's no system
1421
- /// prompt at all, since an empty cacheable block isn't useful.
1422
- fn anthropic_system_blocks(system_instruction: &str) -> Value {
1423
- if system_instruction.is_empty() {
1424
- return json!(system_instruction);
1425
- }
1426
- json!([{
1427
- "type": "text",
1428
- "text": system_instruction,
1429
- "cache_control": { "type": "ephemeral" },
1430
- }])
1431
- }
1432
-
1433
- fn anthropic_messages(messages: &[ChatMessage]) -> Result<Vec<Value>, ChatError> {
1434
- let non_system: Vec<&ChatMessage> = messages
1435
- .iter()
1436
- .filter(|message| message.role != ChatRole::System)
1437
- .collect();
1438
-
1439
- // Anthropic allows up to 4 cache breakpoints. Breakpoints 1 and 2 are system
1440
- // instruction and the last tool definition. For multi-turn agent runs, marking
1441
- // the penultimate turn allows the entire preceding conversation history (steps 1..N-1)
1442
- // to be served from prompt cache with zero reprocessing tokens.
1443
- let cache_idx = if non_system.len() >= 2 {
1444
- Some(non_system.len() - 2)
1445
- } else {
1446
- None
1447
- };
1448
-
1449
- non_system
1450
- .into_iter()
1451
- .enumerate()
1452
- .map(|(i, message)| {
1453
- let role = match message.role {
1454
- ChatRole::Assistant => "assistant",
1455
- _ => "user",
1456
- };
1457
- let mut content = message
1458
- .content
1459
- .iter()
1460
- .map(anthropic_content_block)
1461
- .collect::<Result<Vec<_>, _>>()?;
1462
-
1463
- if Some(i) == cache_idx
1464
- && let Some(last_block) = content.last_mut()
1465
- {
1466
- if let Some(obj) = last_block.as_object_mut() {
1467
- obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
1468
- }
1469
- }
1470
-
1471
- Ok(json!({ "role": role, "content": content }))
1472
- })
1473
- .collect()
1474
- }
1475
-
1476
- /// Builds an Anthropic `content` value for the plain (non-native-messages)
1477
- /// chat path: bare text when there's no image, otherwise a `text` + `image`
1478
- /// parts array. Mirrors `anthropic_content_block`, which handles the same
1479
- /// shape for the native-tool-calling `messages` path. `image_data_uri` may
1480
- /// contain multiple space-separated data URIs, matching Gemini's `gemini_parts`.
1481
- fn anthropic_message_content(text: &str, image_data_uri: Option<&str>) -> Result<Value, ChatError> {
1482
- let Some(image_data) = image_data_uri else {
1483
- return Ok(json!(text));
1484
- };
1485
- let mut parts = vec![json!({ "type": "text", "text": text })];
1486
- for img in image_data.split_whitespace() {
1487
- let (mime_type, data) = split_data_uri(img, "image/")?;
1488
- parts.push(json!({
1489
- "type": "image",
1490
- "source": { "type": "base64", "media_type": mime_type, "data": data },
1491
- }));
1492
- }
1493
- Ok(json!(parts))
1494
- }
1495
-
1496
- fn anthropic_content_block(block: &ContentBlock) -> Result<Value, ChatError> {
1497
- Ok(match block {
1498
- ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
1499
- ContentBlock::ToolUse {
1500
- id, name, input, ..
1501
- } => {
1502
- json!({ "type": "tool_use", "id": id, "name": name, "input": input })
1503
- }
1504
- ContentBlock::ToolResult {
1505
- tool_use_id,
1506
- content,
1507
- is_error,
1508
- } => json!({
1509
- "type": "tool_result",
1510
- "tool_use_id": tool_use_id,
1511
- "content": content,
1512
- "is_error": is_error,
1513
- }),
1514
- ContentBlock::Image { data_uri } => {
1515
- let (mime_type, data) = split_data_uri(data_uri, "image/")?;
1516
- json!({
1517
- "type": "image",
1518
- "source": { "type": "base64", "media_type": mime_type, "data": data },
1519
- })
1520
- }
1521
- // Anthropic's Messages API has no inline audio/video content block; this
1522
- // is unreachable in practice because `require_supported_attachments`
1523
- // rejects audio/video for "anthropic" before a request gets this far.
1524
- ContentBlock::Audio { .. } | ContentBlock::Video { .. } => {
1525
- return Err(ChatError::UnsupportedAttachments("anthropic".into()));
1526
- }
1527
- })
1528
- }
1529
-
1530
- /// Parses an Anthropic Messages API response, collecting all `text` and
1531
- /// `tool_use` content blocks (Anthropic may return a `tool_use` block before,
1532
- /// after, or interleaved with text — unlike the old code, this does not assume
1533
- /// the first block is text).
1534
- // Anthropic reports uncached, cache-read and cache-created input separately.
1535
- fn anthropic_input_tokens(usage: &Value) -> Option<u32> {
1536
- [
1537
- "input_tokens",
1538
- "cache_read_input_tokens",
1539
- "cache_creation_input_tokens",
1540
- ]
1541
- .into_iter()
1542
- .filter_map(|key| usage[key].as_u64())
1543
- .reduce(u64::saturating_add)
1544
- .map(|n| n.min(u32::MAX as u64) as u32)
1545
- }
1546
-
1547
- fn parse_anthropic_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1548
- let blocks = response["content"].as_array().cloned().unwrap_or_default();
1549
- let mut text = String::new();
1550
- let mut thought_parts: Vec<&str> = Vec::new();
1551
- let mut tool_calls = Vec::new();
1552
- for block in &blocks {
1553
- match block["type"].as_str() {
1554
- Some("text") => {
1555
- if let Some(t) = block["text"].as_str() {
1556
- text.push_str(t);
1557
- }
1558
- }
1559
- Some("thinking") => {
1560
- if let Some(t) = block["thinking"].as_str() {
1561
- thought_parts.push(t);
1562
- }
1563
- }
1564
- Some("tool_use") => {
1565
- tool_calls.push(ToolCall {
1566
- id: block["id"].as_str().unwrap_or_default().into(),
1567
- name: block["name"].as_str().unwrap_or_default().into(),
1568
- input: block["input"].clone(),
1569
- thought_signature: None,
1570
- });
1571
- }
1572
- _ => {}
1573
- }
1574
- }
1575
- let direct_thought = if !thought_parts.is_empty() {
1576
- Some(thought_parts.join("").trim().to_string()).filter(|s| !s.is_empty())
1577
- } else {
1578
- None
1579
- };
1580
- let (thought, text) = if direct_thought.is_none() {
1581
- extract_think_tag(&text)
1582
- } else {
1583
- (direct_thought, text)
1584
- };
1585
- if text.is_empty() && tool_calls.is_empty() {
1586
- if let Some(th) = &thought {
1587
- return Ok(ProviderReply {
1588
- model,
1589
- text: th.clone(),
1590
- thought: Some(th.clone()),
1591
- tool_calls: None,
1592
- stop_reason: response["stop_reason"].as_str().map(str::to_owned),
1593
- input_tokens: anthropic_input_tokens(&response["usage"]),
1594
- output_tokens: response["usage"]["output_tokens"]
1595
- .as_u64()
1596
- .map(|n| n as u32),
1597
- });
1598
- }
1599
- return Err(ChatError::MissingResponseText);
1600
- }
1601
- Ok(ProviderReply {
1602
- model,
1603
- text,
1604
- thought,
1605
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1606
- stop_reason: response["stop_reason"].as_str().map(str::to_owned),
1607
- input_tokens: anthropic_input_tokens(&response["usage"]),
1608
- output_tokens: response["usage"]["output_tokens"]
1609
- .as_u64()
1610
- .map(|n| n as u32),
1611
- })
1612
- }
1613
-
1614
- async fn call_huggingface(
1615
- client: &Client,
1616
- config: &MintConfig,
1617
- request: &ChatRequest,
1618
- ) -> Result<ProviderReply, ChatError> {
1619
- let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
1620
- required_key("huggingface", &api_key)?;
1621
- let model = config.hf_model.clone();
1622
- // Hugging Face's router is an OpenAI Chat Completions-compatible
1623
- // endpoint, so it can (and must) take the same native `messages`/`tools`
1624
- // payload `call_openai` builds — this used to hand-roll a bare
1625
- // `{system, user: request.message}` request instead, which ignored the
1626
- // agent loop's full tool-call history and `tools` catalog. `message` is
1627
- // deliberately left empty (see the step-loop in `orchestration/mod.rs`)
1628
- // whenever `messages` carries the real conversation, so any mid-task
1629
- // fallback onto this provider was silently losing all task context —
1630
- // the model would see an empty user turn and reply with generic
1631
- // small talk, which the agent loop then treated as the final answer.
1632
- let response: Value = send_openai_style_request(
1633
- client
1634
- .post("https://router.huggingface.co/v1/chat/completions")
1635
- .bearer_auth(api_key)
1636
- .json(&openai_chat_payload(config, &model, request, false)?),
1637
- "huggingface",
1638
- )
1639
- .await?;
1640
- parse_openai_style_reply(model, &response)
1641
- }
1642
-
1643
- async fn stream_gemini_reply<F>(
1644
- client: &Client,
1645
- config: &MintConfig,
1646
- request: &ChatRequest,
1647
- on_event: &mut F,
1648
- ) -> Result<ProviderReply, ChatError>
1649
- where
1650
- F: FnMut(ChatStreamEvent),
1651
- {
1652
- let api_key = provider_key(&config.api_key, "GEMINI_API_KEY");
1653
- required_key("gemini", &api_key)?;
1654
- let model = config.gemini_model.clone();
1655
- let payload = if request.messages.is_some() {
1656
- gemini_native_payload(request)?
1657
- } else {
1658
- gemini_chat_payload(config, request)?
1659
- };
1660
- let response = client
1661
- .post(format!(
1662
- "https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse"
1663
- ))
1664
- .header("x-goog-api-key", api_key)
1665
- .json(&payload)
1666
- .send()
1667
- .await?
1668
- .error_for_status()?;
1669
- collect_stream_reply(response, StreamFormat::Gemini, model, on_event).await
1670
- }
1671
-
1672
- async fn stream_openai_reply<F>(
1673
- client: &Client,
1674
- config: &MintConfig,
1675
- request: &ChatRequest,
1676
- on_event: &mut F,
1677
- ) -> Result<ProviderReply, ChatError>
1678
- where
1679
- F: FnMut(ChatStreamEvent),
1680
- {
1681
- let local = config.ai_provider == "local_openai";
1682
- let openrouter = config.ai_provider == "openrouter";
1683
- let deepseek = config.ai_provider == "deepseek";
1684
- let api_key = if openrouter {
1685
- provider_key(&config.openrouter_api_key, "OPENROUTER_API_KEY")
1686
- } else if deepseek {
1687
- provider_key(&config.deepseek_api_key, "DEEPSEEK_API_KEY")
1688
- } else {
1689
- provider_key(&config.openai_api_key, "OPENAI_API_KEY")
1690
- };
1691
- if openrouter {
1692
- required_key("openrouter", &api_key)?;
1693
- } else if deepseek {
1694
- required_key("deepseek", &api_key)?;
1695
- } else if !local {
1696
- required_key("openai", &api_key)?;
1697
- }
1698
- let base_url = if openrouter {
1699
- "https://openrouter.ai/api/v1"
1700
- } else if deepseek {
1701
- "https://api.deepseek.com"
1702
- } else if local {
1703
- config.local_api_base_url.trim_end_matches('/')
1704
- } else {
1705
- "https://api.openai.com/v1"
1706
- };
1707
- let model = if openrouter {
1708
- config.openrouter_model.clone()
1709
- } else if deepseek {
1710
- config.deepseek_model.clone()
1711
- } else if local {
1712
- config.local_model_name.clone()
1713
- } else {
1714
- config.openai_model.clone()
1715
- };
1716
- let response = client
1717
- .post(format!("{base_url}/chat/completions"))
1718
- .bearer_auth(if local { "not-needed" } else { &api_key })
1719
- .json(&openai_chat_payload(config, &model, request, true)?)
1720
- .send()
1721
- .await?;
1722
- if !response.status().is_success() {
1723
- let status = response.status();
1724
- let body = response.text().await.unwrap_or_default();
1725
- return Err(ChatError::Api(format!("HTTP {status}: {body}")));
1726
- }
1727
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1728
- }
1729
-
1730
- fn ollama_stream_payload(
1731
- config: &MintConfig,
1732
- request: &ChatRequest,
1733
- model: &str,
1734
- ) -> Result<Value, ChatError> {
1735
- let native = request.messages.is_some()
1736
- && config.tool_calling_mode() == crate::config::ToolCallingMode::Native;
1737
- let mut payload = if native {
1738
- let mut value = json!({
1739
- "model": model,
1740
- "stream": true,
1741
- "messages": ollama_request_messages(request),
1742
- });
1743
- if let Some(tools) = &request.tools {
1744
- value["tools"] = json!(
1745
- tools
1746
- .iter()
1747
- .map(|tool| json!({
1748
- "type": "function",
1749
- "function": {
1750
- "name": tool.name,
1751
- "description": tool.description,
1752
- "parameters": tool.parameters,
1753
- }
1754
- }))
1755
- .collect::<Vec<_>>()
1756
- );
1757
- }
1758
- value
1759
- } else {
1760
- let mut user = json!({ "role": "user", "content": request.message });
1761
- if let Some(images) = ollama_images(request) {
1762
- user["images"] = json!(images);
1763
- }
1764
- json!({
1765
- "model": model,
1766
- "stream": true,
1767
- "messages": [
1768
- { "role": "system", "content": request.system_instruction },
1769
- user
1770
- ]
1771
- })
1772
- };
1773
- payload["options"] = json!({
1774
- "num_ctx": config.ollama_num_ctx,
1775
- "temperature": effective_temperature(config, request),
1776
- });
1777
- let (thinking_enabled, _) = effective_thinking(config, request, model);
1778
- if MintConfig::is_thinking_supported_for_model("ollama", model) {
1779
- payload["think"] = json!(thinking_enabled);
1780
- }
1781
- Ok(payload)
1782
- }
1783
-
1784
- async fn stream_ollama_reply<F>(
1785
- client: &Client,
1786
- config: &MintConfig,
1787
- request: &ChatRequest,
1788
- on_event: &mut F,
1789
- ) -> Result<ProviderReply, ChatError>
1790
- where
1791
- F: FnMut(ChatStreamEvent),
1792
- {
1793
- let host = if config.ollama_host.trim().is_empty() {
1794
- "http://localhost:11434"
1795
- } else {
1796
- config.ollama_host.trim_end_matches('/')
1797
- };
1798
- let model = config.ollama_model.clone();
1799
- let response = client
1800
- .post(format!("{host}/api/chat"))
1801
- .json(&ollama_stream_payload(config, request, &model)?)
1802
- .send()
1803
- .await?
1804
- .error_for_status()?;
1805
- collect_stream_reply(response, StreamFormat::Ollama, model, on_event).await
1806
- }
1807
-
1808
- async fn stream_anthropic_reply<F>(
1809
- client: &Client,
1810
- config: &MintConfig,
1811
- request: &ChatRequest,
1812
- on_event: &mut F,
1813
- ) -> Result<ProviderReply, ChatError>
1814
- where
1815
- F: FnMut(ChatStreamEvent),
1816
- {
1817
- let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
1818
- required_key("anthropic", &api_key)?;
1819
- let model = config.anthropic_model.clone();
1820
- let response = client
1821
- .post("https://api.anthropic.com/v1/messages")
1822
- .header("x-api-key", api_key)
1823
- .header("anthropic-version", "2023-06-01")
1824
- .header("anthropic-beta", "prompt-caching-2024-07-31")
1825
- .json(&anthropic_chat_payload(config, &model, request, true)?)
1826
- .send()
1827
- .await?
1828
- .error_for_status()?;
1829
- collect_stream_reply(response, StreamFormat::Anthropic, model, on_event).await
1830
- }
1831
-
1832
- async fn stream_huggingface_reply<F>(
1833
- client: &Client,
1834
- config: &MintConfig,
1835
- request: &ChatRequest,
1836
- on_event: &mut F,
1837
- ) -> Result<ProviderReply, ChatError>
1838
- where
1839
- F: FnMut(ChatStreamEvent),
1840
- {
1841
- let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
1842
- required_key("huggingface", &api_key)?;
1843
- let model = config.hf_model.clone();
1844
- let response = client
1845
- .post("https://router.huggingface.co/v1/chat/completions")
1846
- .bearer_auth(api_key)
1847
- .json(&openai_chat_payload(config, &model, request, true)?)
1848
- .send()
1849
- .await?
1850
- .error_for_status()?;
1851
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1852
- }
1853
-
1854
- async fn stream_custom_provider_reply<F>(
1855
- client: &Client,
1856
- config: &MintConfig,
1857
- request: &ChatRequest,
1858
- on_event: &mut F,
1859
- ) -> Result<ProviderReply, ChatError>
1860
- where
1861
- F: FnMut(ChatStreamEvent),
1862
- {
1863
- let provider = config
1864
- .resolve_custom_provider(&config.ai_provider)
1865
- .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
1866
- let model = resolve_custom_model(config);
1867
- let mut builder = client
1868
- .post(format!(
1869
- "{}/chat/completions",
1870
- provider.base_url.trim_end_matches('/')
1871
- ))
1872
- .json(&openai_chat_payload(config, &model, request, true)?);
1873
- if !provider.api_key.is_empty() {
1874
- builder = builder.bearer_auth(&provider.api_key);
1875
- }
1876
- for header in &provider.headers {
1877
- if !header.name.is_empty() {
1878
- builder = builder.header(header.name.as_str(), header.value.as_str());
1879
- }
1880
- }
1881
- let response = builder.send().await?.error_for_status()?;
1882
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1883
- }
1884
-
1885
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1886
- enum StreamFormat {
1887
- Gemini,
1888
- OpenAi,
1889
- Ollama,
1890
- Anthropic,
1891
- }
1892
-
1893
- #[derive(Default)]
1894
- struct StreamToolCall {
1895
- id: String,
1896
- name: String,
1897
- arguments: String,
1898
- input: Option<Value>,
1899
- thought_signature: Option<String>,
1900
- }
1901
-
1902
- #[derive(Default)]
1903
- struct StreamReplyAccumulator {
1904
- text: String,
1905
- thought: String,
1906
- tag_buffer: String,
1907
- inside_think_tag: bool,
1908
- tool_calls: Vec<StreamToolCall>,
1909
- stop_reason: Option<String>,
1910
- input_tokens: Option<u32>,
1911
- output_tokens: Option<u32>,
1912
- anthropic_blocks: std::collections::HashMap<usize, usize>,
1913
- }
1914
-
1915
- impl StreamReplyAccumulator {
1916
- fn tool_at(&mut self, index: usize) -> &mut StreamToolCall {
1917
- while self.tool_calls.len() <= index {
1918
- self.tool_calls.push(StreamToolCall::default());
1919
- }
1920
- &mut self.tool_calls[index]
1921
- }
1922
-
1923
- fn emit_text<F>(&mut self, delta: &str, on_event: &mut F)
1924
- where
1925
- F: FnMut(ChatStreamEvent),
1926
- {
1927
- if !delta.is_empty() {
1928
- on_event(ChatStreamEvent::TextDelta {
1929
- delta: delta.to_owned(),
1930
- });
1931
- }
1932
- }
1933
-
1934
- /// Route inline `<think>` content without assuming tag boundaries line up
1935
- /// with provider chunks. A possible partial tag is retained until the next
1936
- /// chunk; ordinary text is emitted immediately.
1937
- fn push_text<F>(&mut self, delta: &str, on_event: &mut F)
1938
- where
1939
- F: FnMut(ChatStreamEvent),
1940
- {
1941
- if delta.is_empty() {
1942
- return;
1943
- }
1944
- self.text.push_str(delta);
1945
- self.tag_buffer.push_str(delta);
1946
- loop {
1947
- let tag = if self.inside_think_tag {
1948
- "</think>"
1949
- } else {
1950
- "<think>"
1951
- };
1952
- if let Some(index) = self.tag_buffer.find(tag) {
1953
- let before = self.tag_buffer[..index].to_owned();
1954
- self.tag_buffer.drain(..index + tag.len());
1955
- if self.inside_think_tag {
1956
- self.push_reasoning(&before, on_event);
1957
- } else {
1958
- self.emit_text(&before, on_event);
1959
- }
1960
- self.inside_think_tag = !self.inside_think_tag;
1961
- continue;
1962
- }
1963
-
1964
- let retain_from = self
1965
- .tag_buffer
1966
- .rfind('<')
1967
- .and_then(|index| tag.starts_with(&self.tag_buffer[index..]).then_some(index));
1968
- let emit_end = retain_from.unwrap_or(self.tag_buffer.len());
1969
- if emit_end > 0 {
1970
- let ready = self.tag_buffer[..emit_end].to_owned();
1971
- self.tag_buffer.drain(..emit_end);
1972
- if self.inside_think_tag {
1973
- self.push_reasoning(&ready, on_event);
1974
- } else {
1975
- self.emit_text(&ready, on_event);
1976
- }
1977
- }
1978
- break;
1979
- }
1980
- }
1981
-
1982
- fn flush_tag_buffer<F>(&mut self, on_event: &mut F)
1983
- where
1984
- F: FnMut(ChatStreamEvent),
1985
- {
1986
- if self.tag_buffer.is_empty() {
1987
- return;
1988
- }
1989
- let remaining = std::mem::take(&mut self.tag_buffer);
1990
- if self.inside_think_tag {
1991
- self.push_reasoning(&remaining, on_event);
1992
- } else {
1993
- self.emit_text(&remaining, on_event);
1994
- }
1995
- }
1996
-
1997
- fn push_reasoning<F>(&mut self, delta: &str, on_event: &mut F)
1998
- where
1999
- F: FnMut(ChatStreamEvent),
2000
- {
2001
- if !delta.is_empty() {
2002
- self.thought.push_str(delta);
2003
- on_event(ChatStreamEvent::ReasoningDelta {
2004
- delta: delta.to_owned(),
2005
- });
2006
- }
2007
- }
2008
-
2009
- fn consume<F>(&mut self, format: StreamFormat, value: &Value, on_event: &mut F)
2010
- where
2011
- F: FnMut(ChatStreamEvent),
2012
- {
2013
- match format {
2014
- StreamFormat::OpenAi => self.consume_openai(value, on_event),
2015
- StreamFormat::Gemini => self.consume_gemini(value, on_event),
2016
- StreamFormat::Ollama => self.consume_ollama(value, on_event),
2017
- StreamFormat::Anthropic => self.consume_anthropic(value, on_event),
2018
- }
2019
- }
2020
-
2021
- fn consume_openai<F>(&mut self, value: &Value, on_event: &mut F)
2022
- where
2023
- F: FnMut(ChatStreamEvent),
2024
- {
2025
- let choice = &value["choices"][0];
2026
- let delta = &choice["delta"];
2027
- if let Some(text) = delta["content"].as_str() {
2028
- self.push_text(text, on_event);
2029
- }
2030
- if let Some(reasoning) = delta["reasoning_content"]
2031
- .as_str()
2032
- .or_else(|| delta["reasoning"].as_str())
2033
- .or_else(|| delta["thinking"].as_str())
2034
- {
2035
- self.push_reasoning(reasoning, on_event);
2036
- }
2037
- if let Some(details) = delta["reasoning_details"].as_array() {
2038
- for detail in details {
2039
- if let Some(reasoning) = detail["text"].as_str() {
2040
- self.push_reasoning(reasoning, on_event);
2041
- }
2042
- }
2043
- }
2044
- if let Some(calls) = delta["tool_calls"].as_array() {
2045
- for (fallback_index, call) in calls.iter().enumerate() {
2046
- let index = call["index"]
2047
- .as_u64()
2048
- .map(|n| n as usize)
2049
- .unwrap_or(fallback_index);
2050
- let name_delta = call["function"]["name"].as_str().map(str::to_owned);
2051
- let arguments = call["function"]["arguments"].as_str().map(str::to_owned);
2052
- let current_name = {
2053
- let tool = self.tool_at(index);
2054
- if let Some(id) = call["id"].as_str() {
2055
- tool.id.push_str(id);
2056
- }
2057
- if let Some(name) = &name_delta {
2058
- tool.name.push_str(name);
2059
- }
2060
- if let Some(arguments) = &arguments {
2061
- tool.arguments.push_str(arguments);
2062
- }
2063
- tool.name.clone()
2064
- };
2065
- if name_delta.is_some() || arguments.is_some() {
2066
- on_event(ChatStreamEvent::ToolCallDelta {
2067
- index,
2068
- name: name_delta.map(|_| current_name),
2069
- arguments,
2070
- input: None,
2071
- });
2072
- }
2073
- }
2074
- }
2075
- if let Some(reason) = choice["finish_reason"].as_str() {
2076
- self.stop_reason = Some(reason.to_owned());
2077
- }
2078
- self.input_tokens = value["usage"]["prompt_tokens"]
2079
- .as_u64()
2080
- .map(|n| n as u32)
2081
- .or(self.input_tokens);
2082
- self.output_tokens = value["usage"]["completion_tokens"]
2083
- .as_u64()
2084
- .map(|n| n as u32)
2085
- .or(self.output_tokens);
2086
- }
2087
-
2088
- fn consume_gemini<F>(&mut self, value: &Value, on_event: &mut F)
2089
- where
2090
- F: FnMut(ChatStreamEvent),
2091
- {
2092
- if let Some(parts) = value["candidates"][0]["content"]["parts"].as_array() {
2093
- for (index, part) in parts.iter().enumerate() {
2094
- if let Some(text) = part["text"].as_str() {
2095
- if part["thought"].as_bool().unwrap_or(false) {
2096
- self.push_reasoning(text, on_event);
2097
- } else {
2098
- self.push_text(text, on_event);
2099
- }
2100
- }
2101
- if let Some(call) = part.get("functionCall") {
2102
- let tool = self.tool_at(index);
2103
- tool.id = format!("call_{index}");
2104
- tool.name = call["name"].as_str().unwrap_or_default().to_owned();
2105
- tool.input = Some(call["args"].clone());
2106
- tool.thought_signature = part["thoughtSignature"].as_str().map(str::to_owned);
2107
- on_event(ChatStreamEvent::ToolCallDelta {
2108
- index,
2109
- name: call["name"].as_str().map(str::to_owned),
2110
- arguments: None,
2111
- input: Some(call["args"].clone()),
2112
- });
2113
- }
2114
- }
2115
- }
2116
- self.stop_reason = value["candidates"][0]["finishReason"]
2117
- .as_str()
2118
- .map(str::to_owned)
2119
- .or_else(|| self.stop_reason.take());
2120
- self.input_tokens = value["usageMetadata"]["promptTokenCount"]
2121
- .as_u64()
2122
- .map(|n| n as u32)
2123
- .or(self.input_tokens);
2124
- self.output_tokens = value["usageMetadata"]["candidatesTokenCount"]
2125
- .as_u64()
2126
- .map(|n| n as u32)
2127
- .or(self.output_tokens);
2128
- }
2129
-
2130
- fn consume_ollama<F>(&mut self, value: &Value, on_event: &mut F)
2131
- where
2132
- F: FnMut(ChatStreamEvent),
2133
- {
2134
- let message = &value["message"];
2135
- if let Some(text) = message["content"].as_str() {
2136
- self.push_text(text, on_event);
2137
- }
2138
- if let Some(reasoning) = message["thinking"]
2139
- .as_str()
2140
- .or_else(|| message["reasoning_content"].as_str())
2141
- {
2142
- self.push_reasoning(reasoning, on_event);
2143
- }
2144
- if let Some(calls) = message["tool_calls"].as_array() {
2145
- for (index, call) in calls.iter().enumerate() {
2146
- let tool = self.tool_at(index);
2147
- tool.id = call["id"]
2148
- .as_str()
2149
- .map(str::to_owned)
2150
- .unwrap_or_else(|| format!("call_{index}"));
2151
- tool.name = call["function"]["name"]
2152
- .as_str()
2153
- .unwrap_or_default()
2154
- .to_owned();
2155
- tool.input = Some(call["function"]["arguments"].clone());
2156
- on_event(ChatStreamEvent::ToolCallDelta {
2157
- index,
2158
- name: call["function"]["name"].as_str().map(str::to_owned),
2159
- arguments: None,
2160
- input: Some(call["function"]["arguments"].clone()),
2161
- });
2162
- }
2163
- }
2164
- self.input_tokens = value["prompt_eval_count"]
2165
- .as_u64()
2166
- .map(|n| n as u32)
2167
- .or(self.input_tokens);
2168
- self.output_tokens = value["eval_count"]
2169
- .as_u64()
2170
- .map(|n| n as u32)
2171
- .or(self.output_tokens);
2172
- if value["done"].as_bool().unwrap_or(false) {
2173
- self.stop_reason = value["done_reason"]
2174
- .as_str()
2175
- .map(str::to_owned)
2176
- .or_else(|| Some("stop".to_owned()));
2177
- }
2178
- }
2179
-
2180
- fn consume_anthropic<F>(&mut self, value: &Value, on_event: &mut F)
2181
- where
2182
- F: FnMut(ChatStreamEvent),
2183
- {
2184
- match value["type"].as_str() {
2185
- Some("message_start") => {
2186
- self.input_tokens = anthropic_input_tokens(&value["message"]["usage"]);
2187
- }
2188
- Some("content_block_start") => {
2189
- let block_index = value["index"].as_u64().unwrap_or(0) as usize;
2190
- let block = &value["content_block"];
2191
- if block["type"].as_str() == Some("tool_use") {
2192
- let tool_index = self.tool_calls.len();
2193
- let tool = self.tool_at(tool_index);
2194
- tool.id = block["id"].as_str().unwrap_or_default().to_owned();
2195
- tool.name = block["name"].as_str().unwrap_or_default().to_owned();
2196
- self.anthropic_blocks.insert(block_index, tool_index);
2197
- on_event(ChatStreamEvent::ToolCallDelta {
2198
- index: tool_index,
2199
- name: block["name"].as_str().map(str::to_owned),
2200
- arguments: None,
2201
- input: None,
2202
- });
2203
- }
2204
- }
2205
- Some("content_block_delta") => {
2206
- let delta = &value["delta"];
2207
- match delta["type"].as_str() {
2208
- Some("text_delta") => {
2209
- if let Some(text) = delta["text"].as_str() {
2210
- self.push_text(text, on_event);
2211
- }
2212
- }
2213
- Some("thinking_delta") => {
2214
- if let Some(thinking) = delta["thinking"].as_str() {
2215
- self.push_reasoning(thinking, on_event);
2216
- }
2217
- }
2218
- Some("input_json_delta") => {
2219
- let block_index = value["index"].as_u64().unwrap_or(0) as usize;
2220
- if let Some(tool_index) = self.anthropic_blocks.get(&block_index).copied()
2221
- && let Some(json) = delta["partial_json"].as_str()
2222
- {
2223
- self.tool_at(tool_index).arguments.push_str(json);
2224
- on_event(ChatStreamEvent::ToolCallDelta {
2225
- index: tool_index,
2226
- name: None,
2227
- arguments: Some(json.to_owned()),
2228
- input: None,
2229
- });
2230
- }
2231
- }
2232
- _ => {}
2233
- }
2234
- }
2235
- Some("message_delta") => {
2236
- self.stop_reason = value["delta"]["stop_reason"]
2237
- .as_str()
2238
- .map(str::to_owned)
2239
- .or_else(|| self.stop_reason.take());
2240
- self.output_tokens = value["usage"]["output_tokens"]
2241
- .as_u64()
2242
- .map(|n| n as u32)
2243
- .or(self.output_tokens);
2244
- }
2245
- _ => {}
2246
- }
2247
- }
2248
-
2249
- fn finish(self, model: String) -> Result<ProviderReply, ChatError> {
2250
- let (tag_thought, clean_text) = extract_think_tag(&self.text);
2251
- let thought = if self.thought.trim().is_empty() {
2252
- tag_thought
2253
- } else {
2254
- Some(self.thought.trim().to_owned())
2255
- };
2256
- let calls: Vec<ToolCall> = self
2257
- .tool_calls
2258
- .into_iter()
2259
- .filter(|tool| !tool.name.is_empty())
2260
- .map(|tool| {
2261
- let input = tool
2262
- .input
2263
- .unwrap_or_else(|| decode_tool_arguments(&Value::String(tool.arguments)));
2264
- ToolCall {
2265
- id: tool.id,
2266
- name: tool.name,
2267
- input,
2268
- thought_signature: tool.thought_signature,
2269
- }
2270
- })
2271
- .collect();
2272
- if clean_text.is_empty() && calls.is_empty() && thought.is_none() {
2273
- return Err(ChatError::MissingResponseText);
2274
- }
2275
- Ok(ProviderReply {
2276
- model,
2277
- text: if clean_text.is_empty() && calls.is_empty() {
2278
- thought.clone().unwrap_or_default()
2279
- } else {
2280
- clean_text
2281
- },
2282
- thought,
2283
- tool_calls: (!calls.is_empty()).then_some(calls),
2284
- stop_reason: self.stop_reason,
2285
- input_tokens: self.input_tokens,
2286
- output_tokens: self.output_tokens,
2287
- })
2288
- }
2289
- }
2290
-
2291
- async fn collect_stream_reply<F>(
2292
- response: reqwest::Response,
2293
- format: StreamFormat,
2294
- model: String,
2295
- on_event: &mut F,
2296
- ) -> Result<ProviderReply, ChatError>
2297
- where
2298
- F: FnMut(ChatStreamEvent),
2299
- {
2300
- let mut bytes = response.bytes_stream();
2301
- let mut buffer = String::new();
2302
- let mut accumulator = StreamReplyAccumulator::default();
2303
- while let Some(chunk) = bytes.next().await {
2304
- buffer.push_str(&String::from_utf8_lossy(&chunk?));
2305
- while let Some(index) = buffer.find('\n') {
2306
- let line = buffer[..index].trim().to_owned();
2307
- buffer.drain(..=index);
2308
- if let Some(value) = parse_stream_value(format, &line) {
2309
- accumulator.consume(format, &value, on_event);
2310
- }
2311
- }
2312
- }
2313
- if let Some(value) = parse_stream_value(format, buffer.trim()) {
2314
- accumulator.consume(format, &value, on_event);
2315
- }
2316
- accumulator.flush_tag_buffer(on_event);
2317
- accumulator.finish(model)
2318
- }
2319
-
2320
- fn parse_stream_value(format: StreamFormat, line: &str) -> Option<Value> {
2321
- let payload = match format {
2322
- StreamFormat::Ollama => line,
2323
- _ => line.strip_prefix("data:")?.trim(),
2324
- };
2325
- if payload.is_empty() || payload == "[DONE]" {
2326
- return None;
2327
- }
2328
- serde_json::from_str(payload).ok()
2329
- }
2330
-
2331
- fn required_key(provider: &str, key: &str) -> Result<(), ChatError> {
2332
- if key.trim().is_empty() {
2333
- Err(ChatError::MissingApiKey(provider.into()))
2334
- } else {
2335
- Ok(())
2336
- }
2337
- }
2338
-
2339
- /// Resolve which model to use for the current custom provider.
2340
- ///
2341
- /// Checks `config.extra["customModelSelections"]` first (set by the UI when the
2342
- /// user picks a specific model from the multi-model dropdown), then falls back to
2343
- /// the first model defined in the `CustomProvider`.
2344
- fn resolve_custom_model(config: &crate::MintConfig) -> String {
2345
- let provider_id = config
2346
- .ai_provider
2347
- .strip_prefix("custom:")
2348
- .unwrap_or(&config.ai_provider);
2349
-
2350
- // Check if a per-provider model selection was persisted in extra config.
2351
- if let Some(selections) = config
2352
- .extra
2353
- .get("customModelSelections")
2354
- .and_then(|v| v.as_object())
2355
- && let Some(model) = selections.get(provider_id).and_then(|v| v.as_str())
2356
- && !model.is_empty()
2357
- {
2358
- return model.to_owned();
2359
- }
2360
-
2361
- // Fall back to the first model in the provider definition.
2362
- config
2363
- .resolve_custom_provider(&config.ai_provider)
2364
- .and_then(|cp| cp.models.first())
2365
- .map(|m| m.model_id.clone())
2366
- .unwrap_or_default()
2367
- }
2368
-
2369
- async fn call_custom_provider(
2370
- client: &Client,
2371
- config: &crate::MintConfig,
2372
- request: &ChatRequest,
2373
- ) -> Result<ProviderReply, ChatError> {
2374
- let provider = config
2375
- .resolve_custom_provider(&config.ai_provider)
2376
- .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
2377
-
2378
- let base_url = provider.base_url.trim_end_matches('/');
2379
- let model = resolve_custom_model(config);
2380
- let payload = openai_chat_payload(config, &model, request, false)?;
2381
-
2382
- let mut builder = client
2383
- .post(format!("{base_url}/chat/completions"))
2384
- .json(&payload);
2385
-
2386
- if !provider.api_key.is_empty() {
2387
- builder = builder.bearer_auth(&provider.api_key);
2388
- }
2389
- for h in &provider.headers {
2390
- if !h.name.is_empty() {
2391
- builder = builder.header(h.name.as_str(), h.value.as_str());
2392
- }
2393
- }
2394
-
2395
- let response: serde_json::Value = builder.send().await?.error_for_status()?.json().await?;
2396
-
2397
- parse_openai_style_reply(model, &response)
2398
- }
2399
-
2400
- fn provider_key(configured: &str, environment_variable: &str) -> String {
2401
- if configured.trim().is_empty() {
2402
- std::env::var(environment_variable).unwrap_or_default()
2403
- } else {
2404
- configured.into()
2405
- }
2406
- }
2407
-
2408
- fn require_supported_attachments(provider: &str, request: &ChatRequest) -> Result<(), ChatError> {
2409
- let has_image = request
2410
- .image_data_uri
2411
- .as_ref()
2412
- .is_some_and(|s| !s.trim().is_empty());
2413
- let has_audio = request
2414
- .audio_data_uri
2415
- .as_ref()
2416
- .is_some_and(|s| !s.trim().is_empty());
2417
- let has_video = request
2418
- .video_data_uri
2419
- .as_ref()
2420
- .is_some_and(|s| !s.trim().is_empty());
2421
-
2422
- match provider {
2423
- "gemini" => Ok(()),
2424
- "ollama" if has_audio || has_video => {
2425
- Err(ChatError::UnsupportedAttachments(provider.into()))
2426
- }
2427
- "ollama" => Ok(()),
2428
- // The real OpenAI API supports audio input natively (`input_audio` content
2429
- // parts, see `openai_audio_part`) — video still isn't implemented for it.
2430
- "openai" if has_video => Err(ChatError::UnsupportedAttachments(provider.into())),
2431
- "openai" => Ok(()),
2432
- // These all build their payload via `openai_chat_payload`/`anthropic_chat_payload`,
2433
- // which support image attachments (see `openai_message_content`/
2434
- // `anthropic_message_content`). Unlike literal `"openai"` above, these are
2435
- // separate services/proxies that aren't confirmed to speak the same
2436
- // `input_audio` schema, so audio/video stay unsupported for them.
2437
- "local_openai" | "openrouter" | "deepseek" | "huggingface" | "anthropic"
2438
- if has_audio || has_video =>
2439
- {
2440
- Err(ChatError::UnsupportedAttachments(provider.into()))
2441
- }
2442
- "local_openai" | "openrouter" | "deepseek" | "huggingface" | "anthropic" => Ok(()),
2443
- // Custom providers route through `call_custom_provider`, which also uses
2444
- // `openai_chat_payload`, so images are supported the same way; audio/video
2445
- // are not implemented for them yet.
2446
- p if p.starts_with("custom:") && (has_audio || has_video) => {
2447
- Err(ChatError::UnsupportedAttachments(provider.into()))
2448
- }
2449
- p if p.starts_with("custom:") => Ok(()),
2450
- _ if has_image || has_audio || has_video => {
2451
- Err(ChatError::UnsupportedAttachments(provider.into()))
2452
- }
2453
- _ => Ok(()),
2454
- }
2455
- }
2456
-
2457
- /// Extract base64 image data from the request for Ollama's `images` field.
2458
- /// Ollama expects a plain array of base64 strings (without the data URI prefix).
2459
- fn ollama_images(request: &ChatRequest) -> Option<Vec<String>> {
2460
- let image_data = request.image_data_uri.as_ref()?;
2461
- let images: Vec<String> = image_data
2462
- .split_whitespace()
2463
- .filter_map(|img| {
2464
- img.strip_prefix("data:")
2465
- .and_then(|payload| payload.split_once(";base64,"))
2466
- .filter(|(mime, data)| mime.starts_with("image/") && !data.is_empty())
2467
- .map(|(_, data)| data.to_owned())
2468
- })
2469
- .collect();
2470
- if images.is_empty() {
2471
- None
2472
- } else {
2473
- Some(images)
2474
- }
2475
- }
2476
-
2477
- fn wants_agent_json(request: &ChatRequest) -> bool {
2478
- let instruction = request.system_instruction.as_str();
2479
- instruction.contains("Return only JSON")
2480
- || (instruction.contains("Return exactly one JSON object per response")
2481
- && instruction.contains("Input formats:")
2482
- && instruction.contains("- finish:"))
2483
- }
2484
-
2485
- fn gemini_chat_payload(config: &MintConfig, request: &ChatRequest) -> Result<Value, ChatError> {
2486
- let temp = effective_temperature(config, request);
2487
- let mut payload = json!({
2488
- "systemInstruction": { "parts": [{ "text": request.system_instruction }] },
2489
- "contents": [{ "role": "user", "parts": gemini_parts(request)? }]
2490
- });
2491
- let mut gen_cfg = if wants_agent_json(request) {
2492
- let mut cfg = gemini_agent_generation_config(config);
2493
- cfg["temperature"] = json!(temp);
2494
- cfg
2495
- } else {
2496
- json!({ "temperature": temp })
2497
- };
2498
- gemini_apply_thinking_config(&mut gen_cfg, config, request, &config.gemini_model);
2499
- payload["generationConfig"] = gen_cfg;
2500
- Ok(payload)
2501
- }
2502
-
2503
- fn gemini_agent_generation_config(config: &MintConfig) -> Value {
2504
- let mut allowed_actions = vec![
2505
- "list_files",
2506
- "read_file",
2507
- "search_code",
2508
- "symbols",
2509
- "find_definition",
2510
- "find_references",
2511
- "semantic_index",
2512
- "semantic_search",
2513
- "knowledge_search",
2514
- "web_search",
2515
- "weather",
2516
- "stock",
2517
- "calculation",
2518
- "browser_open",
2519
- "memory_recall",
2520
- "git_status",
2521
- "git_diff",
2522
- "git_log",
2523
- "git_branch",
2524
- "create_plan",
2525
- "update_plan",
2526
- "request_user_approval",
2527
- "ask_user",
2528
- "detect_project",
2529
- "list_tests",
2530
- "read_diagnostics",
2531
- "view_image",
2532
- "note_write",
2533
- "run_plugin",
2534
- "mcp_tool",
2535
- "run_shell",
2536
- "verify",
2537
- "run_tests",
2538
- "run_typecheck",
2539
- "run_linter",
2540
- "apply_patch",
2541
- "write_file",
2542
- ];
2543
- allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
2544
- allowed_actions.push("finish");
2545
-
2546
- json!({
2547
- "responseMimeType": "application/json",
2548
- "responseSchema": {
2549
- "type": "OBJECT",
2550
- "properties": {
2551
- "thought": { "type": "STRING" },
2552
- "action": {
2553
- "type": "STRING",
2554
- "enum": allowed_actions
2555
- },
2556
- "input": {
2557
- "type": "OBJECT",
2558
- "properties": {
2559
- "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)" },
2560
- "query": { "type": "STRING", "description": "The search query string (required for search_code, semantic_search, knowledge_search, web_search, stock, memory_recall)" },
2561
- "city": { "type": "STRING", "description": "The target city or location name (required for weather)" },
2562
- "expression": { "type": "STRING", "description": "The math expression or percentage calculation (required for calculation)" },
2563
- "url": { "type": "STRING", "description": "The target website URL to open (required for browser_open)" },
2564
- "command": { "type": "STRING", "description": "The local read-only or test shell command to run (required for run_shell)" },
2565
- "commands": {
2566
- "type": "ARRAY",
2567
- "items": { "type": "STRING" },
2568
- "description": "List of verification commands (required for verify)"
2569
- },
2570
- "steps": {
2571
- "type": "ARRAY",
2572
- "items": { "type": "STRING" },
2573
- "description": "Plan steps (for create_plan/update_plan)"
2574
- },
2575
- "fileContent": { "type": "STRING", "description": "The complete new content of a new file (required for write_file, note_write)" },
2576
- "summary": { "type": "STRING", "description": "The final detailed answer, explanation, or response to the user's query (required for finish)" },
2577
- "verification": { "type": "STRING", "description": "The description of checks to run before finishing" },
2578
- "title": { "type": "STRING", "description": "Short approval or plan title" },
2579
- "status": { "type": "STRING", "description": "Plan item status or project status" },
2580
- "startLine": { "type": "INTEGER", "description": "First line to read (1-indexed, for read_file)" },
2581
- "endLine": { "type": "INTEGER", "description": "Last line to read. Defaults to startLine + 239 (for read_file)" },
2582
- "limit": { "type": "INTEGER", "description": "Max number of items/lines/files to return" },
2583
- "server": { "type": "STRING", "description": "MCP server name (for mcp_tool)" },
2584
- "tool": { "type": "STRING", "description": "MCP tool name (for mcp_tool)" },
2585
- "notePath": { "type": "STRING", "description": "The file path for note writing (for note_write)" },
2586
- "name": { "type": "STRING", "description": "Plugin name (for run_plugin)" },
2587
- "instruction": { "type": "STRING", "description": "Instruction to run the plugin (for run_plugin)" },
2588
- "patch": {
2589
- "type": "OBJECT",
2590
- "properties": {
2591
- "path": { "type": "STRING", "description": "The target file path (required for apply_patch)" },
2592
- "hunks": {
2593
- "type": "ARRAY",
2594
- "items": {
2595
- "type": "OBJECT",
2596
- "properties": {
2597
- "oldText": { "type": "STRING", "description": "The exact block of code to replace" },
2598
- "newText": { "type": "STRING", "description": "The replacement block of code" }
2599
- }
2600
- }
2601
- }
2602
- }
2603
- },
2604
- "arguments": { "type": "OBJECT", "description": "MCP tool arguments (for mcp_tool)" }
2605
- },
2606
- "required": ["path", "query", "command", "fileContent", "summary"]
2607
- }
2608
- },
2609
- "required": ["thought", "action", "input"]
2610
- }
2611
- })
2612
- }
2613
-
2614
- /// Resolves the effective sampling temperature for this request, prioritizing
2615
- /// `request.temperature` if present, otherwise falling back to `config.resolved_temperature()`.
2616
- pub(crate) fn effective_temperature(config: &MintConfig, request: &ChatRequest) -> f64 {
2617
- request
2618
- .temperature
2619
- .unwrap_or_else(|| config.resolved_temperature())
2620
- }
2621
-
2622
- /// Resolves (enabled, effort) for this request and model, prioritizing request overrides.
2623
- pub(crate) fn effective_thinking(
2624
- config: &MintConfig,
2625
- request: &ChatRequest,
2626
- model: &str,
2627
- ) -> (bool, String) {
2628
- let enabled = request
2629
- .thinking_enabled
2630
- .unwrap_or_else(|| config.resolved_thinking_enabled_for_model(&config.ai_provider, model));
2631
- let effort = request
2632
- .thinking_effort
2633
- .as_deref()
2634
- .map(str::to_ascii_lowercase)
2635
- .unwrap_or_else(|| config.resolved_thinking_effort_for_model(model).to_string());
2636
- (enabled, effort)
2637
- }
2638
-
2639
- pub(crate) fn gemini_apply_thinking_config(
2640
- gen_cfg: &mut Value,
2641
- config: &MintConfig,
2642
- request: &ChatRequest,
2643
- model: &str,
2644
- ) {
2645
- if !MintConfig::is_thinking_supported_for_model("gemini", model) {
2646
- return;
2647
- }
2648
- let (enabled, effort) = effective_thinking(config, request, model);
2649
- if !enabled {
2650
- gen_cfg["thinkingConfig"] = json!({ "thinkingBudget": 0 });
2651
- } else {
2652
- let budget = match effort.as_str() {
2653
- "low" => 1024,
2654
- "medium" => 4096,
2655
- "high" => 16384,
2656
- "extra_high" => 32768,
2657
- _ => 4096,
2658
- };
2659
- gen_cfg["thinkingConfig"] = json!({ "thinkingBudget": budget });
2660
- }
2661
- }
2662
-
2663
- fn openai_chat_payload(
2664
- config: &MintConfig,
2665
- model: &str,
2666
- request: &ChatRequest,
2667
- stream: bool,
2668
- ) -> Result<Value, ChatError> {
2669
- let messages = if let Some(messages) = &request.messages {
2670
- let mut built = Vec::new();
2671
- if !request.system_instruction.is_empty()
2672
- && !messages.iter().any(|m| m.role == ChatRole::System)
2673
- {
2674
- built.push(json!({ "role": "system", "content": request.system_instruction }));
2675
- }
2676
- built.extend(openai_native_messages(messages)?);
2677
- built
2678
- } else {
2679
- vec![
2680
- json!({ "role": "system", "content": request.system_instruction }),
2681
- json!({
2682
- "role": "user",
2683
- "content": openai_message_content(
2684
- &request.message,
2685
- request.image_data_uri.as_deref(),
2686
- request.audio_data_uri.as_deref(),
2687
- )?,
2688
- }),
2689
- ]
2690
- };
2691
- // Left unset, several OpenAI-compatible providers (DeepSeek in
2692
- // particular) default to a much lower output cap than the model can
2693
- // actually produce, which silently truncates long structured answers
2694
- // (a code block followed by a couple of markdown tables is enough to
2695
- // hit it) — matches the explicit cap already used for Anthropic in
2696
- // `anthropic_chat_payload`/`stream_anthropic`.
2697
- // For Groq endpoints, free tiers enforce a tight 6,000–8,000 TPM limit
2698
- // where a static 8192 max_tokens triggers immediate HTTP 413 / rate limit errors.
2699
- let max_tokens = {
2700
- let is_groq = config
2701
- .resolve_custom_provider(&config.ai_provider)
2702
- .map(|cp| cp.base_url.contains("groq.com"))
2703
- .unwrap_or(false);
2704
- if is_groq {
2705
- if request.tools.is_some() { 2048 } else { 4096 }
2706
- } else {
2707
- 8192
2708
- }
2709
- };
2710
- let mut payload = json!({
2711
- "model": model,
2712
- "stream": stream,
2713
- "messages": messages,
2714
- "max_tokens": max_tokens,
2715
- });
2716
- if stream {
2717
- payload["stream_options"] = json!({ "include_usage": true });
2718
- }
2719
- // OpenAI reasoning models (o1, o3, etc.) reject explicit temperature parameter.
2720
- let is_reasoning_model = model.starts_with("o1") || model.starts_with("o3");
2721
- let (thinking_enabled, thinking_effort) = effective_thinking(config, request, model);
2722
- if is_reasoning_model {
2723
- if thinking_enabled {
2724
- let effort_str = match thinking_effort.as_str() {
2725
- "low" => "low",
2726
- "medium" => "medium",
2727
- "high" | "extra_high" => "high",
2728
- _ => "medium",
2729
- };
2730
- payload["reasoning_effort"] = json!(effort_str);
2731
- } else {
2732
- payload["reasoning_effort"] = json!("low");
2733
- }
2734
- } else {
2735
- payload["temperature"] = json!(effective_temperature(config, request));
2736
- }
2737
- if wants_agent_json(request) {
2738
- payload["response_format"] = json!({ "type": "json_object" });
2739
- }
2740
- if let Some(tools) = &request.tools {
2741
- payload["tools"] = json!(
2742
- tools
2743
- .iter()
2744
- .map(|tool| json!({
2745
- "type": "function",
2746
- "function": {
2747
- "name": tool.name,
2748
- "description": tool.description,
2749
- "parameters": tool.parameters,
2750
- }
2751
- }))
2752
- .collect::<Vec<_>>()
2753
- );
2754
- }
2755
- Ok(payload)
2756
- }
2757
-
2758
- /// Converts structured history into OpenAI Chat Completions message objects,
2759
- /// including assistant `tool_calls` and `role: "tool"` tool-result messages.
2760
- fn openai_native_messages(messages: &[ChatMessage]) -> Result<Vec<Value>, ChatError> {
2761
- let mut result = Vec::new();
2762
- for message in messages {
2763
- match message.role {
2764
- ChatRole::System => {
2765
- result.push(json!({ "role": "system", "content": message_text(message) }));
2766
- }
2767
- ChatRole::User => {
2768
- result.push(json!({ "role": "user", "content": openai_user_content(message)? }));
2769
- }
2770
- ChatRole::Assistant => {
2771
- let text = message_text(message);
2772
- let tool_calls: Vec<Value> = message
2773
- .content
2774
- .iter()
2775
- .filter_map(|block| match block {
2776
- ContentBlock::ToolUse {
2777
- id, name, input, ..
2778
- } => Some(json!({
2779
- "id": id,
2780
- "type": "function",
2781
- "function": { "name": name, "arguments": input.to_string() }
2782
- })),
2783
- _ => None,
2784
- })
2785
- .collect();
2786
- let mut entry = json!({ "role": "assistant" });
2787
- entry["content"] = if text.is_empty() {
2788
- Value::Null
2789
- } else {
2790
- json!(text)
2791
- };
2792
- if !tool_calls.is_empty() {
2793
- entry["tool_calls"] = json!(tool_calls);
2794
- }
2795
- result.push(entry);
2796
- }
2797
- ChatRole::Tool => {
2798
- // OpenAI's Chat Completions `tool` role only accepts a plain
2799
- // string `content` — an image can't be attached to it directly.
2800
- // Any `Image` block riding along with a tool result (e.g. a
2801
- // browser screenshot) is instead emitted as a follow-up `user`
2802
- // message, the same pattern OpenAI's own vision examples use to
2803
- // let a tool result be "shown" to the model.
2804
- let mut pending_images: Vec<&str> = Vec::new();
2805
- for block in &message.content {
2806
- match block {
2807
- ContentBlock::ToolResult {
2808
- tool_use_id,
2809
- content,
2810
- ..
2811
- } => {
2812
- result.push(json!({
2813
- "role": "tool",
2814
- "tool_call_id": tool_use_id,
2815
- "content": content,
2816
- }));
2817
- }
2818
- ContentBlock::Image { data_uri } => pending_images.push(data_uri),
2819
- _ => {}
2820
- }
2821
- }
2822
- if !pending_images.is_empty() {
2823
- let parts: Vec<Value> = pending_images
2824
- .iter()
2825
- .map(|data_uri| {
2826
- json!({ "type": "image_url", "image_url": { "url": data_uri } })
2827
- })
2828
- .collect();
2829
- result.push(json!({ "role": "user", "content": parts }));
2830
- }
2831
- }
2832
- }
2833
- }
2834
- Ok(result)
2835
- }
2836
-
2837
- /// Concatenates all `Text` blocks in a message (tool-use/result/image blocks
2838
- /// contribute nothing).
2839
- fn message_text(message: &ChatMessage) -> String {
2840
- message
2841
- .content
2842
- .iter()
2843
- .filter_map(|block| match block {
2844
- ContentBlock::Text { text } => Some(text.as_str()),
2845
- _ => None,
2846
- })
2847
- .collect::<Vec<_>>()
2848
- .join("")
2849
- }
2850
-
2851
- /// Builds an OpenAI `content` value for the plain (non-native-messages) chat
2852
- /// path: bare text when there's no image/audio, otherwise a `text` +
2853
- /// `image_url`/`input_audio` parts array. Mirrors `openai_user_content`,
2854
- /// which handles the same shape for the native-tool-calling `messages` path.
2855
- /// `image_data_uri`/`audio_data_uri` may each contain multiple
2856
- /// space-separated data URIs, matching Gemini's `gemini_parts`.
2857
- fn openai_message_content(
2858
- text: &str,
2859
- image_data_uri: Option<&str>,
2860
- audio_data_uri: Option<&str>,
2861
- ) -> Result<Value, ChatError> {
2862
- if image_data_uri.is_none() && audio_data_uri.is_none() {
2863
- return Ok(json!(text));
2864
- }
2865
- let mut parts = vec![json!({ "type": "text", "text": text })];
2866
- for img in image_data_uri.into_iter().flat_map(str::split_whitespace) {
2867
- parts.push(json!({ "type": "image_url", "image_url": { "url": img } }));
2868
- }
2869
- for aud in audio_data_uri.into_iter().flat_map(str::split_whitespace) {
2870
- parts.push(openai_audio_part(aud)?);
2871
- }
2872
- Ok(json!(parts))
2873
- }
2874
-
2875
- fn openai_user_content(message: &ChatMessage) -> Result<Value, ChatError> {
2876
- if let [ContentBlock::Text { text }] = message.content.as_slice() {
2877
- return Ok(json!(text));
2878
- }
2879
- let parts: Vec<Value> = message
2880
- .content
2881
- .iter()
2882
- .map(|block| -> Result<Value, ChatError> {
2883
- Ok(match block {
2884
- ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
2885
- ContentBlock::Image { data_uri } => {
2886
- json!({ "type": "image_url", "image_url": { "url": data_uri } })
2887
- }
2888
- ContentBlock::Audio { data_uri } => openai_audio_part(data_uri)?,
2889
- // OpenAI Chat Completions has no inline video content part in this
2890
- // format; unreachable in practice because
2891
- // `require_supported_attachments` rejects video for these
2892
- // providers before a request gets this far. `Value::Null` here
2893
- // is a "skip this block" sentinel, filtered out below.
2894
- ContentBlock::ToolUse { .. }
2895
- | ContentBlock::ToolResult { .. }
2896
- | ContentBlock::Video { .. } => Value::Null,
2897
- })
2898
- })
2899
- .filter_map(|part| match part {
2900
- Ok(Value::Null) => None,
2901
- other => Some(other),
2902
- })
2903
- .collect::<Result<_, _>>()?;
2904
- Ok(json!(parts))
2905
- }
2906
-
2907
- /// Builds an OpenAI `input_audio` content part. Unlike `image_url`, which
2908
- /// takes the whole `data:` URI as-is, OpenAI's audio input wants the base64
2909
- /// payload and format split apart, so the URI has to be parsed here instead
2910
- /// of just passed through.
2911
- fn openai_audio_part(data_uri: &str) -> Result<Value, ChatError> {
2912
- let (mime_type, data) = split_data_uri(data_uri, "audio/")?;
2913
- Ok(json!({
2914
- "type": "input_audio",
2915
- "input_audio": { "data": data, "format": openai_audio_format(&mime_type) }
2916
- }))
2917
- }
2918
-
2919
- /// OpenAI's `input_audio.format` only accepts `"wav"` or `"mp3"` — anything
2920
- /// else (webm, ogg, m4a, …) is mapped to `"mp3"` as the closest-effort
2921
- /// fallback rather than rejected outright; the API itself is the final judge
2922
- /// of whether the actual bytes decode.
2923
- fn openai_audio_format(mime_type: &str) -> &'static str {
2924
- if mime_type.contains("wav") {
2925
- "wav"
2926
- } else {
2927
- "mp3"
2928
- }
2929
- }
2930
-
2931
- /// Splits a `data:<mime>;base64,<data>` URI into `(mime_type, base64_data)`,
2932
- /// validating the mime type starts with `expected_prefix` (e.g. `"image/"`).
2933
- fn split_data_uri(data_uri: &str, expected_prefix: &str) -> Result<(String, String), ChatError> {
2934
- data_uri
2935
- .strip_prefix("data:")
2936
- .and_then(|payload| payload.split_once(";base64,"))
2937
- .filter(|(mime, data)| mime.starts_with(expected_prefix) && !data.is_empty())
2938
- .map(|(mime, data)| (mime.to_owned(), data.to_owned()))
2939
- .ok_or(ChatError::InvalidAttachment)
2940
- }
2941
-
2942
- fn gemini_parts(request: &ChatRequest) -> Result<Vec<Value>, ChatError> {
2943
- let mut parts = vec![json!({ "text": request.message })];
2944
- if let Some(ref image_data) = request.image_data_uri {
2945
- for img in image_data.split_whitespace() {
2946
- let payload = img
2947
- .strip_prefix("data:")
2948
- .and_then(|payload| payload.split_once(";base64,"))
2949
- .filter(|(mime_type, data)| mime_type.starts_with("image/") && !data.is_empty())
2950
- .ok_or(ChatError::InvalidAttachment)?;
2951
- parts.push(json!({
2952
- "inlineData": {
2953
- "mimeType": payload.0,
2954
- "data": payload.1
2955
- }
2956
- }));
2957
- }
2958
- }
2959
- if let Some(ref audio_data) = request.audio_data_uri {
2960
- for aud in audio_data.split_whitespace() {
2961
- let payload = aud
2962
- .strip_prefix("data:")
2963
- .and_then(|payload| payload.split_once(";base64,"))
2964
- .filter(|(mime_type, data)| mime_type.starts_with("audio/") && !data.is_empty())
2965
- .ok_or(ChatError::InvalidAttachment)?;
2966
- parts.push(json!({
2967
- "inlineData": {
2968
- "mimeType": payload.0,
2969
- "data": payload.1
2970
- }
2971
- }));
2972
- }
2973
- }
2974
- if let Some(ref video_data) = request.video_data_uri {
2975
- for vid in video_data.split_whitespace() {
2976
- let payload = vid
2977
- .strip_prefix("data:")
2978
- .and_then(|payload| payload.split_once(";base64,"))
2979
- .filter(|(mime_type, data)| mime_type.starts_with("video/") && !data.is_empty())
2980
- .ok_or(ChatError::InvalidAttachment)?;
2981
- parts.push(json!({
2982
- "inlineData": {
2983
- "mimeType": payload.0,
2984
- "data": payload.1
2985
- }
2986
- }));
2987
- }
2988
- }
2989
- Ok(parts)
2990
- }
2991
-
2992
- #[cfg(test)]
2993
- mod tests {
2994
- use super::*;
2995
-
2996
- #[test]
2997
- fn rejects_missing_api_key() {
2998
- let error = required_key("gemini", "").unwrap_err();
2999
- assert!(matches!(error, ChatError::MissingApiKey(provider) if provider == "gemini"));
3000
- }
3001
-
3002
- #[test]
3003
- fn rejects_video_attachments_for_non_gemini_providers() {
3004
- let request = ChatRequest {
3005
- message: "describe this video".into(),
3006
- system_instruction: String::new(),
3007
- chat_id: None,
3008
- image_data_uri: None,
3009
- audio_data_uri: None,
3010
- video_data_uri: Some("data:video/mp4;base64,AAAA".into()),
3011
- document_attachment: None,
3012
- workspace_path: None,
3013
- agent_id: None,
3014
- plan_mode: false,
3015
- pinned_mcp_server: None,
3016
- messages: None,
3017
- tools: None,
3018
- temperature: None,
3019
- ..Default::default()
3020
- };
3021
-
3022
- let error = require_supported_attachments("openai", &request).unwrap_err();
3023
- assert!(
3024
- matches!(error, ChatError::UnsupportedAttachments(provider) if provider == "openai")
3025
- );
3026
- }
3027
-
3028
- #[test]
3029
- fn parses_stream_provider_formats() {
3030
- let openai = parse_stream_value(
3031
- StreamFormat::OpenAi,
3032
- r#"data: {"choices":[{"delta":{"content":"hello"}}]}"#,
3033
- )
3034
- .unwrap();
3035
- assert_eq!(openai["choices"][0]["delta"]["content"], "hello");
3036
- let ollama =
3037
- parse_stream_value(StreamFormat::Ollama, r#"{"message":{"content":"hi"}}"#).unwrap();
3038
- assert_eq!(ollama["message"]["content"], "hi");
3039
- assert!(parse_stream_value(StreamFormat::Anthropic, "data: [DONE]").is_none());
3040
- }
3041
-
3042
- #[test]
3043
- fn malformed_tool_arguments_survive_provider_parsing_for_rejection() {
3044
- let malformed = r#"{"path":"~/Downloads/YOUTUBE_DL""#;
3045
- let function = json!({"name": "list_files", "arguments": malformed});
3046
- let openai = parse_openai_style_reply(
3047
- "test".into(),
3048
- &json!({
3049
- "choices": [{"message": {"tool_calls": [{"id": "call_1", "function": function}]}}]
3050
- }),
3051
- )
3052
- .unwrap();
3053
- let ollama = parse_ollama_reply(
3054
- "test".into(),
3055
- &json!({
3056
- "message": {"tool_calls": [{"function": function}]}
3057
- }),
3058
- )
3059
- .unwrap();
3060
- let mut accumulator = StreamReplyAccumulator::default();
3061
- accumulator.consume_openai(&json!({
3062
- "choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": function}]}}]
3063
- }), &mut |_| {});
3064
- let streamed = accumulator.finish("test".into()).unwrap();
3065
- for reply in [openai, ollama, streamed] {
3066
- let calls = reply.tool_calls.unwrap();
3067
- assert_eq!(calls.len(), 1);
3068
- assert_eq!(calls[0].input, Value::String(malformed.into()));
3069
- }
3070
- assert_eq!(decode_tool_arguments(&json!("{}")), json!({}));
3071
- assert_eq!(decode_tool_arguments(&Value::Null), Value::Null);
3072
- }
3073
-
3074
- #[test]
3075
- fn accumulates_openai_reasoning_tools_and_usage() {
3076
- let mut accumulator = StreamReplyAccumulator::default();
3077
- let mut events = Vec::new();
3078
- accumulator.consume_openai(
3079
- &json!({
3080
- "choices": [{"delta": {"reasoning_content": "inspect "}}]
3081
- }),
3082
- &mut |event| events.push(event),
3083
- );
3084
- accumulator.consume_openai(&json!({
3085
- "choices": [{"delta": {"reasoning_content": "files", "tool_calls": [{
3086
- "index": 0, "id": "call_1", "function": {"name": "read_file", "arguments": "{\"path\":"}
3087
- }]}}]
3088
- }), &mut |event| events.push(event));
3089
- accumulator.consume_openai(
3090
- &json!({
3091
- "choices": [{"delta": {"tool_calls": [{
3092
- "index": 0, "function": {"arguments": "\"README.md\"}"}
3093
- }]}, "finish_reason": "tool_calls"}],
3094
- "usage": {"prompt_tokens": 10, "completion_tokens": 5}
3095
- }),
3096
- &mut |event| events.push(event),
3097
- );
3098
- accumulator.flush_tag_buffer(&mut |event| events.push(event));
3099
- let reply = accumulator.finish("test".into()).unwrap();
3100
- assert_eq!(reply.thought.as_deref(), Some("inspect files"));
3101
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_calls"));
3102
- assert_eq!(reply.input_tokens, Some(10));
3103
- assert_eq!(reply.tool_calls.unwrap()[0].input["path"], "README.md");
3104
- assert_eq!(
3105
- events
3106
- .iter()
3107
- .filter(|event| matches!(event, ChatStreamEvent::ReasoningDelta { .. }))
3108
- .count(),
3109
- 2
3110
- );
3111
- assert_eq!(
3112
- events
3113
- .iter()
3114
- .filter(|event| matches!(event, ChatStreamEvent::ToolCallDelta { .. }))
3115
- .count(),
3116
- 2
3117
- );
3118
- }
3119
-
3120
- #[test]
3121
- fn streams_think_tags_split_across_chunks() {
3122
- let mut accumulator = StreamReplyAccumulator::default();
3123
- let mut events = Vec::new();
3124
- for chunk in ["ก่อน<thi", "nk>คิด", "อยู่</th", "ink>หลัง"] {
3125
- accumulator.push_text(chunk, &mut |event| events.push(event));
3126
- }
3127
- accumulator.flush_tag_buffer(&mut |event| events.push(event));
3128
- let reply = accumulator.finish("test".into()).unwrap();
3129
- assert_eq!(reply.text, "ก่อนหลัง");
3130
- assert_eq!(reply.thought.as_deref(), Some("คิดอยู่"));
3131
- let streamed_reasoning: String = events
3132
- .iter()
3133
- .filter_map(|event| match event {
3134
- ChatStreamEvent::ReasoningDelta { delta } => Some(delta.as_str()),
3135
- _ => None,
3136
- })
3137
- .collect();
3138
- assert_eq!(streamed_reasoning, "คิดอยู่");
3139
- }
3140
-
3141
- #[test]
3142
- fn builds_gemini_multimodal_parts() {
3143
- let parts = gemini_parts(&ChatRequest {
3144
- message: "describe".into(),
3145
- system_instruction: String::new(),
3146
- chat_id: None,
3147
- image_data_uri: Some("data:image/png;base64,aGk= data:image/jpeg;base64,Ynl5".into()),
3148
- audio_data_uri: None,
3149
- video_data_uri: None,
3150
- document_attachment: None,
3151
- workspace_path: None,
3152
- agent_id: None,
3153
- plan_mode: false,
3154
- pinned_mcp_server: None,
3155
- messages: None,
3156
- tools: None,
3157
- temperature: None,
3158
- ..Default::default()
3159
- })
3160
- .unwrap();
3161
- assert_eq!(parts.len(), 3);
3162
- assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
3163
- assert_eq!(parts[2]["inlineData"]["mimeType"], "image/jpeg");
3164
- }
3165
-
3166
- fn sample_tool() -> ToolSpec {
3167
- ToolSpec {
3168
- name: "read_file".into(),
3169
- description: "Reads a file".into(),
3170
- parameters: json!({
3171
- "type": "object",
3172
- "properties": { "path": { "type": "string" } },
3173
- "required": ["path"]
3174
- }),
3175
- }
3176
- }
3177
-
3178
- fn native_request(messages: Vec<ChatMessage>, tools: Option<Vec<ToolSpec>>) -> ChatRequest {
3179
- ChatRequest {
3180
- message: String::new(),
3181
- system_instruction: "You are Mint.".into(),
3182
- chat_id: None,
3183
- image_data_uri: None,
3184
- audio_data_uri: None,
3185
- video_data_uri: None,
3186
- document_attachment: None,
3187
- workspace_path: None,
3188
- agent_id: None,
3189
- plan_mode: false,
3190
- pinned_mcp_server: None,
3191
- messages: Some(messages),
3192
- tools,
3193
- temperature: None,
3194
- ..Default::default()
3195
- }
3196
- }
3197
-
3198
- #[test]
3199
- fn anthropic_native_payload_includes_tools_and_tool_result_messages() {
3200
- let messages = vec![
3201
- ChatMessage::text(ChatRole::User, "read main.rs"),
3202
- ChatMessage {
3203
- role: ChatRole::Assistant,
3204
- content: vec![ContentBlock::ToolUse {
3205
- id: "call_1".into(),
3206
- name: "read_file".into(),
3207
- input: json!({ "path": "main.rs" }),
3208
- thought_signature: None,
3209
- }],
3210
- },
3211
- ChatMessage {
3212
- role: ChatRole::Tool,
3213
- content: vec![ContentBlock::ToolResult {
3214
- tool_use_id: "call_1".into(),
3215
- content: "fn main() {}".into(),
3216
- is_error: false,
3217
- }],
3218
- },
3219
- ];
3220
- let request = native_request(messages, Some(vec![sample_tool()]));
3221
- let cfg = MintConfig::default();
3222
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3223
-
3224
- assert_eq!(payload["system"][0]["text"], "You are Mint.");
3225
- assert_eq!(payload["tools"][0]["name"], "read_file");
3226
- assert_eq!(payload["tools"][0]["input_schema"]["type"], "object");
3227
- let msgs = payload["messages"].as_array().unwrap();
3228
- assert_eq!(msgs.len(), 3);
3229
- assert_eq!(msgs[1]["role"], "assistant");
3230
- assert_eq!(msgs[1]["content"][0]["type"], "tool_use");
3231
- assert_eq!(msgs[2]["role"], "user");
3232
- assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
3233
- assert_eq!(msgs[2]["content"][0]["tool_use_id"], "call_1");
3234
- }
3235
-
3236
- #[test]
3237
- fn anthropic_payload_marks_the_system_prompt_as_cacheable() {
3238
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
3239
- let cfg = MintConfig::default();
3240
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3241
-
3242
- assert_eq!(payload["system"][0]["type"], "text");
3243
- assert_eq!(payload["system"][0]["text"], "You are Mint.");
3244
- assert_eq!(payload["system"][0]["cache_control"]["type"], "ephemeral");
3245
- }
3246
-
3247
- #[test]
3248
- fn review_anthropic_tool_results_preserve_failure_and_success_flags() {
3249
- for (text, is_error) in [
3250
- ("User denied MCP tool call", true),
3251
- ("MCP call was not approved; user feedback", true),
3252
- ("Error: literal diagnostic label", false),
3253
- ] {
3254
- let request = native_request(
3255
- vec![ChatMessage {
3256
- role: ChatRole::Tool,
3257
- content: vec![ContentBlock::ToolResult {
3258
- tool_use_id: "mcp-call".into(),
3259
- content: text.into(),
3260
- is_error,
3261
- }],
3262
- }],
3263
- None,
3264
- );
3265
- let payload =
3266
- anthropic_chat_payload(&MintConfig::default(), "claude-x", &request, false)
3267
- .unwrap();
3268
- assert_eq!(payload["messages"][0]["content"][0]["is_error"], is_error);
3269
- assert_eq!(payload["messages"][0]["content"][0]["content"], text);
3270
- }
3271
- }
3272
-
3273
- #[test]
3274
- fn anthropic_payload_omits_the_cache_breakpoint_for_an_empty_system_prompt() {
3275
- let mut request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
3276
- request.system_instruction = String::new();
3277
- let cfg = MintConfig::default();
3278
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3279
-
3280
- // Falls back to the plain-string shape rather than an empty cacheable
3281
- // block, matching the pre-caching behavior exactly for this case.
3282
- assert_eq!(payload["system"], "");
3283
- }
3284
-
3285
- #[test]
3286
- fn anthropic_payload_marks_only_the_last_tool_as_the_cache_breakpoint() {
3287
- let second_tool = ToolSpec {
3288
- name: "write_file".into(),
3289
- description: "Writes a file".into(),
3290
- parameters: json!({ "type": "object" }),
3291
- };
3292
- let request = native_request(
3293
- vec![ChatMessage::text(ChatRole::User, "hi")],
3294
- Some(vec![sample_tool(), second_tool]),
3295
- );
3296
- let cfg = MintConfig::default();
3297
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3298
-
3299
- let tools = payload["tools"].as_array().unwrap();
3300
- assert_eq!(tools.len(), 2);
3301
- assert!(tools[0].get("cache_control").is_none());
3302
- assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
3303
- }
3304
-
3305
- #[test]
3306
- fn anthropic_attaches_cache_control_to_penultimate_turn() {
3307
- let request = native_request(
3308
- vec![
3309
- ChatMessage::text(ChatRole::User, "step 1 user"),
3310
- ChatMessage::text(ChatRole::Assistant, "step 1 assistant"),
3311
- ChatMessage::text(ChatRole::User, "step 2 user"),
3312
- ],
3313
- None,
3314
- );
3315
- let cfg = MintConfig::default();
3316
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3317
- let messages = payload["messages"].as_array().unwrap();
3318
- assert_eq!(messages.len(), 3);
3319
- assert!(messages[0]["content"][0].get("cache_control").is_none());
3320
- assert_eq!(
3321
- messages[1]["content"][0]["cache_control"]["type"],
3322
- "ephemeral"
3323
- );
3324
- assert!(messages[2]["content"][0].get("cache_control").is_none());
3325
- }
3326
-
3327
- #[test]
3328
- fn parses_anthropic_reply_with_text_and_tool_use_blocks() {
3329
- let response = json!({
3330
- "stop_reason": "tool_use",
3331
- "content": [
3332
- { "type": "text", "text": "Let me check that file." },
3333
- { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "main.rs" } }
3334
- ]
3335
- });
3336
- let reply = parse_anthropic_reply("claude-x".into(), &response).unwrap();
3337
- assert_eq!(reply.text, "Let me check that file.");
3338
- let calls = reply.tool_calls.unwrap();
3339
- assert_eq!(calls.len(), 1);
3340
- assert_eq!(calls[0].name, "read_file");
3341
- assert_eq!(calls[0].input["path"], "main.rs");
3342
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_use"));
3343
- }
3344
-
3345
- #[test]
3346
- fn parses_anthropic_reply_with_tool_use_only_and_no_text() {
3347
- let response = json!({
3348
- "content": [
3349
- { "type": "tool_use", "id": "toolu_1", "name": "run_shell", "input": { "command": "ls" } }
3350
- ]
3351
- });
3352
- let reply = parse_anthropic_reply("claude-x".into(), &response).unwrap();
3353
- assert_eq!(reply.text, "");
3354
- assert_eq!(reply.tool_calls.unwrap().len(), 1);
3355
- }
3356
-
3357
- #[test]
3358
- fn openai_native_payload_encodes_tool_calls_and_results() {
3359
- let messages = vec![
3360
- ChatMessage::text(ChatRole::User, "list files"),
3361
- ChatMessage {
3362
- role: ChatRole::Assistant,
3363
- content: vec![ContentBlock::ToolUse {
3364
- id: "call_1".into(),
3365
- name: "list_files".into(),
3366
- input: json!({ "path": "." }),
3367
- thought_signature: None,
3368
- }],
3369
- },
3370
- ChatMessage {
3371
- role: ChatRole::Tool,
3372
- content: vec![ContentBlock::ToolResult {
3373
- tool_use_id: "call_1".into(),
3374
- content: "Cargo.toml\nsrc/".into(),
3375
- is_error: false,
3376
- }],
3377
- },
3378
- ];
3379
- let request = native_request(messages, Some(vec![sample_tool()]));
3380
- let cfg = MintConfig::default();
3381
- let payload = openai_chat_payload(&cfg, "gpt-x", &request, false).unwrap();
3382
-
3383
- assert_eq!(payload["tools"][0]["type"], "function");
3384
- assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
3385
- let msgs = payload["messages"].as_array().unwrap();
3386
- // system + user + assistant(tool_calls) + tool
3387
- assert_eq!(msgs.len(), 4);
3388
- assert_eq!(msgs[2]["role"], "assistant");
3389
- assert_eq!(msgs[2]["tool_calls"][0]["function"]["name"], "list_files");
3390
- assert_eq!(msgs[3]["role"], "tool");
3391
- assert_eq!(msgs[3]["tool_call_id"], "call_1");
3392
- }
3393
-
3394
- #[test]
3395
- fn openai_native_payload_forwards_a_screenshot_riding_a_tool_result_as_a_user_turn() {
3396
- // OpenAI's Chat Completions `tool` role only accepts string content, so an
3397
- // `Image` block attached to a tool result (e.g. `browser_screenshot`) must
3398
- // surface as a follow-up `user` message instead of being dropped.
3399
- let messages = vec![ChatMessage {
3400
- role: ChatRole::Tool,
3401
- content: vec![
3402
- ContentBlock::ToolResult {
3403
- tool_use_id: "call_1".into(),
3404
- content: "[Screenshot captured — see attached image]".into(),
3405
- is_error: false,
3406
- },
3407
- ContentBlock::Image {
3408
- data_uri: "data:image/png;base64,QUJD".into(),
3409
- },
3410
- ],
3411
- }];
3412
- let built = openai_native_messages(&messages).unwrap();
3413
- assert_eq!(built.len(), 2);
3414
- assert_eq!(built[0]["role"], "tool");
3415
- assert_eq!(built[0]["tool_call_id"], "call_1");
3416
- assert_eq!(built[1]["role"], "user");
3417
- assert_eq!(built[1]["content"][0]["type"], "image_url");
3418
- assert_eq!(
3419
- built[1]["content"][0]["image_url"]["url"],
3420
- "data:image/png;base64,QUJD"
3421
- );
3422
- }
3423
-
3424
- #[test]
3425
- fn openai_allows_audio_attachments() {
3426
- let request = ChatRequest {
3427
- message: "transcribe this".into(),
3428
- system_instruction: String::new(),
3429
- chat_id: None,
3430
- image_data_uri: None,
3431
- audio_data_uri: Some("data:audio/wav;base64,QUJD".into()),
3432
- video_data_uri: None,
3433
- document_attachment: None,
3434
- workspace_path: None,
3435
- agent_id: None,
3436
- plan_mode: false,
3437
- pinned_mcp_server: None,
3438
- messages: None,
3439
- tools: None,
3440
- temperature: None,
3441
- ..Default::default()
3442
- };
3443
- assert!(require_supported_attachments("openai", &request).is_ok());
3444
- }
3445
-
3446
- #[test]
3447
- fn non_openai_openai_family_providers_still_reject_audio() {
3448
- // Only the real OpenAI API is confirmed to speak the `input_audio`
3449
- // schema — proxies/other services built on the same wire format
3450
- // (local_openai, openrouter, deepseek, huggingface) and Anthropic
3451
- // (no audio modality at all) must stay rejected.
3452
- let request = ChatRequest {
3453
- message: "transcribe this".into(),
3454
- system_instruction: String::new(),
3455
- chat_id: None,
3456
- image_data_uri: None,
3457
- audio_data_uri: Some("data:audio/wav;base64,QUJD".into()),
3458
- video_data_uri: None,
3459
- document_attachment: None,
3460
- workspace_path: None,
3461
- agent_id: None,
3462
- plan_mode: false,
3463
- pinned_mcp_server: None,
3464
- messages: None,
3465
- tools: None,
3466
- temperature: None,
3467
- ..Default::default()
3468
- };
3469
- for provider in [
3470
- "local_openai",
3471
- "openrouter",
3472
- "deepseek",
3473
- "huggingface",
3474
- "anthropic",
3475
- ] {
3476
- let error = require_supported_attachments(provider, &request).unwrap_err();
3477
- assert!(
3478
- matches!(&error, ChatError::UnsupportedAttachments(p) if p == provider),
3479
- "expected {provider} to reject audio, got {error:?}"
3480
- );
3481
- }
3482
- }
3483
-
3484
- #[test]
3485
- fn openai_audio_part_splits_the_data_uri_and_maps_wav_format() {
3486
- let part = openai_audio_part("data:audio/wav;base64,QUJD").unwrap();
3487
- assert_eq!(part["type"], "input_audio");
3488
- assert_eq!(part["input_audio"]["data"], "QUJD");
3489
- assert_eq!(part["input_audio"]["format"], "wav");
3490
- }
3491
-
3492
- #[test]
3493
- fn openai_audio_part_defaults_unrecognized_formats_to_mp3() {
3494
- let part = openai_audio_part("data:audio/webm;base64,QUJD").unwrap();
3495
- assert_eq!(part["input_audio"]["format"], "mp3");
3496
- }
3497
-
3498
- #[test]
3499
- fn openai_audio_part_rejects_a_malformed_data_uri() {
3500
- assert!(openai_audio_part("not-a-data-uri").is_err());
3501
- }
3502
-
3503
- #[test]
3504
- fn openai_message_content_embeds_audio_alongside_text() {
3505
- let content =
3506
- openai_message_content("what is this?", None, Some("data:audio/mp3;base64,QUJD"))
3507
- .unwrap();
3508
- let parts = content.as_array().unwrap();
3509
- assert_eq!(parts[0]["type"], "text");
3510
- assert_eq!(parts[1]["type"], "input_audio");
3511
- assert_eq!(parts[1]["input_audio"]["data"], "QUJD");
3512
- }
3513
-
3514
- #[test]
3515
- fn openai_user_content_encodes_an_audio_content_block() {
3516
- let message = ChatMessage {
3517
- role: ChatRole::User,
3518
- content: vec![
3519
- ContentBlock::Text {
3520
- text: "what is this?".into(),
3521
- },
3522
- ContentBlock::Audio {
3523
- data_uri: "data:audio/wav;base64,QUJD".into(),
3524
- },
3525
- ],
3526
- };
3527
- let content = openai_user_content(&message).unwrap();
3528
- let parts = content.as_array().unwrap();
3529
- assert_eq!(parts.len(), 2);
3530
- assert_eq!(parts[1]["type"], "input_audio");
3531
- assert_eq!(parts[1]["input_audio"]["format"], "wav");
3532
- }
3533
-
3534
- #[test]
3535
- fn parses_openai_style_reply_with_tool_calls() {
3536
- let response = json!({
3537
- "choices": [{
3538
- "finish_reason": "tool_calls",
3539
- "message": {
3540
- "content": null,
3541
- "tool_calls": [{
3542
- "id": "call_1",
3543
- "function": { "name": "read_file", "arguments": "{\"path\":\"main.rs\"}" }
3544
- }]
3545
- }
3546
- }]
3547
- });
3548
- let reply = parse_openai_style_reply("gpt-x".into(), &response).unwrap();
3549
- assert_eq!(reply.text, "");
3550
- let calls = reply.tool_calls.unwrap();
3551
- assert_eq!(calls[0].name, "read_file");
3552
- assert_eq!(calls[0].input["path"], "main.rs");
3553
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_calls"));
3554
- }
3555
-
3556
- #[test]
3557
- fn gemini_sanitize_schema_strips_additional_properties_at_any_depth() {
3558
- let schema = json!({
3559
- "type": "object",
3560
- "properties": {
3561
- "emotions": {
3562
- "type": "object",
3563
- "additionalProperties": { "type": "string" }
3564
- },
3565
- "nested": {
3566
- "type": "array",
3567
- "items": {
3568
- "type": "object",
3569
- "additionalProperties": true
3570
- }
3571
- }
3572
- }
3573
- });
3574
- let sanitized = gemini_sanitize_schema(&schema);
3575
- assert!(
3576
- sanitized["properties"]["emotions"]
3577
- .get("additionalProperties")
3578
- .is_none()
3579
- );
3580
- assert!(
3581
- sanitized["properties"]["nested"]["items"]
3582
- .get("additionalProperties")
3583
- .is_none()
3584
- );
3585
- // Everything else is preserved untouched.
3586
- assert_eq!(sanitized["properties"]["emotions"]["type"], "object");
3587
- }
3588
-
3589
- #[test]
3590
- fn gemini_native_payload_strips_additional_properties_from_tool_schemas() {
3591
- let tool = ToolSpec {
3592
- name: "avatar_signal".into(),
3593
- description: "set avatar state".into(),
3594
- parameters: json!({
3595
- "type": "object",
3596
- "properties": {
3597
- "emotions": {
3598
- "type": "object",
3599
- "additionalProperties": { "type": "string" }
3600
- }
3601
- }
3602
- }),
3603
- };
3604
- let request = native_request(
3605
- vec![ChatMessage::text(ChatRole::User, "hi")],
3606
- Some(vec![tool]),
3607
- );
3608
- let payload = gemini_native_payload(&request).unwrap();
3609
- assert!(
3610
- payload["tools"][0]["functionDeclarations"][0]["parameters"]["properties"]["emotions"]
3611
- .get("additionalProperties")
3612
- .is_none()
3613
- );
3614
- }
3615
-
3616
- #[test]
3617
- fn gemini_native_payload_uses_function_declarations_and_resolves_tool_result_names() {
3618
- let messages = vec![
3619
- ChatMessage::text(ChatRole::User, "check weather"),
3620
- ChatMessage {
3621
- role: ChatRole::Assistant,
3622
- content: vec![ContentBlock::ToolUse {
3623
- id: "call_0".into(),
3624
- name: "weather".into(),
3625
- input: json!({ "city": "Bangkok" }),
3626
- thought_signature: None,
3627
- }],
3628
- },
3629
- ChatMessage {
3630
- role: ChatRole::Tool,
3631
- content: vec![ContentBlock::ToolResult {
3632
- tool_use_id: "call_0".into(),
3633
- content: "32C, sunny".into(),
3634
- is_error: false,
3635
- }],
3636
- },
3637
- ];
3638
- let request = native_request(messages, Some(vec![sample_tool()]));
3639
- let payload = gemini_native_payload(&request).unwrap();
3640
-
3641
- assert_eq!(
3642
- payload["tools"][0]["functionDeclarations"][0]["name"],
3643
- "read_file"
3644
- );
3645
- let contents = payload["contents"].as_array().unwrap();
3646
- assert_eq!(contents[1]["role"], "model");
3647
- assert_eq!(contents[1]["parts"][0]["functionCall"]["name"], "weather");
3648
- assert_eq!(contents[2]["role"], "user");
3649
- // Resolved back to the function name "weather", not the call id "call_0".
3650
- assert_eq!(
3651
- contents[2]["parts"][0]["functionResponse"]["name"],
3652
- "weather"
3653
- );
3654
- }
3655
-
3656
- #[test]
3657
- fn gemini_native_payload_replays_the_thought_signature_on_the_next_turn() {
3658
- // Gemini 3.x rejects a replayed `functionCall` that's missing the
3659
- // `thoughtSignature` the model originally attached to it (400
3660
- // INVALID_ARGUMENT) — without this, every second tool-calling turn
3661
- // failed and silently fell back to a different, weaker provider.
3662
- let messages = vec![
3663
- ChatMessage::text(ChatRole::User, "check weather"),
3664
- ChatMessage {
3665
- role: ChatRole::Assistant,
3666
- content: vec![ContentBlock::ToolUse {
3667
- id: "call_0".into(),
3668
- name: "weather".into(),
3669
- input: json!({ "city": "Bangkok" }),
3670
- thought_signature: Some("opaque-signature-abc".into()),
3671
- }],
3672
- },
3673
- ];
3674
- let request = native_request(messages, Some(vec![sample_tool()]));
3675
- let payload = gemini_native_payload(&request).unwrap();
3676
-
3677
- let contents = payload["contents"].as_array().unwrap();
3678
- assert_eq!(
3679
- contents[1]["parts"][0]["thoughtSignature"],
3680
- "opaque-signature-abc"
3681
- );
3682
- // Sibling of `functionCall`, not nested inside it.
3683
- assert_eq!(contents[1]["parts"][0]["functionCall"]["name"], "weather");
3684
- }
3685
-
3686
- #[test]
3687
- fn gemini_native_payload_omits_thought_signature_when_absent() {
3688
- let messages = vec![ChatMessage {
3689
- role: ChatRole::Assistant,
3690
- content: vec![ContentBlock::ToolUse {
3691
- id: "call_0".into(),
3692
- name: "weather".into(),
3693
- input: json!({ "city": "Bangkok" }),
3694
- thought_signature: None,
3695
- }],
3696
- }];
3697
- let request = native_request(messages, Some(vec![sample_tool()]));
3698
- let payload = gemini_native_payload(&request).unwrap();
3699
-
3700
- let contents = payload["contents"].as_array().unwrap();
3701
- assert!(contents[0]["parts"][0].get("thoughtSignature").is_none());
3702
- }
3703
-
3704
- #[test]
3705
- fn parses_gemini_reply_with_function_call_parts() {
3706
- let response = json!({
3707
- "candidates": [{
3708
- "finishReason": "STOP",
3709
- "content": {
3710
- "parts": [
3711
- { "text": "Checking the weather now." },
3712
- { "functionCall": { "name": "weather", "args": { "city": "Bangkok" } } }
3713
- ]
3714
- }
3715
- }]
3716
- });
3717
- let reply = parse_gemini_reply("gemini-x".into(), &response).unwrap();
3718
- assert_eq!(reply.text, "Checking the weather now.");
3719
- let calls = reply.tool_calls.unwrap();
3720
- assert_eq!(calls[0].name, "weather");
3721
- assert_eq!(calls[0].input["city"], "Bangkok");
3722
- }
3723
-
3724
- #[test]
3725
- fn parses_gemini_reply_captures_the_thought_signature_off_the_function_call_part() {
3726
- let response = json!({
3727
- "candidates": [{
3728
- "finishReason": "STOP",
3729
- "content": {
3730
- "parts": [{
3731
- "functionCall": { "name": "weather", "args": { "city": "Bangkok" } },
3732
- "thoughtSignature": "opaque-signature-abc"
3733
- }]
3734
- }
3735
- }]
3736
- });
3737
- let reply = parse_gemini_reply("gemini-x".into(), &response).unwrap();
3738
- let calls = reply.tool_calls.unwrap();
3739
- assert_eq!(
3740
- calls[0].thought_signature.as_deref(),
3741
- Some("opaque-signature-abc")
3742
- );
3743
- }
3744
-
3745
- #[test]
3746
- fn ollama_native_messages_send_tool_call_arguments_as_object_not_string() {
3747
- let messages = vec![
3748
- ChatMessage::text(ChatRole::User, "list files"),
3749
- ChatMessage {
3750
- role: ChatRole::Assistant,
3751
- content: vec![ContentBlock::ToolUse {
3752
- id: "call_1".into(),
3753
- name: "list_files".into(),
3754
- input: json!({ "path": "." }),
3755
- thought_signature: None,
3756
- }],
3757
- },
3758
- ];
3759
- let built = ollama_native_messages(&messages);
3760
- assert_eq!(built[1]["tool_calls"][0]["function"]["name"], "list_files");
3761
- // Ollama expects `arguments` as a JSON object, unlike OpenAI's stringified form.
3762
- assert!(built[1]["tool_calls"][0]["function"]["arguments"].is_object());
3763
- }
3764
-
3765
- #[test]
3766
- fn ollama_native_messages_forward_a_screenshot_riding_a_tool_result_as_a_user_turn() {
3767
- // Ollama's `/api/chat` `tool` role message has no image field, so an
3768
- // `Image` block attached to a tool result (e.g. `browser_screenshot`)
3769
- // must surface as a follow-up `user` message with the `images` field.
3770
- let messages = vec![ChatMessage {
3771
- role: ChatRole::Tool,
3772
- content: vec![
3773
- ContentBlock::ToolResult {
3774
- tool_use_id: "call_1".into(),
3775
- content: "[Screenshot captured — see attached image]".into(),
3776
- is_error: false,
3777
- },
3778
- ContentBlock::Image {
3779
- data_uri: "data:image/png;base64,QUJD".into(),
3780
- },
3781
- ],
3782
- }];
3783
- let built = ollama_native_messages(&messages);
3784
- assert_eq!(built.len(), 2);
3785
- assert_eq!(built[0]["role"], "tool");
3786
- assert_eq!(built[1]["role"], "user");
3787
- assert_eq!(built[1]["images"][0], "QUJD");
3788
- }
3789
-
3790
- #[test]
3791
- fn parses_ollama_reply_from_native_message_envelope() {
3792
- let response = json!({
3793
- "message": {
3794
- "content": "",
3795
- "tool_calls": [{
3796
- "function": { "name": "list_files", "arguments": { "path": "." } }
3797
- }]
3798
- }
3799
- });
3800
- let reply = parse_ollama_reply("llama3.1".into(), &response).unwrap();
3801
- assert_eq!(reply.tool_calls.unwrap()[0].name, "list_files");
3802
- }
3803
-
3804
- #[test]
3805
- fn extracts_input_and_output_tokens_from_each_provider_usage_shape() {
3806
- let anthropic = parse_anthropic_reply(
3807
- "claude-x".into(),
3808
- &json!({
3809
- "content": [{ "type": "text", "text": "hi" }],
3810
- "usage": { "input_tokens": 100, "output_tokens": 20 }
3811
- }),
3812
- )
3813
- .unwrap();
3814
- assert_eq!(
3815
- (anthropic.input_tokens, anthropic.output_tokens),
3816
- (Some(100), Some(20))
3817
- );
3818
-
3819
- let openai = parse_openai_style_reply(
3820
- "gpt-x".into(),
3821
- &json!({
3822
- "choices": [{ "message": { "content": "hi" } }],
3823
- "usage": { "prompt_tokens": 130, "completion_tokens": 20, "total_tokens": 150 }
3824
- }),
3825
- )
3826
- .unwrap();
3827
- assert_eq!(
3828
- (openai.input_tokens, openai.output_tokens),
3829
- (Some(130), Some(20))
3830
- );
3831
-
3832
- let gemini = parse_gemini_reply(
3833
- "gemini-x".into(),
3834
- &json!({
3835
- "candidates": [{ "content": { "parts": [{ "text": "hi" }] } }],
3836
- "usageMetadata": {
3837
- "promptTokenCount": 160,
3838
- "candidatesTokenCount": 40,
3839
- "totalTokenCount": 200
3840
- }
3841
- }),
3842
- )
3843
- .unwrap();
3844
- assert_eq!(
3845
- (gemini.input_tokens, gemini.output_tokens),
3846
- (Some(160), Some(40))
3847
- );
3848
-
3849
- let ollama = parse_ollama_reply(
3850
- "llama3.1".into(),
3851
- &json!({
3852
- "message": { "content": "hi" },
3853
- "prompt_eval_count": 80,
3854
- "eval_count": 40
3855
- }),
3856
- )
3857
- .unwrap();
3858
- assert_eq!(
3859
- (ollama.input_tokens, ollama.output_tokens),
3860
- (Some(80), Some(40))
3861
- );
3862
- }
3863
- #[test]
3864
- fn anthropic_cached_prompt_tokens_count_toward_context_in_json_and_streams() {
3865
- let usage = json!({"input_tokens":100,"cache_read_input_tokens":98_000,"cache_creation_input_tokens":1_000,"output_tokens":400,"cache_creation":{"ephemeral_5m_input_tokens":1_000}});
3866
- let reply = parse_anthropic_reply(
3867
- "claude-test".into(),
3868
- &json!({"content":[{"type":"text","text":"Ready"}],"usage":usage}),
3869
- )
3870
- .unwrap();
3871
- assert_eq!(reply.input_tokens, Some(99_100));
3872
- let mut stream = StreamReplyAccumulator::default();
3873
- stream.consume_anthropic(
3874
- &json!({"type":"message_start","message":{"usage":usage}}),
3875
- &mut |_| {},
3876
- );
3877
- stream.consume_anthropic(
3878
- &json!({"type":"content_block_delta","delta":{"type":"text_delta","text":"Ready"}}),
3879
- &mut |_| {},
3880
- );
3881
- stream.consume_anthropic(
3882
- &json!({"type":"message_delta","usage":{"output_tokens":400}}),
3883
- &mut |_| {},
3884
- );
3885
- let reply = stream.finish("claude-test".into()).unwrap();
3886
- assert_eq!(reply.input_tokens, Some(99_100));
3887
- assert_eq!(reply.output_tokens, Some(400));
3888
- }
3889
-
3890
- #[test]
3891
- fn missing_usage_data_yields_no_token_counts() {
3892
- let reply = parse_openai_style_reply(
3893
- "gpt-x".into(),
3894
- &json!({ "choices": [{ "message": { "content": "hi" } }] }),
3895
- )
3896
- .unwrap();
3897
- assert_eq!((reply.input_tokens, reply.output_tokens), (None, None));
3898
- }
3899
-
3900
- #[tokio::test]
3901
- async fn send_openai_style_request_classifies_a_connect_failure_as_network_unavailable() {
3902
- let client = Client::new();
3903
- // Port 1 is reserved and nothing listens on it, so this fails fast
3904
- // with a connection error rather than timing out.
3905
- let result = send_openai_style_request(
3906
- client.post("http://127.0.0.1:1/chat/completions"),
3907
- "deepseek",
3908
- )
3909
- .await;
3910
- assert!(
3911
- matches!(result, Err(ChatError::NetworkUnavailable)),
3912
- "expected NetworkUnavailable, got {result:?}"
3913
- );
3914
- }
3915
-
3916
- #[tokio::test]
3917
- async fn send_openai_style_request_classifies_402_as_insufficient_balance() {
3918
- use tokio::io::{AsyncReadExt, AsyncWriteExt};
3919
- use tokio::net::TcpListener;
3920
-
3921
- let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3922
- let addr = listener.local_addr().unwrap();
3923
- tokio::spawn(async move {
3924
- let (mut socket, _) = listener.accept().await.unwrap();
3925
- let mut buf = [0u8; 1024];
3926
- let _ = socket.read(&mut buf).await;
3927
- let body = "{}";
3928
- let response = format!(
3929
- "HTTP/1.1 402 Payment Required\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3930
- body.len()
3931
- );
3932
- let _ = socket.write_all(response.as_bytes()).await;
3933
- let _ = socket.shutdown().await;
3934
- });
3935
-
3936
- let client = Client::new();
3937
- let result = send_openai_style_request(
3938
- client.post(format!("http://{addr}/chat/completions")),
3939
- "deepseek",
3940
- )
3941
- .await;
3942
- assert!(
3943
- matches!(&result, Err(ChatError::InsufficientBalance(p)) if p == "deepseek"),
3944
- "expected InsufficientBalance(\"deepseek\"), got {result:?}"
3945
- );
3946
- }
3947
-
3948
- #[test]
3949
- fn fallback_provider_order_moves_gemini_to_the_front() {
3950
- let config = MintConfig {
3951
- anthropic_api_key: "a".into(),
3952
- openai_api_key: "b".into(),
3953
- deepseek_api_key: "c".into(),
3954
- api_key: "d".into(), // gemini
3955
- hf_api_key: "e".into(),
3956
- ..MintConfig::default()
3957
- };
3958
- let order = fallback_provider_order(&config);
3959
- assert_eq!(order[0], "gemini");
3960
- // Everyone else keeps their existing relative order from
3961
- // `available_providers()` — only gemini moves.
3962
- let without_gemini: Vec<&String> = order.iter().skip(1).collect();
3963
- assert_eq!(
3964
- without_gemini,
3965
- vec!["anthropic", "openai", "deepseek", "huggingface", "ollama"]
3966
- );
3967
- }
3968
-
3969
- #[test]
3970
- fn fallback_provider_order_is_unchanged_without_gemini_configured() {
3971
- let config = MintConfig {
3972
- deepseek_api_key: "c".into(),
3973
- hf_api_key: "e".into(),
3974
- ..MintConfig::default()
3975
- };
3976
- assert_eq!(
3977
- fallback_provider_order(&config),
3978
- config.available_providers()
3979
- );
3980
- }
3981
-
3982
- #[test]
3983
- fn openai_payload_includes_smart_temperature_for_deepseek_and_omits_for_o1() {
3984
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
3985
-
3986
- // DeepSeek smart default is 0.6
3987
- let ds_config = MintConfig {
3988
- ai_provider: "deepseek".into(),
3989
- deepseek_model: "deepseek-chat".into(),
3990
- ..MintConfig::default()
3991
- };
3992
- let ds_payload = openai_chat_payload(&ds_config, "deepseek-chat", &request, false).unwrap();
3993
- assert_eq!(ds_payload["temperature"], 0.6);
3994
-
3995
- // Standard OpenAI model default is 0.2
3996
- let openai_config = MintConfig {
3997
- ai_provider: "openai".into(),
3998
- openai_model: "gpt-4o".into(),
3999
- ..MintConfig::default()
4000
- };
4001
- let openai_payload =
4002
- openai_chat_payload(&openai_config, "gpt-4o", &request, false).unwrap();
4003
- assert_eq!(openai_payload["temperature"], 0.2);
4004
-
4005
- // Reasoning models (o1, o3) omit temperature parameter
4006
- let o1_payload =
4007
- openai_chat_payload(&openai_config, "o1-preview", &request, false).unwrap();
4008
- assert!(o1_payload.get("temperature").is_none());
4009
-
4010
- let o3_payload = openai_chat_payload(&openai_config, "o3-mini", &request, false).unwrap();
4011
- assert!(o3_payload.get("temperature").is_none());
4012
-
4013
- // Explicit request temperature override
4014
- let mut override_req = request.clone();
4015
- override_req.temperature = Some(0.75);
4016
- let override_payload =
4017
- openai_chat_payload(&ds_config, "deepseek-chat", &override_req, false).unwrap();
4018
- assert_eq!(override_payload["temperature"], 0.75);
4019
- }
4020
-
4021
- #[test]
4022
- fn anthropic_payload_includes_effective_temperature() {
4023
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
4024
- let config = MintConfig {
4025
- ai_provider: "anthropic".into(),
4026
- anthropic_model: "claude-sonnet-5".into(),
4027
- thinking_enabled: false,
4028
- ..MintConfig::default()
4029
- };
4030
- let payload = anthropic_chat_payload(&config, "claude-sonnet-5", &request, false).unwrap();
4031
- assert_eq!(payload["temperature"], 0.2);
4032
- }
4033
-
4034
- #[test]
4035
- fn parse_openai_style_reply_extracts_deepseek_reasoning_content() {
4036
- let response = json!({
4037
- "choices": [{
4038
- "message": {
4039
- "role": "assistant",
4040
- "content": "",
4041
- "reasoning_content": "Evaluating 15 + 6 * (8 - 3) - 14 / 2 step by step...",
4042
- "tool_calls": [{
4043
- "id": "call_calc_1",
4044
- "type": "function",
4045
- "function": {
4046
- "name": "calculation",
4047
- "arguments": "{\"expression\":\"15 + 6 * (8 - 3) - 14 / 2\"}"
4048
- }
4049
- }]
4050
- },
4051
- "finish_reason": "tool_calls"
4052
- }]
4053
- });
4054
- let reply = parse_openai_style_reply("deepseek-reasoner".into(), &response).unwrap();
4055
- assert_eq!(reply.text, "");
4056
- assert_eq!(
4057
- reply.thought.as_deref(),
4058
- Some("Evaluating 15 + 6 * (8 - 3) - 14 / 2 step by step...")
4059
- );
4060
- let calls = reply.tool_calls.unwrap();
4061
- assert_eq!(calls.len(), 1);
4062
- assert_eq!(calls[0].name, "calculation");
4063
- }
4064
-
4065
- #[test]
4066
- fn parse_openai_style_reply_extracts_openrouter_reasoning() {
4067
- let response = json!({
4068
- "choices": [{
4069
- "message": {
4070
- "role": "assistant",
4071
- "content": "The answer is 38.",
4072
- "reasoning": "First parentheses: 8 - 3 = 5..."
4073
- },
4074
- "finish_reason": "stop"
4075
- }]
4076
- });
4077
- let reply = parse_openai_style_reply("deepseek/deepseek-r1".into(), &response).unwrap();
4078
- assert_eq!(reply.text, "The answer is 38.");
4079
- assert_eq!(
4080
- reply.thought.as_deref(),
4081
- Some("First parentheses: 8 - 3 = 5...")
4082
- );
4083
- }
4084
-
4085
- #[test]
4086
- fn parse_openai_style_reply_extracts_think_tags() {
4087
- let response = json!({
4088
- "choices": [{
4089
- "message": {
4090
- "role": "assistant",
4091
- "content": "<think>\nLet me solve this problem.\n</think>\nThe result is 42."
4092
- },
4093
- "finish_reason": "stop"
4094
- }]
4095
- });
4096
- let reply = parse_openai_style_reply("qwq-32b".into(), &response).unwrap();
4097
- assert_eq!(reply.text, "The result is 42.");
4098
- assert_eq!(reply.thought.as_deref(), Some("Let me solve this problem."));
4099
- }
4100
-
4101
- #[test]
4102
- fn parse_gemini_reply_extracts_thought_parts() {
4103
- let response = json!({
4104
- "candidates": [{
4105
- "content": {
4106
- "parts": [
4107
- { "text": "Thinking through the formula...", "thought": true },
4108
- { "text": "The answer is 38." }
4109
- ]
4110
- },
4111
- "finishReason": "STOP"
4112
- }]
4113
- });
4114
- let reply = parse_gemini_reply("gemini-2.0-flash-thinking".into(), &response).unwrap();
4115
- assert_eq!(reply.text, "The answer is 38.");
4116
- assert_eq!(
4117
- reply.thought.as_deref(),
4118
- Some("Thinking through the formula...")
4119
- );
4120
- }
4121
-
4122
- #[test]
4123
- fn parse_anthropic_reply_extracts_thinking_blocks() {
4124
- let response = json!({
4125
- "content": [
4126
- { "type": "thinking", "thinking": "Let's break down the arithmetic..." },
4127
- { "type": "text", "text": "The result is 38." }
4128
- ],
4129
- "stop_reason": "end_turn"
4130
- });
4131
- let reply = parse_anthropic_reply("claude-3-7-sonnet".into(), &response).unwrap();
4132
- assert_eq!(reply.text, "The result is 38.");
4133
- assert_eq!(
4134
- reply.thought.as_deref(),
4135
- Some("Let's break down the arithmetic...")
4136
- );
4137
- }
4138
-
4139
- #[test]
4140
- fn parse_ollama_reply_extracts_thinking_field() {
4141
- let response = json!({
4142
- "message": {
4143
- "role": "assistant",
4144
- "content": "Result is 38.",
4145
- "thinking": "Ollama internal thinking trace..."
4146
- }
4147
- });
4148
- let reply = parse_ollama_reply("deepseek-r1:8b".into(), &response).unwrap();
4149
- assert_eq!(reply.text, "Result is 38.");
4150
- assert_eq!(
4151
- reply.thought.as_deref(),
4152
- Some("Ollama internal thinking trace...")
4153
- );
4154
- }
4155
- }