@pheem49/mint 1.17.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 (421) 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 -71
  5. package/crates/mint-cli/src/agent/diff_render.rs +66 -44
  6. package/crates/mint-cli/src/agent/live_status.rs +21 -4
  7. package/crates/mint-cli/src/agent/markdown_render.rs +342 -5
  8. package/crates/mint-cli/src/agent.rs +25 -10
  9. package/crates/mint-cli/src/commands/agent.rs +4 -92
  10. package/crates/mint-cli/src/commands/browser.rs +2 -15
  11. package/crates/mint-cli/src/commands/integrations.rs +3 -3
  12. package/crates/mint-cli/src/commands/mod.rs +0 -3
  13. package/crates/mint-cli/src/commands/tailnet_web.rs +1 -0
  14. package/crates/mint-cli/src/interactive/chat_tui/dialog.rs +572 -0
  15. package/crates/mint-cli/src/interactive/chat_tui/mod.rs +1427 -0
  16. package/crates/mint-cli/src/interactive/chat_tui/render.rs +1524 -0
  17. package/crates/mint-cli/src/interactive/chat_tui/selection.rs +401 -0
  18. package/crates/mint-cli/src/interactive/chat_tui/state.rs +1833 -0
  19. package/crates/mint-cli/src/interactive/chat_tui/tests.rs +3414 -0
  20. package/crates/mint-cli/src/interactive/chat_tui/thought.rs +202 -0
  21. package/crates/mint-cli/src/interactive/chat_tui/transcript.rs +728 -0
  22. package/crates/mint-cli/src/interactive/chat_tui/watermark.rs +148 -0
  23. package/crates/mint-cli/src/interactive/command_ui.rs +53 -5
  24. package/crates/mint-cli/src/interactive/composer.rs +186 -0
  25. package/crates/mint-cli/src/interactive/format.rs +78 -3
  26. package/crates/mint-cli/src/interactive/input_box.rs +61 -1
  27. package/crates/mint-cli/src/interactive/resume_picker.rs +367 -25
  28. package/crates/mint-cli/src/interactive/slash_commands.rs +648 -371
  29. package/crates/mint-cli/src/interactive/tool_activity.rs +93 -20
  30. package/crates/mint-cli/src/interactive.rs +37 -0
  31. package/crates/mint-cli/src/main.rs +6 -3
  32. package/crates/mint-cli/src/markdown.rs +2 -37
  33. package/crates/mint-cli/src/onboard.rs +700 -282
  34. package/crates/mint-cli/src/setup.rs +1342 -624
  35. package/crates/mint-cli/src/terminal_theme.rs +159 -35
  36. package/crates/mint-cli/tests/library_intake.rs +1 -0
  37. package/crates/mint-core/src/agent/chat/anthropic.rs +318 -0
  38. package/crates/mint-core/src/agent/chat/custom.rs +149 -0
  39. package/crates/mint-core/src/agent/chat/gemini.rs +518 -0
  40. package/crates/mint-core/src/agent/chat/mod.rs +857 -0
  41. package/crates/mint-core/src/agent/chat/ollama.rs +321 -0
  42. package/crates/mint-core/src/agent/chat/openai.rs +582 -0
  43. package/crates/mint-core/src/agent/chat/streaming.rs +469 -0
  44. package/crates/mint-core/src/agent/chat/tests.rs +1479 -0
  45. package/crates/mint-core/src/agent/memory.rs +56 -2
  46. package/crates/mint-core/src/agent/tasks.rs +2 -2
  47. package/crates/mint-core/src/api_server/routes/chat.rs +24 -2
  48. package/crates/mint-core/src/api_server/routes/gemini_live.rs +14 -1
  49. package/crates/mint-core/src/api_server/routes/html_preview.rs +1 -8
  50. package/crates/mint-core/src/api_server/routes/sessions.rs +21 -0
  51. package/crates/mint-core/src/avatar_bridge.rs +0 -22
  52. package/crates/mint-core/src/browser/cdp.rs +10 -4
  53. package/crates/mint-core/src/browser/headless.rs +582 -0
  54. package/crates/mint-core/src/browser/input.rs +7 -62
  55. package/crates/mint-core/src/browser/interact.rs +0 -14
  56. package/crates/mint-core/src/browser/mod.rs +8 -29
  57. package/crates/mint-core/src/browser/navigate.rs +2 -36
  58. package/crates/mint-core/src/browser/runtime.rs +2 -4
  59. package/crates/mint-core/src/browser/session.rs +50 -466
  60. package/crates/mint-core/src/browser/workflow.rs +142 -32
  61. package/crates/mint-core/src/cron/store.rs +2 -2
  62. package/crates/mint-core/src/integrations/mcp.rs +6 -8
  63. package/crates/mint-core/src/lib.rs +0 -5
  64. package/crates/mint-core/src/media/gemini_live.rs +636 -86
  65. package/crates/mint-core/src/media/library/migration.rs +26 -0
  66. package/crates/mint-core/src/media/library/tests.rs +1 -1
  67. package/crates/mint-core/src/media/library/thumbnails.rs +8 -2
  68. package/crates/mint-core/src/media/pictures.rs +1 -1
  69. package/crates/mint-core/src/media/tool_images.rs +92 -1
  70. package/crates/mint-core/src/mods/manager.rs +101 -35
  71. package/crates/mint-core/src/orchestration/context_render.rs +441 -24
  72. package/crates/mint-core/src/orchestration/decision_parsing.rs +531 -16
  73. package/crates/mint-core/src/orchestration/full_skill_result_tests.rs +12 -0
  74. package/crates/mint-core/src/orchestration/loop_guard.rs +145 -0
  75. package/crates/mint-core/src/orchestration/mcp_outcome_tests.rs +864 -0
  76. package/crates/mint-core/src/orchestration/memory_skill.rs +88 -28
  77. package/crates/mint-core/src/orchestration/mod.rs +320 -4249
  78. package/crates/mint-core/src/orchestration/reliability.rs +67 -27
  79. package/crates/mint-core/src/orchestration/review_regressions.rs +5 -0
  80. package/crates/mint-core/src/orchestration/tests.rs +1665 -0
  81. package/crates/mint-core/src/orchestration/tools/dispatch.rs +967 -0
  82. package/crates/mint-core/src/orchestration/tools/files.rs +1 -0
  83. package/crates/mint-core/src/orchestration/tools/mod.rs +5 -1
  84. package/crates/mint-core/src/orchestration/tools/planning.rs +102 -42
  85. package/crates/mint-core/src/orchestration/tools/shell.rs +129 -58
  86. package/crates/mint-core/src/orchestration/truncation.rs +170 -13
  87. package/crates/mint-core/src/orchestration/types.rs +648 -0
  88. package/crates/mint-core/src/orchestration/verification.rs +0 -3
  89. package/crates/mint-core/src/orchestration/workspace_helpers.rs +24 -6
  90. package/crates/mint-core/src/prompts/agent.rs +52 -76
  91. package/crates/mint-core/src/prompts/tool_catalog.rs +8 -151
  92. package/crates/mint-core/src/search/files.rs +20 -0
  93. package/crates/mint-core/src/slash/mod.rs +228 -265
  94. package/crates/mint-core/src/slash/models.rs +6 -1
  95. package/crates/mint-core/src/system/bg_shell.rs +1 -0
  96. package/crates/mint-core/src/system/config.rs +176 -34
  97. package/crates/mint-mod-runtime/src/lib.rs +37 -23
  98. package/out/web/assets/ArtifactPreviewPanel-Cx6BVOOF.js +2 -0
  99. package/out/web/assets/CalculationCard-D2E74u5q.js +1 -0
  100. package/out/web/assets/CliSessionsView-ZbdtSjvK.css +1 -0
  101. package/out/web/assets/{CliSessionsView-MI8kXMAb.js → CliSessionsView-z5i6qGyb.js} +1 -1
  102. package/out/web/assets/CodeReviewPage-BK2bOScd.css +1 -0
  103. package/out/web/assets/CodeReviewPage-Dp6zHuHu.js +2 -0
  104. package/out/web/assets/{CommandPalette-BD8SlTE5.js → CommandPalette-DaKlzbKs.js} +1 -1
  105. package/out/web/assets/ImageGenCard-BMCJHP9O.js +1 -0
  106. package/out/web/assets/ImageSearchCard-Cld_SOUg.js +1 -0
  107. package/out/web/assets/{ImageStudioPanel-BP7N4rLr.js → ImageStudioPanel-B5N7TjeB.js} +1 -1
  108. package/out/web/assets/LibraryPage-CMB89rJ0.css +1 -0
  109. package/out/web/assets/LibraryPage-DbZEi4W_.js +2 -0
  110. package/out/web/assets/LinkedFoldersView-9B9DIPoZ.js +1 -0
  111. package/out/web/assets/{McpRegistryPicker-CoS83vtc.js → McpRegistryPicker-DnXPlzI7.js} +1 -1
  112. package/out/web/assets/McpServersView-CiNBNUl4.js +1 -0
  113. package/out/web/assets/MermaidCard-BBDNEXbY.js +2 -0
  114. package/out/web/assets/MintDashboard-C_UYPoNV.js +119 -0
  115. package/out/web/assets/MintDashboard-DTd8C_c8.css +1 -0
  116. package/out/web/assets/PlanCard-C2x5pajq.js +1 -0
  117. package/out/web/assets/{PluginsView-jBYmcKh4.js → PluginsView-DoTg9MVa.js} +1 -1
  118. package/out/web/assets/RewindModal-B5oowrR0.js +1 -0
  119. package/out/web/assets/{ScheduledTasksView-5uw9-V9H.js → ScheduledTasksView-BD5DVPkZ.js} +1 -1
  120. package/out/web/assets/{SelectField-DXBQtrUK.js → SelectField-Cy6CghrU.js} +1 -1
  121. package/out/web/assets/SettingsWindow-BYl_oCFh.js +4 -0
  122. package/out/web/assets/SettingsWindow-Ckcgnjt8.css +1 -0
  123. package/out/web/assets/{SkillsView-C-gp9J7m.js → SkillsView-CIeWwq5z.js} +1 -1
  124. package/out/web/assets/StockCard-DxUciKyp.js +1 -0
  125. package/out/web/assets/ToolSurfacePage-qBgs_VHq.css +1 -0
  126. package/out/web/assets/ToolSurfacePage-saVbVrkE.js +38 -0
  127. package/out/web/assets/{UiFeatureCard-6BwxTOCe.js → UiFeatureCard-CdY0hGWy.js} +1 -1
  128. package/out/web/assets/{UiGridCard-DvZcLNxb.js → UiGridCard-BBK3I5hC.js} +1 -1
  129. package/out/web/assets/UiMockupWidget-CpR9EDuc.js +1 -0
  130. package/out/web/assets/{VeoStudioPanel-DOj7_WR3.css → VeoStudioPanel-9kU9YLEN.css} +1 -1
  131. package/out/web/assets/{VeoStudioPanel-DfsOJKv0.js → VeoStudioPanel-Co6-wEWM.js} +2 -2
  132. package/out/web/assets/WeatherCard-Buzi7Vfu.js +1 -0
  133. package/out/web/assets/WorkspacePdfPreview-Bi8Zzyir.js +2 -0
  134. package/out/web/assets/{abnfDiagram-VCTEODGH-BOo3Ckng.js → abnfDiagram-VCTEODGH-CMfR95N2.js} +1 -1
  135. package/out/web/assets/{architecture-7GRP2DOG-ZOwheW3n.js → architecture-7GRP2DOG-DhAM4HyO.js} +1 -1
  136. package/out/web/assets/{architectureDiagram-5GKGNRK7-DC2FDzVd.js → architectureDiagram-5GKGNRK7-Yk3TUJ_F.js} +1 -1
  137. package/out/web/assets/{blockDiagram-I7D4REHJ-BrKFWdAH.js → blockDiagram-I7D4REHJ-X9fBgJik.js} +1 -1
  138. package/out/web/assets/{c4Diagram-7LVT6UL2-BHTHK1Yd.js → c4Diagram-7LVT6UL2-Bk9x7N-8.js} +1 -1
  139. package/out/web/assets/{chunk-4HAMMTFA-BYFkZwNj.js → chunk-4HAMMTFA-Dx5fk1_0.js} +1 -1
  140. package/out/web/assets/{chunk-75Z2AOVW-BSKfSKSA.js → chunk-75Z2AOVW-BszTQoM4.js} +1 -1
  141. package/out/web/assets/{chunk-DU6HZSFF-vIhsiW6n.js → chunk-DU6HZSFF-DK-As3rk.js} +1 -1
  142. package/out/web/assets/{chunk-F27PBJKO-BlYpW9xq.js → chunk-F27PBJKO-LmncZZIS.js} +1 -1
  143. package/out/web/assets/{chunk-GMAD6QVW-BIBz_Ug5.js → chunk-GMAD6QVW-DSQ_sDRJ.js} +1 -1
  144. package/out/web/assets/{chunk-GVQU2GXP-NPSHxeqC.js → chunk-GVQU2GXP-Cno3ulje.js} +1 -1
  145. package/out/web/assets/{chunk-IMKFNOWR-YlJK8_Fp.js → chunk-IMKFNOWR-bIzfY9Jc.js} +1 -1
  146. package/out/web/assets/{chunk-L3NEJ4N5-DMYi4-af.js → chunk-L3NEJ4N5-Dl6eOa0D.js} +1 -1
  147. package/out/web/assets/{chunk-OSK3NFVY-Begd3-jA.js → chunk-OSK3NFVY-BaUGJ-cM.js} +1 -1
  148. package/out/web/assets/{chunk-P2QGCYS3-D1I4xFHN.js → chunk-P2QGCYS3-_066ndhH.js} +1 -1
  149. package/out/web/assets/{chunk-POPQ4Y6H-CafZZzNE.js → chunk-POPQ4Y6H-YT1AKhTv.js} +1 -1
  150. package/out/web/assets/{chunk-PWAF6VOD-Dwh6_Lnt.js → chunk-PWAF6VOD-CrEgDsxh.js} +1 -1
  151. package/out/web/assets/{chunk-SHT3W25Y-B0XrCSEb.js → chunk-SHT3W25Y-_KwaRLOl.js} +1 -1
  152. package/out/web/assets/{chunk-SVP7TREG-CxLsddxC.js → chunk-SVP7TREG-BQ1qb32L.js} +1 -1
  153. package/out/web/assets/{chunk-TICWLB2K-y156QInb.js → chunk-TICWLB2K-BDyehCtg.js} +1 -1
  154. package/out/web/assets/classDiagram-ZZMXUADV-BoSpzRCJ.js +1 -0
  155. package/out/web/assets/classDiagram-v2-VYDZK3BY-BoSpzRCJ.js +1 -0
  156. package/out/web/assets/{config-DqXJXeZM.css → config-2suVFPZg.css} +1 -1
  157. package/out/web/assets/config-CkRCdkxn.js +2 -0
  158. package/out/web/assets/{cynefin-OW5HDTMX-SMVvrLf-.js → cynefin-OW5HDTMX-Dl_31aaN.js} +1 -1
  159. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-rZznN24T.js → cynefinDiagram-5FMLGOSQ-Bpir8jha.js} +1 -1
  160. package/out/web/assets/{dagre-GXQ25YYZ-BOToydFu.js → dagre-GXQ25YYZ-D9xu3nsq.js} +1 -1
  161. package/out/web/assets/{diagram-S7CK7UJ4-TMVaGQEJ.js → diagram-S7CK7UJ4-DeveoEeB.js} +1 -1
  162. package/out/web/assets/{diagram-UQ7AKVKN-C2JK-GP2.js → diagram-UQ7AKVKN-p4xWRESV.js} +1 -1
  163. package/out/web/assets/{diagram-VSXAHHWV-Cw8VSi2K.js → diagram-VSXAHHWV-C7qz9NDF.js} +1 -1
  164. package/out/web/assets/{diagram-VX7I27RA-DXp4fKk9.js → diagram-VX7I27RA-DRiCa3Vd.js} +1 -1
  165. package/out/web/assets/{diagram-Z3DM3KII-CzSicYiW.js → diagram-Z3DM3KII-Bt7zduQX.js} +1 -1
  166. package/out/web/assets/{ebnfDiagram-PWID7BFC-Dqj-zqPu.js → ebnfDiagram-PWID7BFC-DV9WpwdR.js} +1 -1
  167. package/out/web/assets/{erDiagram-RLTQ6QDP-DZwFZAuD.js → erDiagram-RLTQ6QDP-BJ_nwG2m.js} +1 -1
  168. package/out/web/assets/{eventmodeling-NTZA5JFV-C0O-Lvgp.js → eventmodeling-NTZA5JFV-BTBMrfVa.js} +1 -1
  169. package/out/web/assets/flowDiagram-HODETNUW-Cdlq3cJL.js +1 -0
  170. package/out/web/assets/{ganttDiagram-EL5Y4UJY-Da4ULXTl.js → ganttDiagram-EL5Y4UJY-DQLdsXrC.js} +1 -1
  171. package/out/web/assets/{gitGraph-4MIJSDKK-B9wNFZIP.js → gitGraph-4MIJSDKK-CL1OEOQ_.js} +1 -1
  172. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-RjrhB0MP.js → gitGraphDiagram-WWUBYQGX-8cSi42H7.js} +1 -1
  173. package/out/web/assets/index-BocxnrDG.js +168 -0
  174. package/out/web/assets/index-yeh_r55a.css +1 -0
  175. package/out/web/assets/{info-A6RAGUB7-B3dfCpeY.js → info-A6RAGUB7-C1zsX5sc.js} +1 -1
  176. package/out/web/assets/{infoDiagram-27XIBGKW-QVWkLVx7.js → infoDiagram-27XIBGKW-DsRYtIyI.js} +1 -1
  177. package/out/web/assets/{ishikawaDiagram-5VMMS53U-CLFNIRI9.js → ishikawaDiagram-5VMMS53U-B2crwIXj.js} +1 -1
  178. package/out/web/assets/{journeyDiagram-3NMN7TZE-fgOQadYF.js → journeyDiagram-3NMN7TZE-BTH-GyVR.js} +1 -1
  179. package/out/web/assets/{kanban-definition-UXKFOSKX-G3Q_9g9O.js → kanban-definition-UXKFOSKX-BeRfCnuA.js} +1 -1
  180. package/out/web/assets/{mermaid-parser.core-v3Jm7nco.js → mermaid-parser.core-BJwgCBBc.js} +3 -3
  181. package/out/web/assets/{mermaid.core-DTEjLUA1.js → mermaid.core-CsDDodN8.js} +4 -4
  182. package/out/web/assets/{mindmap-definition-YA3MSWOX-C_D42_fF.js → mindmap-definition-YA3MSWOX-B42efOQ1.js} +1 -1
  183. package/out/web/assets/{modelManager-BPLycQrN.js → modelManager-B3ZFMh8v.js} +2 -2
  184. package/out/web/assets/{oauthManager-BUK7ic7q.js → oauthManager-Ch5Du2fF.js} +1 -1
  185. package/out/web/assets/{packet-AYTQ26CC-B-R-SHlF.js → packet-AYTQ26CC-B6xUyE6r.js} +1 -1
  186. package/out/web/assets/{pegDiagram-XKGWAZYB-Da2q_giz.js → pegDiagram-XKGWAZYB-1LabVycL.js} +1 -1
  187. package/out/web/assets/{pie-WAS4IAKB-BC8F0N7-.js → pie-WAS4IAKB-DJwo-nf_.js} +1 -1
  188. package/out/web/assets/{pieDiagram-E7YTZNPT-Iy9REJNC.js → pieDiagram-E7YTZNPT-icmAqzy4.js} +1 -1
  189. package/out/web/assets/{quadrantDiagram-AXDQQJYC-ztsiYw7h.js → quadrantDiagram-AXDQQJYC-CZ-wEx41.js} +1 -1
  190. package/out/web/assets/{radar-RG4KPBEZ-B_1P8Z_z.js → radar-RG4KPBEZ-D7d-Xriw.js} +1 -1
  191. package/out/web/assets/{railroad-74A4TZTK-DuTqczSo.js → railroad-74A4TZTK-viuhdYbs.js} +1 -1
  192. package/out/web/assets/{railroad-abnf-HS5TGJTU-Df29WLmp.js → railroad-abnf-HS5TGJTU-DNXiCNea.js} +1 -1
  193. package/out/web/assets/{railroad-ebnf-LZEXJU2U-ePFMo-qp.js → railroad-ebnf-LZEXJU2U-nkSu1-0C.js} +1 -1
  194. package/out/web/assets/{railroad-peg-WCYAUIDC-DYXLW4_E.js → railroad-peg-WCYAUIDC-BhbMeQzz.js} +1 -1
  195. package/out/web/assets/{railroadDiagram-O6MQD6OU-LStnZf2S.js → railroadDiagram-O6MQD6OU-CLpG1jUX.js} +1 -1
  196. package/out/web/assets/{requirementDiagram-BXWQKSXE-DmzonMH-.js → requirementDiagram-BXWQKSXE-kRDrvWrc.js} +1 -1
  197. package/out/web/assets/{sankeyDiagram-P5KCCOFB-B8vjmUU1.js → sankeyDiagram-P5KCCOFB-DxAwmq4x.js} +1 -1
  198. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-CTnplOAX.js → sequenceDiagram-WJ2MYXX4-aLgY-6Wz.js} +1 -1
  199. package/out/web/assets/{stateDiagram-D77RDMKH-BFrIaLdI.js → stateDiagram-D77RDMKH-D5QWfOhp.js} +1 -1
  200. package/out/web/assets/stateDiagram-v2-MP3YSRHH-D6OiZoAP.js +1 -0
  201. package/out/web/assets/{swimlanes-42K2YHIH-BXGngUNU.js → swimlanes-42K2YHIH-CRs5UyA-.js} +1 -1
  202. package/out/web/assets/swimlanesDiagram-VR7AAH4N-UgEXIsV0.js +8 -0
  203. package/out/web/assets/syntaxHighlight-C-wAGT84.js +3 -0
  204. package/out/web/assets/{timeline-definition-24CTP7MA-CegH7n43.js → timeline-definition-24CTP7MA-Denqd9qb.js} +1 -1
  205. package/out/web/assets/{treeView-Q6P3EWNA-DTpJv2Tw.js → treeView-Q6P3EWNA-0my3Z3s4.js} +1 -1
  206. package/out/web/assets/{treemap-WGGIJYW6-i8M9iJU6.js → treemap-WGGIJYW6-B_EjkLYq.js} +1 -1
  207. package/out/web/assets/{vennDiagram-4TSXK5OY-Dpzo1S8U.js → vennDiagram-4TSXK5OY-DxVg3Ewr.js} +1 -1
  208. package/out/web/assets/{wardley-WFR3VGLG-BrNsgtfw.js → wardley-WFR3VGLG-CGD-Tj4p.js} +1 -1
  209. package/out/web/assets/{wardleyDiagram-VM6X3IG4-lQ92LFE1.js → wardleyDiagram-VM6X3IG4-CmfS39Xm.js} +1 -1
  210. package/out/web/assets/{xychartDiagram-S5SC5T6Z-BaOaEoBd.js → xychartDiagram-S5SC5T6Z-BVM9bKis.js} +1 -1
  211. package/out/web/index-web.html +2 -2
  212. package/out/web/index.html +2 -2
  213. package/package.json +2 -2
  214. package/shared-modules/CODEOWNERS +3 -3
  215. package/shared-modules/SDL2/SDL2_mixer.json +27 -0
  216. package/shared-modules/appstream/README.md +13 -0
  217. package/shared-modules/appstream/appstream-compose.json +111 -0
  218. package/shared-modules/dbus-glib/dbus-glib.json +2 -2
  219. package/shared-modules/libportal/libportal.json +23 -0
  220. package/shared-modules/linux-audio/fluidsynth2-static.json +2 -2
  221. package/shared-modules/linux-audio/fluidsynth2.json +2 -2
  222. package/shared-modules/linux-audio/libinstpatch.json +2 -2
  223. package/shared-modules/linux-audio/liblo-static.json +3 -0
  224. package/shared-modules/luajit/luajit.json +2 -2
  225. package/shared-modules/squashfs-tools/squashfs-tools.json +0 -3
  226. package/slash-commands.json +7 -2
  227. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  228. package/src/renderer/shared/agentProgress.ts +51 -3
  229. package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -56
  230. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +15 -1
  231. package/src/renderer/shared/components/Settings/GeneralTab.tsx +25 -12
  232. package/src/renderer/shared/components/Settings/ThemeTab.test.tsx +45 -0
  233. package/src/renderer/shared/components/Settings/ThemeTab.tsx +106 -3
  234. package/src/renderer/shared/components/activity/AgentActivityDrawer.tsx +67 -17
  235. package/src/renderer/shared/components/activity/AgentActivityTable.tsx +376 -134
  236. package/src/renderer/shared/components/activity/AskUserActivityCard.test.tsx +122 -0
  237. package/src/renderer/shared/components/activity/AskUserActivityCard.tsx +70 -0
  238. package/src/renderer/shared/components/activity/McpToolResults.tsx +39 -2
  239. package/src/renderer/shared/components/activity/SubagentDagView.tsx +7 -7
  240. package/src/renderer/shared/components/activity/ViewedImages.tsx +41 -2
  241. package/src/renderer/shared/components/app/ChunkErrorBoundary.tsx +3 -3
  242. package/src/renderer/shared/components/app/CommandPalette.tsx +7 -7
  243. package/src/renderer/shared/components/app/DesktopTitlebar.tsx +2 -2
  244. package/src/renderer/shared/components/app/MintDashboard.tsx +27 -17
  245. package/src/renderer/shared/components/app/ModalErrorBoundary.tsx +3 -3
  246. package/src/renderer/shared/components/cards/CalculationCard.tsx +3 -3
  247. package/src/renderer/shared/components/cards/ImageGenCard.tsx +2 -2
  248. package/src/renderer/shared/components/cards/ImageSearchCard.tsx +2 -2
  249. package/src/renderer/shared/components/cards/MermaidCard.tsx +1 -1
  250. package/src/renderer/shared/components/cards/PlanCard.test.tsx +41 -0
  251. package/src/renderer/shared/components/cards/PlanCard.tsx +32 -0
  252. package/src/renderer/shared/components/cards/ReleaseNotesCard.test.tsx +29 -0
  253. package/src/renderer/shared/components/cards/ReleaseNotesCard.tsx +137 -0
  254. package/src/renderer/shared/components/cards/StockCard.tsx +7 -7
  255. package/src/renderer/shared/components/cards/ToolResultCards.tsx +11 -3
  256. package/src/renderer/shared/components/cards/UiMockupWidget.tsx +4 -4
  257. package/src/renderer/shared/components/cards/WeatherCard.tsx +5 -5
  258. package/src/renderer/shared/components/chat/ApprovalCard.test.tsx +11 -0
  259. package/src/renderer/shared/components/chat/ApprovalCard.tsx +21 -2
  260. package/src/renderer/shared/components/chat/ArtifactCards.test.tsx +69 -0
  261. package/src/renderer/shared/components/chat/ArtifactCards.tsx +238 -0
  262. package/src/renderer/shared/components/chat/AttachedDocumentCard.test.tsx +39 -0
  263. package/src/renderer/shared/components/chat/AttachedDocumentCard.tsx +220 -0
  264. package/src/renderer/shared/components/chat/ChatCodeBlock.test.tsx +33 -0
  265. package/src/renderer/shared/components/chat/ChatCodeBlock.tsx +15 -1
  266. package/src/renderer/shared/components/chat/ChatMessageItem.test.tsx +40 -2
  267. package/src/renderer/shared/components/chat/ChatMessageItem.tsx +180 -34
  268. package/src/renderer/shared/components/chat/ChatModeSelector.css +227 -0
  269. package/src/renderer/shared/components/chat/ChatModeSelector.test.tsx +45 -0
  270. package/src/renderer/shared/components/chat/ChatModeSelector.tsx +198 -0
  271. package/src/renderer/shared/components/chat/ChatPanel.test.tsx +22 -0
  272. package/src/renderer/shared/components/chat/ChatPanel.tsx +227 -53
  273. package/src/renderer/shared/components/chat/DiffReviewModal.tsx +6 -6
  274. package/src/renderer/shared/components/chat/GeminiLiveOverlay.test.tsx +142 -1
  275. package/src/renderer/shared/components/chat/GeminiLiveOverlay.tsx +45 -3
  276. package/src/renderer/shared/components/chat/ModelSelectorPopover.tsx +42 -14
  277. package/src/renderer/shared/components/chat/RewindModal.tsx +10 -10
  278. package/src/renderer/shared/components/chat/SlashSuggestions.test.tsx +14 -0
  279. package/src/renderer/shared/components/chat/SourcesBlock.test.tsx +53 -0
  280. package/src/renderer/shared/components/chat/SourcesBlock.tsx +236 -90
  281. package/src/renderer/shared/components/chat/TurnFileChanges.test.tsx +45 -1
  282. package/src/renderer/shared/components/chat/TurnFileChanges.tsx +202 -169
  283. package/src/renderer/shared/components/extensions/CliSessionsView.tsx +5 -4
  284. package/src/renderer/shared/components/extensions/McpRegistryPicker.tsx +3 -3
  285. package/src/renderer/shared/components/studio/LibraryPage.tsx +48 -5
  286. package/src/renderer/shared/components/workspace/ArtifactPreviewPanel.tsx +125 -13
  287. package/src/renderer/shared/components/workspace/CodeReviewPage.test.tsx +50 -0
  288. package/src/renderer/shared/components/workspace/CodeReviewPage.tsx +77 -16
  289. package/src/renderer/shared/components/workspace/GitBranchSelector.tsx +78 -22
  290. package/src/renderer/shared/components/workspace/ToolSurfacePage.test.tsx +13 -0
  291. package/src/renderer/shared/components/workspace/ToolSurfacePage.tsx +48 -28
  292. package/src/renderer/shared/components/workspace/WorkspacePdfPreview.tsx +266 -47
  293. package/src/renderer/shared/components/workspace/WorkspacePreviewActions.test.tsx +18 -6
  294. package/src/renderer/shared/components/workspace/background-terminals.css +11 -11
  295. package/src/renderer/shared/constants/config.ts +1 -5
  296. package/src/renderer/shared/constants/models.test.ts +51 -0
  297. package/src/renderer/shared/constants/models.ts +139 -32
  298. package/src/renderer/shared/conversation/lifecycle.test.ts +91 -3
  299. package/src/renderer/shared/conversation/lifecycle.ts +104 -5
  300. package/src/renderer/shared/conversation/progressiveStream.test.ts +83 -0
  301. package/src/renderer/shared/conversation/progressiveStream.ts +219 -0
  302. package/src/renderer/shared/css/agent-observability.css +133 -0
  303. package/src/renderer/shared/css/chat-ui-cards.css +13 -13
  304. package/src/renderer/shared/css/code-review.css +63 -34
  305. package/src/renderer/shared/css/code-sessions.css +47 -18
  306. package/src/renderer/shared/css/collapsed-paste.css +1 -1
  307. package/src/renderer/shared/css/error-notice.css +1 -1
  308. package/src/renderer/shared/css/file-changes.css +165 -80
  309. package/src/renderer/shared/css/library.css +105 -10
  310. package/src/renderer/shared/css/management-views.css +1 -1
  311. package/src/renderer/shared/css/mcp-results.css +5 -5
  312. package/src/renderer/shared/css/model-selector.css +18 -18
  313. package/src/renderer/shared/css/mods.css +7 -7
  314. package/src/renderer/shared/css/notification-notice.css +2 -2
  315. package/src/renderer/shared/css/queued-messages.css +5 -5
  316. package/src/renderer/shared/css/settings/general.css +18 -13
  317. package/src/renderer/shared/css/settings/profile.css +5 -5
  318. package/src/renderer/shared/css/settings/theme.css +5 -5
  319. package/src/renderer/shared/css/slash-choice.css +2 -2
  320. package/src/renderer/shared/css/slash-suggestions.css +3 -3
  321. package/src/renderer/shared/css/sources-block.css +41 -2
  322. package/src/renderer/shared/css/tools-panel.css +2 -2
  323. package/src/renderer/shared/css/ui-tailwind.css +8 -0
  324. package/src/renderer/shared/css/veo-studio.css +2 -2
  325. package/src/renderer/shared/css/viewed-images.css +7 -7
  326. package/src/renderer/shared/platform.ts +2 -1
  327. package/src/renderer/shared/theme/themeManager.test.ts +17 -0
  328. package/src/renderer/shared/theme/themeManager.ts +21 -4
  329. package/src/renderer/shared/theme/themes.ts +54 -0
  330. package/src/renderer/shared/theme/tokens.css +6 -4
  331. package/src/renderer/shared/types.ts +1 -1
  332. package/src/renderer/shared/utils/agentActivity.test.ts +468 -2
  333. package/src/renderer/shared/utils/agentActivity.ts +602 -35
  334. package/src/renderer/shared/utils/artifactBrowser.test.ts +37 -32
  335. package/src/renderer/shared/utils/artifactBrowser.ts +17 -19
  336. package/src/renderer/shared/utils/attachedDocument.test.ts +73 -0
  337. package/src/renderer/shared/utils/attachedDocument.ts +49 -0
  338. package/src/renderer/shared/utils/fileIcons.test.ts +59 -0
  339. package/src/renderer/shared/utils/fileIcons.ts +149 -16
  340. package/src/renderer/shared/utils/markdown.tsx +72 -19
  341. package/src/renderer/shared/utils/markdown.webImages.test.tsx +16 -0
  342. package/src/renderer/shared/utils/mcpResults.test.tsx +29 -0
  343. package/src/renderer/shared/utils/slashCommandProcessor.ts +4 -1
  344. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +11 -1
  345. package/src/renderer/shared/utils/syntaxHighlight.tsx +51 -3
  346. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +324 -51
  347. package/src/renderer/shared/utils/workspaceDrag.test.ts +153 -0
  348. package/src/renderer/shared/utils/workspaceDrag.ts +142 -0
  349. package/src/renderer/shared/utils/workspaceMedia.ts +10 -2
  350. package/src/renderer/src/App.tsx +14 -8
  351. package/src/renderer/src/agentProgress.ts +1 -0
  352. package/src/renderer/src/components/ScreenPicker.css +1 -1
  353. package/src/renderer/src/components/WorkspacePanel.tsx +6 -5
  354. package/src/renderer/src/css/app-shell.css +4 -4
  355. package/src/renderer/src/css/chat.css +1261 -241
  356. package/src/renderer/src/css/desktop-tools.css +11 -11
  357. package/src/renderer/src/css/sidebar-workspace.css +2 -2
  358. package/src/renderer/src/css/tool-surfaces.css +12 -12
  359. package/src/renderer/src/index.css +38 -20
  360. package/src/renderer/src/tauri.ts +22 -2
  361. package/src/renderer/src-web/App.tsx +14 -0
  362. package/src/renderer/src-web/agentProgress.ts +1 -0
  363. package/src/renderer/src-web/css/app-shell.css +4 -4
  364. package/src/renderer/src-web/css/chat.css +1278 -190
  365. package/src/renderer/src-web/css/mobile.css +29 -13
  366. package/src/renderer/src-web/css/settings/mobile.css +2 -2
  367. package/src/renderer/src-web/css/sidebar-workspace.css +1 -1
  368. package/src/renderer/src-web/index.css +38 -20
  369. package/src/renderer/src-web/tauri.ts +22 -2
  370. package/src/renderer/tests/browserArtifacts.browser.tsx +1 -1
  371. package/src/renderer/tests/library.browser.tsx +3 -0
  372. package/src/renderer/tests/liveVoice.browser.html +1 -0
  373. package/src/renderer/tests/workspaceDrop.browser.tsx +1 -1
  374. package/src-tauri/src/app/lifecycle.rs +1 -14
  375. package/src-tauri/src/background/headless.rs +14 -8
  376. package/src-tauri/src/commands/conversation.rs +38 -0
  377. package/src-tauri/src/native/mod.rs +0 -1
  378. package/src-tauri/src/native/system.rs +4 -47
  379. package/crates/mint-cli/src/interactive/chat_tui.rs +0 -8855
  380. package/crates/mint-core/src/agent/chat.rs +0 -4394
  381. package/crates/mint-core/src/browser/lifecycle.rs +0 -291
  382. package/crates/mint-core/src/browser/logging.rs +0 -20
  383. package/crates/mint-core/src/orchestration/tools/browser.rs +0 -31
  384. package/out/web/assets/ArtifactPreviewPanel-S0dPfYFx.js +0 -2
  385. package/out/web/assets/CalculationCard-CRjP3E1j.js +0 -1
  386. package/out/web/assets/CliSessionsView-C3Br2v5Y.css +0 -1
  387. package/out/web/assets/CodeReviewPage-CUJceQme.css +0 -1
  388. package/out/web/assets/CodeReviewPage-VCUgPla_.js +0 -2
  389. package/out/web/assets/ImageGenCard-BCXxkVJX.js +0 -1
  390. package/out/web/assets/ImageSearchCard-N3yQOr8Z.js +0 -1
  391. package/out/web/assets/LibraryPage-DAxUTAg7.css +0 -1
  392. package/out/web/assets/LibraryPage-WlviRdR8.js +0 -2
  393. package/out/web/assets/LinkedFoldersView-CKRgroWK.js +0 -1
  394. package/out/web/assets/McpServersView-D3wHnuHg.js +0 -1
  395. package/out/web/assets/MermaidCard-NqrHdyJW.js +0 -2
  396. package/out/web/assets/MintDashboard-B2DJsDDO.css +0 -1
  397. package/out/web/assets/MintDashboard-COw_xol7.js +0 -63
  398. package/out/web/assets/RewindModal-DempuDod.js +0 -1
  399. package/out/web/assets/SettingsWindow-2YP3yj7s.js +0 -4
  400. package/out/web/assets/SettingsWindow-Dz8LQVgD.css +0 -1
  401. package/out/web/assets/StockCard-BDMCe7mx.js +0 -1
  402. package/out/web/assets/ToolSurfacePage-B0J_q0Jl.js +0 -38
  403. package/out/web/assets/ToolSurfacePage-CRIQ1owD.css +0 -1
  404. package/out/web/assets/UiMockupWidget-DD73QWg1.js +0 -1
  405. package/out/web/assets/WeatherCard-CPucIfq8.js +0 -1
  406. package/out/web/assets/WorkspacePdfPreview-Dx_lkjNu.js +0 -2
  407. package/out/web/assets/classDiagram-ZZMXUADV-BgJ5WtSp.js +0 -1
  408. package/out/web/assets/classDiagram-v2-VYDZK3BY-BgJ5WtSp.js +0 -1
  409. package/out/web/assets/config-CvIO5hkn.js +0 -2
  410. package/out/web/assets/flowDiagram-HODETNUW-uqCHJond.js +0 -1
  411. package/out/web/assets/index-BmpulIkg.js +0 -168
  412. package/out/web/assets/index-CkvBwhX_.css +0 -1
  413. package/out/web/assets/stateDiagram-v2-MP3YSRHH-uf3kdNW4.js +0 -1
  414. package/out/web/assets/swimlanesDiagram-VR7AAH4N-Y8q5bQce.js +0 -8
  415. package/shared-modules/libsecret/libsecret.json +0 -23
  416. package/shared-modules/libusb/libusb.json +0 -27
  417. package/shared-modules/lzo/lzo.json +0 -27
  418. package/src/renderer/src/components/BrowserShell.tsx +0 -108
  419. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +0 -91
  420. package/src/renderer/src/components/browser-shell.css +0 -185
  421. package/src-tauri/src/native/browser.rs +0 -252
@@ -1,4394 +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) => sanitize_and_parse_tool_arguments(raw).unwrap_or_else(|| arguments.clone()),
997
- None => arguments.clone(),
998
- }
999
- }
1000
-
1001
- /// Sanitizes LLM tool call argument string: strips markdown fences, DeepSeek DSML tokens,
1002
- /// thinking tags, extracts inner JSON objects, and handles trailing commas before failing.
1003
- fn sanitize_and_parse_tool_arguments(raw: &str) -> Option<Value> {
1004
- let trimmed = raw.trim();
1005
- if trimmed.is_empty() {
1006
- return None;
1007
- }
1008
- if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
1009
- return Some(val);
1010
- }
1011
-
1012
- // 1. Strip markdown code fences if wrapped in ```json ... ```
1013
- let without_fences = if let Some(stripped) = trimmed.strip_prefix("```json") {
1014
- stripped.strip_suffix("```").unwrap_or(stripped).trim()
1015
- } else if let Some(stripped) = trimmed.strip_prefix("```") {
1016
- stripped.strip_suffix("```").unwrap_or(stripped).trim()
1017
- } else {
1018
- trimmed
1019
- };
1020
- if let Ok(val) = serde_json::from_str::<Value>(without_fences) {
1021
- return Some(val);
1022
- }
1023
-
1024
- // 2. Strip known special tokens (DeepSeek DSML markers, thinking tags, end of sentence markers)
1025
- let cleaned = without_fences
1026
- .replace("</||DSML||", "")
1027
- .replace("<||DSML||", "")
1028
- .replace("<|end of sentence|>", "")
1029
- .replace("</think>", "");
1030
-
1031
- if let Ok(val) = serde_json::from_str::<Value>(&cleaned) {
1032
- return Some(val);
1033
- }
1034
-
1035
- // 3. Extract JSON object boundary between first '{' and last '}'
1036
- if let (Some(start), Some(end)) = (cleaned.find('{'), cleaned.rfind('}')) {
1037
- if start < end {
1038
- let slice = &cleaned[start..=end];
1039
- if let Ok(val) = serde_json::from_str::<Value>(slice) {
1040
- return Some(val);
1041
- }
1042
- // 4. Strip trailing commas before closing braces/brackets
1043
- let without_trailing_commas = strip_json_trailing_commas(slice);
1044
- if let Ok(val) = serde_json::from_str::<Value>(&without_trailing_commas) {
1045
- return Some(val);
1046
- }
1047
- }
1048
- }
1049
-
1050
- None
1051
- }
1052
-
1053
- /// Simple non-destructive removal of trailing commas before closing braces/brackets
1054
- fn strip_json_trailing_commas(input: &str) -> String {
1055
- let mut out = String::with_capacity(input.len());
1056
- let mut chars = input.chars().peekable();
1057
- let mut in_str = false;
1058
- let mut escape = false;
1059
-
1060
- while let Some(ch) = chars.next() {
1061
- if in_str {
1062
- if escape {
1063
- escape = false;
1064
- } else if ch == '\\' {
1065
- escape = true;
1066
- } else if ch == '"' {
1067
- in_str = false;
1068
- }
1069
- out.push(ch);
1070
- } else {
1071
- if ch == '"' {
1072
- in_str = true;
1073
- out.push(ch);
1074
- } else if ch == ',' {
1075
- // Check if following non-whitespace character is '}' or ']'
1076
- let mut peek_chars = chars.clone();
1077
- let mut is_trailing = false;
1078
- while let Some(next_ch) = peek_chars.next() {
1079
- if next_ch.is_whitespace() {
1080
- continue;
1081
- }
1082
- if next_ch == '}' || next_ch == ']' {
1083
- is_trailing = true;
1084
- }
1085
- break;
1086
- }
1087
- if !is_trailing {
1088
- out.push(ch);
1089
- }
1090
- } else {
1091
- out.push(ch);
1092
- }
1093
- }
1094
- }
1095
- out
1096
- }
1097
-
1098
- /// Parses an OpenAI Chat Completions-shaped response (`choices[0].message`),
1099
- /// extracting both plain text and any native `tool_calls`. Shared by OpenAI,
1100
- /// custom OpenAI-compatible providers, and (for its response shape only) reused
1101
- /// by nothing else — Ollama's native `/api/chat` response is shaped differently
1102
- /// and has its own parser below.
1103
- fn parse_openai_style_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1104
- let message = &response["choices"][0]["message"];
1105
- let raw_text = message["content"].as_str().unwrap_or_default().to_string();
1106
- let direct_thought = message["reasoning_content"]
1107
- .as_str()
1108
- .or_else(|| message["reasoning"].as_str())
1109
- .or_else(|| message["thought"].as_str())
1110
- .or_else(|| message["thinking"].as_str())
1111
- .map(|s| s.trim().to_string())
1112
- .filter(|s| !s.is_empty());
1113
-
1114
- let (thought, text) = if direct_thought.is_none() {
1115
- extract_think_tag(&raw_text)
1116
- } else {
1117
- (direct_thought, raw_text)
1118
- };
1119
-
1120
- let tool_calls: Vec<ToolCall> = message["tool_calls"]
1121
- .as_array()
1122
- .map(|calls| {
1123
- calls
1124
- .iter()
1125
- .filter_map(|call| {
1126
- let name = call["function"]["name"].as_str()?.to_string();
1127
- let input = decode_tool_arguments(&call["function"]["arguments"]);
1128
- Some(ToolCall {
1129
- id: call["id"].as_str().unwrap_or_default().to_string(),
1130
- name,
1131
- input,
1132
- thought_signature: None,
1133
- })
1134
- })
1135
- .collect()
1136
- })
1137
- .unwrap_or_default();
1138
- if text.is_empty() && tool_calls.is_empty() {
1139
- if let Some(th) = &thought {
1140
- return Ok(ProviderReply {
1141
- model,
1142
- text: th.clone(),
1143
- thought: Some(th.clone()),
1144
- tool_calls: None,
1145
- stop_reason: response["choices"][0]["finish_reason"]
1146
- .as_str()
1147
- .map(str::to_owned),
1148
- input_tokens: response["usage"]["prompt_tokens"]
1149
- .as_u64()
1150
- .map(|n| n as u32),
1151
- output_tokens: response["usage"]["completion_tokens"]
1152
- .as_u64()
1153
- .map(|n| n as u32),
1154
- });
1155
- }
1156
- return Err(ChatError::MissingResponseText);
1157
- }
1158
- Ok(ProviderReply {
1159
- model,
1160
- text,
1161
- thought,
1162
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1163
- stop_reason: response["choices"][0]["finish_reason"]
1164
- .as_str()
1165
- .map(str::to_owned),
1166
- input_tokens: response["usage"]["prompt_tokens"]
1167
- .as_u64()
1168
- .map(|n| n as u32),
1169
- output_tokens: response["usage"]["completion_tokens"]
1170
- .as_u64()
1171
- .map(|n| n as u32),
1172
- })
1173
- }
1174
-
1175
- async fn call_ollama(
1176
- client: &Client,
1177
- config: &MintConfig,
1178
- request: &ChatRequest,
1179
- ) -> Result<ProviderReply, ChatError> {
1180
- let host = if config.ollama_host.trim().is_empty() {
1181
- "http://localhost:11434"
1182
- } else {
1183
- config.ollama_host.trim_end_matches('/')
1184
- };
1185
- let model = config.ollama_model.clone();
1186
- let native = request.messages.is_some()
1187
- && config.tool_calling_mode() == crate::config::ToolCallingMode::Native;
1188
- let mut body = if native {
1189
- let mut payload = json!({
1190
- "model": model,
1191
- "stream": false,
1192
- "messages": ollama_request_messages(request),
1193
- });
1194
- if let Some(tools) = &request.tools {
1195
- payload["tools"] = json!(
1196
- tools
1197
- .iter()
1198
- .map(|tool| json!({
1199
- "type": "function",
1200
- "function": {
1201
- "name": tool.name,
1202
- "description": tool.description,
1203
- "parameters": tool.parameters,
1204
- }
1205
- }))
1206
- .collect::<Vec<_>>()
1207
- );
1208
- }
1209
- payload
1210
- } else {
1211
- let mut user_message = json!({ "role": "user", "content": request.message });
1212
- if let Some(images) = ollama_images(request) {
1213
- user_message["images"] = json!(images);
1214
- }
1215
- json!({
1216
- "model": model,
1217
- "stream": false,
1218
- "messages": [
1219
- { "role": "system", "content": request.system_instruction },
1220
- user_message
1221
- ]
1222
- })
1223
- };
1224
- // Force the context window Mint plans against (`context_window_tokens`)
1225
- // instead of leaving Ollama on its tiny ~4K default, which silently
1226
- // truncates anything longer.
1227
- let temp = effective_temperature(config, request);
1228
- body["options"] = json!({
1229
- "num_ctx": config.ollama_num_ctx,
1230
- "temperature": temp,
1231
- });
1232
- let (thinking_enabled, _) = effective_thinking(config, request, &model);
1233
- if MintConfig::is_thinking_supported_for_model("ollama", &model) {
1234
- body["think"] = json!(thinking_enabled);
1235
- }
1236
- let response: Value = client
1237
- .post(format!("{host}/api/chat"))
1238
- .json(&body)
1239
- .send()
1240
- .await?
1241
- .error_for_status()?
1242
- .json()
1243
- .await?;
1244
- parse_ollama_reply(model, &response)
1245
- }
1246
-
1247
- /// Converts structured history into Ollama's native `/api/chat` message shape.
1248
- /// Unlike OpenAI, Ollama expects tool-call `arguments` as a JSON object (not a
1249
- /// stringified JSON blob) and doesn't require a `tool_call_id` on tool-result
1250
- /// messages.
1251
- fn ollama_request_messages(request: &ChatRequest) -> Vec<Value> {
1252
- let history = request.messages.as_deref().unwrap_or(&[]);
1253
- let mut result = ollama_native_messages(history);
1254
- if !request.system_instruction.is_empty() && !history.iter().any(|m| m.role == ChatRole::System)
1255
- {
1256
- result.insert(
1257
- 0,
1258
- json!({"role":"system","content":request.system_instruction}),
1259
- );
1260
- }
1261
- result
1262
- }
1263
-
1264
- fn ollama_native_messages(messages: &[ChatMessage]) -> Vec<Value> {
1265
- let mut result = Vec::new();
1266
- for message in messages {
1267
- match message.role {
1268
- ChatRole::System => {
1269
- result.push(json!({ "role": "system", "content": message_text(message) }));
1270
- }
1271
- ChatRole::User => {
1272
- let mut entry = json!({ "role": "user", "content": message_text(message) });
1273
- let images: Vec<String> = message
1274
- .content
1275
- .iter()
1276
- .filter_map(|block| match block {
1277
- ContentBlock::Image { data_uri } => split_data_uri(data_uri, "image/")
1278
- .ok()
1279
- .map(|(_, data)| data),
1280
- _ => None,
1281
- })
1282
- .collect();
1283
- if !images.is_empty() {
1284
- entry["images"] = json!(images);
1285
- }
1286
- result.push(entry);
1287
- }
1288
- ChatRole::Assistant => {
1289
- let text = message_text(message);
1290
- let tool_calls: Vec<Value> = message
1291
- .content
1292
- .iter()
1293
- .filter_map(|block| match block {
1294
- ContentBlock::ToolUse { name, input, .. } => {
1295
- Some(json!({ "function": { "name": name, "arguments": input } }))
1296
- }
1297
- _ => None,
1298
- })
1299
- .collect();
1300
- let mut entry = json!({ "role": "assistant", "content": text });
1301
- if !tool_calls.is_empty() {
1302
- entry["tool_calls"] = json!(tool_calls);
1303
- }
1304
- result.push(entry);
1305
- }
1306
- ChatRole::Tool => {
1307
- // Ollama's `/api/chat` `tool` role message has no image field —
1308
- // like OpenAI, an `Image` block riding along with a tool result
1309
- // (e.g. a browser screenshot) is emitted as a follow-up `user`
1310
- // message using the same `images` field regular user turns use.
1311
- let mut pending_images: Vec<String> = Vec::new();
1312
- for block in &message.content {
1313
- match block {
1314
- ContentBlock::ToolResult { content, .. } => {
1315
- result.push(json!({ "role": "tool", "content": content }));
1316
- }
1317
- ContentBlock::Image { data_uri } => {
1318
- if let Ok((_, data)) = split_data_uri(data_uri, "image/") {
1319
- pending_images.push(data);
1320
- }
1321
- }
1322
- _ => {}
1323
- }
1324
- }
1325
- if !pending_images.is_empty() {
1326
- result.push(json!({
1327
- "role": "user",
1328
- "content": "",
1329
- "images": pending_images,
1330
- }));
1331
- }
1332
- }
1333
- }
1334
- }
1335
- result
1336
- }
1337
-
1338
- /// Parses Ollama's native `/api/chat` response (`{"message": {...}}`), which is
1339
- /// shaped differently from the OpenAI Chat Completions envelope
1340
- /// (`{"choices": [{"message": {...}}]}`) that `parse_openai_style_reply` handles.
1341
- fn parse_ollama_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1342
- let message = &response["message"];
1343
- let raw_text = message["content"].as_str().unwrap_or_default().to_string();
1344
- let direct_thought = message["thinking"]
1345
- .as_str()
1346
- .or_else(|| message["reasoning_content"].as_str())
1347
- .map(|s| s.trim().to_string())
1348
- .filter(|s| !s.is_empty());
1349
-
1350
- let (thought, text) = if direct_thought.is_none() {
1351
- extract_think_tag(&raw_text)
1352
- } else {
1353
- (direct_thought, raw_text)
1354
- };
1355
-
1356
- let tool_calls: Vec<ToolCall> = message["tool_calls"]
1357
- .as_array()
1358
- .map(|calls| {
1359
- calls
1360
- .iter()
1361
- .enumerate()
1362
- .filter_map(|(index, call)| {
1363
- let name = call["function"]["name"].as_str()?.to_string();
1364
- let arguments = &call["function"]["arguments"];
1365
- let input = decode_tool_arguments(arguments);
1366
- let id = call["id"]
1367
- .as_str()
1368
- .map(str::to_owned)
1369
- .unwrap_or_else(|| format!("call_{index}"));
1370
- Some(ToolCall {
1371
- id,
1372
- name,
1373
- input,
1374
- thought_signature: None,
1375
- })
1376
- })
1377
- .collect()
1378
- })
1379
- .unwrap_or_default();
1380
- if text.is_empty() && tool_calls.is_empty() {
1381
- if let Some(th) = &thought {
1382
- return Ok(ProviderReply {
1383
- model,
1384
- text: th.clone(),
1385
- thought: Some(th.clone()),
1386
- tool_calls: None,
1387
- stop_reason: None,
1388
- input_tokens: response["prompt_eval_count"].as_u64().map(|n| n as u32),
1389
- output_tokens: response["eval_count"].as_u64().map(|n| n as u32),
1390
- });
1391
- }
1392
- return Err(ChatError::MissingResponseText);
1393
- }
1394
- Ok(ProviderReply {
1395
- model,
1396
- text,
1397
- thought,
1398
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1399
- stop_reason: None,
1400
- input_tokens: response["prompt_eval_count"].as_u64().map(|n| n as u32),
1401
- output_tokens: response["eval_count"].as_u64().map(|n| n as u32),
1402
- })
1403
- }
1404
-
1405
- async fn call_anthropic(
1406
- client: &Client,
1407
- config: &MintConfig,
1408
- request: &ChatRequest,
1409
- ) -> Result<ProviderReply, ChatError> {
1410
- let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
1411
- required_key("anthropic", &api_key)?;
1412
- let model = config.anthropic_model.clone();
1413
- let payload = anthropic_chat_payload(config, &model, request, false)?;
1414
- let response: Value = client
1415
- .post("https://api.anthropic.com/v1/messages")
1416
- .header("x-api-key", api_key)
1417
- .header("anthropic-version", "2023-06-01")
1418
- .header("anthropic-beta", "prompt-caching-2024-07-31")
1419
- .json(&payload)
1420
- .send()
1421
- .await?
1422
- .error_for_status()?
1423
- .json()
1424
- .await?;
1425
- parse_anthropic_reply(model, &response)
1426
- }
1427
-
1428
- /// Builds the Anthropic Messages API payload. When `request.messages` is set,
1429
- /// uses the structured history and (if present) `request.tools` for native
1430
- /// tool-calling; otherwise falls back to the single flat `message` field exactly
1431
- /// as before (legacy JSON-prompt callers are unaffected).
1432
- fn anthropic_chat_payload(
1433
- config: &MintConfig,
1434
- model: &str,
1435
- request: &ChatRequest,
1436
- stream: bool,
1437
- ) -> Result<Value, ChatError> {
1438
- let system = anthropic_system_blocks(&request.system_instruction);
1439
- let (thinking_enabled, thinking_effort) = effective_thinking(config, request, model);
1440
- let is_thinking_supported = MintConfig::is_thinking_supported_for_model("anthropic", model);
1441
- let use_thinking = thinking_enabled && is_thinking_supported;
1442
-
1443
- let (budget, max_tokens, temp) = if use_thinking {
1444
- let b = match thinking_effort.as_str() {
1445
- "low" => 1024,
1446
- "medium" => 4096,
1447
- "high" => 16384,
1448
- "extra_high" => 32768,
1449
- _ => 4096,
1450
- };
1451
- (Some(b), b + 8192, 1.0)
1452
- } else {
1453
- (None, 8192, effective_temperature(config, request))
1454
- };
1455
-
1456
- let mut payload = if let Some(messages) = &request.messages {
1457
- json!({
1458
- "model": model,
1459
- "max_tokens": max_tokens,
1460
- "system": system,
1461
- "messages": anthropic_messages(messages)?,
1462
- "temperature": temp,
1463
- })
1464
- } else {
1465
- json!({
1466
- "model": model,
1467
- "max_tokens": max_tokens,
1468
- "system": system,
1469
- "messages": [{
1470
- "role": "user",
1471
- "content": anthropic_message_content(&request.message, request.image_data_uri.as_deref())?,
1472
- }],
1473
- "temperature": temp,
1474
- })
1475
- };
1476
- if let Some(b) = budget {
1477
- payload["thinking"] = json!({
1478
- "type": "enabled",
1479
- "budget_tokens": b,
1480
- });
1481
- }
1482
- if stream {
1483
- payload["stream"] = json!(true);
1484
- }
1485
- if let Some(tools) = &request.tools {
1486
- let last_idx = tools.len().saturating_sub(1);
1487
- payload["tools"] = json!(
1488
- tools
1489
- .iter()
1490
- .enumerate()
1491
- .map(|(i, tool)| {
1492
- let mut value = json!({
1493
- "name": tool.name,
1494
- "description": tool.description,
1495
- "input_schema": tool.parameters,
1496
- });
1497
- // The tool catalog is identical on every step of an agent
1498
- // run — marking the last tool definition as a cache
1499
- // breakpoint caches the whole system+tools prefix (Anthropic
1500
- // caches everything up to and including a marked block), so
1501
- // repeated steps hit cache instead of reprocessing it.
1502
- if i == last_idx {
1503
- value["cache_control"] = json!({ "type": "ephemeral" });
1504
- }
1505
- value
1506
- })
1507
- .collect::<Vec<_>>()
1508
- );
1509
- }
1510
- Ok(payload)
1511
- }
1512
-
1513
- /// Anthropic requires `system` to be an array of text blocks (not a bare
1514
- /// string) to attach a `cache_control` breakpoint to it. The system prompt is
1515
- /// identical on every step of an agent run, so marking it cacheable turns
1516
- /// repeated multi-step turns into a cache hit instead of a full reprocess
1517
- /// each time. Falls back to the old bare-string shape when there's no system
1518
- /// prompt at all, since an empty cacheable block isn't useful.
1519
- fn anthropic_system_blocks(system_instruction: &str) -> Value {
1520
- if system_instruction.is_empty() {
1521
- return json!(system_instruction);
1522
- }
1523
- json!([{
1524
- "type": "text",
1525
- "text": system_instruction,
1526
- "cache_control": { "type": "ephemeral" },
1527
- }])
1528
- }
1529
-
1530
- fn anthropic_messages(messages: &[ChatMessage]) -> Result<Vec<Value>, ChatError> {
1531
- let non_system: Vec<&ChatMessage> = messages
1532
- .iter()
1533
- .filter(|message| message.role != ChatRole::System)
1534
- .collect();
1535
-
1536
- // Anthropic allows up to 4 cache breakpoints. Breakpoints 1 and 2 are system
1537
- // instruction and the last tool definition. For multi-turn agent runs, marking
1538
- // the penultimate turn allows the entire preceding conversation history (steps 1..N-1)
1539
- // to be served from prompt cache with zero reprocessing tokens.
1540
- let cache_idx = if non_system.len() >= 2 {
1541
- Some(non_system.len() - 2)
1542
- } else {
1543
- None
1544
- };
1545
-
1546
- non_system
1547
- .into_iter()
1548
- .enumerate()
1549
- .map(|(i, message)| {
1550
- let role = match message.role {
1551
- ChatRole::Assistant => "assistant",
1552
- _ => "user",
1553
- };
1554
- let mut content = message
1555
- .content
1556
- .iter()
1557
- .map(anthropic_content_block)
1558
- .collect::<Result<Vec<_>, _>>()?;
1559
-
1560
- if Some(i) == cache_idx
1561
- && let Some(last_block) = content.last_mut()
1562
- {
1563
- if let Some(obj) = last_block.as_object_mut() {
1564
- obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
1565
- }
1566
- }
1567
-
1568
- Ok(json!({ "role": role, "content": content }))
1569
- })
1570
- .collect()
1571
- }
1572
-
1573
- /// Builds an Anthropic `content` value for the plain (non-native-messages)
1574
- /// chat path: bare text when there's no image, otherwise a `text` + `image`
1575
- /// parts array. Mirrors `anthropic_content_block`, which handles the same
1576
- /// shape for the native-tool-calling `messages` path. `image_data_uri` may
1577
- /// contain multiple space-separated data URIs, matching Gemini's `gemini_parts`.
1578
- fn anthropic_message_content(text: &str, image_data_uri: Option<&str>) -> Result<Value, ChatError> {
1579
- let Some(image_data) = image_data_uri else {
1580
- return Ok(json!(text));
1581
- };
1582
- let mut parts = vec![json!({ "type": "text", "text": text })];
1583
- for img in image_data.split_whitespace() {
1584
- let (mime_type, data) = split_data_uri(img, "image/")?;
1585
- parts.push(json!({
1586
- "type": "image",
1587
- "source": { "type": "base64", "media_type": mime_type, "data": data },
1588
- }));
1589
- }
1590
- Ok(json!(parts))
1591
- }
1592
-
1593
- fn anthropic_content_block(block: &ContentBlock) -> Result<Value, ChatError> {
1594
- Ok(match block {
1595
- ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
1596
- ContentBlock::ToolUse {
1597
- id, name, input, ..
1598
- } => {
1599
- json!({ "type": "tool_use", "id": id, "name": name, "input": input })
1600
- }
1601
- ContentBlock::ToolResult {
1602
- tool_use_id,
1603
- content,
1604
- is_error,
1605
- } => json!({
1606
- "type": "tool_result",
1607
- "tool_use_id": tool_use_id,
1608
- "content": content,
1609
- "is_error": is_error,
1610
- }),
1611
- ContentBlock::Image { data_uri } => {
1612
- let (mime_type, data) = split_data_uri(data_uri, "image/")?;
1613
- json!({
1614
- "type": "image",
1615
- "source": { "type": "base64", "media_type": mime_type, "data": data },
1616
- })
1617
- }
1618
- // Anthropic's Messages API has no inline audio/video content block; this
1619
- // is unreachable in practice because `require_supported_attachments`
1620
- // rejects audio/video for "anthropic" before a request gets this far.
1621
- ContentBlock::Audio { .. } | ContentBlock::Video { .. } => {
1622
- return Err(ChatError::UnsupportedAttachments("anthropic".into()));
1623
- }
1624
- })
1625
- }
1626
-
1627
- /// Parses an Anthropic Messages API response, collecting all `text` and
1628
- /// `tool_use` content blocks (Anthropic may return a `tool_use` block before,
1629
- /// after, or interleaved with text — unlike the old code, this does not assume
1630
- /// the first block is text).
1631
- // Anthropic reports uncached, cache-read and cache-created input separately.
1632
- fn anthropic_input_tokens(usage: &Value) -> Option<u32> {
1633
- [
1634
- "input_tokens",
1635
- "cache_read_input_tokens",
1636
- "cache_creation_input_tokens",
1637
- ]
1638
- .into_iter()
1639
- .filter_map(|key| usage[key].as_u64())
1640
- .reduce(u64::saturating_add)
1641
- .map(|n| n.min(u32::MAX as u64) as u32)
1642
- }
1643
-
1644
- fn parse_anthropic_reply(model: String, response: &Value) -> Result<ProviderReply, ChatError> {
1645
- let blocks = response["content"].as_array().cloned().unwrap_or_default();
1646
- let mut text = String::new();
1647
- let mut thought_parts: Vec<&str> = Vec::new();
1648
- let mut tool_calls = Vec::new();
1649
- for block in &blocks {
1650
- match block["type"].as_str() {
1651
- Some("text") => {
1652
- if let Some(t) = block["text"].as_str() {
1653
- text.push_str(t);
1654
- }
1655
- }
1656
- Some("thinking") => {
1657
- if let Some(t) = block["thinking"].as_str() {
1658
- thought_parts.push(t);
1659
- }
1660
- }
1661
- Some("tool_use") => {
1662
- tool_calls.push(ToolCall {
1663
- id: block["id"].as_str().unwrap_or_default().into(),
1664
- name: block["name"].as_str().unwrap_or_default().into(),
1665
- input: block["input"].clone(),
1666
- thought_signature: None,
1667
- });
1668
- }
1669
- _ => {}
1670
- }
1671
- }
1672
- let direct_thought = if !thought_parts.is_empty() {
1673
- Some(thought_parts.join("").trim().to_string()).filter(|s| !s.is_empty())
1674
- } else {
1675
- None
1676
- };
1677
- let (thought, text) = if direct_thought.is_none() {
1678
- extract_think_tag(&text)
1679
- } else {
1680
- (direct_thought, text)
1681
- };
1682
- if text.is_empty() && tool_calls.is_empty() {
1683
- if let Some(th) = &thought {
1684
- return Ok(ProviderReply {
1685
- model,
1686
- text: th.clone(),
1687
- thought: Some(th.clone()),
1688
- tool_calls: None,
1689
- stop_reason: response["stop_reason"].as_str().map(str::to_owned),
1690
- input_tokens: anthropic_input_tokens(&response["usage"]),
1691
- output_tokens: response["usage"]["output_tokens"]
1692
- .as_u64()
1693
- .map(|n| n as u32),
1694
- });
1695
- }
1696
- return Err(ChatError::MissingResponseText);
1697
- }
1698
- Ok(ProviderReply {
1699
- model,
1700
- text,
1701
- thought,
1702
- tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
1703
- stop_reason: response["stop_reason"].as_str().map(str::to_owned),
1704
- input_tokens: anthropic_input_tokens(&response["usage"]),
1705
- output_tokens: response["usage"]["output_tokens"]
1706
- .as_u64()
1707
- .map(|n| n as u32),
1708
- })
1709
- }
1710
-
1711
- async fn call_huggingface(
1712
- client: &Client,
1713
- config: &MintConfig,
1714
- request: &ChatRequest,
1715
- ) -> Result<ProviderReply, ChatError> {
1716
- let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
1717
- required_key("huggingface", &api_key)?;
1718
- let model = config.hf_model.clone();
1719
- // Hugging Face's router is an OpenAI Chat Completions-compatible
1720
- // endpoint, so it can (and must) take the same native `messages`/`tools`
1721
- // payload `call_openai` builds — this used to hand-roll a bare
1722
- // `{system, user: request.message}` request instead, which ignored the
1723
- // agent loop's full tool-call history and `tools` catalog. `message` is
1724
- // deliberately left empty (see the step-loop in `orchestration/mod.rs`)
1725
- // whenever `messages` carries the real conversation, so any mid-task
1726
- // fallback onto this provider was silently losing all task context —
1727
- // the model would see an empty user turn and reply with generic
1728
- // small talk, which the agent loop then treated as the final answer.
1729
- let response: Value = send_openai_style_request(
1730
- client
1731
- .post("https://router.huggingface.co/v1/chat/completions")
1732
- .bearer_auth(api_key)
1733
- .json(&openai_chat_payload(config, &model, request, false)?),
1734
- "huggingface",
1735
- )
1736
- .await?;
1737
- parse_openai_style_reply(model, &response)
1738
- }
1739
-
1740
- async fn stream_gemini_reply<F>(
1741
- client: &Client,
1742
- config: &MintConfig,
1743
- request: &ChatRequest,
1744
- on_event: &mut F,
1745
- ) -> Result<ProviderReply, ChatError>
1746
- where
1747
- F: FnMut(ChatStreamEvent),
1748
- {
1749
- let api_key = provider_key(&config.api_key, "GEMINI_API_KEY");
1750
- required_key("gemini", &api_key)?;
1751
- let model = config.gemini_model.clone();
1752
- let payload = if request.messages.is_some() {
1753
- gemini_native_payload(request)?
1754
- } else {
1755
- gemini_chat_payload(config, request)?
1756
- };
1757
- let response = client
1758
- .post(format!(
1759
- "https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse"
1760
- ))
1761
- .header("x-goog-api-key", api_key)
1762
- .json(&payload)
1763
- .send()
1764
- .await?
1765
- .error_for_status()?;
1766
- collect_stream_reply(response, StreamFormat::Gemini, model, on_event).await
1767
- }
1768
-
1769
- async fn stream_openai_reply<F>(
1770
- client: &Client,
1771
- config: &MintConfig,
1772
- request: &ChatRequest,
1773
- on_event: &mut F,
1774
- ) -> Result<ProviderReply, ChatError>
1775
- where
1776
- F: FnMut(ChatStreamEvent),
1777
- {
1778
- let local = config.ai_provider == "local_openai";
1779
- let openrouter = config.ai_provider == "openrouter";
1780
- let deepseek = config.ai_provider == "deepseek";
1781
- let api_key = if openrouter {
1782
- provider_key(&config.openrouter_api_key, "OPENROUTER_API_KEY")
1783
- } else if deepseek {
1784
- provider_key(&config.deepseek_api_key, "DEEPSEEK_API_KEY")
1785
- } else {
1786
- provider_key(&config.openai_api_key, "OPENAI_API_KEY")
1787
- };
1788
- if openrouter {
1789
- required_key("openrouter", &api_key)?;
1790
- } else if deepseek {
1791
- required_key("deepseek", &api_key)?;
1792
- } else if !local {
1793
- required_key("openai", &api_key)?;
1794
- }
1795
- let base_url = if openrouter {
1796
- "https://openrouter.ai/api/v1"
1797
- } else if deepseek {
1798
- "https://api.deepseek.com"
1799
- } else if local {
1800
- config.local_api_base_url.trim_end_matches('/')
1801
- } else {
1802
- "https://api.openai.com/v1"
1803
- };
1804
- let model = if openrouter {
1805
- config.openrouter_model.clone()
1806
- } else if deepseek {
1807
- config.deepseek_model.clone()
1808
- } else if local {
1809
- config.local_model_name.clone()
1810
- } else {
1811
- config.openai_model.clone()
1812
- };
1813
- let response = client
1814
- .post(format!("{base_url}/chat/completions"))
1815
- .bearer_auth(if local { "not-needed" } else { &api_key })
1816
- .json(&openai_chat_payload(config, &model, request, true)?)
1817
- .send()
1818
- .await?;
1819
- if !response.status().is_success() {
1820
- let status = response.status();
1821
- let body = response.text().await.unwrap_or_default();
1822
- return Err(ChatError::Api(format!("HTTP {status}: {body}")));
1823
- }
1824
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1825
- }
1826
-
1827
- fn ollama_stream_payload(
1828
- config: &MintConfig,
1829
- request: &ChatRequest,
1830
- model: &str,
1831
- ) -> Result<Value, ChatError> {
1832
- let native = request.messages.is_some()
1833
- && config.tool_calling_mode() == crate::config::ToolCallingMode::Native;
1834
- let mut payload = if native {
1835
- let mut value = json!({
1836
- "model": model,
1837
- "stream": true,
1838
- "messages": ollama_request_messages(request),
1839
- });
1840
- if let Some(tools) = &request.tools {
1841
- value["tools"] = json!(
1842
- tools
1843
- .iter()
1844
- .map(|tool| json!({
1845
- "type": "function",
1846
- "function": {
1847
- "name": tool.name,
1848
- "description": tool.description,
1849
- "parameters": tool.parameters,
1850
- }
1851
- }))
1852
- .collect::<Vec<_>>()
1853
- );
1854
- }
1855
- value
1856
- } else {
1857
- let mut user = json!({ "role": "user", "content": request.message });
1858
- if let Some(images) = ollama_images(request) {
1859
- user["images"] = json!(images);
1860
- }
1861
- json!({
1862
- "model": model,
1863
- "stream": true,
1864
- "messages": [
1865
- { "role": "system", "content": request.system_instruction },
1866
- user
1867
- ]
1868
- })
1869
- };
1870
- payload["options"] = json!({
1871
- "num_ctx": config.ollama_num_ctx,
1872
- "temperature": effective_temperature(config, request),
1873
- });
1874
- let (thinking_enabled, _) = effective_thinking(config, request, model);
1875
- if MintConfig::is_thinking_supported_for_model("ollama", model) {
1876
- payload["think"] = json!(thinking_enabled);
1877
- }
1878
- Ok(payload)
1879
- }
1880
-
1881
- async fn stream_ollama_reply<F>(
1882
- client: &Client,
1883
- config: &MintConfig,
1884
- request: &ChatRequest,
1885
- on_event: &mut F,
1886
- ) -> Result<ProviderReply, ChatError>
1887
- where
1888
- F: FnMut(ChatStreamEvent),
1889
- {
1890
- let host = if config.ollama_host.trim().is_empty() {
1891
- "http://localhost:11434"
1892
- } else {
1893
- config.ollama_host.trim_end_matches('/')
1894
- };
1895
- let model = config.ollama_model.clone();
1896
- let response = client
1897
- .post(format!("{host}/api/chat"))
1898
- .json(&ollama_stream_payload(config, request, &model)?)
1899
- .send()
1900
- .await?
1901
- .error_for_status()?;
1902
- collect_stream_reply(response, StreamFormat::Ollama, model, on_event).await
1903
- }
1904
-
1905
- async fn stream_anthropic_reply<F>(
1906
- client: &Client,
1907
- config: &MintConfig,
1908
- request: &ChatRequest,
1909
- on_event: &mut F,
1910
- ) -> Result<ProviderReply, ChatError>
1911
- where
1912
- F: FnMut(ChatStreamEvent),
1913
- {
1914
- let api_key = provider_key(&config.anthropic_api_key, "ANTHROPIC_API_KEY");
1915
- required_key("anthropic", &api_key)?;
1916
- let model = config.anthropic_model.clone();
1917
- let response = client
1918
- .post("https://api.anthropic.com/v1/messages")
1919
- .header("x-api-key", api_key)
1920
- .header("anthropic-version", "2023-06-01")
1921
- .header("anthropic-beta", "prompt-caching-2024-07-31")
1922
- .json(&anthropic_chat_payload(config, &model, request, true)?)
1923
- .send()
1924
- .await?
1925
- .error_for_status()?;
1926
- collect_stream_reply(response, StreamFormat::Anthropic, model, on_event).await
1927
- }
1928
-
1929
- async fn stream_huggingface_reply<F>(
1930
- client: &Client,
1931
- config: &MintConfig,
1932
- request: &ChatRequest,
1933
- on_event: &mut F,
1934
- ) -> Result<ProviderReply, ChatError>
1935
- where
1936
- F: FnMut(ChatStreamEvent),
1937
- {
1938
- let api_key = provider_key(&config.hf_api_key, "HF_TOKEN");
1939
- required_key("huggingface", &api_key)?;
1940
- let model = config.hf_model.clone();
1941
- let response = client
1942
- .post("https://router.huggingface.co/v1/chat/completions")
1943
- .bearer_auth(api_key)
1944
- .json(&openai_chat_payload(config, &model, request, true)?)
1945
- .send()
1946
- .await?
1947
- .error_for_status()?;
1948
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1949
- }
1950
-
1951
- async fn stream_custom_provider_reply<F>(
1952
- client: &Client,
1953
- config: &MintConfig,
1954
- request: &ChatRequest,
1955
- on_event: &mut F,
1956
- ) -> Result<ProviderReply, ChatError>
1957
- where
1958
- F: FnMut(ChatStreamEvent),
1959
- {
1960
- let provider = config
1961
- .resolve_custom_provider(&config.ai_provider)
1962
- .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
1963
- let model = resolve_custom_model(config);
1964
- let mut builder = client
1965
- .post(format!(
1966
- "{}/chat/completions",
1967
- provider.base_url.trim_end_matches('/')
1968
- ))
1969
- .json(&openai_chat_payload(config, &model, request, true)?);
1970
- if !provider.api_key.is_empty() {
1971
- builder = builder.bearer_auth(&provider.api_key);
1972
- }
1973
- for header in &provider.headers {
1974
- if !header.name.is_empty() {
1975
- builder = builder.header(header.name.as_str(), header.value.as_str());
1976
- }
1977
- }
1978
- let response = builder.send().await?.error_for_status()?;
1979
- collect_stream_reply(response, StreamFormat::OpenAi, model, on_event).await
1980
- }
1981
-
1982
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1983
- enum StreamFormat {
1984
- Gemini,
1985
- OpenAi,
1986
- Ollama,
1987
- Anthropic,
1988
- }
1989
-
1990
- #[derive(Default)]
1991
- struct StreamToolCall {
1992
- id: String,
1993
- name: String,
1994
- arguments: String,
1995
- input: Option<Value>,
1996
- thought_signature: Option<String>,
1997
- }
1998
-
1999
- #[derive(Default)]
2000
- struct StreamReplyAccumulator {
2001
- text: String,
2002
- thought: String,
2003
- tag_buffer: String,
2004
- inside_think_tag: bool,
2005
- tool_calls: Vec<StreamToolCall>,
2006
- stop_reason: Option<String>,
2007
- input_tokens: Option<u32>,
2008
- output_tokens: Option<u32>,
2009
- anthropic_blocks: std::collections::HashMap<usize, usize>,
2010
- }
2011
-
2012
- impl StreamReplyAccumulator {
2013
- fn tool_at(&mut self, index: usize) -> &mut StreamToolCall {
2014
- while self.tool_calls.len() <= index {
2015
- self.tool_calls.push(StreamToolCall::default());
2016
- }
2017
- &mut self.tool_calls[index]
2018
- }
2019
-
2020
- fn emit_text<F>(&mut self, delta: &str, on_event: &mut F)
2021
- where
2022
- F: FnMut(ChatStreamEvent),
2023
- {
2024
- if !delta.is_empty() {
2025
- on_event(ChatStreamEvent::TextDelta {
2026
- delta: delta.to_owned(),
2027
- });
2028
- }
2029
- }
2030
-
2031
- /// Route inline `<think>` content without assuming tag boundaries line up
2032
- /// with provider chunks. A possible partial tag is retained until the next
2033
- /// chunk; ordinary text is emitted immediately.
2034
- fn push_text<F>(&mut self, delta: &str, on_event: &mut F)
2035
- where
2036
- F: FnMut(ChatStreamEvent),
2037
- {
2038
- if delta.is_empty() {
2039
- return;
2040
- }
2041
- self.text.push_str(delta);
2042
- self.tag_buffer.push_str(delta);
2043
- loop {
2044
- let tag = if self.inside_think_tag {
2045
- "</think>"
2046
- } else {
2047
- "<think>"
2048
- };
2049
- if let Some(index) = self.tag_buffer.find(tag) {
2050
- let before = self.tag_buffer[..index].to_owned();
2051
- self.tag_buffer.drain(..index + tag.len());
2052
- if self.inside_think_tag {
2053
- self.push_reasoning(&before, on_event);
2054
- } else {
2055
- self.emit_text(&before, on_event);
2056
- }
2057
- self.inside_think_tag = !self.inside_think_tag;
2058
- continue;
2059
- }
2060
-
2061
- let retain_from = self
2062
- .tag_buffer
2063
- .rfind('<')
2064
- .and_then(|index| tag.starts_with(&self.tag_buffer[index..]).then_some(index));
2065
- let emit_end = retain_from.unwrap_or(self.tag_buffer.len());
2066
- if emit_end > 0 {
2067
- let ready = self.tag_buffer[..emit_end].to_owned();
2068
- self.tag_buffer.drain(..emit_end);
2069
- if self.inside_think_tag {
2070
- self.push_reasoning(&ready, on_event);
2071
- } else {
2072
- self.emit_text(&ready, on_event);
2073
- }
2074
- }
2075
- break;
2076
- }
2077
- }
2078
-
2079
- fn flush_tag_buffer<F>(&mut self, on_event: &mut F)
2080
- where
2081
- F: FnMut(ChatStreamEvent),
2082
- {
2083
- if self.tag_buffer.is_empty() {
2084
- return;
2085
- }
2086
- let remaining = std::mem::take(&mut self.tag_buffer);
2087
- if self.inside_think_tag {
2088
- self.push_reasoning(&remaining, on_event);
2089
- } else {
2090
- self.emit_text(&remaining, on_event);
2091
- }
2092
- }
2093
-
2094
- fn push_reasoning<F>(&mut self, delta: &str, on_event: &mut F)
2095
- where
2096
- F: FnMut(ChatStreamEvent),
2097
- {
2098
- if !delta.is_empty() {
2099
- self.thought.push_str(delta);
2100
- on_event(ChatStreamEvent::ReasoningDelta {
2101
- delta: delta.to_owned(),
2102
- });
2103
- }
2104
- }
2105
-
2106
- fn consume<F>(&mut self, format: StreamFormat, value: &Value, on_event: &mut F)
2107
- where
2108
- F: FnMut(ChatStreamEvent),
2109
- {
2110
- match format {
2111
- StreamFormat::OpenAi => self.consume_openai(value, on_event),
2112
- StreamFormat::Gemini => self.consume_gemini(value, on_event),
2113
- StreamFormat::Ollama => self.consume_ollama(value, on_event),
2114
- StreamFormat::Anthropic => self.consume_anthropic(value, on_event),
2115
- }
2116
- }
2117
-
2118
- fn consume_openai<F>(&mut self, value: &Value, on_event: &mut F)
2119
- where
2120
- F: FnMut(ChatStreamEvent),
2121
- {
2122
- let choice = &value["choices"][0];
2123
- let delta = &choice["delta"];
2124
- if let Some(text) = delta["content"].as_str() {
2125
- self.push_text(text, on_event);
2126
- }
2127
- if let Some(reasoning) = delta["reasoning_content"]
2128
- .as_str()
2129
- .or_else(|| delta["reasoning"].as_str())
2130
- .or_else(|| delta["thinking"].as_str())
2131
- {
2132
- self.push_reasoning(reasoning, on_event);
2133
- }
2134
- if let Some(details) = delta["reasoning_details"].as_array() {
2135
- for detail in details {
2136
- if let Some(reasoning) = detail["text"].as_str() {
2137
- self.push_reasoning(reasoning, on_event);
2138
- }
2139
- }
2140
- }
2141
- if let Some(calls) = delta["tool_calls"].as_array() {
2142
- for (fallback_index, call) in calls.iter().enumerate() {
2143
- let index = call["index"]
2144
- .as_u64()
2145
- .map(|n| n as usize)
2146
- .unwrap_or(fallback_index);
2147
- let name_delta = call["function"]["name"].as_str().map(str::to_owned);
2148
- let arguments = call["function"]["arguments"].as_str().map(str::to_owned);
2149
- let current_name = {
2150
- let tool = self.tool_at(index);
2151
- if let Some(id) = call["id"].as_str() {
2152
- tool.id.push_str(id);
2153
- }
2154
- if let Some(name) = &name_delta {
2155
- tool.name.push_str(name);
2156
- }
2157
- if let Some(arguments) = &arguments {
2158
- tool.arguments.push_str(arguments);
2159
- }
2160
- tool.name.clone()
2161
- };
2162
- if name_delta.is_some() || arguments.is_some() {
2163
- on_event(ChatStreamEvent::ToolCallDelta {
2164
- index,
2165
- name: name_delta.map(|_| current_name),
2166
- arguments,
2167
- input: None,
2168
- });
2169
- }
2170
- }
2171
- }
2172
- if let Some(reason) = choice["finish_reason"].as_str() {
2173
- self.stop_reason = Some(reason.to_owned());
2174
- }
2175
- self.input_tokens = value["usage"]["prompt_tokens"]
2176
- .as_u64()
2177
- .map(|n| n as u32)
2178
- .or(self.input_tokens);
2179
- self.output_tokens = value["usage"]["completion_tokens"]
2180
- .as_u64()
2181
- .map(|n| n as u32)
2182
- .or(self.output_tokens);
2183
- }
2184
-
2185
- fn consume_gemini<F>(&mut self, value: &Value, on_event: &mut F)
2186
- where
2187
- F: FnMut(ChatStreamEvent),
2188
- {
2189
- if let Some(parts) = value["candidates"][0]["content"]["parts"].as_array() {
2190
- for (index, part) in parts.iter().enumerate() {
2191
- if let Some(text) = part["text"].as_str() {
2192
- if part["thought"].as_bool().unwrap_or(false) {
2193
- self.push_reasoning(text, on_event);
2194
- } else {
2195
- self.push_text(text, on_event);
2196
- }
2197
- }
2198
- if let Some(call) = part.get("functionCall") {
2199
- let tool = self.tool_at(index);
2200
- tool.id = format!("call_{index}");
2201
- tool.name = call["name"].as_str().unwrap_or_default().to_owned();
2202
- tool.input = Some(call["args"].clone());
2203
- tool.thought_signature = part["thoughtSignature"].as_str().map(str::to_owned);
2204
- on_event(ChatStreamEvent::ToolCallDelta {
2205
- index,
2206
- name: call["name"].as_str().map(str::to_owned),
2207
- arguments: None,
2208
- input: Some(call["args"].clone()),
2209
- });
2210
- }
2211
- }
2212
- }
2213
- self.stop_reason = value["candidates"][0]["finishReason"]
2214
- .as_str()
2215
- .map(str::to_owned)
2216
- .or_else(|| self.stop_reason.take());
2217
- self.input_tokens = value["usageMetadata"]["promptTokenCount"]
2218
- .as_u64()
2219
- .map(|n| n as u32)
2220
- .or(self.input_tokens);
2221
- self.output_tokens = value["usageMetadata"]["candidatesTokenCount"]
2222
- .as_u64()
2223
- .map(|n| n as u32)
2224
- .or(self.output_tokens);
2225
- }
2226
-
2227
- fn consume_ollama<F>(&mut self, value: &Value, on_event: &mut F)
2228
- where
2229
- F: FnMut(ChatStreamEvent),
2230
- {
2231
- let message = &value["message"];
2232
- if let Some(text) = message["content"].as_str() {
2233
- self.push_text(text, on_event);
2234
- }
2235
- if let Some(reasoning) = message["thinking"]
2236
- .as_str()
2237
- .or_else(|| message["reasoning_content"].as_str())
2238
- {
2239
- self.push_reasoning(reasoning, on_event);
2240
- }
2241
- if let Some(calls) = message["tool_calls"].as_array() {
2242
- for (index, call) in calls.iter().enumerate() {
2243
- let tool = self.tool_at(index);
2244
- tool.id = call["id"]
2245
- .as_str()
2246
- .map(str::to_owned)
2247
- .unwrap_or_else(|| format!("call_{index}"));
2248
- tool.name = call["function"]["name"]
2249
- .as_str()
2250
- .unwrap_or_default()
2251
- .to_owned();
2252
- tool.input = Some(call["function"]["arguments"].clone());
2253
- on_event(ChatStreamEvent::ToolCallDelta {
2254
- index,
2255
- name: call["function"]["name"].as_str().map(str::to_owned),
2256
- arguments: None,
2257
- input: Some(call["function"]["arguments"].clone()),
2258
- });
2259
- }
2260
- }
2261
- self.input_tokens = value["prompt_eval_count"]
2262
- .as_u64()
2263
- .map(|n| n as u32)
2264
- .or(self.input_tokens);
2265
- self.output_tokens = value["eval_count"]
2266
- .as_u64()
2267
- .map(|n| n as u32)
2268
- .or(self.output_tokens);
2269
- if value["done"].as_bool().unwrap_or(false) {
2270
- self.stop_reason = value["done_reason"]
2271
- .as_str()
2272
- .map(str::to_owned)
2273
- .or_else(|| Some("stop".to_owned()));
2274
- }
2275
- }
2276
-
2277
- fn consume_anthropic<F>(&mut self, value: &Value, on_event: &mut F)
2278
- where
2279
- F: FnMut(ChatStreamEvent),
2280
- {
2281
- match value["type"].as_str() {
2282
- Some("message_start") => {
2283
- self.input_tokens = anthropic_input_tokens(&value["message"]["usage"]);
2284
- }
2285
- Some("content_block_start") => {
2286
- let block_index = value["index"].as_u64().unwrap_or(0) as usize;
2287
- let block = &value["content_block"];
2288
- if block["type"].as_str() == Some("tool_use") {
2289
- let tool_index = self.tool_calls.len();
2290
- let tool = self.tool_at(tool_index);
2291
- tool.id = block["id"].as_str().unwrap_or_default().to_owned();
2292
- tool.name = block["name"].as_str().unwrap_or_default().to_owned();
2293
- self.anthropic_blocks.insert(block_index, tool_index);
2294
- on_event(ChatStreamEvent::ToolCallDelta {
2295
- index: tool_index,
2296
- name: block["name"].as_str().map(str::to_owned),
2297
- arguments: None,
2298
- input: None,
2299
- });
2300
- }
2301
- }
2302
- Some("content_block_delta") => {
2303
- let delta = &value["delta"];
2304
- match delta["type"].as_str() {
2305
- Some("text_delta") => {
2306
- if let Some(text) = delta["text"].as_str() {
2307
- self.push_text(text, on_event);
2308
- }
2309
- }
2310
- Some("thinking_delta") => {
2311
- if let Some(thinking) = delta["thinking"].as_str() {
2312
- self.push_reasoning(thinking, on_event);
2313
- }
2314
- }
2315
- Some("input_json_delta") => {
2316
- let block_index = value["index"].as_u64().unwrap_or(0) as usize;
2317
- if let Some(tool_index) = self.anthropic_blocks.get(&block_index).copied()
2318
- && let Some(json) = delta["partial_json"].as_str()
2319
- {
2320
- self.tool_at(tool_index).arguments.push_str(json);
2321
- on_event(ChatStreamEvent::ToolCallDelta {
2322
- index: tool_index,
2323
- name: None,
2324
- arguments: Some(json.to_owned()),
2325
- input: None,
2326
- });
2327
- }
2328
- }
2329
- _ => {}
2330
- }
2331
- }
2332
- Some("message_delta") => {
2333
- self.stop_reason = value["delta"]["stop_reason"]
2334
- .as_str()
2335
- .map(str::to_owned)
2336
- .or_else(|| self.stop_reason.take());
2337
- self.output_tokens = value["usage"]["output_tokens"]
2338
- .as_u64()
2339
- .map(|n| n as u32)
2340
- .or(self.output_tokens);
2341
- }
2342
- _ => {}
2343
- }
2344
- }
2345
-
2346
- fn finish(self, model: String) -> Result<ProviderReply, ChatError> {
2347
- let (tag_thought, clean_text) = extract_think_tag(&self.text);
2348
- let thought = if self.thought.trim().is_empty() {
2349
- tag_thought
2350
- } else {
2351
- Some(self.thought.trim().to_owned())
2352
- };
2353
- let calls: Vec<ToolCall> = self
2354
- .tool_calls
2355
- .into_iter()
2356
- .filter(|tool| !tool.name.is_empty())
2357
- .map(|tool| {
2358
- let input = tool
2359
- .input
2360
- .unwrap_or_else(|| decode_tool_arguments(&Value::String(tool.arguments)));
2361
- ToolCall {
2362
- id: tool.id,
2363
- name: tool.name,
2364
- input,
2365
- thought_signature: tool.thought_signature,
2366
- }
2367
- })
2368
- .collect();
2369
- if clean_text.is_empty() && calls.is_empty() && thought.is_none() {
2370
- return Err(ChatError::MissingResponseText);
2371
- }
2372
- Ok(ProviderReply {
2373
- model,
2374
- text: if clean_text.is_empty() && calls.is_empty() {
2375
- thought.clone().unwrap_or_default()
2376
- } else {
2377
- clean_text
2378
- },
2379
- thought,
2380
- tool_calls: (!calls.is_empty()).then_some(calls),
2381
- stop_reason: self.stop_reason,
2382
- input_tokens: self.input_tokens,
2383
- output_tokens: self.output_tokens,
2384
- })
2385
- }
2386
- }
2387
-
2388
- async fn collect_stream_reply<F>(
2389
- response: reqwest::Response,
2390
- format: StreamFormat,
2391
- model: String,
2392
- on_event: &mut F,
2393
- ) -> Result<ProviderReply, ChatError>
2394
- where
2395
- F: FnMut(ChatStreamEvent),
2396
- {
2397
- let mut bytes = response.bytes_stream();
2398
- let mut buffer = String::new();
2399
- let mut accumulator = StreamReplyAccumulator::default();
2400
- while let Some(chunk) = bytes.next().await {
2401
- buffer.push_str(&String::from_utf8_lossy(&chunk?));
2402
- while let Some(index) = buffer.find('\n') {
2403
- let line = buffer[..index].trim().to_owned();
2404
- buffer.drain(..=index);
2405
- if let Some(value) = parse_stream_value(format, &line) {
2406
- accumulator.consume(format, &value, on_event);
2407
- }
2408
- }
2409
- }
2410
- if let Some(value) = parse_stream_value(format, buffer.trim()) {
2411
- accumulator.consume(format, &value, on_event);
2412
- }
2413
- accumulator.flush_tag_buffer(on_event);
2414
- accumulator.finish(model)
2415
- }
2416
-
2417
- fn parse_stream_value(format: StreamFormat, line: &str) -> Option<Value> {
2418
- let payload = match format {
2419
- StreamFormat::Ollama => line,
2420
- _ => line.strip_prefix("data:")?.trim(),
2421
- };
2422
- if payload.is_empty() || payload == "[DONE]" {
2423
- return None;
2424
- }
2425
- serde_json::from_str(payload).ok()
2426
- }
2427
-
2428
- fn required_key(provider: &str, key: &str) -> Result<(), ChatError> {
2429
- if key.trim().is_empty() {
2430
- Err(ChatError::MissingApiKey(provider.into()))
2431
- } else {
2432
- Ok(())
2433
- }
2434
- }
2435
-
2436
- /// Resolve which model to use for the current custom provider.
2437
- ///
2438
- /// Checks `config.extra["customModelSelections"]` first (set by the UI when the
2439
- /// user picks a specific model from the multi-model dropdown), then falls back to
2440
- /// the first model defined in the `CustomProvider`.
2441
- fn resolve_custom_model(config: &crate::MintConfig) -> String {
2442
- let provider_id = config
2443
- .ai_provider
2444
- .strip_prefix("custom:")
2445
- .unwrap_or(&config.ai_provider);
2446
-
2447
- // Check if a per-provider model selection was persisted in extra config.
2448
- if let Some(selections) = config
2449
- .extra
2450
- .get("customModelSelections")
2451
- .and_then(|v| v.as_object())
2452
- && let Some(model) = selections.get(provider_id).and_then(|v| v.as_str())
2453
- && !model.is_empty()
2454
- {
2455
- return model.to_owned();
2456
- }
2457
-
2458
- // Fall back to the first model in the provider definition.
2459
- config
2460
- .resolve_custom_provider(&config.ai_provider)
2461
- .and_then(|cp| cp.models.first())
2462
- .map(|m| m.model_id.clone())
2463
- .unwrap_or_default()
2464
- }
2465
-
2466
- async fn call_custom_provider(
2467
- client: &Client,
2468
- config: &crate::MintConfig,
2469
- request: &ChatRequest,
2470
- ) -> Result<ProviderReply, ChatError> {
2471
- let provider = config
2472
- .resolve_custom_provider(&config.ai_provider)
2473
- .ok_or_else(|| ChatError::UnsupportedProvider(config.ai_provider.clone()))?;
2474
-
2475
- let base_url = provider.base_url.trim_end_matches('/');
2476
- let model = resolve_custom_model(config);
2477
- let payload = openai_chat_payload(config, &model, request, false)?;
2478
-
2479
- let mut builder = client
2480
- .post(format!("{base_url}/chat/completions"))
2481
- .json(&payload);
2482
-
2483
- if !provider.api_key.is_empty() {
2484
- builder = builder.bearer_auth(&provider.api_key);
2485
- }
2486
- for h in &provider.headers {
2487
- if !h.name.is_empty() {
2488
- builder = builder.header(h.name.as_str(), h.value.as_str());
2489
- }
2490
- }
2491
-
2492
- let response: serde_json::Value = builder.send().await?.error_for_status()?.json().await?;
2493
-
2494
- parse_openai_style_reply(model, &response)
2495
- }
2496
-
2497
- fn provider_key(configured: &str, environment_variable: &str) -> String {
2498
- if configured.trim().is_empty() {
2499
- std::env::var(environment_variable).unwrap_or_default()
2500
- } else {
2501
- configured.into()
2502
- }
2503
- }
2504
-
2505
- fn require_supported_attachments(provider: &str, request: &ChatRequest) -> Result<(), ChatError> {
2506
- let has_image = request
2507
- .image_data_uri
2508
- .as_ref()
2509
- .is_some_and(|s| !s.trim().is_empty());
2510
- let has_audio = request
2511
- .audio_data_uri
2512
- .as_ref()
2513
- .is_some_and(|s| !s.trim().is_empty());
2514
- let has_video = request
2515
- .video_data_uri
2516
- .as_ref()
2517
- .is_some_and(|s| !s.trim().is_empty());
2518
-
2519
- match provider {
2520
- "gemini" => Ok(()),
2521
- "ollama" if has_audio || has_video => {
2522
- Err(ChatError::UnsupportedAttachments(provider.into()))
2523
- }
2524
- "ollama" => Ok(()),
2525
- // The real OpenAI API supports audio input natively (`input_audio` content
2526
- // parts, see `openai_audio_part`) — video still isn't implemented for it.
2527
- "openai" if has_video => Err(ChatError::UnsupportedAttachments(provider.into())),
2528
- "openai" => Ok(()),
2529
- // These all build their payload via `openai_chat_payload`/`anthropic_chat_payload`,
2530
- // which support image attachments (see `openai_message_content`/
2531
- // `anthropic_message_content`). Unlike literal `"openai"` above, these are
2532
- // separate services/proxies that aren't confirmed to speak the same
2533
- // `input_audio` schema, so audio/video stay unsupported for them.
2534
- "local_openai" | "openrouter" | "deepseek" | "huggingface" | "anthropic"
2535
- if has_audio || has_video =>
2536
- {
2537
- Err(ChatError::UnsupportedAttachments(provider.into()))
2538
- }
2539
- "local_openai" | "openrouter" | "deepseek" | "huggingface" | "anthropic" => Ok(()),
2540
- // Custom providers route through `call_custom_provider`, which also uses
2541
- // `openai_chat_payload`, so images are supported the same way; audio/video
2542
- // are not implemented for them yet.
2543
- p if p.starts_with("custom:") && (has_audio || has_video) => {
2544
- Err(ChatError::UnsupportedAttachments(provider.into()))
2545
- }
2546
- p if p.starts_with("custom:") => Ok(()),
2547
- _ if has_image || has_audio || has_video => {
2548
- Err(ChatError::UnsupportedAttachments(provider.into()))
2549
- }
2550
- _ => Ok(()),
2551
- }
2552
- }
2553
-
2554
- /// Extract base64 image data from the request for Ollama's `images` field.
2555
- /// Ollama expects a plain array of base64 strings (without the data URI prefix).
2556
- fn ollama_images(request: &ChatRequest) -> Option<Vec<String>> {
2557
- let image_data = request.image_data_uri.as_ref()?;
2558
- let images: Vec<String> = image_data
2559
- .split_whitespace()
2560
- .filter_map(|img| {
2561
- img.strip_prefix("data:")
2562
- .and_then(|payload| payload.split_once(";base64,"))
2563
- .filter(|(mime, data)| mime.starts_with("image/") && !data.is_empty())
2564
- .map(|(_, data)| data.to_owned())
2565
- })
2566
- .collect();
2567
- if images.is_empty() {
2568
- None
2569
- } else {
2570
- Some(images)
2571
- }
2572
- }
2573
-
2574
- fn wants_agent_json(request: &ChatRequest) -> bool {
2575
- let instruction = request.system_instruction.as_str();
2576
- instruction.contains("Return only JSON")
2577
- || (instruction.contains("Return exactly one JSON object per response")
2578
- && instruction.contains("Input formats:")
2579
- && instruction.contains("- finish:"))
2580
- }
2581
-
2582
- fn gemini_chat_payload(config: &MintConfig, request: &ChatRequest) -> Result<Value, ChatError> {
2583
- let temp = effective_temperature(config, request);
2584
- let mut payload = json!({
2585
- "systemInstruction": { "parts": [{ "text": request.system_instruction }] },
2586
- "contents": [{ "role": "user", "parts": gemini_parts(request)? }]
2587
- });
2588
- let mut gen_cfg = if wants_agent_json(request) {
2589
- let mut cfg = gemini_agent_generation_config(config);
2590
- cfg["temperature"] = json!(temp);
2591
- cfg
2592
- } else {
2593
- json!({ "temperature": temp })
2594
- };
2595
- gemini_apply_thinking_config(&mut gen_cfg, config, request, &config.gemini_model);
2596
- payload["generationConfig"] = gen_cfg;
2597
- Ok(payload)
2598
- }
2599
-
2600
- fn gemini_agent_generation_config(config: &MintConfig) -> Value {
2601
- let mut allowed_actions = vec![
2602
- "list_files",
2603
- "read_file",
2604
- "search_code",
2605
- "symbols",
2606
- "find_definition",
2607
- "find_references",
2608
- "semantic_index",
2609
- "semantic_search",
2610
- "knowledge_search",
2611
- "web_search",
2612
- "weather",
2613
- "stock",
2614
- "calculation",
2615
- "browser_open",
2616
- "memory_recall",
2617
- "git_status",
2618
- "git_diff",
2619
- "git_log",
2620
- "git_branch",
2621
- "create_plan",
2622
- "update_plan",
2623
- "request_user_approval",
2624
- "ask_user",
2625
- "detect_project",
2626
- "list_tests",
2627
- "read_diagnostics",
2628
- "view_image",
2629
- "note_write",
2630
- "run_plugin",
2631
- "mcp_tool",
2632
- "run_shell",
2633
- "verify",
2634
- "run_tests",
2635
- "run_typecheck",
2636
- "run_linter",
2637
- "apply_patch",
2638
- "write_file",
2639
- ];
2640
- allowed_actions.retain(|action| !config.disabled_tools.contains(&action.to_string()));
2641
- allowed_actions.push("finish");
2642
-
2643
- json!({
2644
- "responseMimeType": "application/json",
2645
- "responseSchema": {
2646
- "type": "OBJECT",
2647
- "properties": {
2648
- "thought": { "type": "STRING" },
2649
- "action": {
2650
- "type": "STRING",
2651
- "enum": allowed_actions
2652
- },
2653
- "input": {
2654
- "type": "OBJECT",
2655
- "properties": {
2656
- "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)" },
2657
- "query": { "type": "STRING", "description": "The search query string (required for search_code, semantic_search, knowledge_search, web_search, stock, memory_recall)" },
2658
- "city": { "type": "STRING", "description": "The target city or location name (required for weather)" },
2659
- "expression": { "type": "STRING", "description": "The math expression or percentage calculation (required for calculation)" },
2660
- "url": { "type": "STRING", "description": "The target website URL to open (required for browser_open)" },
2661
- "command": { "type": "STRING", "description": "The local read-only or test shell command to run (required for run_shell)" },
2662
- "commands": {
2663
- "type": "ARRAY",
2664
- "items": { "type": "STRING" },
2665
- "description": "List of verification commands (required for verify)"
2666
- },
2667
- "steps": {
2668
- "type": "ARRAY",
2669
- "items": { "type": "STRING" },
2670
- "description": "Plan steps (for create_plan/update_plan)"
2671
- },
2672
- "fileContent": { "type": "STRING", "description": "The complete new content of a new file (required for write_file, note_write)" },
2673
- "summary": { "type": "STRING", "description": "The final detailed answer, explanation, or response to the user's query (required for finish)" },
2674
- "verification": { "type": "STRING", "description": "The description of checks to run before finishing" },
2675
- "title": { "type": "STRING", "description": "Short approval or plan title" },
2676
- "status": { "type": "STRING", "description": "Plan item status or project status" },
2677
- "startLine": { "type": "INTEGER", "description": "First line to read (1-indexed, for read_file)" },
2678
- "endLine": { "type": "INTEGER", "description": "Last line to read. Defaults to startLine + 239 (for read_file)" },
2679
- "limit": { "type": "INTEGER", "description": "Max number of items/lines/files to return" },
2680
- "server": { "type": "STRING", "description": "MCP server name (for mcp_tool)" },
2681
- "tool": { "type": "STRING", "description": "MCP tool name (for mcp_tool)" },
2682
- "notePath": { "type": "STRING", "description": "The file path for note writing (for note_write)" },
2683
- "name": { "type": "STRING", "description": "Plugin name (for run_plugin)" },
2684
- "instruction": { "type": "STRING", "description": "Instruction to run the plugin (for run_plugin)" },
2685
- "patch": {
2686
- "type": "OBJECT",
2687
- "properties": {
2688
- "path": { "type": "STRING", "description": "The target file path (required for apply_patch)" },
2689
- "hunks": {
2690
- "type": "ARRAY",
2691
- "items": {
2692
- "type": "OBJECT",
2693
- "properties": {
2694
- "oldText": { "type": "STRING", "description": "The exact block of code to replace" },
2695
- "newText": { "type": "STRING", "description": "The replacement block of code" }
2696
- }
2697
- }
2698
- }
2699
- }
2700
- },
2701
- "arguments": { "type": "OBJECT", "description": "MCP tool arguments (for mcp_tool)" }
2702
- },
2703
- "required": ["path", "query", "command", "fileContent", "summary"]
2704
- }
2705
- },
2706
- "required": ["thought", "action", "input"]
2707
- }
2708
- })
2709
- }
2710
-
2711
- /// Resolves the effective sampling temperature for this request, prioritizing
2712
- /// `request.temperature` if present, otherwise falling back to `config.resolved_temperature()`.
2713
- pub(crate) fn effective_temperature(config: &MintConfig, request: &ChatRequest) -> f64 {
2714
- request
2715
- .temperature
2716
- .unwrap_or_else(|| config.resolved_temperature())
2717
- }
2718
-
2719
- /// Resolves (enabled, effort) for this request and model, prioritizing request overrides.
2720
- pub(crate) fn effective_thinking(
2721
- config: &MintConfig,
2722
- request: &ChatRequest,
2723
- model: &str,
2724
- ) -> (bool, String) {
2725
- let enabled = request
2726
- .thinking_enabled
2727
- .unwrap_or_else(|| config.resolved_thinking_enabled_for_model(&config.ai_provider, model));
2728
- let effort = request
2729
- .thinking_effort
2730
- .as_deref()
2731
- .map(str::to_ascii_lowercase)
2732
- .unwrap_or_else(|| config.resolved_thinking_effort_for_model(model).to_string());
2733
- (enabled, effort)
2734
- }
2735
-
2736
- pub(crate) fn gemini_apply_thinking_config(
2737
- gen_cfg: &mut Value,
2738
- config: &MintConfig,
2739
- request: &ChatRequest,
2740
- model: &str,
2741
- ) {
2742
- if !MintConfig::is_thinking_supported_for_model("gemini", model) {
2743
- return;
2744
- }
2745
- let (enabled, effort) = effective_thinking(config, request, model);
2746
- if !enabled {
2747
- gen_cfg["thinkingConfig"] = json!({ "thinkingBudget": 0 });
2748
- } else {
2749
- let budget = match effort.as_str() {
2750
- "low" => 1024,
2751
- "medium" => 4096,
2752
- "high" => 16384,
2753
- "extra_high" => 32768,
2754
- _ => 4096,
2755
- };
2756
- gen_cfg["thinkingConfig"] = json!({ "thinkingBudget": budget });
2757
- }
2758
- }
2759
-
2760
- fn openai_chat_payload(
2761
- config: &MintConfig,
2762
- model: &str,
2763
- request: &ChatRequest,
2764
- stream: bool,
2765
- ) -> Result<Value, ChatError> {
2766
- let messages = if let Some(messages) = &request.messages {
2767
- let mut built = Vec::new();
2768
- if !request.system_instruction.is_empty()
2769
- && !messages.iter().any(|m| m.role == ChatRole::System)
2770
- {
2771
- built.push(json!({ "role": "system", "content": request.system_instruction }));
2772
- }
2773
- built.extend(openai_native_messages(messages)?);
2774
- built
2775
- } else {
2776
- vec![
2777
- json!({ "role": "system", "content": request.system_instruction }),
2778
- json!({
2779
- "role": "user",
2780
- "content": openai_message_content(
2781
- &request.message,
2782
- request.image_data_uri.as_deref(),
2783
- request.audio_data_uri.as_deref(),
2784
- )?,
2785
- }),
2786
- ]
2787
- };
2788
- // Left unset, several OpenAI-compatible providers (DeepSeek in
2789
- // particular) default to a much lower output cap than the model can
2790
- // actually produce, which silently truncates long structured answers
2791
- // (a code block followed by a couple of markdown tables is enough to
2792
- // hit it) — matches the explicit cap already used for Anthropic in
2793
- // `anthropic_chat_payload`/`stream_anthropic`.
2794
- // For Groq endpoints, free tiers enforce a tight 6,000–8,000 TPM limit
2795
- // where a static 8192 max_tokens triggers immediate HTTP 413 / rate limit errors.
2796
- let max_tokens = {
2797
- let is_groq = config
2798
- .resolve_custom_provider(&config.ai_provider)
2799
- .map(|cp| cp.base_url.contains("groq.com"))
2800
- .unwrap_or(false);
2801
- if is_groq {
2802
- if request.tools.is_some() { 2048 } else { 4096 }
2803
- } else {
2804
- 8192
2805
- }
2806
- };
2807
- let mut payload = json!({
2808
- "model": model,
2809
- "stream": stream,
2810
- "messages": messages,
2811
- "max_tokens": max_tokens,
2812
- });
2813
- if stream {
2814
- payload["stream_options"] = json!({ "include_usage": true });
2815
- }
2816
- // OpenAI reasoning models (o1, o3, etc.) reject explicit temperature parameter.
2817
- let is_reasoning_model = model.starts_with("o1") || model.starts_with("o3");
2818
- let (thinking_enabled, thinking_effort) = effective_thinking(config, request, model);
2819
- if is_reasoning_model {
2820
- if thinking_enabled {
2821
- let effort_str = match thinking_effort.as_str() {
2822
- "low" => "low",
2823
- "medium" => "medium",
2824
- "high" | "extra_high" => "high",
2825
- _ => "medium",
2826
- };
2827
- payload["reasoning_effort"] = json!(effort_str);
2828
- } else {
2829
- payload["reasoning_effort"] = json!("low");
2830
- }
2831
- } else {
2832
- payload["temperature"] = json!(effective_temperature(config, request));
2833
- }
2834
- if wants_agent_json(request) {
2835
- payload["response_format"] = json!({ "type": "json_object" });
2836
- }
2837
- if let Some(tools) = &request.tools {
2838
- payload["tools"] = json!(
2839
- tools
2840
- .iter()
2841
- .map(|tool| json!({
2842
- "type": "function",
2843
- "function": {
2844
- "name": tool.name,
2845
- "description": tool.description,
2846
- "parameters": tool.parameters,
2847
- }
2848
- }))
2849
- .collect::<Vec<_>>()
2850
- );
2851
- }
2852
- Ok(payload)
2853
- }
2854
-
2855
- /// Converts structured history into OpenAI Chat Completions message objects,
2856
- /// including assistant `tool_calls` and `role: "tool"` tool-result messages.
2857
- fn openai_native_messages(messages: &[ChatMessage]) -> Result<Vec<Value>, ChatError> {
2858
- let mut result = Vec::new();
2859
- for message in messages {
2860
- match message.role {
2861
- ChatRole::System => {
2862
- result.push(json!({ "role": "system", "content": message_text(message) }));
2863
- }
2864
- ChatRole::User => {
2865
- result.push(json!({ "role": "user", "content": openai_user_content(message)? }));
2866
- }
2867
- ChatRole::Assistant => {
2868
- let text = message_text(message);
2869
- let tool_calls: Vec<Value> = message
2870
- .content
2871
- .iter()
2872
- .filter_map(|block| match block {
2873
- ContentBlock::ToolUse {
2874
- id, name, input, ..
2875
- } => Some(json!({
2876
- "id": id,
2877
- "type": "function",
2878
- "function": { "name": name, "arguments": input.to_string() }
2879
- })),
2880
- _ => None,
2881
- })
2882
- .collect();
2883
- let mut entry = json!({ "role": "assistant" });
2884
- entry["content"] = if text.is_empty() {
2885
- Value::Null
2886
- } else {
2887
- json!(text)
2888
- };
2889
- if !tool_calls.is_empty() {
2890
- entry["tool_calls"] = json!(tool_calls);
2891
- // DeepSeek and other reasoning-enabled OpenAI-compatible APIs enforce that
2892
- // any assistant message containing `tool_calls` must include `reasoning_content`
2893
- // when reasoning/thinking mode is active. Providing `""` prevents HTTP 400 rejection.
2894
- entry["reasoning_content"] = json!("");
2895
- }
2896
- result.push(entry);
2897
- }
2898
- ChatRole::Tool => {
2899
- // OpenAI's Chat Completions `tool` role only accepts a plain
2900
- // string `content` — an image can't be attached to it directly.
2901
- // Any `Image` block riding along with a tool result (e.g. a
2902
- // browser screenshot) is instead emitted as a follow-up `user`
2903
- // message, the same pattern OpenAI's own vision examples use to
2904
- // let a tool result be "shown" to the model.
2905
- let mut pending_images: Vec<&str> = Vec::new();
2906
- for block in &message.content {
2907
- match block {
2908
- ContentBlock::ToolResult {
2909
- tool_use_id,
2910
- content,
2911
- ..
2912
- } => {
2913
- result.push(json!({
2914
- "role": "tool",
2915
- "tool_call_id": tool_use_id,
2916
- "content": content,
2917
- }));
2918
- }
2919
- ContentBlock::Image { data_uri } => pending_images.push(data_uri),
2920
- _ => {}
2921
- }
2922
- }
2923
- if !pending_images.is_empty() {
2924
- let parts: Vec<Value> = pending_images
2925
- .iter()
2926
- .map(|data_uri| {
2927
- json!({ "type": "image_url", "image_url": { "url": data_uri } })
2928
- })
2929
- .collect();
2930
- result.push(json!({ "role": "user", "content": parts }));
2931
- }
2932
- }
2933
- }
2934
- }
2935
- Ok(result)
2936
- }
2937
-
2938
- /// Concatenates all `Text` blocks in a message (tool-use/result/image blocks
2939
- /// contribute nothing).
2940
- fn message_text(message: &ChatMessage) -> String {
2941
- message
2942
- .content
2943
- .iter()
2944
- .filter_map(|block| match block {
2945
- ContentBlock::Text { text } => Some(text.as_str()),
2946
- _ => None,
2947
- })
2948
- .collect::<Vec<_>>()
2949
- .join("")
2950
- }
2951
-
2952
- /// Builds an OpenAI `content` value for the plain (non-native-messages) chat
2953
- /// path: bare text when there's no image/audio, otherwise a `text` +
2954
- /// `image_url`/`input_audio` parts array. Mirrors `openai_user_content`,
2955
- /// which handles the same shape for the native-tool-calling `messages` path.
2956
- /// `image_data_uri`/`audio_data_uri` may each contain multiple
2957
- /// space-separated data URIs, matching Gemini's `gemini_parts`.
2958
- fn openai_message_content(
2959
- text: &str,
2960
- image_data_uri: Option<&str>,
2961
- audio_data_uri: Option<&str>,
2962
- ) -> Result<Value, ChatError> {
2963
- if image_data_uri.is_none() && audio_data_uri.is_none() {
2964
- return Ok(json!(text));
2965
- }
2966
- let mut parts = vec![json!({ "type": "text", "text": text })];
2967
- for img in image_data_uri.into_iter().flat_map(str::split_whitespace) {
2968
- parts.push(json!({ "type": "image_url", "image_url": { "url": img } }));
2969
- }
2970
- for aud in audio_data_uri.into_iter().flat_map(str::split_whitespace) {
2971
- parts.push(openai_audio_part(aud)?);
2972
- }
2973
- Ok(json!(parts))
2974
- }
2975
-
2976
- fn openai_user_content(message: &ChatMessage) -> Result<Value, ChatError> {
2977
- if let [ContentBlock::Text { text }] = message.content.as_slice() {
2978
- return Ok(json!(text));
2979
- }
2980
- let parts: Vec<Value> = message
2981
- .content
2982
- .iter()
2983
- .map(|block| -> Result<Value, ChatError> {
2984
- Ok(match block {
2985
- ContentBlock::Text { text } => json!({ "type": "text", "text": text }),
2986
- ContentBlock::Image { data_uri } => {
2987
- json!({ "type": "image_url", "image_url": { "url": data_uri } })
2988
- }
2989
- ContentBlock::Audio { data_uri } => openai_audio_part(data_uri)?,
2990
- // OpenAI Chat Completions has no inline video content part in this
2991
- // format; unreachable in practice because
2992
- // `require_supported_attachments` rejects video for these
2993
- // providers before a request gets this far. `Value::Null` here
2994
- // is a "skip this block" sentinel, filtered out below.
2995
- ContentBlock::ToolUse { .. }
2996
- | ContentBlock::ToolResult { .. }
2997
- | ContentBlock::Video { .. } => Value::Null,
2998
- })
2999
- })
3000
- .filter_map(|part| match part {
3001
- Ok(Value::Null) => None,
3002
- other => Some(other),
3003
- })
3004
- .collect::<Result<_, _>>()?;
3005
- Ok(json!(parts))
3006
- }
3007
-
3008
- /// Builds an OpenAI `input_audio` content part. Unlike `image_url`, which
3009
- /// takes the whole `data:` URI as-is, OpenAI's audio input wants the base64
3010
- /// payload and format split apart, so the URI has to be parsed here instead
3011
- /// of just passed through.
3012
- fn openai_audio_part(data_uri: &str) -> Result<Value, ChatError> {
3013
- let (mime_type, data) = split_data_uri(data_uri, "audio/")?;
3014
- Ok(json!({
3015
- "type": "input_audio",
3016
- "input_audio": { "data": data, "format": openai_audio_format(&mime_type) }
3017
- }))
3018
- }
3019
-
3020
- /// OpenAI's `input_audio.format` only accepts `"wav"` or `"mp3"` — anything
3021
- /// else (webm, ogg, m4a, …) is mapped to `"mp3"` as the closest-effort
3022
- /// fallback rather than rejected outright; the API itself is the final judge
3023
- /// of whether the actual bytes decode.
3024
- fn openai_audio_format(mime_type: &str) -> &'static str {
3025
- if mime_type.contains("wav") {
3026
- "wav"
3027
- } else {
3028
- "mp3"
3029
- }
3030
- }
3031
-
3032
- /// Splits a `data:<mime>;base64,<data>` URI into `(mime_type, base64_data)`,
3033
- /// validating the mime type starts with `expected_prefix` (e.g. `"image/"`).
3034
- fn split_data_uri(data_uri: &str, expected_prefix: &str) -> Result<(String, String), ChatError> {
3035
- data_uri
3036
- .strip_prefix("data:")
3037
- .and_then(|payload| payload.split_once(";base64,"))
3038
- .filter(|(mime, data)| mime.starts_with(expected_prefix) && !data.is_empty())
3039
- .map(|(mime, data)| (mime.to_owned(), data.to_owned()))
3040
- .ok_or(ChatError::InvalidAttachment)
3041
- }
3042
-
3043
- fn gemini_parts(request: &ChatRequest) -> Result<Vec<Value>, ChatError> {
3044
- let mut parts = vec![json!({ "text": request.message })];
3045
- if let Some(ref image_data) = request.image_data_uri {
3046
- for img in image_data.split_whitespace() {
3047
- let payload = img
3048
- .strip_prefix("data:")
3049
- .and_then(|payload| payload.split_once(";base64,"))
3050
- .filter(|(mime_type, data)| mime_type.starts_with("image/") && !data.is_empty())
3051
- .ok_or(ChatError::InvalidAttachment)?;
3052
- parts.push(json!({
3053
- "inlineData": {
3054
- "mimeType": payload.0,
3055
- "data": payload.1
3056
- }
3057
- }));
3058
- }
3059
- }
3060
- if let Some(ref audio_data) = request.audio_data_uri {
3061
- for aud in audio_data.split_whitespace() {
3062
- let payload = aud
3063
- .strip_prefix("data:")
3064
- .and_then(|payload| payload.split_once(";base64,"))
3065
- .filter(|(mime_type, data)| mime_type.starts_with("audio/") && !data.is_empty())
3066
- .ok_or(ChatError::InvalidAttachment)?;
3067
- parts.push(json!({
3068
- "inlineData": {
3069
- "mimeType": payload.0,
3070
- "data": payload.1
3071
- }
3072
- }));
3073
- }
3074
- }
3075
- if let Some(ref video_data) = request.video_data_uri {
3076
- for vid in video_data.split_whitespace() {
3077
- let payload = vid
3078
- .strip_prefix("data:")
3079
- .and_then(|payload| payload.split_once(";base64,"))
3080
- .filter(|(mime_type, data)| mime_type.starts_with("video/") && !data.is_empty())
3081
- .ok_or(ChatError::InvalidAttachment)?;
3082
- parts.push(json!({
3083
- "inlineData": {
3084
- "mimeType": payload.0,
3085
- "data": payload.1
3086
- }
3087
- }));
3088
- }
3089
- }
3090
- Ok(parts)
3091
- }
3092
-
3093
- #[cfg(test)]
3094
- mod tests {
3095
- use super::*;
3096
-
3097
- #[test]
3098
- fn rejects_missing_api_key() {
3099
- let error = required_key("gemini", "").unwrap_err();
3100
- assert!(matches!(error, ChatError::MissingApiKey(provider) if provider == "gemini"));
3101
- }
3102
-
3103
- #[test]
3104
- fn rejects_video_attachments_for_non_gemini_providers() {
3105
- let request = ChatRequest {
3106
- message: "describe this video".into(),
3107
- system_instruction: String::new(),
3108
- chat_id: None,
3109
- image_data_uri: None,
3110
- audio_data_uri: None,
3111
- video_data_uri: Some("data:video/mp4;base64,AAAA".into()),
3112
- document_attachment: None,
3113
- workspace_path: None,
3114
- agent_id: None,
3115
- plan_mode: false,
3116
- pinned_mcp_server: None,
3117
- messages: None,
3118
- tools: None,
3119
- temperature: None,
3120
- ..Default::default()
3121
- };
3122
-
3123
- let error = require_supported_attachments("openai", &request).unwrap_err();
3124
- assert!(
3125
- matches!(error, ChatError::UnsupportedAttachments(provider) if provider == "openai")
3126
- );
3127
- }
3128
-
3129
- #[test]
3130
- fn parses_stream_provider_formats() {
3131
- let openai = parse_stream_value(
3132
- StreamFormat::OpenAi,
3133
- r#"data: {"choices":[{"delta":{"content":"hello"}}]}"#,
3134
- )
3135
- .unwrap();
3136
- assert_eq!(openai["choices"][0]["delta"]["content"], "hello");
3137
- let ollama =
3138
- parse_stream_value(StreamFormat::Ollama, r#"{"message":{"content":"hi"}}"#).unwrap();
3139
- assert_eq!(ollama["message"]["content"], "hi");
3140
- assert!(parse_stream_value(StreamFormat::Anthropic, "data: [DONE]").is_none());
3141
- }
3142
-
3143
- #[test]
3144
- fn malformed_tool_arguments_survive_provider_parsing_for_rejection() {
3145
- let malformed = r#"{"path":"~/Downloads/YOUTUBE_DL""#;
3146
- let function = json!({"name": "list_files", "arguments": malformed});
3147
- let openai = parse_openai_style_reply(
3148
- "test".into(),
3149
- &json!({
3150
- "choices": [{"message": {"tool_calls": [{"id": "call_1", "function": function}]}}]
3151
- }),
3152
- )
3153
- .unwrap();
3154
- let ollama = parse_ollama_reply(
3155
- "test".into(),
3156
- &json!({
3157
- "message": {"tool_calls": [{"function": function}]}
3158
- }),
3159
- )
3160
- .unwrap();
3161
- let mut accumulator = StreamReplyAccumulator::default();
3162
- accumulator.consume_openai(&json!({
3163
- "choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": function}]}}]
3164
- }), &mut |_| {});
3165
- let streamed = accumulator.finish("test".into()).unwrap();
3166
- for reply in [openai, ollama, streamed] {
3167
- let calls = reply.tool_calls.unwrap();
3168
- assert_eq!(calls.len(), 1);
3169
- assert_eq!(calls[0].input, Value::String(malformed.into()));
3170
- }
3171
- assert_eq!(decode_tool_arguments(&json!("{}")), json!({}));
3172
- assert_eq!(decode_tool_arguments(&Value::Null), Value::Null);
3173
- }
3174
-
3175
- #[test]
3176
- fn accumulates_openai_reasoning_tools_and_usage() {
3177
- let mut accumulator = StreamReplyAccumulator::default();
3178
- let mut events = Vec::new();
3179
- accumulator.consume_openai(
3180
- &json!({
3181
- "choices": [{"delta": {"reasoning_content": "inspect "}}]
3182
- }),
3183
- &mut |event| events.push(event),
3184
- );
3185
- accumulator.consume_openai(&json!({
3186
- "choices": [{"delta": {"reasoning_content": "files", "tool_calls": [{
3187
- "index": 0, "id": "call_1", "function": {"name": "read_file", "arguments": "{\"path\":"}
3188
- }]}}]
3189
- }), &mut |event| events.push(event));
3190
- accumulator.consume_openai(
3191
- &json!({
3192
- "choices": [{"delta": {"tool_calls": [{
3193
- "index": 0, "function": {"arguments": "\"README.md\"}"}
3194
- }]}, "finish_reason": "tool_calls"}],
3195
- "usage": {"prompt_tokens": 10, "completion_tokens": 5}
3196
- }),
3197
- &mut |event| events.push(event),
3198
- );
3199
- accumulator.flush_tag_buffer(&mut |event| events.push(event));
3200
- let reply = accumulator.finish("test".into()).unwrap();
3201
- assert_eq!(reply.thought.as_deref(), Some("inspect files"));
3202
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_calls"));
3203
- assert_eq!(reply.input_tokens, Some(10));
3204
- assert_eq!(reply.tool_calls.unwrap()[0].input["path"], "README.md");
3205
- assert_eq!(
3206
- events
3207
- .iter()
3208
- .filter(|event| matches!(event, ChatStreamEvent::ReasoningDelta { .. }))
3209
- .count(),
3210
- 2
3211
- );
3212
- assert_eq!(
3213
- events
3214
- .iter()
3215
- .filter(|event| matches!(event, ChatStreamEvent::ToolCallDelta { .. }))
3216
- .count(),
3217
- 2
3218
- );
3219
- }
3220
-
3221
- #[test]
3222
- fn streams_think_tags_split_across_chunks() {
3223
- let mut accumulator = StreamReplyAccumulator::default();
3224
- let mut events = Vec::new();
3225
- for chunk in ["ก่อน<thi", "nk>คิด", "อยู่</th", "ink>หลัง"] {
3226
- accumulator.push_text(chunk, &mut |event| events.push(event));
3227
- }
3228
- accumulator.flush_tag_buffer(&mut |event| events.push(event));
3229
- let reply = accumulator.finish("test".into()).unwrap();
3230
- assert_eq!(reply.text, "ก่อนหลัง");
3231
- assert_eq!(reply.thought.as_deref(), Some("คิดอยู่"));
3232
- let streamed_reasoning: String = events
3233
- .iter()
3234
- .filter_map(|event| match event {
3235
- ChatStreamEvent::ReasoningDelta { delta } => Some(delta.as_str()),
3236
- _ => None,
3237
- })
3238
- .collect();
3239
- assert_eq!(streamed_reasoning, "คิดอยู่");
3240
- }
3241
-
3242
- #[test]
3243
- fn builds_gemini_multimodal_parts() {
3244
- let parts = gemini_parts(&ChatRequest {
3245
- message: "describe".into(),
3246
- system_instruction: String::new(),
3247
- chat_id: None,
3248
- image_data_uri: Some("data:image/png;base64,aGk= data:image/jpeg;base64,Ynl5".into()),
3249
- audio_data_uri: None,
3250
- video_data_uri: None,
3251
- document_attachment: None,
3252
- workspace_path: None,
3253
- agent_id: None,
3254
- plan_mode: false,
3255
- pinned_mcp_server: None,
3256
- messages: None,
3257
- tools: None,
3258
- temperature: None,
3259
- ..Default::default()
3260
- })
3261
- .unwrap();
3262
- assert_eq!(parts.len(), 3);
3263
- assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
3264
- assert_eq!(parts[2]["inlineData"]["mimeType"], "image/jpeg");
3265
- }
3266
-
3267
- fn sample_tool() -> ToolSpec {
3268
- ToolSpec {
3269
- name: "read_file".into(),
3270
- description: "Reads a file".into(),
3271
- parameters: json!({
3272
- "type": "object",
3273
- "properties": { "path": { "type": "string" } },
3274
- "required": ["path"]
3275
- }),
3276
- }
3277
- }
3278
-
3279
- fn native_request(messages: Vec<ChatMessage>, tools: Option<Vec<ToolSpec>>) -> ChatRequest {
3280
- ChatRequest {
3281
- message: String::new(),
3282
- system_instruction: "You are Mint.".into(),
3283
- chat_id: None,
3284
- image_data_uri: None,
3285
- audio_data_uri: None,
3286
- video_data_uri: None,
3287
- document_attachment: None,
3288
- workspace_path: None,
3289
- agent_id: None,
3290
- plan_mode: false,
3291
- pinned_mcp_server: None,
3292
- messages: Some(messages),
3293
- tools,
3294
- temperature: None,
3295
- ..Default::default()
3296
- }
3297
- }
3298
-
3299
- #[test]
3300
- fn anthropic_native_payload_includes_tools_and_tool_result_messages() {
3301
- let messages = vec![
3302
- ChatMessage::text(ChatRole::User, "read main.rs"),
3303
- ChatMessage {
3304
- role: ChatRole::Assistant,
3305
- content: vec![ContentBlock::ToolUse {
3306
- id: "call_1".into(),
3307
- name: "read_file".into(),
3308
- input: json!({ "path": "main.rs" }),
3309
- thought_signature: None,
3310
- }],
3311
- },
3312
- ChatMessage {
3313
- role: ChatRole::Tool,
3314
- content: vec![ContentBlock::ToolResult {
3315
- tool_use_id: "call_1".into(),
3316
- content: "fn main() {}".into(),
3317
- is_error: false,
3318
- }],
3319
- },
3320
- ];
3321
- let request = native_request(messages, Some(vec![sample_tool()]));
3322
- let cfg = MintConfig::default();
3323
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3324
-
3325
- assert_eq!(payload["system"][0]["text"], "You are Mint.");
3326
- assert_eq!(payload["tools"][0]["name"], "read_file");
3327
- assert_eq!(payload["tools"][0]["input_schema"]["type"], "object");
3328
- let msgs = payload["messages"].as_array().unwrap();
3329
- assert_eq!(msgs.len(), 3);
3330
- assert_eq!(msgs[1]["role"], "assistant");
3331
- assert_eq!(msgs[1]["content"][0]["type"], "tool_use");
3332
- assert_eq!(msgs[2]["role"], "user");
3333
- assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
3334
- assert_eq!(msgs[2]["content"][0]["tool_use_id"], "call_1");
3335
- }
3336
-
3337
- #[test]
3338
- fn anthropic_payload_marks_the_system_prompt_as_cacheable() {
3339
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
3340
- let cfg = MintConfig::default();
3341
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3342
-
3343
- assert_eq!(payload["system"][0]["type"], "text");
3344
- assert_eq!(payload["system"][0]["text"], "You are Mint.");
3345
- assert_eq!(payload["system"][0]["cache_control"]["type"], "ephemeral");
3346
- }
3347
-
3348
- #[test]
3349
- fn review_anthropic_tool_results_preserve_failure_and_success_flags() {
3350
- for (text, is_error) in [
3351
- ("User denied MCP tool call", true),
3352
- ("MCP call was not approved; user feedback", true),
3353
- ("Error: literal diagnostic label", false),
3354
- ] {
3355
- let request = native_request(
3356
- vec![ChatMessage {
3357
- role: ChatRole::Tool,
3358
- content: vec![ContentBlock::ToolResult {
3359
- tool_use_id: "mcp-call".into(),
3360
- content: text.into(),
3361
- is_error,
3362
- }],
3363
- }],
3364
- None,
3365
- );
3366
- let payload =
3367
- anthropic_chat_payload(&MintConfig::default(), "claude-x", &request, false)
3368
- .unwrap();
3369
- assert_eq!(payload["messages"][0]["content"][0]["is_error"], is_error);
3370
- assert_eq!(payload["messages"][0]["content"][0]["content"], text);
3371
- }
3372
- }
3373
-
3374
- #[test]
3375
- fn anthropic_payload_omits_the_cache_breakpoint_for_an_empty_system_prompt() {
3376
- let mut request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
3377
- request.system_instruction = String::new();
3378
- let cfg = MintConfig::default();
3379
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3380
-
3381
- // Falls back to the plain-string shape rather than an empty cacheable
3382
- // block, matching the pre-caching behavior exactly for this case.
3383
- assert_eq!(payload["system"], "");
3384
- }
3385
-
3386
- #[test]
3387
- fn anthropic_payload_marks_only_the_last_tool_as_the_cache_breakpoint() {
3388
- let second_tool = ToolSpec {
3389
- name: "write_file".into(),
3390
- description: "Writes a file".into(),
3391
- parameters: json!({ "type": "object" }),
3392
- };
3393
- let request = native_request(
3394
- vec![ChatMessage::text(ChatRole::User, "hi")],
3395
- Some(vec![sample_tool(), second_tool]),
3396
- );
3397
- let cfg = MintConfig::default();
3398
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3399
-
3400
- let tools = payload["tools"].as_array().unwrap();
3401
- assert_eq!(tools.len(), 2);
3402
- assert!(tools[0].get("cache_control").is_none());
3403
- assert_eq!(tools[1]["cache_control"]["type"], "ephemeral");
3404
- }
3405
-
3406
- #[test]
3407
- fn anthropic_attaches_cache_control_to_penultimate_turn() {
3408
- let request = native_request(
3409
- vec![
3410
- ChatMessage::text(ChatRole::User, "step 1 user"),
3411
- ChatMessage::text(ChatRole::Assistant, "step 1 assistant"),
3412
- ChatMessage::text(ChatRole::User, "step 2 user"),
3413
- ],
3414
- None,
3415
- );
3416
- let cfg = MintConfig::default();
3417
- let payload = anthropic_chat_payload(&cfg, "claude-x", &request, false).unwrap();
3418
- let messages = payload["messages"].as_array().unwrap();
3419
- assert_eq!(messages.len(), 3);
3420
- assert!(messages[0]["content"][0].get("cache_control").is_none());
3421
- assert_eq!(
3422
- messages[1]["content"][0]["cache_control"]["type"],
3423
- "ephemeral"
3424
- );
3425
- assert!(messages[2]["content"][0].get("cache_control").is_none());
3426
- }
3427
-
3428
- #[test]
3429
- fn parses_anthropic_reply_with_text_and_tool_use_blocks() {
3430
- let response = json!({
3431
- "stop_reason": "tool_use",
3432
- "content": [
3433
- { "type": "text", "text": "Let me check that file." },
3434
- { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "main.rs" } }
3435
- ]
3436
- });
3437
- let reply = parse_anthropic_reply("claude-x".into(), &response).unwrap();
3438
- assert_eq!(reply.text, "Let me check that file.");
3439
- let calls = reply.tool_calls.unwrap();
3440
- assert_eq!(calls.len(), 1);
3441
- assert_eq!(calls[0].name, "read_file");
3442
- assert_eq!(calls[0].input["path"], "main.rs");
3443
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_use"));
3444
- }
3445
-
3446
- #[test]
3447
- fn parses_anthropic_reply_with_tool_use_only_and_no_text() {
3448
- let response = json!({
3449
- "content": [
3450
- { "type": "tool_use", "id": "toolu_1", "name": "run_shell", "input": { "command": "ls" } }
3451
- ]
3452
- });
3453
- let reply = parse_anthropic_reply("claude-x".into(), &response).unwrap();
3454
- assert_eq!(reply.text, "");
3455
- assert_eq!(reply.tool_calls.unwrap().len(), 1);
3456
- }
3457
-
3458
- #[test]
3459
- fn openai_native_payload_encodes_tool_calls_and_results() {
3460
- let messages = vec![
3461
- ChatMessage::text(ChatRole::User, "list files"),
3462
- ChatMessage {
3463
- role: ChatRole::Assistant,
3464
- content: vec![ContentBlock::ToolUse {
3465
- id: "call_1".into(),
3466
- name: "list_files".into(),
3467
- input: json!({ "path": "." }),
3468
- thought_signature: None,
3469
- }],
3470
- },
3471
- ChatMessage {
3472
- role: ChatRole::Tool,
3473
- content: vec![ContentBlock::ToolResult {
3474
- tool_use_id: "call_1".into(),
3475
- content: "Cargo.toml\nsrc/".into(),
3476
- is_error: false,
3477
- }],
3478
- },
3479
- ];
3480
- let request = native_request(messages, Some(vec![sample_tool()]));
3481
- let cfg = MintConfig::default();
3482
- let payload = openai_chat_payload(&cfg, "gpt-x", &request, false).unwrap();
3483
-
3484
- assert_eq!(payload["tools"][0]["type"], "function");
3485
- assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
3486
- let msgs = payload["messages"].as_array().unwrap();
3487
- // system + user + assistant(tool_calls) + tool
3488
- assert_eq!(msgs.len(), 4);
3489
- assert_eq!(msgs[2]["role"], "assistant");
3490
- assert_eq!(msgs[2]["tool_calls"][0]["function"]["name"], "list_files");
3491
- assert_eq!(msgs[3]["role"], "tool");
3492
- assert_eq!(msgs[3]["tool_call_id"], "call_1");
3493
- }
3494
-
3495
- #[test]
3496
- fn openai_native_payload_forwards_a_screenshot_riding_a_tool_result_as_a_user_turn() {
3497
- // OpenAI's Chat Completions `tool` role only accepts string content, so an
3498
- // `Image` block attached to a tool result (e.g. `browser_screenshot`) must
3499
- // surface as a follow-up `user` message instead of being dropped.
3500
- let messages = vec![ChatMessage {
3501
- role: ChatRole::Tool,
3502
- content: vec![
3503
- ContentBlock::ToolResult {
3504
- tool_use_id: "call_1".into(),
3505
- content: "[Screenshot captured — see attached image]".into(),
3506
- is_error: false,
3507
- },
3508
- ContentBlock::Image {
3509
- data_uri: "data:image/png;base64,QUJD".into(),
3510
- },
3511
- ],
3512
- }];
3513
- let built = openai_native_messages(&messages).unwrap();
3514
- assert_eq!(built.len(), 2);
3515
- assert_eq!(built[0]["role"], "tool");
3516
- assert_eq!(built[0]["tool_call_id"], "call_1");
3517
- assert_eq!(built[1]["role"], "user");
3518
- assert_eq!(built[1]["content"][0]["type"], "image_url");
3519
- assert_eq!(
3520
- built[1]["content"][0]["image_url"]["url"],
3521
- "data:image/png;base64,QUJD"
3522
- );
3523
- }
3524
-
3525
- #[test]
3526
- fn openai_native_messages_attaches_reasoning_content_for_tool_calls() {
3527
- let messages = vec![ChatMessage {
3528
- role: ChatRole::Assistant,
3529
- content: vec![ContentBlock::ToolUse {
3530
- id: "call_abc".into(),
3531
- name: "read_file".into(),
3532
- input: json!({"path": "src/main.rs"}),
3533
- thought_signature: None,
3534
- }],
3535
- }];
3536
- let built = openai_native_messages(&messages).unwrap();
3537
- assert_eq!(built.len(), 1);
3538
- assert_eq!(built[0]["role"], "assistant");
3539
- assert_eq!(built[0]["reasoning_content"], json!(""));
3540
- assert_eq!(built[0]["tool_calls"][0]["function"]["name"], "read_file");
3541
- }
3542
-
3543
- #[test]
3544
- fn openai_serializes_compacted_history_with_role_alternation() {
3545
- let messages = vec![
3546
- ChatMessage::text(ChatRole::User, "Fix the bug"),
3547
- ChatMessage::text(
3548
- ChatRole::Assistant,
3549
- "Summary of earlier steps:\n- read file.rs\n- found syntax error",
3550
- ),
3551
- ChatMessage::text(
3552
- ChatRole::User,
3553
- "Please continue the task from where you left off based on the summary above.",
3554
- ),
3555
- ChatMessage {
3556
- role: ChatRole::Assistant,
3557
- content: vec![ContentBlock::ToolUse {
3558
- id: "call_1".into(),
3559
- name: "apply_patch".into(),
3560
- input: json!({"path": "file.rs", "patch": "..."}),
3561
- thought_signature: None,
3562
- }],
3563
- },
3564
- ChatMessage {
3565
- role: ChatRole::Tool,
3566
- content: vec![ContentBlock::ToolResult {
3567
- tool_use_id: "call_1".into(),
3568
- content: "Patch applied".into(),
3569
- is_error: false,
3570
- }],
3571
- },
3572
- ];
3573
- let built = openai_native_messages(&messages).unwrap();
3574
- assert_eq!(built.len(), 5);
3575
- assert_eq!(built[0]["role"], "user");
3576
- assert_eq!(built[1]["role"], "assistant");
3577
- assert_eq!(built[2]["role"], "user");
3578
- assert_eq!(built[3]["role"], "assistant");
3579
- assert_eq!(built[4]["role"], "tool");
3580
- }
3581
-
3582
- #[test]
3583
- fn anthropic_serializes_compacted_history_with_role_alternation() {
3584
- let messages = vec![
3585
- ChatMessage::text(ChatRole::User, "Fix the bug"),
3586
- ChatMessage::text(ChatRole::Assistant, "Summary of earlier steps"),
3587
- ChatMessage::text(ChatRole::User, "Please continue"),
3588
- ChatMessage {
3589
- role: ChatRole::Assistant,
3590
- content: vec![ContentBlock::ToolUse {
3591
- id: "toolu_1".into(),
3592
- name: "read_file".into(),
3593
- input: json!({"path": "file.rs"}),
3594
- thought_signature: None,
3595
- }],
3596
- },
3597
- ChatMessage {
3598
- role: ChatRole::Tool,
3599
- content: vec![ContentBlock::ToolResult {
3600
- tool_use_id: "toolu_1".into(),
3601
- content: "Code content".into(),
3602
- is_error: false,
3603
- }],
3604
- },
3605
- ];
3606
- let built = anthropic_messages(&messages).unwrap();
3607
- assert_eq!(built.len(), 5);
3608
- assert_eq!(built[0]["role"], "user");
3609
- assert_eq!(built[1]["role"], "assistant");
3610
- assert_eq!(built[2]["role"], "user");
3611
- assert_eq!(built[3]["role"], "assistant");
3612
- assert_eq!(built[4]["role"], "user");
3613
- }
3614
-
3615
- #[test]
3616
- fn gemini_serializes_compacted_history_with_role_alternation() {
3617
- let messages = vec![
3618
- ChatMessage::text(ChatRole::User, "Fix the bug"),
3619
- ChatMessage::text(ChatRole::Assistant, "Summary of earlier steps"),
3620
- ChatMessage::text(ChatRole::User, "Please continue"),
3621
- ChatMessage {
3622
- role: ChatRole::Assistant,
3623
- content: vec![ContentBlock::ToolUse {
3624
- id: "call_1".into(),
3625
- name: "read_file".into(),
3626
- input: json!({"path": "file.rs"}),
3627
- thought_signature: None,
3628
- }],
3629
- },
3630
- ChatMessage {
3631
- role: ChatRole::Tool,
3632
- content: vec![ContentBlock::ToolResult {
3633
- tool_use_id: "call_1".into(),
3634
- content: "Code content".into(),
3635
- is_error: false,
3636
- }],
3637
- },
3638
- ];
3639
- let built = gemini_native_contents(&messages).unwrap();
3640
- assert!(built.len() >= 4);
3641
- }
3642
-
3643
- #[test]
3644
- fn openai_allows_audio_attachments() {
3645
- let request = ChatRequest {
3646
- message: "transcribe this".into(),
3647
- system_instruction: String::new(),
3648
- chat_id: None,
3649
- image_data_uri: None,
3650
- audio_data_uri: Some("data:audio/wav;base64,QUJD".into()),
3651
- video_data_uri: None,
3652
- document_attachment: None,
3653
- workspace_path: None,
3654
- agent_id: None,
3655
- plan_mode: false,
3656
- pinned_mcp_server: None,
3657
- messages: None,
3658
- tools: None,
3659
- temperature: None,
3660
- ..Default::default()
3661
- };
3662
- assert!(require_supported_attachments("openai", &request).is_ok());
3663
- }
3664
-
3665
- #[test]
3666
- fn non_openai_openai_family_providers_still_reject_audio() {
3667
- // Only the real OpenAI API is confirmed to speak the `input_audio`
3668
- // schema — proxies/other services built on the same wire format
3669
- // (local_openai, openrouter, deepseek, huggingface) and Anthropic
3670
- // (no audio modality at all) must stay rejected.
3671
- let request = ChatRequest {
3672
- message: "transcribe this".into(),
3673
- system_instruction: String::new(),
3674
- chat_id: None,
3675
- image_data_uri: None,
3676
- audio_data_uri: Some("data:audio/wav;base64,QUJD".into()),
3677
- video_data_uri: None,
3678
- document_attachment: None,
3679
- workspace_path: None,
3680
- agent_id: None,
3681
- plan_mode: false,
3682
- pinned_mcp_server: None,
3683
- messages: None,
3684
- tools: None,
3685
- temperature: None,
3686
- ..Default::default()
3687
- };
3688
- for provider in [
3689
- "local_openai",
3690
- "openrouter",
3691
- "deepseek",
3692
- "huggingface",
3693
- "anthropic",
3694
- ] {
3695
- let error = require_supported_attachments(provider, &request).unwrap_err();
3696
- assert!(
3697
- matches!(&error, ChatError::UnsupportedAttachments(p) if p == provider),
3698
- "expected {provider} to reject audio, got {error:?}"
3699
- );
3700
- }
3701
- }
3702
-
3703
- #[test]
3704
- fn openai_audio_part_splits_the_data_uri_and_maps_wav_format() {
3705
- let part = openai_audio_part("data:audio/wav;base64,QUJD").unwrap();
3706
- assert_eq!(part["type"], "input_audio");
3707
- assert_eq!(part["input_audio"]["data"], "QUJD");
3708
- assert_eq!(part["input_audio"]["format"], "wav");
3709
- }
3710
-
3711
- #[test]
3712
- fn openai_audio_part_defaults_unrecognized_formats_to_mp3() {
3713
- let part = openai_audio_part("data:audio/webm;base64,QUJD").unwrap();
3714
- assert_eq!(part["input_audio"]["format"], "mp3");
3715
- }
3716
-
3717
- #[test]
3718
- fn openai_audio_part_rejects_a_malformed_data_uri() {
3719
- assert!(openai_audio_part("not-a-data-uri").is_err());
3720
- }
3721
-
3722
- #[test]
3723
- fn openai_message_content_embeds_audio_alongside_text() {
3724
- let content =
3725
- openai_message_content("what is this?", None, Some("data:audio/mp3;base64,QUJD"))
3726
- .unwrap();
3727
- let parts = content.as_array().unwrap();
3728
- assert_eq!(parts[0]["type"], "text");
3729
- assert_eq!(parts[1]["type"], "input_audio");
3730
- assert_eq!(parts[1]["input_audio"]["data"], "QUJD");
3731
- }
3732
-
3733
- #[test]
3734
- fn openai_user_content_encodes_an_audio_content_block() {
3735
- let message = ChatMessage {
3736
- role: ChatRole::User,
3737
- content: vec![
3738
- ContentBlock::Text {
3739
- text: "what is this?".into(),
3740
- },
3741
- ContentBlock::Audio {
3742
- data_uri: "data:audio/wav;base64,QUJD".into(),
3743
- },
3744
- ],
3745
- };
3746
- let content = openai_user_content(&message).unwrap();
3747
- let parts = content.as_array().unwrap();
3748
- assert_eq!(parts.len(), 2);
3749
- assert_eq!(parts[1]["type"], "input_audio");
3750
- assert_eq!(parts[1]["input_audio"]["format"], "wav");
3751
- }
3752
-
3753
- #[test]
3754
- fn parses_openai_style_reply_with_tool_calls() {
3755
- let response = json!({
3756
- "choices": [{
3757
- "finish_reason": "tool_calls",
3758
- "message": {
3759
- "content": null,
3760
- "tool_calls": [{
3761
- "id": "call_1",
3762
- "function": { "name": "read_file", "arguments": "{\"path\":\"main.rs\"}" }
3763
- }]
3764
- }
3765
- }]
3766
- });
3767
- let reply = parse_openai_style_reply("gpt-x".into(), &response).unwrap();
3768
- assert_eq!(reply.text, "");
3769
- let calls = reply.tool_calls.unwrap();
3770
- assert_eq!(calls[0].name, "read_file");
3771
- assert_eq!(calls[0].input["path"], "main.rs");
3772
- assert_eq!(reply.stop_reason.as_deref(), Some("tool_calls"));
3773
- }
3774
-
3775
- #[test]
3776
- fn gemini_sanitize_schema_strips_additional_properties_at_any_depth() {
3777
- let schema = json!({
3778
- "type": "object",
3779
- "properties": {
3780
- "emotions": {
3781
- "type": "object",
3782
- "additionalProperties": { "type": "string" }
3783
- },
3784
- "nested": {
3785
- "type": "array",
3786
- "items": {
3787
- "type": "object",
3788
- "additionalProperties": true
3789
- }
3790
- }
3791
- }
3792
- });
3793
- let sanitized = gemini_sanitize_schema(&schema);
3794
- assert!(
3795
- sanitized["properties"]["emotions"]
3796
- .get("additionalProperties")
3797
- .is_none()
3798
- );
3799
- assert!(
3800
- sanitized["properties"]["nested"]["items"]
3801
- .get("additionalProperties")
3802
- .is_none()
3803
- );
3804
- // Everything else is preserved untouched.
3805
- assert_eq!(sanitized["properties"]["emotions"]["type"], "object");
3806
- }
3807
-
3808
- #[test]
3809
- fn gemini_native_payload_strips_additional_properties_from_tool_schemas() {
3810
- let tool = ToolSpec {
3811
- name: "avatar_signal".into(),
3812
- description: "set avatar state".into(),
3813
- parameters: json!({
3814
- "type": "object",
3815
- "properties": {
3816
- "emotions": {
3817
- "type": "object",
3818
- "additionalProperties": { "type": "string" }
3819
- }
3820
- }
3821
- }),
3822
- };
3823
- let request = native_request(
3824
- vec![ChatMessage::text(ChatRole::User, "hi")],
3825
- Some(vec![tool]),
3826
- );
3827
- let payload = gemini_native_payload(&request).unwrap();
3828
- assert!(
3829
- payload["tools"][0]["functionDeclarations"][0]["parameters"]["properties"]["emotions"]
3830
- .get("additionalProperties")
3831
- .is_none()
3832
- );
3833
- }
3834
-
3835
- #[test]
3836
- fn gemini_native_payload_uses_function_declarations_and_resolves_tool_result_names() {
3837
- let messages = vec![
3838
- ChatMessage::text(ChatRole::User, "check weather"),
3839
- ChatMessage {
3840
- role: ChatRole::Assistant,
3841
- content: vec![ContentBlock::ToolUse {
3842
- id: "call_0".into(),
3843
- name: "weather".into(),
3844
- input: json!({ "city": "Bangkok" }),
3845
- thought_signature: None,
3846
- }],
3847
- },
3848
- ChatMessage {
3849
- role: ChatRole::Tool,
3850
- content: vec![ContentBlock::ToolResult {
3851
- tool_use_id: "call_0".into(),
3852
- content: "32C, sunny".into(),
3853
- is_error: false,
3854
- }],
3855
- },
3856
- ];
3857
- let request = native_request(messages, Some(vec![sample_tool()]));
3858
- let payload = gemini_native_payload(&request).unwrap();
3859
-
3860
- assert_eq!(
3861
- payload["tools"][0]["functionDeclarations"][0]["name"],
3862
- "read_file"
3863
- );
3864
- let contents = payload["contents"].as_array().unwrap();
3865
- assert_eq!(contents[1]["role"], "model");
3866
- assert_eq!(contents[1]["parts"][0]["functionCall"]["name"], "weather");
3867
- assert_eq!(contents[2]["role"], "user");
3868
- // Resolved back to the function name "weather", not the call id "call_0".
3869
- assert_eq!(
3870
- contents[2]["parts"][0]["functionResponse"]["name"],
3871
- "weather"
3872
- );
3873
- }
3874
-
3875
- #[test]
3876
- fn gemini_native_payload_replays_the_thought_signature_on_the_next_turn() {
3877
- // Gemini 3.x rejects a replayed `functionCall` that's missing the
3878
- // `thoughtSignature` the model originally attached to it (400
3879
- // INVALID_ARGUMENT) — without this, every second tool-calling turn
3880
- // failed and silently fell back to a different, weaker provider.
3881
- let messages = vec![
3882
- ChatMessage::text(ChatRole::User, "check weather"),
3883
- ChatMessage {
3884
- role: ChatRole::Assistant,
3885
- content: vec![ContentBlock::ToolUse {
3886
- id: "call_0".into(),
3887
- name: "weather".into(),
3888
- input: json!({ "city": "Bangkok" }),
3889
- thought_signature: Some("opaque-signature-abc".into()),
3890
- }],
3891
- },
3892
- ];
3893
- let request = native_request(messages, Some(vec![sample_tool()]));
3894
- let payload = gemini_native_payload(&request).unwrap();
3895
-
3896
- let contents = payload["contents"].as_array().unwrap();
3897
- assert_eq!(
3898
- contents[1]["parts"][0]["thoughtSignature"],
3899
- "opaque-signature-abc"
3900
- );
3901
- // Sibling of `functionCall`, not nested inside it.
3902
- assert_eq!(contents[1]["parts"][0]["functionCall"]["name"], "weather");
3903
- }
3904
-
3905
- #[test]
3906
- fn gemini_native_payload_omits_thought_signature_when_absent() {
3907
- let messages = vec![ChatMessage {
3908
- role: ChatRole::Assistant,
3909
- content: vec![ContentBlock::ToolUse {
3910
- id: "call_0".into(),
3911
- name: "weather".into(),
3912
- input: json!({ "city": "Bangkok" }),
3913
- thought_signature: None,
3914
- }],
3915
- }];
3916
- let request = native_request(messages, Some(vec![sample_tool()]));
3917
- let payload = gemini_native_payload(&request).unwrap();
3918
-
3919
- let contents = payload["contents"].as_array().unwrap();
3920
- assert!(contents[0]["parts"][0].get("thoughtSignature").is_none());
3921
- }
3922
-
3923
- #[test]
3924
- fn parses_gemini_reply_with_function_call_parts() {
3925
- let response = json!({
3926
- "candidates": [{
3927
- "finishReason": "STOP",
3928
- "content": {
3929
- "parts": [
3930
- { "text": "Checking the weather now." },
3931
- { "functionCall": { "name": "weather", "args": { "city": "Bangkok" } } }
3932
- ]
3933
- }
3934
- }]
3935
- });
3936
- let reply = parse_gemini_reply("gemini-x".into(), &response).unwrap();
3937
- assert_eq!(reply.text, "Checking the weather now.");
3938
- let calls = reply.tool_calls.unwrap();
3939
- assert_eq!(calls[0].name, "weather");
3940
- assert_eq!(calls[0].input["city"], "Bangkok");
3941
- }
3942
-
3943
- #[test]
3944
- fn parses_gemini_reply_captures_the_thought_signature_off_the_function_call_part() {
3945
- let response = json!({
3946
- "candidates": [{
3947
- "finishReason": "STOP",
3948
- "content": {
3949
- "parts": [{
3950
- "functionCall": { "name": "weather", "args": { "city": "Bangkok" } },
3951
- "thoughtSignature": "opaque-signature-abc"
3952
- }]
3953
- }
3954
- }]
3955
- });
3956
- let reply = parse_gemini_reply("gemini-x".into(), &response).unwrap();
3957
- let calls = reply.tool_calls.unwrap();
3958
- assert_eq!(
3959
- calls[0].thought_signature.as_deref(),
3960
- Some("opaque-signature-abc")
3961
- );
3962
- }
3963
-
3964
- #[test]
3965
- fn ollama_native_messages_send_tool_call_arguments_as_object_not_string() {
3966
- let messages = vec![
3967
- ChatMessage::text(ChatRole::User, "list files"),
3968
- ChatMessage {
3969
- role: ChatRole::Assistant,
3970
- content: vec![ContentBlock::ToolUse {
3971
- id: "call_1".into(),
3972
- name: "list_files".into(),
3973
- input: json!({ "path": "." }),
3974
- thought_signature: None,
3975
- }],
3976
- },
3977
- ];
3978
- let built = ollama_native_messages(&messages);
3979
- assert_eq!(built[1]["tool_calls"][0]["function"]["name"], "list_files");
3980
- // Ollama expects `arguments` as a JSON object, unlike OpenAI's stringified form.
3981
- assert!(built[1]["tool_calls"][0]["function"]["arguments"].is_object());
3982
- }
3983
-
3984
- #[test]
3985
- fn ollama_native_messages_forward_a_screenshot_riding_a_tool_result_as_a_user_turn() {
3986
- // Ollama's `/api/chat` `tool` role message has no image field, so an
3987
- // `Image` block attached to a tool result (e.g. `browser_screenshot`)
3988
- // must surface as a follow-up `user` message with the `images` field.
3989
- let messages = vec![ChatMessage {
3990
- role: ChatRole::Tool,
3991
- content: vec![
3992
- ContentBlock::ToolResult {
3993
- tool_use_id: "call_1".into(),
3994
- content: "[Screenshot captured — see attached image]".into(),
3995
- is_error: false,
3996
- },
3997
- ContentBlock::Image {
3998
- data_uri: "data:image/png;base64,QUJD".into(),
3999
- },
4000
- ],
4001
- }];
4002
- let built = ollama_native_messages(&messages);
4003
- assert_eq!(built.len(), 2);
4004
- assert_eq!(built[0]["role"], "tool");
4005
- assert_eq!(built[1]["role"], "user");
4006
- assert_eq!(built[1]["images"][0], "QUJD");
4007
- }
4008
-
4009
- #[test]
4010
- fn parses_ollama_reply_from_native_message_envelope() {
4011
- let response = json!({
4012
- "message": {
4013
- "content": "",
4014
- "tool_calls": [{
4015
- "function": { "name": "list_files", "arguments": { "path": "." } }
4016
- }]
4017
- }
4018
- });
4019
- let reply = parse_ollama_reply("llama3.1".into(), &response).unwrap();
4020
- assert_eq!(reply.tool_calls.unwrap()[0].name, "list_files");
4021
- }
4022
-
4023
- #[test]
4024
- fn extracts_input_and_output_tokens_from_each_provider_usage_shape() {
4025
- let anthropic = parse_anthropic_reply(
4026
- "claude-x".into(),
4027
- &json!({
4028
- "content": [{ "type": "text", "text": "hi" }],
4029
- "usage": { "input_tokens": 100, "output_tokens": 20 }
4030
- }),
4031
- )
4032
- .unwrap();
4033
- assert_eq!(
4034
- (anthropic.input_tokens, anthropic.output_tokens),
4035
- (Some(100), Some(20))
4036
- );
4037
-
4038
- let openai = parse_openai_style_reply(
4039
- "gpt-x".into(),
4040
- &json!({
4041
- "choices": [{ "message": { "content": "hi" } }],
4042
- "usage": { "prompt_tokens": 130, "completion_tokens": 20, "total_tokens": 150 }
4043
- }),
4044
- )
4045
- .unwrap();
4046
- assert_eq!(
4047
- (openai.input_tokens, openai.output_tokens),
4048
- (Some(130), Some(20))
4049
- );
4050
-
4051
- let gemini = parse_gemini_reply(
4052
- "gemini-x".into(),
4053
- &json!({
4054
- "candidates": [{ "content": { "parts": [{ "text": "hi" }] } }],
4055
- "usageMetadata": {
4056
- "promptTokenCount": 160,
4057
- "candidatesTokenCount": 40,
4058
- "totalTokenCount": 200
4059
- }
4060
- }),
4061
- )
4062
- .unwrap();
4063
- assert_eq!(
4064
- (gemini.input_tokens, gemini.output_tokens),
4065
- (Some(160), Some(40))
4066
- );
4067
-
4068
- let ollama = parse_ollama_reply(
4069
- "llama3.1".into(),
4070
- &json!({
4071
- "message": { "content": "hi" },
4072
- "prompt_eval_count": 80,
4073
- "eval_count": 40
4074
- }),
4075
- )
4076
- .unwrap();
4077
- assert_eq!(
4078
- (ollama.input_tokens, ollama.output_tokens),
4079
- (Some(80), Some(40))
4080
- );
4081
- }
4082
- #[test]
4083
- fn anthropic_cached_prompt_tokens_count_toward_context_in_json_and_streams() {
4084
- 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}});
4085
- let reply = parse_anthropic_reply(
4086
- "claude-test".into(),
4087
- &json!({"content":[{"type":"text","text":"Ready"}],"usage":usage}),
4088
- )
4089
- .unwrap();
4090
- assert_eq!(reply.input_tokens, Some(99_100));
4091
- let mut stream = StreamReplyAccumulator::default();
4092
- stream.consume_anthropic(
4093
- &json!({"type":"message_start","message":{"usage":usage}}),
4094
- &mut |_| {},
4095
- );
4096
- stream.consume_anthropic(
4097
- &json!({"type":"content_block_delta","delta":{"type":"text_delta","text":"Ready"}}),
4098
- &mut |_| {},
4099
- );
4100
- stream.consume_anthropic(
4101
- &json!({"type":"message_delta","usage":{"output_tokens":400}}),
4102
- &mut |_| {},
4103
- );
4104
- let reply = stream.finish("claude-test".into()).unwrap();
4105
- assert_eq!(reply.input_tokens, Some(99_100));
4106
- assert_eq!(reply.output_tokens, Some(400));
4107
- }
4108
-
4109
- #[test]
4110
- fn missing_usage_data_yields_no_token_counts() {
4111
- let reply = parse_openai_style_reply(
4112
- "gpt-x".into(),
4113
- &json!({ "choices": [{ "message": { "content": "hi" } }] }),
4114
- )
4115
- .unwrap();
4116
- assert_eq!((reply.input_tokens, reply.output_tokens), (None, None));
4117
- }
4118
-
4119
- #[tokio::test]
4120
- async fn send_openai_style_request_classifies_a_connect_failure_as_network_unavailable() {
4121
- let client = Client::new();
4122
- // Port 1 is reserved and nothing listens on it, so this fails fast
4123
- // with a connection error rather than timing out.
4124
- let result = send_openai_style_request(
4125
- client.post("http://127.0.0.1:1/chat/completions"),
4126
- "deepseek",
4127
- )
4128
- .await;
4129
- assert!(
4130
- matches!(result, Err(ChatError::NetworkUnavailable)),
4131
- "expected NetworkUnavailable, got {result:?}"
4132
- );
4133
- }
4134
-
4135
- #[tokio::test]
4136
- async fn send_openai_style_request_classifies_402_as_insufficient_balance() {
4137
- use tokio::io::{AsyncReadExt, AsyncWriteExt};
4138
- use tokio::net::TcpListener;
4139
-
4140
- let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4141
- let addr = listener.local_addr().unwrap();
4142
- tokio::spawn(async move {
4143
- let (mut socket, _) = listener.accept().await.unwrap();
4144
- let mut buf = [0u8; 1024];
4145
- let _ = socket.read(&mut buf).await;
4146
- let body = "{}";
4147
- let response = format!(
4148
- "HTTP/1.1 402 Payment Required\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4149
- body.len()
4150
- );
4151
- let _ = socket.write_all(response.as_bytes()).await;
4152
- let _ = socket.shutdown().await;
4153
- });
4154
-
4155
- let client = Client::new();
4156
- let result = send_openai_style_request(
4157
- client.post(format!("http://{addr}/chat/completions")),
4158
- "deepseek",
4159
- )
4160
- .await;
4161
- assert!(
4162
- matches!(&result, Err(ChatError::InsufficientBalance(p)) if p == "deepseek"),
4163
- "expected InsufficientBalance(\"deepseek\"), got {result:?}"
4164
- );
4165
- }
4166
-
4167
- #[test]
4168
- fn fallback_provider_order_moves_gemini_to_the_front() {
4169
- let config = MintConfig {
4170
- anthropic_api_key: "a".into(),
4171
- openai_api_key: "b".into(),
4172
- deepseek_api_key: "c".into(),
4173
- api_key: "d".into(), // gemini
4174
- hf_api_key: "e".into(),
4175
- ..MintConfig::default()
4176
- };
4177
- let order = fallback_provider_order(&config);
4178
- assert_eq!(order[0], "gemini");
4179
- // Everyone else keeps their existing relative order from
4180
- // `available_providers()` — only gemini moves.
4181
- let without_gemini: Vec<&String> = order.iter().skip(1).collect();
4182
- assert_eq!(
4183
- without_gemini,
4184
- vec!["anthropic", "openai", "deepseek", "huggingface", "ollama"]
4185
- );
4186
- }
4187
-
4188
- #[test]
4189
- fn fallback_provider_order_is_unchanged_without_gemini_configured() {
4190
- let config = MintConfig {
4191
- deepseek_api_key: "c".into(),
4192
- hf_api_key: "e".into(),
4193
- ..MintConfig::default()
4194
- };
4195
- assert_eq!(
4196
- fallback_provider_order(&config),
4197
- config.available_providers()
4198
- );
4199
- }
4200
-
4201
- #[test]
4202
- fn openai_payload_includes_smart_temperature_for_deepseek_and_omits_for_o1() {
4203
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
4204
-
4205
- // DeepSeek smart default is 0.6
4206
- let ds_config = MintConfig {
4207
- ai_provider: "deepseek".into(),
4208
- deepseek_model: "deepseek-chat".into(),
4209
- ..MintConfig::default()
4210
- };
4211
- let ds_payload = openai_chat_payload(&ds_config, "deepseek-chat", &request, false).unwrap();
4212
- assert_eq!(ds_payload["temperature"], 0.6);
4213
-
4214
- // Standard OpenAI model default is 0.2
4215
- let openai_config = MintConfig {
4216
- ai_provider: "openai".into(),
4217
- openai_model: "gpt-4o".into(),
4218
- ..MintConfig::default()
4219
- };
4220
- let openai_payload =
4221
- openai_chat_payload(&openai_config, "gpt-4o", &request, false).unwrap();
4222
- assert_eq!(openai_payload["temperature"], 0.2);
4223
-
4224
- // Reasoning models (o1, o3) omit temperature parameter
4225
- let o1_payload =
4226
- openai_chat_payload(&openai_config, "o1-preview", &request, false).unwrap();
4227
- assert!(o1_payload.get("temperature").is_none());
4228
-
4229
- let o3_payload = openai_chat_payload(&openai_config, "o3-mini", &request, false).unwrap();
4230
- assert!(o3_payload.get("temperature").is_none());
4231
-
4232
- // Explicit request temperature override
4233
- let mut override_req = request.clone();
4234
- override_req.temperature = Some(0.75);
4235
- let override_payload =
4236
- openai_chat_payload(&ds_config, "deepseek-chat", &override_req, false).unwrap();
4237
- assert_eq!(override_payload["temperature"], 0.75);
4238
- }
4239
-
4240
- #[test]
4241
- fn anthropic_payload_includes_effective_temperature() {
4242
- let request = native_request(vec![ChatMessage::text(ChatRole::User, "hi")], None);
4243
- let config = MintConfig {
4244
- ai_provider: "anthropic".into(),
4245
- anthropic_model: "claude-sonnet-5".into(),
4246
- thinking_enabled: false,
4247
- ..MintConfig::default()
4248
- };
4249
- let payload = anthropic_chat_payload(&config, "claude-sonnet-5", &request, false).unwrap();
4250
- assert_eq!(payload["temperature"], 0.2);
4251
- }
4252
-
4253
- #[test]
4254
- fn parse_openai_style_reply_extracts_deepseek_reasoning_content() {
4255
- let response = json!({
4256
- "choices": [{
4257
- "message": {
4258
- "role": "assistant",
4259
- "content": "",
4260
- "reasoning_content": "Evaluating 15 + 6 * (8 - 3) - 14 / 2 step by step...",
4261
- "tool_calls": [{
4262
- "id": "call_calc_1",
4263
- "type": "function",
4264
- "function": {
4265
- "name": "calculation",
4266
- "arguments": "{\"expression\":\"15 + 6 * (8 - 3) - 14 / 2\"}"
4267
- }
4268
- }]
4269
- },
4270
- "finish_reason": "tool_calls"
4271
- }]
4272
- });
4273
- let reply = parse_openai_style_reply("deepseek-reasoner".into(), &response).unwrap();
4274
- assert_eq!(reply.text, "");
4275
- assert_eq!(
4276
- reply.thought.as_deref(),
4277
- Some("Evaluating 15 + 6 * (8 - 3) - 14 / 2 step by step...")
4278
- );
4279
- let calls = reply.tool_calls.unwrap();
4280
- assert_eq!(calls.len(), 1);
4281
- assert_eq!(calls[0].name, "calculation");
4282
- }
4283
-
4284
- #[test]
4285
- fn parse_openai_style_reply_extracts_openrouter_reasoning() {
4286
- let response = json!({
4287
- "choices": [{
4288
- "message": {
4289
- "role": "assistant",
4290
- "content": "The answer is 38.",
4291
- "reasoning": "First parentheses: 8 - 3 = 5..."
4292
- },
4293
- "finish_reason": "stop"
4294
- }]
4295
- });
4296
- let reply = parse_openai_style_reply("deepseek/deepseek-r1".into(), &response).unwrap();
4297
- assert_eq!(reply.text, "The answer is 38.");
4298
- assert_eq!(
4299
- reply.thought.as_deref(),
4300
- Some("First parentheses: 8 - 3 = 5...")
4301
- );
4302
- }
4303
-
4304
- #[test]
4305
- fn parse_openai_style_reply_extracts_think_tags() {
4306
- let response = json!({
4307
- "choices": [{
4308
- "message": {
4309
- "role": "assistant",
4310
- "content": "<think>\nLet me solve this problem.\n</think>\nThe result is 42."
4311
- },
4312
- "finish_reason": "stop"
4313
- }]
4314
- });
4315
- let reply = parse_openai_style_reply("qwq-32b".into(), &response).unwrap();
4316
- assert_eq!(reply.text, "The result is 42.");
4317
- assert_eq!(reply.thought.as_deref(), Some("Let me solve this problem."));
4318
- }
4319
-
4320
- #[test]
4321
- fn parse_gemini_reply_extracts_thought_parts() {
4322
- let response = json!({
4323
- "candidates": [{
4324
- "content": {
4325
- "parts": [
4326
- { "text": "Thinking through the formula...", "thought": true },
4327
- { "text": "The answer is 38." }
4328
- ]
4329
- },
4330
- "finishReason": "STOP"
4331
- }]
4332
- });
4333
- let reply = parse_gemini_reply("gemini-2.0-flash-thinking".into(), &response).unwrap();
4334
- assert_eq!(reply.text, "The answer is 38.");
4335
- assert_eq!(
4336
- reply.thought.as_deref(),
4337
- Some("Thinking through the formula...")
4338
- );
4339
- }
4340
-
4341
- #[test]
4342
- fn parse_anthropic_reply_extracts_thinking_blocks() {
4343
- let response = json!({
4344
- "content": [
4345
- { "type": "thinking", "thinking": "Let's break down the arithmetic..." },
4346
- { "type": "text", "text": "The result is 38." }
4347
- ],
4348
- "stop_reason": "end_turn"
4349
- });
4350
- let reply = parse_anthropic_reply("claude-3-7-sonnet".into(), &response).unwrap();
4351
- assert_eq!(reply.text, "The result is 38.");
4352
- assert_eq!(
4353
- reply.thought.as_deref(),
4354
- Some("Let's break down the arithmetic...")
4355
- );
4356
- }
4357
-
4358
- #[test]
4359
- fn parse_ollama_reply_extracts_thinking_field() {
4360
- let response = json!({
4361
- "message": {
4362
- "role": "assistant",
4363
- "content": "Result is 38.",
4364
- "thinking": "Ollama internal thinking trace..."
4365
- }
4366
- });
4367
- let reply = parse_ollama_reply("deepseek-r1:8b".into(), &response).unwrap();
4368
- assert_eq!(reply.text, "Result is 38.");
4369
- assert_eq!(
4370
- reply.thought.as_deref(),
4371
- Some("Ollama internal thinking trace...")
4372
- );
4373
- }
4374
-
4375
- #[test]
4376
- fn decode_tool_arguments_sanitizes_fences_tokens_and_trailing_commas() {
4377
- // 1. Markdown fences
4378
- let fenced =
4379
- Value::String("```json\n{\"command\": \"curl -s https://example.com\"}\n```".into());
4380
- let decoded = decode_tool_arguments(&fenced);
4381
- assert_eq!(decoded["command"], "curl -s https://example.com");
4382
-
4383
- // 2. DeepSeek DSML token
4384
- let dsml = Value::String("{\"command\": \"ls -la\"}</||DSML||".into());
4385
- let decoded_dsml = decode_tool_arguments(&dsml);
4386
- assert_eq!(decoded_dsml["command"], "ls -la");
4387
-
4388
- // 3. Trailing commas
4389
- let trailing = Value::String("{\"command\": \"echo ok\", \"flag\": true, }".into());
4390
- let decoded_trailing = decode_tool_arguments(&trailing);
4391
- assert_eq!(decoded_trailing["command"], "echo ok");
4392
- assert_eq!(decoded_trailing["flag"], true);
4393
- }
4394
- }