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