@vantran-se/openchamber-web 1.24.3 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/bin/lib/commands-connect-url.js +3 -8
- package/bin/lib/commands-tunnel.js +3 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BjJHrzmU.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
- package/dist/assets/DiffView-CjxUovbM.js +4 -0
- package/dist/assets/FilesView-DFWD0RQR.js +17 -0
- package/dist/assets/{GitView-CDOJy5Ay.js → GitView-BWa7e5vw.js} +4 -4
- package/dist/assets/{GuestAttachDialog-D3S0Rjic.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
- package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
- package/dist/assets/{JsonTreeView-D0C9nU7K.js → JsonTreeView-3sGwqexh.js} +1 -1
- package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
- package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
- package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
- package/dist/assets/{PatchDiff-DnEhjXcC.js → PatchDiff-DS-IMU1j.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DyxZVVVR.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
- package/dist/assets/PlanView-BCk_VobP.js +5 -0
- package/dist/assets/PluginPane-BPP1dbck.js +3 -0
- package/dist/assets/SettingsView-kMmrpn43.js +29 -0
- package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
- package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
- package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-0pyr_mHY.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
- package/dist/assets/{WalkthroughView-yNj4nOP5.js → WalkthroughView-lap5btCF.js} +2 -2
- package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
- package/dist/assets/{commitAndPush-DvJKa3Ks.js → commitAndPush-CwGJfJG-.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-DxTBMuu-.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
- package/dist/assets/de-CndEg67T.js +10 -0
- package/dist/assets/es-DglvPlaC.js +16 -0
- package/dist/assets/fr-DejPCpbS.js +14 -0
- package/dist/assets/index-C2r64QfD.js +1 -0
- package/dist/assets/index-CLcM8HKx.css +1 -0
- package/dist/assets/index-CvLgnuNI.js +41 -0
- package/dist/assets/{isDiffPlainText-D7FkBMP6.js → isDiffPlainText-D47a6hMP.js} +1 -1
- package/dist/assets/{isFilePlainText-CL2vsrIZ.js → isFilePlainText-B-mGgeKl.js} +1 -1
- package/dist/assets/ja-CVpQawCd.js +16 -0
- package/dist/assets/ko-Bw446h9j.js +16 -0
- package/dist/assets/main-Bz4WWXbr.js +2 -0
- package/dist/assets/main-oreqDsOV.js +896 -0
- package/dist/assets/{markdownCore-Dh7WEsdM.js → markdownCore-D2e5sp9y.js} +4 -4
- package/dist/assets/miniChat-D1v2gsBI.js +2 -0
- package/dist/assets/mobile-DBj3lCf2.js +2 -0
- package/dist/assets/{patchFileDiff-Bsba8KFp.js → patchFileDiff-BpeUOV91.js} +3 -3
- package/dist/assets/pl-BYNlPdhm.js +16 -0
- package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
- package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
- package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
- package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
- package/dist/assets/{shikiHighlight-8_3csmr6.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-CHo2fCgW.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
- package/dist/assets/tr-DzmbxLy-.js +7 -0
- package/dist/assets/uk-C7vQnwro.js +16 -0
- package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
- package/dist/assets/{useInlineCommentController-DkQ_gqpn.js → useInlineCommentController-CuUuakrt.js} +2 -2
- package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
- package/dist/assets/{vendor-codemirror-language-data-CdzUmUyZ.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
- package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
- package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
- package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
- package/dist/assets/{web-components-B8IcVQAA.js → web-components-B6tKQyJN.js} +2 -2
- package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
- package/dist/assets/zh-TW-DtII5bDb.js +16 -0
- package/dist/index.html +13 -7
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +6 -5
- package/server/index.d.ts +1 -0
- package/server/index.js +246 -91
- package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
- package/server/lib/agent-tool/runtime.js +135 -54
- package/server/lib/agent-tool/runtime.test.js +155 -87
- package/server/lib/browser-control/DOCUMENTATION.md +37 -2
- package/server/lib/browser-control/provider.js +217 -0
- package/server/lib/browser-control/provider.test.js +251 -0
- package/server/lib/context-obligatory/runtime.js +61 -50
- package/server/lib/context-obligatory/runtime.test.js +62 -162
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +68 -57
- package/server/lib/event-stream/directory-ws-bridge.js +1 -1
- package/server/lib/event-stream/global-hub.js +10 -1
- package/server/lib/event-stream/global-hub.test.js +27 -26
- package/server/lib/event-stream/index.js +6 -0
- package/server/lib/event-stream/protocol.js +26 -19
- package/server/lib/event-stream/protocol.test.js +18 -7
- package/server/lib/event-stream/rebind.test.js +5 -5
- package/server/lib/event-stream/runtime.test.js +17 -15
- package/server/lib/event-stream/translate-v2.js +313 -0
- package/server/lib/event-stream/translate-v2.test.js +209 -0
- package/server/lib/event-stream/upstream-reader.test.js +16 -16
- package/server/lib/fs/DOCUMENTATION.md +4 -2
- package/server/lib/fs/byte-range.js +35 -0
- package/server/lib/fs/byte-range.test.js +31 -0
- package/server/lib/fs/routes.js +47 -42
- package/server/lib/fs/routes.test.js +123 -18
- package/server/lib/git/service.js +12 -0
- package/server/lib/github/DOCUMENTATION.md +1 -1
- package/server/lib/github/pr-status.js +22 -2
- package/server/lib/github/pr-status.test.js +27 -14
- package/server/lib/guests/DOCUMENTATION.md +13 -0
- package/server/lib/guests/catalog.js +12 -0
- package/server/lib/guests/catalog.test.js +33 -0
- package/server/lib/guests/routes.js +10 -1
- package/server/lib/guests/service.js +192 -14
- package/server/lib/guests/service.test.js +201 -1
- package/server/lib/guests/surface.js +630 -0
- package/server/lib/guests/surface.test.js +486 -0
- package/server/lib/markdown-image-grants/routes.js +18 -5
- package/server/lib/markdown-image-grants/routes.test.js +9 -6
- package/server/lib/message-queue/DOCUMENTATION.md +33 -22
- package/server/lib/message-queue/runtime.js +155 -100
- package/server/lib/message-queue/runtime.test.js +168 -82
- package/server/lib/notifications/DOCUMENTATION.md +28 -0
- package/server/lib/notifications/control-stream.http.test.js +68 -0
- package/server/lib/notifications/emitter-runtime.js +15 -0
- package/server/lib/notifications/emitter-runtime.test.js +32 -0
- package/server/lib/notifications/push-runtime.js +5 -1
- package/server/lib/notifications/push-runtime.test.js +2 -2
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +115 -26
- package/server/lib/notifications/template-runtime.js +13 -15
- package/server/lib/notifications/template-runtime.test.js +16 -9
- package/server/lib/notifications/trigger-runtime.test.js +187 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
- package/server/lib/openchamber-control/actions.js +3 -2
- package/server/lib/openchamber-control/error.js +1 -1
- package/server/lib/openchamber-control/file-open.js +59 -0
- package/server/lib/openchamber-control/file-open.test.js +57 -0
- package/server/lib/openchamber-control/routes.test.js +12 -0
- package/server/lib/openchamber-control/service.js +102 -49
- package/server/lib/openchamber-control/service.test.js +84 -44
- package/server/lib/openchamber-sessions/archive-store.js +224 -0
- package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
- package/server/lib/openchamber-sessions/opencode-client.js +22 -0
- package/server/lib/openchamber-sessions/routes.js +398 -328
- package/server/lib/openchamber-sessions/routes.test.js +777 -740
- package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
- package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
- package/server/lib/opencode/DOCUMENTATION.md +449 -29
- package/server/lib/opencode/agents.js +233 -327
- package/server/lib/opencode/auth.js +41 -44
- package/server/lib/opencode/auth.test.js +92 -0
- package/server/lib/opencode/cli-upgrade.d.ts +4 -0
- package/server/lib/opencode/cli-upgrade.js +18 -0
- package/server/lib/opencode/cli-upgrade.test.js +36 -0
- package/server/lib/opencode/commands.js +164 -145
- package/server/lib/opencode/compatibility.d.ts +16 -0
- package/server/lib/opencode/compatibility.js +88 -0
- package/server/lib/opencode/compatibility.test.js +62 -0
- package/server/lib/opencode/config-entity-routes.js +58 -29
- package/server/lib/opencode/config-entity-v2.test.js +290 -0
- package/server/lib/opencode/config-mutation-response.js +17 -7
- package/server/lib/opencode/config-mutation-response.test.js +4 -7
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.d.ts +183 -0
- package/server/lib/opencode/config-v2.js +801 -0
- package/server/lib/opencode/config-v2.test.js +466 -0
- package/server/lib/opencode/credential-db.js +144 -0
- package/server/lib/opencode/credential-db.test.js +104 -0
- package/server/lib/opencode/env-runtime.js +48 -9
- package/server/lib/opencode/env-runtime.test.js +45 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -5
- package/server/lib/opencode/lifecycle-process.test.js +7 -4
- package/server/lib/opencode/lifecycle.js +85 -30
- package/server/lib/opencode/lifecycle.test.js +80 -8
- package/server/lib/opencode/login-shell-env.js +27 -0
- package/server/lib/opencode/managed-config-file.js +119 -0
- package/server/lib/opencode/managed-config-file.test.js +152 -0
- package/server/lib/opencode/managed-plugin-config.js +33 -13
- package/server/lib/opencode/mcp.js +43 -149
- package/server/lib/opencode/network-runtime.js +6 -7
- package/server/lib/opencode/openchamber-routes.test.js +2 -5
- package/server/lib/opencode/plugin-routes.js +3 -3
- package/server/lib/opencode/plugin-routes.test.js +6 -24
- package/server/lib/opencode/plugins.js +91 -40
- package/server/lib/opencode/plugins.test.js +28 -15
- package/server/lib/opencode/providers.js +63 -83
- package/server/lib/opencode/providers.test.js +128 -59
- package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
- package/server/lib/opencode/proxy.js +179 -129
- package/server/lib/opencode/pwa-manifest-routes.js +4 -2
- package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
- package/server/lib/opencode/response-envelope.js +14 -0
- package/server/lib/opencode/response-envelope.test.js +23 -0
- package/server/lib/opencode/routes-upgrade.test.js +73 -116
- package/server/lib/opencode/routes.js +121 -428
- package/server/lib/opencode/server-utils-runtime.js +10 -4
- package/server/lib/opencode/session-activity.js +78 -0
- package/server/lib/opencode/session-runtime.js +21 -10
- package/server/lib/opencode/session-runtime.test.js +9 -9
- package/server/lib/opencode/settings-helpers.js +9 -9
- package/server/lib/opencode/settings-helpers.test.js +7 -12
- package/server/lib/opencode/settings-registry.json +6 -8
- package/server/lib/opencode/settings-runtime.js +18 -1
- package/server/lib/opencode/settings-runtime.test.js +31 -2
- package/server/lib/opencode/shared.js +54 -22
- package/server/lib/opencode/shared.test.js +8 -4
- package/server/lib/opencode/shutdown-runtime.js +54 -7
- package/server/lib/opencode/shutdown-runtime.test.js +224 -30
- package/server/lib/opencode/skill-routes.js +23 -24
- package/server/lib/opencode/skills.js +7 -5
- package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
- package/server/lib/opencode/upgrade-capability.test.js +1 -1
- package/server/lib/opencode/v1-migration-topup.js +288 -0
- package/server/lib/opencode/v1-migration-topup.test.js +266 -0
- package/server/lib/opencode/v2-install.d.ts +8 -0
- package/server/lib/opencode/v2-install.js +158 -0
- package/server/lib/opencode/v2-install.test.js +124 -0
- package/server/lib/opencode/watcher.js +10 -3
- package/server/lib/opencode/watcher.test.js +17 -21
- package/server/lib/opencode/websearch-config.js +66 -0
- package/server/lib/package-manager.js +55 -68
- package/server/lib/package-manager.test.js +241 -75
- package/server/lib/permission-auto-accept/runtime.js +38 -22
- package/server/lib/permission-auto-accept/runtime.test.js +39 -17
- package/server/lib/quota/DOCUMENTATION.md +4 -0
- package/server/lib/quota/providers/xai.js +4 -4
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +7 -0
- package/server/lib/routing/DOCUMENTATION.md +52 -21
- package/server/lib/routing/defaults.js +23 -6
- package/server/lib/routing/jev.js +17 -7
- package/server/lib/routing/routes.http.test.js +68 -33
- package/server/lib/routing/routes.js +65 -22
- package/server/lib/routing/runtime.js +145 -48
- package/server/lib/routing/runtime.test.js +79 -55
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
- package/server/lib/scheduled-tasks/runtime.js +81 -74
- package/server/lib/scheduled-tasks/runtime.test.js +53 -1
- package/server/lib/session-assist/DOCUMENTATION.md +8 -3
- package/server/lib/session-assist/context.js +122 -46
- package/server/lib/session-assist/context.test.js +117 -49
- package/server/lib/session-assist/runtime.js +52 -22
- package/server/lib/session-assist/runtime.test.js +145 -173
- package/server/lib/session-goal/DOCUMENTATION.md +23 -15
- package/server/lib/session-goal/create.js +9 -12
- package/server/lib/session-goal/create.test.js +27 -28
- package/server/lib/session-goal/runtime.js +164 -77
- package/server/lib/session-goal/runtime.test.js +271 -519
- package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
- package/server/lib/session-knowledge/runtime.js +59 -41
- package/server/lib/session-knowledge/runtime.test.js +90 -20
- package/server/lib/small-model/DOCUMENTATION.md +154 -208
- package/server/lib/small-model/client.js +113 -0
- package/server/lib/small-model/index.js +296 -158
- package/server/lib/small-model/index.test.js +423 -256
- package/server/lib/small-model/routes.js +1 -1
- package/server/lib/spaces/DOCUMENTATION.md +807 -0
- package/server/lib/spaces/code-in-bait.js +185 -0
- package/server/lib/spaces/code-in.js +641 -0
- package/server/lib/spaces/code-in.test.js +1048 -0
- package/server/lib/spaces/errors.js +8 -0
- package/server/lib/spaces/exec-http.js +94 -0
- package/server/lib/spaces/gatekeeper-channel.js +202 -0
- package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
- package/server/lib/spaces/gatekeeper-program.cjs +914 -0
- package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
- package/server/lib/spaces/hardening.js +457 -0
- package/server/lib/spaces/hardening.test.js +368 -0
- package/server/lib/spaces/host-git.js +127 -0
- package/server/lib/spaces/host-git.test.js +137 -0
- package/server/lib/spaces/labels.js +234 -0
- package/server/lib/spaces/labels.test.js +219 -0
- package/server/lib/spaces/layout.js +184 -0
- package/server/lib/spaces/layout.test.js +30 -0
- package/server/lib/spaces/manager.js +82 -0
- package/server/lib/spaces/manager.test.js +114 -0
- package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
- package/server/lib/spaces/places/contract-suite.js +156 -0
- package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
- package/server/lib/spaces/places/contract.test.js +6 -0
- package/server/lib/spaces/places/docker-engine.js +126 -0
- package/server/lib/spaces/places/docker-live-support.js +202 -0
- package/server/lib/spaces/places/docker-tools.js +186 -0
- package/server/lib/spaces/places/docker.js +587 -0
- package/server/lib/spaces/places/docker.test.js +1610 -0
- package/server/lib/spaces/places/escape-suite.js +1018 -0
- package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
- package/server/lib/spaces/places/fake-docker.js +375 -0
- package/server/lib/spaces/places/memory-place.js +103 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
- package/server/lib/spaces/places/registry.js +43 -0
- package/server/lib/spaces/places/registry.test.js +50 -0
- package/server/lib/spaces/places/server.docker.live.test.js +225 -0
- package/server/lib/spaces/run-command.js +261 -0
- package/server/lib/spaces/run-command.test.js +396 -0
- package/server/lib/spaces/space-server.js +190 -0
- package/server/lib/spaces/space-server.test.js +335 -0
- package/server/lib/spaces/tools-filler.js +82 -0
- package/server/lib/spaces/tools-filler.test.js +148 -0
- package/server/lib/spaces/tools-pack.js +49 -0
- package/server/lib/spaces/tools-pack.test.js +57 -0
- package/server/lib/spaces/tools.js +138 -0
- package/server/lib/spaces/tools.test.js +153 -0
- package/server/lib/tts/openai-sdk.js +11 -0
- package/server/lib/tts/service.js +7 -5
- package/server/lib/tts/stt.js +3 -2
- package/server/lib/tunnels/DOCUMENTATION.md +2 -0
- package/server/lib/tunnels/routes.js +7 -0
- package/server/lib/tunnels/routes.test.js +55 -0
- package/server/lib/ui-auth/ui-auth.js +38 -2
- package/server/lib/ui-auth/ui-auth.test.js +42 -0
- package/server/lib/ui-auth/ui-passkeys.js +12 -10
- package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
- package/server/lib/walkthrough/pull-request.js +61 -8
- package/server/lib/walkthrough/pull-request.test.js +54 -1
- package/server/lib/walkthrough/routes.js +20 -0
- package/server/lib/walkthrough/routes.test.js +19 -0
- package/server/opencode-proxy.test.js +190 -166
- package/server/opencode-worktree-readiness.test.js +5 -4
- package/dist/assets/DiffView-COVQhWGd.js +0 -4
- package/dist/assets/FilesView-B1V5JxFX.js +0 -13
- package/dist/assets/LinearIssuesView-D7FKg1XJ.js +0 -1
- package/dist/assets/MarkdownImageGallery-BrFjAVEf.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DWepTafh.js +0 -12
- package/dist/assets/OnboardingScreen-Dyxxiqdt.js +0 -1
- package/dist/assets/PlanView-LW2z_EAP.js +0 -5
- package/dist/assets/PluginPane-B4tKno2h.js +0 -3
- package/dist/assets/SettingsView-DEaMulN9.js +0 -29
- package/dist/assets/SettingsWindow-FLDJXOgQ.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +0 -1
- package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +0 -15
- package/dist/assets/ToolPartDiffPreview-CkR4msKP.js +0 -8
- package/dist/assets/appThemeRegistry-BuUKhbYQ.js +0 -1
- package/dist/assets/de-DgxgFyGG.js +0 -10
- package/dist/assets/es-BFsZj5F_.js +0 -16
- package/dist/assets/fr-jdZ5KKQi.js +0 -14
- package/dist/assets/index-CKHjPKbq.js +0 -1
- package/dist/assets/index-Cbci4i1W.js +0 -40
- package/dist/assets/index-DqaMWNrC.css +0 -1
- package/dist/assets/ja-C5kKrhO4.js +0 -16
- package/dist/assets/ko-DXDTT_0O.js +0 -16
- package/dist/assets/main-CZALtVek.js +0 -896
- package/dist/assets/main-CgXMBNvG.js +0 -2
- package/dist/assets/miniChat-CwTmDixi.js +0 -2
- package/dist/assets/mobile-CLf2vCtO.js +0 -2
- package/dist/assets/pl-D-7x4Y8Q.js +0 -16
- package/dist/assets/pt-BR-CSBZrHa8.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +0 -2
- package/dist/assets/renderMobileApp-V4hYd32H.js +0 -25
- package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +0 -25
- package/dist/assets/scheduledTasksApi-DzjbLd8P.js +0 -1
- package/dist/assets/tr-Dr-oJ22L.js +0 -7
- package/dist/assets/uk-9Nx83UcI.js +0 -16
- package/dist/assets/useAppFontEffects-khZXpFL_.js +0 -7333
- package/dist/assets/useRootScrollLock-DdvHf9Pt.js +0 -2
- package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
- package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
- package/dist/assets/zh-CN-Bxy_Hj3r.js +0 -16
- package/dist/assets/zh-TW-0BB4gpHM.js +0 -16
- package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
- package/server/lib/routing/feature-flag.js +0 -11
- package/server/lib/small-model/call.js +0 -851
- package/server/lib/small-model/call.test.js +0 -1292
- package/server/lib/small-model/catalog.js +0 -13
- package/server/lib/small-model/resolve.js +0 -131
- package/server/lib/small-model/resolve.test.js +0 -197
- package/server/lib/small-model/runtime-providers.js +0 -164
- package/server/lib/small-model/runtime-providers.test.js +0 -118
- package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
- package/server/lib/system-prompt/runtime.js +0 -49
- package/server/lib/system-prompt/runtime.test.js +0 -76
- package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
|
@@ -1,1292 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
// readConfig reads merged opencode config layers from disk; mock it so each
|
|
7
|
-
// test controls the provider config without touching the filesystem. call.js
|
|
8
|
-
// imports the config readers and a plain-object predicate from shared.js, so
|
|
9
|
-
// the rest of that module is left untouched for this file.
|
|
10
|
-
vi.mock('../opencode/shared.js', () => ({
|
|
11
|
-
readConfig: vi.fn(),
|
|
12
|
-
readConfigLayers: vi.fn(),
|
|
13
|
-
// Pure predicate with no disk access — the real implementation, so header
|
|
14
|
-
// parsing is exercised rather than stubbed.
|
|
15
|
-
isPlainObject: (value) => value instanceof Object && !Array.isArray(value),
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) }));
|
|
19
|
-
|
|
20
|
-
const { callSmallModel } = await import('./call.js');
|
|
21
|
-
const { readConfig, readConfigLayers } = await import('../opencode/shared.js');
|
|
22
|
-
const { getRuntimeProvider } = await import('./runtime-providers.js');
|
|
23
|
-
|
|
24
|
-
// Minimal catalog fragment used by the catalog-based base URL resolution case.
|
|
25
|
-
const CATALOG = {
|
|
26
|
-
mistral: {
|
|
27
|
-
id: 'mistral',
|
|
28
|
-
name: 'Mistral',
|
|
29
|
-
api: 'https://api.mistral.ai/v1',
|
|
30
|
-
models: {
|
|
31
|
-
'mistral-small-latest': { id: 'mistral-small-latest' },
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const ok = (content) => ({
|
|
37
|
-
ok: true,
|
|
38
|
-
status: 200,
|
|
39
|
-
json: async () => ({
|
|
40
|
-
choices: [{ message: { content }, finish_reason: 'stop' }],
|
|
41
|
-
}),
|
|
42
|
-
text: async () => JSON.stringify({
|
|
43
|
-
choices: [{ message: { content }, finish_reason: 'stop' }],
|
|
44
|
-
}),
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const lastCall = (mock) => {
|
|
48
|
-
const [url, init] = mock.mock.calls.at(-1);
|
|
49
|
-
return { url: String(url), init };
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Regression coverage for the small-model dispatch to custom OpenAI-compatible
|
|
53
|
-
// providers — credential and endpoint resolution, precedence, and non-leakage.
|
|
54
|
-
describe('callSmallModel — custom provider config', () => {
|
|
55
|
-
let fetchMock;
|
|
56
|
-
let originalFetch;
|
|
57
|
-
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
fetchMock = vi.fn();
|
|
60
|
-
originalFetch = globalThis.fetch;
|
|
61
|
-
globalThis.fetch = fetchMock;
|
|
62
|
-
readConfig.mockReset();
|
|
63
|
-
readConfigLayers.mockReset();
|
|
64
|
-
// Default: OpenCode knows nothing, so resolution stays file-based.
|
|
65
|
-
getRuntimeProvider.mockReset();
|
|
66
|
-
getRuntimeProvider.mockResolvedValue(null);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterEach(() => {
|
|
70
|
-
globalThis.fetch = originalFetch;
|
|
71
|
-
vi.restoreAllMocks();
|
|
72
|
-
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
|
|
73
|
-
delete process.env.OPENCHAMBER_TEST_GATEWAY_KEY;
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('config-supplied credentials (no auth.json entry)', () => {
|
|
77
|
-
it('resolves an OpenCode file variable before sending the API key', async () => {
|
|
78
|
-
const secretPath = path.join(os.homedir(), '.secret');
|
|
79
|
-
const originalReadFileSync = fs.readFileSync;
|
|
80
|
-
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath, ...args) => {
|
|
81
|
-
if (filePath === secretPath) return 'sk-file-key\n';
|
|
82
|
-
return originalReadFileSync(filePath, ...args);
|
|
83
|
-
});
|
|
84
|
-
readConfig.mockReturnValue({
|
|
85
|
-
provider: {
|
|
86
|
-
custom: {
|
|
87
|
-
options: { apiKey: '{file:~/.secret}', baseURL: 'https://proxy.example.test/v1' },
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
92
|
-
|
|
93
|
-
await callSmallModel({
|
|
94
|
-
auth: {},
|
|
95
|
-
catalog: {},
|
|
96
|
-
workingDirectory: '/proj',
|
|
97
|
-
providerID: 'custom',
|
|
98
|
-
modelID: 'model',
|
|
99
|
-
prompt: 'hi',
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-file-key');
|
|
103
|
-
expect(JSON.stringify(fetchMock.mock.calls[0][1])).not.toContain('{file:');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('resolves an OpenCode environment variable before sending the API key', async () => {
|
|
107
|
-
process.env.OPENCHAMBER_TEST_PROVIDER_KEY = 'sk-env-key';
|
|
108
|
-
readConfig.mockReturnValue({
|
|
109
|
-
provider: {
|
|
110
|
-
custom: {
|
|
111
|
-
options: { apiKey: '{env:OPENCHAMBER_TEST_PROVIDER_KEY}', baseURL: 'https://proxy.example.test/v1' },
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
116
|
-
|
|
117
|
-
await callSmallModel({
|
|
118
|
-
auth: {},
|
|
119
|
-
catalog: {},
|
|
120
|
-
workingDirectory: '/proj',
|
|
121
|
-
providerID: 'custom',
|
|
122
|
-
modelID: 'model',
|
|
123
|
-
prompt: 'hi',
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-env-key');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('sends configured provider headers alongside the bearer token', async () => {
|
|
130
|
-
process.env.OPENCHAMBER_TEST_GATEWAY_KEY = 'sub-key';
|
|
131
|
-
readConfig.mockReturnValue({
|
|
132
|
-
provider: {
|
|
133
|
-
custom: {
|
|
134
|
-
options: {
|
|
135
|
-
apiKey: 'sk-config',
|
|
136
|
-
baseURL: 'https://proxy.example.test/v1',
|
|
137
|
-
headers: {
|
|
138
|
-
'Ocp-Apim-Subscription-Key': '{env:OPENCHAMBER_TEST_GATEWAY_KEY}',
|
|
139
|
-
'x-tenant': 'team',
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
146
|
-
|
|
147
|
-
await callSmallModel({
|
|
148
|
-
auth: {},
|
|
149
|
-
catalog: {},
|
|
150
|
-
workingDirectory: '/proj',
|
|
151
|
-
providerID: 'custom',
|
|
152
|
-
modelID: 'model',
|
|
153
|
-
prompt: 'hi',
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const { init } = lastCall(fetchMock);
|
|
157
|
-
expect(init.headers['Ocp-Apim-Subscription-Key']).toBe('sub-key');
|
|
158
|
-
expect(init.headers['x-tenant']).toBe('team');
|
|
159
|
-
expect(init.headers.Authorization).toBe('Bearer sk-config');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('resolves a relative header file from the config layer that defines it', async () => {
|
|
163
|
-
const configPath = '/config/opencode.json';
|
|
164
|
-
const secretPath = '/config/gateway-key';
|
|
165
|
-
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
|
166
|
-
if (filePath === secretPath) return 'sub-key\n';
|
|
167
|
-
throw new Error(`Unexpected file read: ${filePath}`);
|
|
168
|
-
});
|
|
169
|
-
const provider = {
|
|
170
|
-
custom: {
|
|
171
|
-
options: {
|
|
172
|
-
apiKey: 'sk-config',
|
|
173
|
-
baseURL: 'https://proxy.example.test/v1',
|
|
174
|
-
headers: { 'x-gateway-key': '{file:./gateway-key}' },
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
readConfig.mockReturnValue({ provider });
|
|
179
|
-
readConfigLayers.mockReturnValue({
|
|
180
|
-
customConfig: {},
|
|
181
|
-
projectConfig: {},
|
|
182
|
-
userConfig: { provider },
|
|
183
|
-
paths: { customPath: null, projectPath: '/project/opencode.json', userPath: configPath },
|
|
184
|
-
});
|
|
185
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
186
|
-
|
|
187
|
-
await callSmallModel({
|
|
188
|
-
auth: {},
|
|
189
|
-
catalog: {},
|
|
190
|
-
workingDirectory: '/project',
|
|
191
|
-
providerID: 'custom',
|
|
192
|
-
modelID: 'model',
|
|
193
|
-
prompt: 'hi',
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
expect(lastCall(fetchMock).init.headers['x-gateway-key']).toBe('sub-key');
|
|
197
|
-
expect(fs.readFileSync).toHaveBeenCalledWith(secretPath, 'utf8');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('overrides Authorization without depending on header-name casing', async () => {
|
|
201
|
-
readConfig.mockReturnValue({
|
|
202
|
-
provider: {
|
|
203
|
-
custom: {
|
|
204
|
-
options: {
|
|
205
|
-
apiKey: 'sk-config',
|
|
206
|
-
baseURL: 'https://proxy.example.test/v1',
|
|
207
|
-
headers: { authorization: 'Basic gateway-token' },
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
213
|
-
|
|
214
|
-
await callSmallModel({
|
|
215
|
-
auth: {},
|
|
216
|
-
catalog: {},
|
|
217
|
-
workingDirectory: '/project',
|
|
218
|
-
providerID: 'custom',
|
|
219
|
-
modelID: 'model',
|
|
220
|
-
prompt: 'hi',
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const headers = lastCall(fetchMock).init.headers;
|
|
224
|
-
expect(headers.authorization).toBe('Basic gateway-token');
|
|
225
|
-
expect(headers.Authorization).toBeUndefined();
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => {
|
|
229
|
-
readConfig.mockReturnValue({
|
|
230
|
-
provider: {
|
|
231
|
-
custom: {
|
|
232
|
-
options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' },
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
fetchMock.mockResolvedValue(ok('hello'));
|
|
237
|
-
|
|
238
|
-
const text = await callSmallModel({
|
|
239
|
-
auth: {},
|
|
240
|
-
catalog: {},
|
|
241
|
-
workingDirectory: '/proj',
|
|
242
|
-
providerID: 'custom',
|
|
243
|
-
modelID: 'gpt-4o-mini',
|
|
244
|
-
prompt: 'hi',
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
expect(text).toBe('hello');
|
|
248
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
249
|
-
|
|
250
|
-
const { url, init } = lastCall(fetchMock);
|
|
251
|
-
// Config baseURL is used, never the hardcoded OpenAI endpoint.
|
|
252
|
-
expect(url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
253
|
-
expect(url).not.toContain('api.openai.com');
|
|
254
|
-
// Config apiKey becomes the bearer credential.
|
|
255
|
-
expect(init.headers.Authorization).toBe('Bearer test-key');
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it('trims a trailing slash from the configured baseURL', async () => {
|
|
259
|
-
readConfig.mockReturnValue({
|
|
260
|
-
provider: {
|
|
261
|
-
custom: { options: { apiKey: 'k', baseURL: 'https://proxy.example.test/v1/' } },
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
265
|
-
|
|
266
|
-
await callSmallModel({
|
|
267
|
-
auth: {},
|
|
268
|
-
catalog: {},
|
|
269
|
-
workingDirectory: '/proj',
|
|
270
|
-
providerID: 'custom',
|
|
271
|
-
modelID: 'gpt-4o-mini',
|
|
272
|
-
prompt: 'hi',
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
expect(lastCall(fetchMock).url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('throws "No OpenCode login found for provider" when neither auth.json nor config apiKey exists', async () => {
|
|
279
|
-
readConfig.mockReturnValue({
|
|
280
|
-
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
const error = await callSmallModel({
|
|
284
|
-
auth: {},
|
|
285
|
-
catalog: {},
|
|
286
|
-
workingDirectory: '/proj',
|
|
287
|
-
providerID: 'custom',
|
|
288
|
-
modelID: 'gpt-4o-mini',
|
|
289
|
-
prompt: 'hi',
|
|
290
|
-
}).then(() => null, (e) => e);
|
|
291
|
-
|
|
292
|
-
expect(error).toMatchObject({
|
|
293
|
-
message: 'No OpenCode login found for provider "custom"',
|
|
294
|
-
code: 'no-provider-login',
|
|
295
|
-
statusCode: 401,
|
|
296
|
-
providerID: 'custom',
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// The credential gate fires before any network call.
|
|
300
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it('treats a blank/whitespace apiKey in config as absent', async () => {
|
|
304
|
-
readConfig.mockReturnValue({
|
|
305
|
-
provider: {
|
|
306
|
-
custom: { options: { apiKey: ' ', baseURL: 'https://proxy.example.test/v1' } },
|
|
307
|
-
},
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
await expect(callSmallModel({
|
|
311
|
-
auth: {},
|
|
312
|
-
catalog: {},
|
|
313
|
-
workingDirectory: '/proj',
|
|
314
|
-
providerID: 'custom',
|
|
315
|
-
modelID: 'gpt-4o-mini',
|
|
316
|
-
prompt: 'hi',
|
|
317
|
-
})).rejects.toThrow('No OpenCode login found for provider "custom"');
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
describe('resolution order when auth.json is also present', () => {
|
|
322
|
-
it('uses the auth.json credential with the config baseURL', async () => {
|
|
323
|
-
readConfig.mockReturnValue({
|
|
324
|
-
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
|
|
325
|
-
});
|
|
326
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
327
|
-
|
|
328
|
-
const text = await callSmallModel({
|
|
329
|
-
auth: { custom: { type: 'api', key: 'authjson-key' } },
|
|
330
|
-
catalog: {},
|
|
331
|
-
workingDirectory: '/proj',
|
|
332
|
-
providerID: 'custom',
|
|
333
|
-
modelID: 'gpt-4o-mini',
|
|
334
|
-
prompt: 'hi',
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
expect(text).toBe('done');
|
|
338
|
-
const { url, init } = lastCall(fetchMock);
|
|
339
|
-
expect(url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
340
|
-
expect(init.headers.Authorization).toBe('Bearer authjson-key');
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('prefers the config apiKey over an auth.json credential when both are present (matches OpenCode)', async () => {
|
|
344
|
-
readConfig.mockReturnValue({
|
|
345
|
-
provider: {
|
|
346
|
-
custom: { options: { apiKey: 'config-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
350
|
-
|
|
351
|
-
await callSmallModel({
|
|
352
|
-
auth: { custom: { type: 'api', key: 'authjson-key' } },
|
|
353
|
-
catalog: {},
|
|
354
|
-
workingDirectory: '/proj',
|
|
355
|
-
providerID: 'custom',
|
|
356
|
-
modelID: 'gpt-4o-mini',
|
|
357
|
-
prompt: 'hi',
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// OpenCode's resolveSDK reads options.apiKey first and only falls back to
|
|
361
|
-
// auth.json's key when config has none — so the config key wins and the
|
|
362
|
-
// auth.json credential must never be sent.
|
|
363
|
-
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer config-key');
|
|
364
|
-
expect(JSON.stringify(fetchMock.mock.calls[0][1])).not.toContain('authjson-key');
|
|
365
|
-
});
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
describe('openai provider custom baseURL override', () => {
|
|
369
|
-
it('respects provider.openai.options.baseURL over the hardcoded OpenAI endpoint', async () => {
|
|
370
|
-
readConfig.mockReturnValue({
|
|
371
|
-
provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } },
|
|
372
|
-
});
|
|
373
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
374
|
-
|
|
375
|
-
await callSmallModel({
|
|
376
|
-
auth: { openai: { type: 'api', key: 'sk-openai' } },
|
|
377
|
-
catalog: {},
|
|
378
|
-
workingDirectory: '/proj',
|
|
379
|
-
providerID: 'openai',
|
|
380
|
-
modelID: 'gpt-4o-mini',
|
|
381
|
-
prompt: 'hi',
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
const { url, init } = lastCall(fetchMock);
|
|
385
|
-
expect(url).toBe('https://gateway.example.test/v1/chat/completions');
|
|
386
|
-
expect(url).not.toContain('api.openai.com');
|
|
387
|
-
expect(init.headers.Authorization).toBe('Bearer sk-openai');
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it('falls back to https://api.openai.com/v1 when no openai baseURL override is configured', async () => {
|
|
391
|
-
readConfig.mockReturnValue({});
|
|
392
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
393
|
-
|
|
394
|
-
await callSmallModel({
|
|
395
|
-
auth: { openai: { type: 'api', key: 'sk-openai' } },
|
|
396
|
-
catalog: {},
|
|
397
|
-
workingDirectory: '/proj',
|
|
398
|
-
providerID: 'openai',
|
|
399
|
-
modelID: 'gpt-4o-mini',
|
|
400
|
-
prompt: 'hi',
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
expect(lastCall(fetchMock).url).toBe('https://api.openai.com/v1/chat/completions');
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it('still requires a credential: a baseURL alone does not authenticate openai', async () => {
|
|
407
|
-
readConfig.mockReturnValue({
|
|
408
|
-
provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } },
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
await expect(callSmallModel({
|
|
412
|
-
auth: {},
|
|
413
|
-
catalog: {},
|
|
414
|
-
workingDirectory: '/proj',
|
|
415
|
-
providerID: 'openai',
|
|
416
|
-
modelID: 'gpt-4o-mini',
|
|
417
|
-
prompt: 'hi',
|
|
418
|
-
})).rejects.toThrow('No OpenCode login found for provider "openai"');
|
|
419
|
-
});
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
describe('anthropic provider custom baseURL override', () => {
|
|
423
|
-
const anthropicOk = (text) => ({
|
|
424
|
-
ok: true,
|
|
425
|
-
status: 200,
|
|
426
|
-
json: async () => ({ content: [{ type: 'text', text }] }),
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('respects provider.anthropic.options.baseURL over the hardcoded Anthropic endpoint', async () => {
|
|
430
|
-
readConfig.mockReturnValue({
|
|
431
|
-
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456/v1' } } },
|
|
432
|
-
});
|
|
433
|
-
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
434
|
-
|
|
435
|
-
await callSmallModel({
|
|
436
|
-
auth: { anthropic: { type: 'api', key: 'dummy' } },
|
|
437
|
-
catalog: {},
|
|
438
|
-
workingDirectory: '/proj',
|
|
439
|
-
providerID: 'anthropic',
|
|
440
|
-
modelID: 'claude-haiku-4-5',
|
|
441
|
-
prompt: 'hi',
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
const { url, init } = lastCall(fetchMock);
|
|
445
|
-
expect(url).toBe('http://127.0.0.1:3456/v1/messages');
|
|
446
|
-
expect(url).not.toContain('api.anthropic.com');
|
|
447
|
-
expect(init.headers['x-api-key']).toBe('dummy');
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
it('uses a bare-host baseURL as-is without inserting /v1, matching @ai-sdk/anthropic', async () => {
|
|
451
|
-
readConfig.mockReturnValue({
|
|
452
|
-
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456' } } },
|
|
453
|
-
});
|
|
454
|
-
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
455
|
-
|
|
456
|
-
await callSmallModel({
|
|
457
|
-
auth: { anthropic: { type: 'api', key: 'dummy' } },
|
|
458
|
-
catalog: {},
|
|
459
|
-
workingDirectory: '/proj',
|
|
460
|
-
providerID: 'anthropic',
|
|
461
|
-
modelID: 'claude-haiku-4-5',
|
|
462
|
-
prompt: 'hi',
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
expect(lastCall(fetchMock).url).toBe('http://127.0.0.1:3456/messages');
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it('falls back to https://api.anthropic.com when no anthropic baseURL override is configured', async () => {
|
|
469
|
-
readConfig.mockReturnValue({});
|
|
470
|
-
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
471
|
-
|
|
472
|
-
await callSmallModel({
|
|
473
|
-
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
474
|
-
catalog: {},
|
|
475
|
-
workingDirectory: '/proj',
|
|
476
|
-
providerID: 'anthropic',
|
|
477
|
-
modelID: 'claude-haiku-4-5',
|
|
478
|
-
prompt: 'hi',
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
expect(lastCall(fetchMock).url).toBe('https://api.anthropic.com/v1/messages');
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
describe('catalog-based base URL (no config override)', () => {
|
|
486
|
-
it('identifies OpenCode Go requests with the owning conversation', async () => {
|
|
487
|
-
readConfig.mockReturnValue({});
|
|
488
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
489
|
-
|
|
490
|
-
await callSmallModel({
|
|
491
|
-
auth: { 'opencode-go': { type: 'api', key: 'go-key' } },
|
|
492
|
-
catalog: {
|
|
493
|
-
'opencode-go': {
|
|
494
|
-
id: 'opencode-go',
|
|
495
|
-
api: 'https://opencode.ai/zen/go/v1',
|
|
496
|
-
models: { utility: { id: 'utility' } },
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
workingDirectory: '/proj',
|
|
500
|
-
sessionID: 'ses_conversation',
|
|
501
|
-
providerID: 'opencode-go',
|
|
502
|
-
modelID: 'utility',
|
|
503
|
-
prompt: 'hi',
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
expect(lastCall(fetchMock).init.headers['x-opencode-session']).toBe('ses_conversation');
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it('uses the catalog api field when no config baseURL is set', async () => {
|
|
510
|
-
readConfig.mockReturnValue({});
|
|
511
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
512
|
-
|
|
513
|
-
await callSmallModel({
|
|
514
|
-
auth: { mistral: { type: 'api', key: 'mistral-key' } },
|
|
515
|
-
catalog: CATALOG,
|
|
516
|
-
workingDirectory: '/proj',
|
|
517
|
-
providerID: 'mistral',
|
|
518
|
-
modelID: 'mistral-small-latest',
|
|
519
|
-
prompt: 'hi',
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
const { url, init } = lastCall(fetchMock);
|
|
523
|
-
expect(url).toBe('https://api.mistral.ai/v1/chat/completions');
|
|
524
|
-
expect(init.headers.Authorization).toBe('Bearer mistral-key');
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
it('throws when a non-openai provider has no catalog api and no config baseURL', async () => {
|
|
528
|
-
readConfig.mockReturnValue({});
|
|
529
|
-
|
|
530
|
-
await expect(callSmallModel({
|
|
531
|
-
auth: { custom: { type: 'api', key: 'k' } },
|
|
532
|
-
catalog: {},
|
|
533
|
-
workingDirectory: '/proj',
|
|
534
|
-
providerID: 'custom',
|
|
535
|
-
modelID: 'gpt-4o-mini',
|
|
536
|
-
prompt: 'hi',
|
|
537
|
-
})).rejects.toThrow('Provider "custom" has no known API base URL');
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// A plugin registers its provider inside the running OpenCode process, so
|
|
541
|
-
// neither the config nor auth.json knows anything about it. This is the
|
|
542
|
-
// case that used to fail with "has no known API base URL" (#2666).
|
|
543
|
-
it('uses the endpoint and credential OpenCode resolved for a plugin provider', async () => {
|
|
544
|
-
readConfig.mockReturnValue({});
|
|
545
|
-
getRuntimeProvider.mockResolvedValue({
|
|
546
|
-
id: 'llmapi',
|
|
547
|
-
apiKey: 'plugin-key',
|
|
548
|
-
baseURL: 'https://api.llmapi.ai/v1',
|
|
549
|
-
anonymousZen: false,
|
|
550
|
-
});
|
|
551
|
-
const fetchMock = vi.fn(async () => ok('done'));
|
|
552
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
553
|
-
|
|
554
|
-
await callSmallModel({
|
|
555
|
-
auth: {},
|
|
556
|
-
catalog: {},
|
|
557
|
-
workingDirectory: '/proj',
|
|
558
|
-
providerID: 'llmapi',
|
|
559
|
-
modelID: 'claude-opus-4-8',
|
|
560
|
-
prompt: 'hi',
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
const { url, init } = lastCall(fetchMock);
|
|
564
|
-
expect(url).toBe('https://api.llmapi.ai/v1/chat/completions');
|
|
565
|
-
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
it('uses the selected runtime model endpoint', async () => {
|
|
569
|
-
readConfig.mockReturnValue({});
|
|
570
|
-
getRuntimeProvider.mockResolvedValue({
|
|
571
|
-
id: 'runtime-provider',
|
|
572
|
-
apiKey: 'plugin-key',
|
|
573
|
-
baseURL: 'https://runtime-provider/v1beta',
|
|
574
|
-
models: new Map([
|
|
575
|
-
['first-model', { api: { url: 'https://runtime-provider/v1beta', npm: '@ai-sdk/google' } }],
|
|
576
|
-
['selected-model', { api: { url: 'https://runtime-provider/v1', npm: '@ai-sdk/openai' } }],
|
|
577
|
-
]),
|
|
578
|
-
anonymousZen: false,
|
|
579
|
-
});
|
|
580
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
581
|
-
|
|
582
|
-
await callSmallModel({
|
|
583
|
-
auth: {},
|
|
584
|
-
catalog: {},
|
|
585
|
-
workingDirectory: '/proj',
|
|
586
|
-
providerID: 'runtime-provider',
|
|
587
|
-
modelID: 'selected-model',
|
|
588
|
-
prompt: 'hi',
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
const { url, init } = lastCall(fetchMock);
|
|
592
|
-
expect(url).toBe('https://runtime-provider/v1/chat/completions');
|
|
593
|
-
expect(url).not.toContain('/v1beta');
|
|
594
|
-
expect(JSON.parse(init.body).model).toBe('selected-model');
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
|
598
|
-
readConfig.mockReturnValue({});
|
|
599
|
-
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
|
600
|
-
// api.openai.com answers it with 401, so it must not stand in for the
|
|
601
|
-
// codex path.
|
|
602
|
-
getRuntimeProvider.mockResolvedValue({
|
|
603
|
-
id: 'openai',
|
|
604
|
-
apiKey: 'oauth-access-token',
|
|
605
|
-
baseURL: null,
|
|
606
|
-
anonymousZen: false,
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
await expect(callSmallModel({
|
|
610
|
-
auth: {},
|
|
611
|
-
catalog: {},
|
|
612
|
-
workingDirectory: '/proj',
|
|
613
|
-
providerID: 'openai',
|
|
614
|
-
modelID: 'gpt-5.4-mini',
|
|
615
|
-
prompt: 'hi',
|
|
616
|
-
})).rejects.toMatchObject({ code: 'no-provider-login' });
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
it('prefers an explicit config baseURL over the runtime endpoint', async () => {
|
|
620
|
-
readConfig.mockReturnValue({ provider: { custom: { options: { baseURL: 'https://configured.example/v1' } } } });
|
|
621
|
-
getRuntimeProvider.mockResolvedValue({
|
|
622
|
-
id: 'custom',
|
|
623
|
-
apiKey: 'runtime-key',
|
|
624
|
-
baseURL: 'https://runtime.example/v1',
|
|
625
|
-
anonymousZen: false,
|
|
626
|
-
});
|
|
627
|
-
const fetchMock = vi.fn(async () => ok('done'));
|
|
628
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
629
|
-
|
|
630
|
-
await callSmallModel({
|
|
631
|
-
auth: { custom: { type: 'api', key: 'auth-key' } },
|
|
632
|
-
catalog: {},
|
|
633
|
-
workingDirectory: '/proj',
|
|
634
|
-
providerID: 'custom',
|
|
635
|
-
modelID: 'm',
|
|
636
|
-
prompt: 'hi',
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
expect(lastCall(fetchMock).url).toBe('https://configured.example/v1/chat/completions');
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
describe('config-supplied key does not leak', () => {
|
|
644
|
-
// The config-supplied key must stay in-memory: never copied into catalog
|
|
645
|
-
// metadata, the response, or the request body.
|
|
646
|
-
it('does not mutate the catalog or echo the key in the request/response', async () => {
|
|
647
|
-
const catalog = { custom: { id: 'custom', models: {} } };
|
|
648
|
-
const catalogBefore = JSON.parse(JSON.stringify(catalog));
|
|
649
|
-
readConfig.mockReturnValue({
|
|
650
|
-
provider: {
|
|
651
|
-
custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
fetchMock.mockResolvedValue(ok('the answer'));
|
|
655
|
-
|
|
656
|
-
const text = await callSmallModel({
|
|
657
|
-
auth: {},
|
|
658
|
-
catalog,
|
|
659
|
-
workingDirectory: '/proj',
|
|
660
|
-
providerID: 'custom',
|
|
661
|
-
modelID: 'gpt-4o-mini',
|
|
662
|
-
prompt: 'hi',
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
// Response text is exactly the model output — no credential echoed back.
|
|
666
|
-
expect(text).toBe('the answer');
|
|
667
|
-
// Catalog object left untouched (key stays in-memory only).
|
|
668
|
-
expect(catalog).toEqual(catalogBefore);
|
|
669
|
-
|
|
670
|
-
const { url, init } = lastCall(fetchMock);
|
|
671
|
-
// The key rides only in the Authorization header.
|
|
672
|
-
expect(url).not.toContain('test-key');
|
|
673
|
-
const body = JSON.parse(init.body);
|
|
674
|
-
expect(JSON.stringify(body)).not.toContain('test-key');
|
|
675
|
-
});
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
describe('merged config layers', () => {
|
|
679
|
-
it('reads the provider config for the supplied working directory', async () => {
|
|
680
|
-
readConfig.mockReturnValue({
|
|
681
|
-
provider: {
|
|
682
|
-
custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
683
|
-
},
|
|
684
|
-
});
|
|
685
|
-
fetchMock.mockResolvedValue(ok('ok'));
|
|
686
|
-
|
|
687
|
-
await callSmallModel({
|
|
688
|
-
auth: {},
|
|
689
|
-
catalog: {},
|
|
690
|
-
workingDirectory: '/path/to/project',
|
|
691
|
-
providerID: 'custom',
|
|
692
|
-
modelID: 'gpt-4o-mini',
|
|
693
|
-
prompt: 'hi',
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// readConfig merges global + project-scoped layers for this directory;
|
|
697
|
-
// confirm callSmallModel passes the working directory straight through.
|
|
698
|
-
expect(readConfig).toHaveBeenCalledWith('/path/to/project');
|
|
699
|
-
});
|
|
700
|
-
});
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
describe('callSmallModel — Google thinking configuration', () => {
|
|
704
|
-
let fetchMock;
|
|
705
|
-
let originalFetch;
|
|
706
|
-
|
|
707
|
-
beforeEach(() => {
|
|
708
|
-
fetchMock = vi.fn();
|
|
709
|
-
originalFetch = globalThis.fetch;
|
|
710
|
-
globalThis.fetch = fetchMock;
|
|
711
|
-
readConfig.mockReset();
|
|
712
|
-
readConfig.mockReturnValue({});
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
afterEach(() => {
|
|
716
|
-
globalThis.fetch = originalFetch;
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
const googleResponse = (text) => ({
|
|
720
|
-
ok: true,
|
|
721
|
-
status: 200,
|
|
722
|
-
json: async () => ({ candidates: [{ content: { parts: [{ text }] } }] }),
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it('uses thinkingLevel for Gemini 3 Flash models', async () => {
|
|
726
|
-
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
727
|
-
|
|
728
|
-
const text = await callSmallModel({
|
|
729
|
-
auth: { google: { type: 'api', key: 'google-key' } },
|
|
730
|
-
catalog: {},
|
|
731
|
-
workingDirectory: '/proj',
|
|
732
|
-
providerID: 'google',
|
|
733
|
-
modelID: 'gemini-3.1-flash-lite-preview',
|
|
734
|
-
prompt: 'generate',
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
expect(text).toBe('generated commit');
|
|
738
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
739
|
-
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingLevel: 'minimal' });
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it('keeps thinkingBudget disabled for Gemini 2.5 Flash models', async () => {
|
|
743
|
-
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
744
|
-
|
|
745
|
-
await callSmallModel({
|
|
746
|
-
auth: { google: { type: 'api', key: 'google-key' } },
|
|
747
|
-
catalog: {},
|
|
748
|
-
workingDirectory: '/proj',
|
|
749
|
-
providerID: 'google',
|
|
750
|
-
modelID: 'gemini-2.5-flash-lite',
|
|
751
|
-
prompt: 'generate',
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
755
|
-
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 });
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
it('omits thinkingConfig for other Google/Gemini models', async () => {
|
|
759
|
-
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
760
|
-
|
|
761
|
-
await callSmallModel({
|
|
762
|
-
auth: { google: { type: 'api', key: 'google-key' } },
|
|
763
|
-
catalog: {},
|
|
764
|
-
workingDirectory: '/proj',
|
|
765
|
-
providerID: 'google',
|
|
766
|
-
modelID: 'gemini-1.5-flash',
|
|
767
|
-
prompt: 'generate',
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
771
|
-
expect(body.generationConfig.thinkingConfig).toBeUndefined();
|
|
772
|
-
});
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
describe('callSmallModel — OpenAI-compatible thinking toggle', () => {
|
|
776
|
-
let fetchMock;
|
|
777
|
-
let originalFetch;
|
|
778
|
-
|
|
779
|
-
beforeEach(() => {
|
|
780
|
-
fetchMock = vi.fn();
|
|
781
|
-
originalFetch = globalThis.fetch;
|
|
782
|
-
globalThis.fetch = fetchMock;
|
|
783
|
-
readConfig.mockReset();
|
|
784
|
-
readConfig.mockReturnValue({});
|
|
785
|
-
readConfigLayers.mockReset();
|
|
786
|
-
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
afterEach(() => {
|
|
790
|
-
globalThis.fetch = originalFetch;
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
const requestBody = async ({ providerID, modelID }) => {
|
|
794
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
795
|
-
await callSmallModel({
|
|
796
|
-
auth: { [providerID]: { type: 'api', key: 'sk-test' } },
|
|
797
|
-
catalog: { [providerID]: { id: providerID, api: 'https://provider.test/v1', models: {} } },
|
|
798
|
-
workingDirectory: '/proj',
|
|
799
|
-
providerID,
|
|
800
|
-
modelID,
|
|
801
|
-
prompt: 'summarize',
|
|
802
|
-
});
|
|
803
|
-
return JSON.parse(lastCall(fetchMock).init.body);
|
|
804
|
-
};
|
|
805
|
-
|
|
806
|
-
it('does not send the thinking field for a GLM model served by OpenCode Go', async () => {
|
|
807
|
-
const body = await requestBody({ providerID: 'opencode-go', modelID: 'glm-5.3-flash' });
|
|
808
|
-
expect(body.thinking).toBeUndefined();
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
it('disables thinking on the Z.ai and Zhipu endpoints', async () => {
|
|
812
|
-
const zai = await requestBody({ providerID: 'zai-coding-plan', modelID: 'glm-5.3-flash' });
|
|
813
|
-
expect(zai.thinking).toEqual({ type: 'disabled' });
|
|
814
|
-
const zhipu = await requestBody({ providerID: 'zhipuai', modelID: 'glm-5.3-flash' });
|
|
815
|
-
expect(zhipu.thinking).toEqual({ type: 'disabled' });
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
it('keeps the GLM switch for a custom-named provider pointed at a Z.ai endpoint', async () => {
|
|
819
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
820
|
-
await callSmallModel({
|
|
821
|
-
auth: { 'my-glm': { type: 'api', key: 'sk-test' } },
|
|
822
|
-
catalog: { 'my-glm': { id: 'my-glm', api: 'https://api.z.ai/api/coding/paas/v4', models: {} } },
|
|
823
|
-
workingDirectory: '/proj',
|
|
824
|
-
providerID: 'my-glm',
|
|
825
|
-
modelID: 'glm-5.3-flash',
|
|
826
|
-
prompt: 'summarize',
|
|
827
|
-
});
|
|
828
|
-
expect(JSON.parse(lastCall(fetchMock).init.body).thinking).toEqual({ type: 'disabled' });
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
const minimaxBody = async (providerEntry) => {
|
|
832
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
833
|
-
await callSmallModel({
|
|
834
|
-
auth: { host: { type: 'api', key: 'sk-test' } },
|
|
835
|
-
catalog: { host: { id: 'host', api: 'https://provider.test/v1', ...providerEntry } },
|
|
836
|
-
workingDirectory: '/proj',
|
|
837
|
-
providerID: 'host',
|
|
838
|
-
modelID: 'minimax-m3',
|
|
839
|
-
prompt: 'summarize',
|
|
840
|
-
});
|
|
841
|
-
return JSON.parse(lastCall(fetchMock).init.body);
|
|
842
|
-
};
|
|
843
|
-
|
|
844
|
-
it('disables MiniMax M3 thinking behind an OpenAI-compatible adapter', async () => {
|
|
845
|
-
const body = await minimaxBody({ npm: '@ai-sdk/openai-compatible', models: {} });
|
|
846
|
-
expect(body.thinking).toEqual({ type: 'disabled' });
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
it('honors a model-level adapter over the provider one for MiniMax M3', async () => {
|
|
850
|
-
const body = await minimaxBody({
|
|
851
|
-
npm: '@ai-sdk/deepinfra',
|
|
852
|
-
models: { 'minimax-m3': { provider: { npm: '@ai-sdk/anthropic' } } },
|
|
853
|
-
});
|
|
854
|
-
expect(body.thinking).toEqual({ type: 'disabled' });
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
it('does not send the MiniMax M3 switch through an adapter that has none', async () => {
|
|
858
|
-
const body = await minimaxBody({ npm: '@ai-sdk/deepinfra', models: {} });
|
|
859
|
-
expect(body.thinking).toBeUndefined();
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
it('treats a provider with no reported adapter as OpenAI-compatible for MiniMax M3', async () => {
|
|
863
|
-
const body = await minimaxBody({ models: {} });
|
|
864
|
-
expect(body.thinking).toEqual({ type: 'disabled' });
|
|
865
|
-
});
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
describe('callSmallModel — providers with an SDK-owned endpoint', () => {
|
|
869
|
-
let fetchMock;
|
|
870
|
-
let originalFetch;
|
|
871
|
-
|
|
872
|
-
beforeEach(() => {
|
|
873
|
-
fetchMock = vi.fn();
|
|
874
|
-
originalFetch = globalThis.fetch;
|
|
875
|
-
globalThis.fetch = fetchMock;
|
|
876
|
-
readConfig.mockReset();
|
|
877
|
-
readConfig.mockReturnValue({});
|
|
878
|
-
readConfigLayers.mockReset();
|
|
879
|
-
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
afterEach(() => {
|
|
883
|
-
globalThis.fetch = originalFetch;
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
it.each([
|
|
887
|
-
['groq', 'https://api.groq.com/openai/v1'],
|
|
888
|
-
['xai', 'https://api.x.ai/v1'],
|
|
889
|
-
['mistral', 'https://api.mistral.ai/v1'],
|
|
890
|
-
['cerebras', 'https://api.cerebras.ai/v1'],
|
|
891
|
-
['togetherai', 'https://api.together.xyz/v1'],
|
|
892
|
-
['deepinfra', 'https://api.deepinfra.com/v1/openai'],
|
|
893
|
-
['perplexity', 'https://api.perplexity.ai'],
|
|
894
|
-
['cohere', 'https://api.cohere.ai/compatibility/v1'],
|
|
895
|
-
])('calls %s at its default endpoint when the catalog has no api URL', async (providerID, baseURL) => {
|
|
896
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
897
|
-
|
|
898
|
-
const text = await callSmallModel({
|
|
899
|
-
auth: { [providerID]: { type: 'api', key: 'key-test' } },
|
|
900
|
-
catalog: { [providerID]: { id: providerID, api: null, models: {} } },
|
|
901
|
-
workingDirectory: '/proj',
|
|
902
|
-
providerID,
|
|
903
|
-
modelID: 'some-model',
|
|
904
|
-
prompt: 'summarize',
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
expect(text).toBe('done');
|
|
908
|
-
expect(lastCall(fetchMock).url).toBe(`${baseURL}/chat/completions`);
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
it('prefers a catalog api URL over the default endpoint', async () => {
|
|
912
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
913
|
-
|
|
914
|
-
await callSmallModel({
|
|
915
|
-
auth: { groq: { type: 'api', key: 'key-test' } },
|
|
916
|
-
catalog: { groq: { id: 'groq', api: 'https://catalog.test/v1', models: {} } },
|
|
917
|
-
workingDirectory: '/proj',
|
|
918
|
-
providerID: 'groq',
|
|
919
|
-
modelID: 'some-model',
|
|
920
|
-
prompt: 'summarize',
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
expect(lastCall(fetchMock).url).toBe('https://catalog.test/v1/chat/completions');
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
it('prefers a configured baseURL over the default endpoint', async () => {
|
|
927
|
-
readConfig.mockReturnValue({
|
|
928
|
-
provider: { groq: { options: { baseURL: 'https://proxy.test/v1' } } },
|
|
929
|
-
});
|
|
930
|
-
fetchMock.mockResolvedValue(ok('done'));
|
|
931
|
-
|
|
932
|
-
await callSmallModel({
|
|
933
|
-
auth: { groq: { type: 'api', key: 'key-test' } },
|
|
934
|
-
catalog: { groq: { id: 'groq', api: null, models: {} } },
|
|
935
|
-
workingDirectory: '/proj',
|
|
936
|
-
providerID: 'groq',
|
|
937
|
-
modelID: 'some-model',
|
|
938
|
-
prompt: 'summarize',
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
expect(lastCall(fetchMock).url).toBe('https://proxy.test/v1/chat/completions');
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
it('still fails clearly for a provider with no endpoint anywhere', async () => {
|
|
945
|
-
await expect(callSmallModel({
|
|
946
|
-
auth: { mystery: { type: 'api', key: 'sk-test' } },
|
|
947
|
-
catalog: {},
|
|
948
|
-
workingDirectory: '/proj',
|
|
949
|
-
providerID: 'mystery',
|
|
950
|
-
modelID: 'some-model',
|
|
951
|
-
prompt: 'summarize',
|
|
952
|
-
})).rejects.toThrow('has no known API base URL');
|
|
953
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
954
|
-
});
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
describe('callSmallModel — GitHub Copilot endpoint routing', () => {
|
|
958
|
-
let fetchMock;
|
|
959
|
-
let originalFetch;
|
|
960
|
-
|
|
961
|
-
const copilotAuth = (overrides = {}) => ({
|
|
962
|
-
'github-copilot': {
|
|
963
|
-
type: 'oauth',
|
|
964
|
-
access: 'test-token',
|
|
965
|
-
refresh: 'test-token',
|
|
966
|
-
expires: 0,
|
|
967
|
-
...overrides,
|
|
968
|
-
},
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
const jsonResponse = (payload, status = 200) => new Response(
|
|
972
|
-
JSON.stringify(payload),
|
|
973
|
-
{
|
|
974
|
-
status,
|
|
975
|
-
headers: { 'Content-Type': 'application/json' },
|
|
976
|
-
},
|
|
977
|
-
);
|
|
978
|
-
|
|
979
|
-
const callCopilot = (modelID, options = {}) => callSmallModel({
|
|
980
|
-
auth: copilotAuth(options.auth),
|
|
981
|
-
catalog: {},
|
|
982
|
-
workingDirectory: '/proj',
|
|
983
|
-
providerID: 'github-copilot',
|
|
984
|
-
modelID,
|
|
985
|
-
prompt: 'summarize this diff',
|
|
986
|
-
system: 'Write a commit message',
|
|
987
|
-
maxOutputTokens: 100,
|
|
988
|
-
});
|
|
989
|
-
|
|
990
|
-
beforeEach(() => {
|
|
991
|
-
fetchMock = vi.fn();
|
|
992
|
-
originalFetch = globalThis.fetch;
|
|
993
|
-
globalThis.fetch = fetchMock;
|
|
994
|
-
readConfig.mockReset();
|
|
995
|
-
readConfig.mockReturnValue({});
|
|
996
|
-
readConfigLayers.mockReset();
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
afterEach(() => {
|
|
1000
|
-
globalThis.fetch = originalFetch;
|
|
1001
|
-
vi.restoreAllMocks();
|
|
1002
|
-
});
|
|
1003
|
-
|
|
1004
|
-
it('routes a model advertising /responses through the Responses API', async () => {
|
|
1005
|
-
fetchMock
|
|
1006
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1007
|
-
data: [{
|
|
1008
|
-
id: 'mai-code-1-flash-picker',
|
|
1009
|
-
supported_endpoints: ['/responses'],
|
|
1010
|
-
}],
|
|
1011
|
-
}))
|
|
1012
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1013
|
-
output: [{
|
|
1014
|
-
type: 'message',
|
|
1015
|
-
content: [{ type: 'output_text', text: 'feat: add summary' }],
|
|
1016
|
-
}],
|
|
1017
|
-
}));
|
|
1018
|
-
|
|
1019
|
-
await expect(callCopilot('mai-code-1-flash-picker')).resolves.toBe('feat: add summary');
|
|
1020
|
-
|
|
1021
|
-
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1022
|
-
expect(String(fetchMock.mock.calls[0][0])).toBe('https://api.githubcopilot.com/models');
|
|
1023
|
-
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/responses');
|
|
1024
|
-
const body = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
1025
|
-
expect(body).toMatchObject({
|
|
1026
|
-
model: 'mai-code-1-flash-picker',
|
|
1027
|
-
instructions: 'Write a commit message',
|
|
1028
|
-
max_output_tokens: 100,
|
|
1029
|
-
stream: false,
|
|
1030
|
-
store: false,
|
|
1031
|
-
input: [{
|
|
1032
|
-
role: 'user',
|
|
1033
|
-
content: [{ type: 'input_text', text: 'summarize this diff' }],
|
|
1034
|
-
}],
|
|
1035
|
-
});
|
|
1036
|
-
expect(JSON.stringify(body)).not.toContain('test-token');
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
it('prefers /v1/messages when a model advertises multiple endpoints', async () => {
|
|
1040
|
-
fetchMock
|
|
1041
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1042
|
-
data: [{
|
|
1043
|
-
id: 'claude-opus-4.7',
|
|
1044
|
-
supported_endpoints: ['/chat/completions', '/responses', '/v1/messages'],
|
|
1045
|
-
}],
|
|
1046
|
-
}))
|
|
1047
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1048
|
-
content: [{ type: 'text', text: 'fix: route Claude correctly' }],
|
|
1049
|
-
}));
|
|
1050
|
-
|
|
1051
|
-
await expect(callCopilot('claude-opus-4.7')).resolves.toBe('fix: route Claude correctly');
|
|
1052
|
-
|
|
1053
|
-
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/v1/messages');
|
|
1054
|
-
const body = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
1055
|
-
expect(body).toMatchObject({
|
|
1056
|
-
model: 'claude-opus-4.7',
|
|
1057
|
-
system: 'Write a commit message',
|
|
1058
|
-
max_tokens: 100,
|
|
1059
|
-
messages: [{ role: 'user', content: 'summarize this diff' }],
|
|
1060
|
-
});
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
it('uses /chat/completions when the model advertises the chat endpoint', async () => {
|
|
1064
|
-
fetchMock
|
|
1065
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1066
|
-
data: [{
|
|
1067
|
-
id: 'gpt-5.4-nano',
|
|
1068
|
-
supported_endpoints: ['/chat/completions'],
|
|
1069
|
-
}],
|
|
1070
|
-
}))
|
|
1071
|
-
.mockResolvedValueOnce(ok('chore: update summary'));
|
|
1072
|
-
|
|
1073
|
-
await expect(callCopilot('gpt-5.4-nano')).resolves.toBe('chore: update summary');
|
|
1074
|
-
|
|
1075
|
-
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions');
|
|
1076
|
-
});
|
|
1077
|
-
|
|
1078
|
-
it('keeps chat completions as the legacy default when endpoint metadata is absent', async () => {
|
|
1079
|
-
fetchMock
|
|
1080
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1081
|
-
data: [{ id: 'gpt-4o-mini' }],
|
|
1082
|
-
}))
|
|
1083
|
-
.mockResolvedValueOnce(ok('docs: clarify behavior'));
|
|
1084
|
-
|
|
1085
|
-
await expect(callCopilot('gpt-4o-mini')).resolves.toBe('docs: clarify behavior');
|
|
1086
|
-
|
|
1087
|
-
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions');
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
it('uses the enterprise Copilot host for metadata and generation', async () => {
|
|
1091
|
-
fetchMock
|
|
1092
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1093
|
-
data: [{
|
|
1094
|
-
id: 'mai-code-1-flash-picker',
|
|
1095
|
-
supported_endpoints: ['/responses'],
|
|
1096
|
-
}],
|
|
1097
|
-
}))
|
|
1098
|
-
.mockResolvedValueOnce(jsonResponse({
|
|
1099
|
-
output_text: 'fix: support enterprise routing',
|
|
1100
|
-
}));
|
|
1101
|
-
|
|
1102
|
-
await expect(callCopilot('mai-code-1-flash-picker', {
|
|
1103
|
-
auth: { enterpriseUrl: 'https://ghe.example.com/' },
|
|
1104
|
-
})).resolves.toBe('fix: support enterprise routing');
|
|
1105
|
-
|
|
1106
|
-
expect(String(fetchMock.mock.calls[0][0])).toBe('https://copilot-api.ghe.example.com/models');
|
|
1107
|
-
expect(String(fetchMock.mock.calls[1][0])).toBe('https://copilot-api.ghe.example.com/responses');
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
it('surfaces Copilot model metadata request failures without generating', async () => {
|
|
1111
|
-
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unavailable' }, 503));
|
|
1112
|
-
|
|
1113
|
-
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
1114
|
-
.rejects.toThrow('GitHub Copilot models request failed with 503');
|
|
1115
|
-
|
|
1116
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1117
|
-
});
|
|
1118
|
-
|
|
1119
|
-
it('rejects missing models and unsupported advertised endpoints', async () => {
|
|
1120
|
-
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
1121
|
-
data: [{ id: 'different-model', supported_endpoints: ['/responses'] }],
|
|
1122
|
-
}));
|
|
1123
|
-
|
|
1124
|
-
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
1125
|
-
.rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" was not returned by /models');
|
|
1126
|
-
|
|
1127
|
-
fetchMock.mockReset();
|
|
1128
|
-
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
1129
|
-
data: [{
|
|
1130
|
-
id: 'mai-code-1-flash-picker',
|
|
1131
|
-
supported_endpoints: ['/future'],
|
|
1132
|
-
}],
|
|
1133
|
-
}));
|
|
1134
|
-
|
|
1135
|
-
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
1136
|
-
.rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" has no supported text endpoint');
|
|
1137
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1138
|
-
});
|
|
1139
|
-
});
|
|
1140
|
-
|
|
1141
|
-
// Structured output has no single wire format: each provider family needs its
|
|
1142
|
-
// own request shape and its own extraction, and one family cannot do it at all.
|
|
1143
|
-
// These lock the per-format translation so a provider is never silently sent a
|
|
1144
|
-
// schema it will ignore.
|
|
1145
|
-
describe('callSmallModel — structured output', () => {
|
|
1146
|
-
let fetchMock;
|
|
1147
|
-
let originalFetch;
|
|
1148
|
-
|
|
1149
|
-
const SCHEMA = {
|
|
1150
|
-
type: 'object',
|
|
1151
|
-
properties: { title: { type: 'string' } },
|
|
1152
|
-
required: ['title'],
|
|
1153
|
-
additionalProperties: false,
|
|
1154
|
-
};
|
|
1155
|
-
|
|
1156
|
-
beforeEach(() => {
|
|
1157
|
-
fetchMock = vi.fn();
|
|
1158
|
-
originalFetch = globalThis.fetch;
|
|
1159
|
-
globalThis.fetch = fetchMock;
|
|
1160
|
-
readConfig.mockReset();
|
|
1161
|
-
readConfig.mockReturnValue({});
|
|
1162
|
-
readConfigLayers.mockReset();
|
|
1163
|
-
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
afterEach(() => {
|
|
1167
|
-
globalThis.fetch = originalFetch;
|
|
1168
|
-
});
|
|
1169
|
-
|
|
1170
|
-
it('sends a json_schema response_format on OpenAI-compatible chat', async () => {
|
|
1171
|
-
fetchMock.mockResolvedValue(ok('{"title":"ok"}'));
|
|
1172
|
-
|
|
1173
|
-
const text = await callSmallModel({
|
|
1174
|
-
auth: { openai: { type: 'api', key: 'sk-test' } },
|
|
1175
|
-
catalog: {},
|
|
1176
|
-
workingDirectory: '/proj',
|
|
1177
|
-
providerID: 'openai',
|
|
1178
|
-
modelID: 'gpt-5.4-mini',
|
|
1179
|
-
prompt: 'summarize',
|
|
1180
|
-
responseSchema: SCHEMA,
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
|
-
expect(text).toBe('{"title":"ok"}');
|
|
1184
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
1185
|
-
expect(body.response_format).toEqual({
|
|
1186
|
-
type: 'json_schema',
|
|
1187
|
-
json_schema: { name: 'response', strict: true, schema: SCHEMA },
|
|
1188
|
-
});
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
it('omits response_format entirely when no schema is requested', async () => {
|
|
1192
|
-
fetchMock.mockResolvedValue(ok('plain text'));
|
|
1193
|
-
|
|
1194
|
-
await callSmallModel({
|
|
1195
|
-
auth: { openai: { type: 'api', key: 'sk-test' } },
|
|
1196
|
-
catalog: {},
|
|
1197
|
-
workingDirectory: '/proj',
|
|
1198
|
-
providerID: 'openai',
|
|
1199
|
-
modelID: 'gpt-5.4-mini',
|
|
1200
|
-
prompt: 'summarize',
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
1204
|
-
expect(body.response_format).toBeUndefined();
|
|
1205
|
-
});
|
|
1206
|
-
|
|
1207
|
-
it('forces a single tool call on the Anthropic messages API and returns its input', async () => {
|
|
1208
|
-
fetchMock.mockResolvedValue({
|
|
1209
|
-
ok: true,
|
|
1210
|
-
status: 200,
|
|
1211
|
-
json: async () => ({
|
|
1212
|
-
content: [
|
|
1213
|
-
{ type: 'text', text: 'thinking out loud' },
|
|
1214
|
-
{ type: 'tool_use', name: 'response', input: { title: 'ok' } },
|
|
1215
|
-
],
|
|
1216
|
-
}),
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
|
-
const text = await callSmallModel({
|
|
1220
|
-
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
1221
|
-
catalog: {},
|
|
1222
|
-
workingDirectory: '/proj',
|
|
1223
|
-
providerID: 'anthropic',
|
|
1224
|
-
modelID: 'claude-haiku-4-5',
|
|
1225
|
-
prompt: 'summarize',
|
|
1226
|
-
responseSchema: SCHEMA,
|
|
1227
|
-
});
|
|
1228
|
-
|
|
1229
|
-
expect(JSON.parse(text)).toEqual({ title: 'ok' });
|
|
1230
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
1231
|
-
expect(body.tool_choice).toEqual({ type: 'tool', name: 'response' });
|
|
1232
|
-
expect(body.tools[0].input_schema).toEqual(SCHEMA);
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
it('fails loudly when Anthropic answers with prose instead of the tool call', async () => {
|
|
1236
|
-
fetchMock.mockResolvedValue({
|
|
1237
|
-
ok: true,
|
|
1238
|
-
status: 200,
|
|
1239
|
-
json: async () => ({ content: [{ type: 'text', text: 'here you go' }] }),
|
|
1240
|
-
});
|
|
1241
|
-
|
|
1242
|
-
await expect(callSmallModel({
|
|
1243
|
-
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
1244
|
-
catalog: {},
|
|
1245
|
-
workingDirectory: '/proj',
|
|
1246
|
-
providerID: 'anthropic',
|
|
1247
|
-
modelID: 'claude-haiku-4-5',
|
|
1248
|
-
prompt: 'summarize',
|
|
1249
|
-
responseSchema: SCHEMA,
|
|
1250
|
-
})).rejects.toThrow('returned no structured output');
|
|
1251
|
-
});
|
|
1252
|
-
|
|
1253
|
-
it('strips JSON Schema keywords Google rejects', async () => {
|
|
1254
|
-
fetchMock.mockResolvedValue({
|
|
1255
|
-
ok: true,
|
|
1256
|
-
status: 200,
|
|
1257
|
-
json: async () => ({ candidates: [{ content: { parts: [{ text: '{"title":"ok"}' }] } }] }),
|
|
1258
|
-
});
|
|
1259
|
-
|
|
1260
|
-
await callSmallModel({
|
|
1261
|
-
auth: { google: { type: 'api', key: 'google-key' } },
|
|
1262
|
-
catalog: {},
|
|
1263
|
-
workingDirectory: '/proj',
|
|
1264
|
-
providerID: 'google',
|
|
1265
|
-
modelID: 'gemini-2.5-flash',
|
|
1266
|
-
prompt: 'summarize',
|
|
1267
|
-
responseSchema: { ...SCHEMA, $schema: 'https://json-schema.org/draft/2020-12/schema' },
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
1271
|
-
expect(body.generationConfig.responseMimeType).toBe('application/json');
|
|
1272
|
-
expect(body.generationConfig.responseSchema).toEqual({
|
|
1273
|
-
type: 'object',
|
|
1274
|
-
properties: { title: { type: 'string' } },
|
|
1275
|
-
required: ['title'],
|
|
1276
|
-
});
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
it('refuses a schema on the ChatGPT-plan backend instead of returning prose', async () => {
|
|
1280
|
-
await expect(callSmallModel({
|
|
1281
|
-
auth: { openai: { type: 'oauth', access: 'token', refresh: 'refresh' } },
|
|
1282
|
-
catalog: {},
|
|
1283
|
-
workingDirectory: '/proj',
|
|
1284
|
-
providerID: 'openai',
|
|
1285
|
-
modelID: 'gpt-5.4-mini',
|
|
1286
|
-
prompt: 'summarize',
|
|
1287
|
-
responseSchema: SCHEMA,
|
|
1288
|
-
})).rejects.toThrow('does not support structured output');
|
|
1289
|
-
|
|
1290
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
1291
|
-
});
|
|
1292
|
-
});
|