@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,363 +1,530 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import { registerSmallModelRoutes } from './routes.js';
|
|
3
|
+
import http from 'node:http';
|
|
2
4
|
import os from 'os';
|
|
3
5
|
import path from 'path';
|
|
4
|
-
import { afterAll,
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
5
7
|
|
|
6
|
-
// The settings override is read straight from disk
|
|
7
|
-
//
|
|
8
|
-
// happens to have configured.
|
|
8
|
+
// The settings override is read straight from disk, so without this the suite
|
|
9
|
+
// would resolve whatever small model the developer running it has configured.
|
|
9
10
|
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-'));
|
|
10
11
|
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
|
|
11
12
|
|
|
12
|
-
vi.mock('../opencode/auth.js', () => ({ readAuthFile: vi.fn() }));
|
|
13
|
-
vi.mock('../opencode/shared.js', () => ({
|
|
14
|
-
readConfig: vi.fn(),
|
|
15
|
-
readConfigLayers: vi.fn(),
|
|
16
|
-
}));
|
|
17
|
-
vi.mock('./catalog.js', () => ({
|
|
18
|
-
getModelCatalog: vi.fn(),
|
|
19
|
-
getCatalogProvider: vi.fn(),
|
|
20
|
-
}));
|
|
21
|
-
vi.mock('./call.js', () => ({
|
|
22
|
-
DEDICATED_WIRE_FORMAT_PROVIDERS: new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']),
|
|
23
|
-
callSmallModel: vi.fn(),
|
|
24
|
-
resolveProviderLogin: vi.fn(async ({ auth, providerID }) => {
|
|
25
|
-
const entry = auth?.[providerID];
|
|
26
|
-
return entry && typeof entry === 'object' ? entry : null;
|
|
27
|
-
}),
|
|
28
|
-
}));
|
|
29
|
-
vi.mock('./runtime-providers.js', () => ({
|
|
30
|
-
getRuntimeProviderSnapshot: vi.fn(async () => null),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
13
|
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
|
|
34
|
-
const {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
|
50
|
-
getModelCatalog.mockResolvedValue({});
|
|
51
|
-
callSmallModel.mockReset();
|
|
52
|
-
getRuntimeProviderSnapshot.mockResolvedValue(null);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('rejects Claude Code with an actionable error before transport dispatch', async () => {
|
|
56
|
-
await expect(generateSmallModelText({
|
|
57
|
-
prompt: 'summarize this',
|
|
58
|
-
model: 'claude-code/haiku',
|
|
59
|
-
})).rejects.toMatchObject({
|
|
60
|
-
statusCode: 422,
|
|
61
|
-
code: 'small-model-provider-unsupported',
|
|
62
|
-
});
|
|
63
|
-
expect(callSmallModel).not.toHaveBeenCalled();
|
|
64
|
-
});
|
|
14
|
+
const { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } = await import('./client.js');
|
|
15
|
+
|
|
16
|
+
// A real OpenCode stub over HTTP. The module talks to OpenCode through
|
|
17
|
+
// `@opencode/client`, so driving it with a socket exercises the same request
|
|
18
|
+
// shapes the running server sees — no module substitution involved.
|
|
19
|
+
const state = {
|
|
20
|
+
models: [],
|
|
21
|
+
providers: [],
|
|
22
|
+
defaultModel: null,
|
|
23
|
+
generate: () => ({ text: 'generated' }),
|
|
24
|
+
requests: [],
|
|
25
|
+
generateErrors: [],
|
|
26
|
+
};
|
|
65
27
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
28
|
+
const MODEL = (overrides = {}) => ({
|
|
29
|
+
id: 'claude-haiku-4-5',
|
|
30
|
+
modelID: 'claude-haiku-4-5',
|
|
31
|
+
providerID: 'anthropic',
|
|
32
|
+
name: 'Claude Haiku',
|
|
33
|
+
enabled: true,
|
|
34
|
+
limit: { context: 8_000, output: 4_000 },
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
69
37
|
|
|
70
|
-
|
|
71
|
-
// is a façade over the Claude Agent SDK: every call spawns the CLI and
|
|
72
|
-
// spends the user's Claude subscription. The refusal is about that cost, so
|
|
73
|
-
// an available endpoint must not lift it.
|
|
74
|
-
it('still refuses Claude Code when a plugin publishes an HTTP endpoint for it', async () => {
|
|
75
|
-
getRuntimeProviderSnapshot.mockResolvedValue({
|
|
76
|
-
providers: new Map([['claude-code', { id: 'claude-code', apiKey: 'plugin-key', baseURL: 'http://127.0.0.1:60668/v1', anonymousZen: false }]]),
|
|
77
|
-
connected: new Set(['claude-code']),
|
|
78
|
-
});
|
|
38
|
+
const LOCATION = { directory: '/proj', project: { id: 'p', directory: '/proj', canonical: '/proj' } };
|
|
79
39
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
model: 'claude-code/haiku',
|
|
83
|
-
})).rejects.toMatchObject({ code: 'small-model-provider-unsupported' });
|
|
84
|
-
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
|
|
40
|
+
let server;
|
|
41
|
+
let baseUrl;
|
|
85
42
|
|
|
86
|
-
|
|
43
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
44
|
+
let raw = '';
|
|
45
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
try {
|
|
48
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
49
|
+
} catch {
|
|
50
|
+
resolve({});
|
|
51
|
+
}
|
|
87
52
|
});
|
|
88
53
|
});
|
|
89
54
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
server = http.createServer(async (req, res) => {
|
|
57
|
+
const url = new URL(req.url, 'http://localhost');
|
|
58
|
+
const body = req.method === 'POST' ? await readBody(req) : {};
|
|
59
|
+
state.requests.push({ method: req.method, path: url.pathname, body, headers: req.headers });
|
|
60
|
+
const send = (payload) => {
|
|
61
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
62
|
+
res.end(JSON.stringify(payload));
|
|
63
|
+
};
|
|
64
|
+
if (url.pathname === '/api/model') return send({ location: LOCATION, data: state.models });
|
|
65
|
+
if (url.pathname === '/api/model/default') return send({ location: LOCATION, data: state.defaultModel });
|
|
66
|
+
if (url.pathname === '/api/provider') return send({ location: LOCATION, data: state.providers });
|
|
67
|
+
if (url.pathname === '/api/experimental/generate') {
|
|
68
|
+
const error = state.generateErrors.shift();
|
|
69
|
+
if (error) {
|
|
70
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
71
|
+
return res.end(JSON.stringify(error));
|
|
72
|
+
}
|
|
73
|
+
return send({ location: LOCATION, data: state.generate(body) });
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(404).end('{}');
|
|
101
76
|
});
|
|
77
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
78
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
79
|
+
});
|
|
102
80
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
81
|
+
afterAll(() => {
|
|
82
|
+
server?.close();
|
|
83
|
+
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
|
|
84
|
+
});
|
|
107
85
|
|
|
108
|
-
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
state.models = [MODEL()];
|
|
88
|
+
state.providers = [{ id: 'anthropic', name: 'Anthropic', activation: 'auto', package: 'x' }];
|
|
89
|
+
state.defaultModel = MODEL();
|
|
90
|
+
state.generate = () => ({ text: 'generated' });
|
|
91
|
+
state.requests = [];
|
|
92
|
+
state.generateErrors = [];
|
|
93
|
+
configureOpenCodeRuntimeProviders({
|
|
94
|
+
buildOpenCodeUrl: (requestPath) => `${baseUrl}${requestPath.startsWith('/') ? requestPath : `/${requestPath}`}`,
|
|
95
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
|
|
109
96
|
});
|
|
97
|
+
resetOpenCodeRuntimeProviders();
|
|
98
|
+
});
|
|
110
99
|
|
|
111
|
-
|
|
112
|
-
getRuntimeProviderSnapshot.mockResolvedValue(snapshot([
|
|
113
|
-
{ id: 'endpointless', apiKey: 'plugin-key', baseURL: null, anonymousZen: false },
|
|
114
|
-
]));
|
|
100
|
+
const lastGenerate = () => state.requests.filter((entry) => entry.path === '/api/experimental/generate').at(-1);
|
|
115
101
|
|
|
116
|
-
|
|
102
|
+
describe('generateSmallModelText', () => {
|
|
103
|
+
const unavailable = { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' };
|
|
104
|
+
const options = { prompt: 'write a commit', model: 'zai-coding-plan/glm-5.3-flash' };
|
|
105
|
+
|
|
106
|
+
it('retries a cold catalog once with the same model and prompt', async () => {
|
|
107
|
+
state.generateErrors = [unavailable];
|
|
108
|
+
const result = await generateSmallModelText(options);
|
|
109
|
+
expect(result.text).toBe('generated');
|
|
110
|
+
const calls = state.requests.filter((entry) => entry.path === '/api/experimental/generate');
|
|
111
|
+
expect(calls).toHaveLength(2);
|
|
112
|
+
expect(calls[0].body).toEqual(calls[1].body);
|
|
117
113
|
});
|
|
118
114
|
|
|
119
|
-
it('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
it('reports persistent model unavailability after one retry', async () => {
|
|
116
|
+
state.generateErrors = [unavailable, unavailable];
|
|
117
|
+
await expect(generateSmallModelText(options)).rejects.toMatchObject({
|
|
118
|
+
message: unavailable.message, statusCode: 503, code: 'small-model-unavailable',
|
|
119
|
+
});
|
|
120
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(2);
|
|
121
|
+
});
|
|
124
122
|
|
|
125
|
-
|
|
123
|
+
it('does not retry other invalid requests', async () => {
|
|
124
|
+
state.generateErrors = [{ _tag: 'InvalidRequestError', message: 'Invalid prompt' }];
|
|
125
|
+
await expect(generateSmallModelText(options)).rejects.toMatchObject({ message: 'Invalid prompt' });
|
|
126
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
|
|
126
127
|
});
|
|
127
128
|
|
|
128
|
-
it('
|
|
129
|
-
|
|
129
|
+
it('does not retry provider failures with the same message', async () => {
|
|
130
|
+
state.generateErrors = [{ _tag: 'ServiceUnavailableError', message: unavailable.message }];
|
|
131
|
+
await expect(generateSmallModelText(options)).rejects.toMatchObject({ _tag: 'ServiceUnavailableError' });
|
|
132
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
|
|
133
|
+
});
|
|
130
134
|
|
|
131
|
-
|
|
135
|
+
it('cancels without sending the retry', async () => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
state.generateErrors = [unavailable];
|
|
138
|
+
const pending = generateSmallModelText({ ...options, signal: controller.signal });
|
|
139
|
+
const timer = setTimeout(() => controller.abort(), 100);
|
|
140
|
+
try {
|
|
141
|
+
await expect(pending).rejects.toThrow();
|
|
142
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
132
146
|
});
|
|
133
|
-
});
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
'claude-haiku-4-5': { id: 'claude-haiku-4-5', limit: { context: 8_000 }, structured_output: true },
|
|
141
|
-
'legacy-tiny': { id: 'legacy-tiny', limit: { context: 8_000 }, structured_output: false },
|
|
142
|
-
'unlisted-capability': { id: 'unlisted-capability', limit: { context: 8_000 } },
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
};
|
|
148
|
+
it('honors the timeout during the retry delay', async () => {
|
|
149
|
+
state.generateErrors = [unavailable];
|
|
150
|
+
await expect(generateSmallModelText({ ...options, timeoutMs: 100 })).rejects.toThrow();
|
|
151
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
|
|
152
|
+
});
|
|
146
153
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
model: 'anthropic/claude-haiku-4-5',
|
|
150
|
-
directory: '/proj',
|
|
151
|
-
...overrides,
|
|
152
|
-
});
|
|
154
|
+
it('sends the prompt to /api/generate on the resolved model', async () => {
|
|
155
|
+
const result = await generateSmallModelText({ prompt: 'summarize this', directory: '/proj' });
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
// The fixture's only model is a haiku: the family scan finds it before
|
|
158
|
+
// OpenCode's default is even asked.
|
|
159
|
+
expect(result).toMatchObject({ text: 'generated', providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'small' });
|
|
160
|
+
expect(lastGenerate().body).toEqual({
|
|
161
|
+
prompt: 'summarize this',
|
|
162
|
+
model: { id: 'claude-haiku-4-5', providerID: 'anthropic' },
|
|
163
|
+
});
|
|
161
164
|
});
|
|
162
165
|
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
it('scopes the request to the directory', async () => {
|
|
167
|
+
await generateSmallModelText({ prompt: 'hi', directory: '/proj/sub dir' });
|
|
168
|
+
|
|
169
|
+
expect(lastGenerate().headers['x-opencode-directory']).toBe(encodeURIComponent('/proj/sub dir'));
|
|
165
170
|
});
|
|
166
171
|
|
|
167
|
-
it('
|
|
168
|
-
|
|
172
|
+
it('leads the prompt with the system instructions', async () => {
|
|
173
|
+
await generateSmallModelText({ prompt: 'the task', system: 'you are terse', directory: '/proj' });
|
|
169
174
|
|
|
170
|
-
expect(
|
|
171
|
-
const sent = callSmallModel.mock.calls.at(-1)[0].prompt;
|
|
172
|
-
expect(sent.length).toBeLessThan(20_000);
|
|
173
|
-
expect(sent.endsWith('…')).toBe(true);
|
|
175
|
+
expect(lastGenerate().body.prompt).toBe('you are terse\n\nthe task');
|
|
174
176
|
});
|
|
175
177
|
|
|
176
|
-
it('
|
|
177
|
-
await
|
|
178
|
-
.rejects.toMatchObject({
|
|
179
|
-
statusCode: 413,
|
|
180
|
-
code: 'context-too-small',
|
|
181
|
-
requiredChars: 20_000,
|
|
182
|
-
availableChars: 16_000,
|
|
183
|
-
});
|
|
178
|
+
it('honours an explicit request model', async () => {
|
|
179
|
+
const result = await generateSmallModelText({ prompt: 'hi', model: 'openai/gpt-5.6-luna', directory: '/proj' });
|
|
184
180
|
|
|
185
|
-
expect(
|
|
181
|
+
expect(result.source).toBe('request');
|
|
182
|
+
expect(lastGenerate().body.model).toEqual({ id: 'gpt-5.6-luna', providerID: 'openai' });
|
|
186
183
|
});
|
|
187
184
|
|
|
188
|
-
it('
|
|
189
|
-
|
|
185
|
+
it('refuses Claude Code before any request reaches the model', async () => {
|
|
186
|
+
await expect(generateSmallModelText({ prompt: 'hi', model: 'claude-code/haiku', directory: '/proj' }))
|
|
187
|
+
.rejects.toMatchObject({ statusCode: 422, code: 'small-model-provider-unsupported' });
|
|
190
188
|
|
|
191
|
-
expect(
|
|
192
|
-
expect(callSmallModel.mock.calls.at(-1)[0].prompt).toBe('short prompt');
|
|
189
|
+
expect(lastGenerate()).toBeUndefined();
|
|
193
190
|
});
|
|
194
191
|
|
|
195
|
-
it('
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
timeoutMs: 240_000,
|
|
203
|
-
signal: controller.signal,
|
|
204
|
-
}));
|
|
205
|
-
|
|
206
|
-
expect(callSmallModel.mock.calls.at(-1)[0]).toMatchObject({
|
|
207
|
-
responseSchema: schema,
|
|
208
|
-
timeoutMs: 240_000,
|
|
209
|
-
signal: controller.signal,
|
|
192
|
+
it('stays on the session provider when the caller forbids switching', async () => {
|
|
193
|
+
const result = await generateSmallModelText({
|
|
194
|
+
prompt: 'hi',
|
|
195
|
+
directory: '/proj',
|
|
196
|
+
preferredProviderID: 'openai',
|
|
197
|
+
preferredModelID: 'gpt-5.6-luna',
|
|
198
|
+
restrictToPreferredProvider: true,
|
|
210
199
|
});
|
|
200
|
+
|
|
201
|
+
expect(result).toMatchObject({ providerID: 'openai', modelID: 'gpt-5.6-luna', source: 'session-model' });
|
|
211
202
|
});
|
|
212
|
-
});
|
|
213
203
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
204
|
+
it('prefers the small model of the session provider over the session model itself', async () => {
|
|
205
|
+
state.models = [
|
|
206
|
+
MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
|
|
207
|
+
MODEL({ id: 'claude-haiku-4-5', modelID: 'claude-haiku-4-5', family: 'claude-haiku', time: { released: 1 } }),
|
|
208
|
+
MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', time: { released: 2 } }),
|
|
209
|
+
MODEL({ id: 'claude-haiku-6', modelID: 'claude-haiku-6', family: 'claude-haiku', time: { released: 3 }, status: 'beta' }),
|
|
210
|
+
MODEL({ id: 'gpt-5.6-luna', modelID: 'gpt-5.6-luna', providerID: 'openai', family: 'gpt-luna' }),
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const result = await generateSmallModelText({
|
|
214
|
+
prompt: 'hi',
|
|
215
|
+
directory: '/proj',
|
|
216
|
+
preferredProviderID: 'anthropic',
|
|
217
|
+
preferredModelID: 'claude-sonnet-5',
|
|
218
|
+
restrictToPreferredProvider: true,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Newest active haiku of the session provider; the other provider's
|
|
222
|
+
// gpt-luna ranks higher in the family list but is another subscription.
|
|
223
|
+
expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-5', source: 'session-provider-small' });
|
|
224
|
+
expect(lastGenerate().body.model).toEqual({ id: 'claude-haiku-5', providerID: 'anthropic' });
|
|
219
225
|
});
|
|
220
226
|
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
it('without a session, takes the newest small model of any provider before the default', async () => {
|
|
228
|
+
state.models = [
|
|
229
|
+
MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
|
|
230
|
+
MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', time: { released: 9 } }),
|
|
231
|
+
MODEL({ id: 'gemini-3.5-flash', modelID: 'gemini-3.5-flash', providerID: 'google', family: 'gemini-flash', time: { released: 1 } }),
|
|
232
|
+
MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'google', family: 'gemini-flash', time: { released: 2 } }),
|
|
233
|
+
];
|
|
234
|
+
state.defaultModel = state.models[0];
|
|
235
|
+
|
|
236
|
+
const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
|
|
237
|
+
|
|
238
|
+
// gemini-flash outranks claude-haiku in the family list, so the newest
|
|
239
|
+
// gemini-flash wins even though haiku was released later.
|
|
240
|
+
expect(result).toMatchObject({ providerID: 'google', modelID: 'gemini-3.6-flash', source: 'small' });
|
|
223
241
|
});
|
|
224
242
|
|
|
225
|
-
it('
|
|
226
|
-
|
|
243
|
+
it('with a session but no restriction, still prefers the session provider, then any provider', async () => {
|
|
244
|
+
state.models = [
|
|
245
|
+
MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
|
|
246
|
+
MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'google', family: 'gemini-flash' }),
|
|
247
|
+
];
|
|
248
|
+
state.defaultModel = state.models[0];
|
|
227
249
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
contextKnown: true,
|
|
234
|
-
structuredOutput: true,
|
|
235
|
-
hasLogin: true,
|
|
250
|
+
const result = await generateSmallModelText({
|
|
251
|
+
prompt: 'hi',
|
|
252
|
+
directory: '/proj',
|
|
253
|
+
preferredProviderID: 'anthropic',
|
|
254
|
+
preferredModelID: 'claude-sonnet-5',
|
|
236
255
|
});
|
|
237
|
-
});
|
|
238
256
|
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
// Anthropic has no small family here, and the caller allows switching.
|
|
258
|
+
expect(result).toMatchObject({ providerID: 'google', modelID: 'gemini-3.6-flash', source: 'small' });
|
|
259
|
+
});
|
|
241
260
|
|
|
242
|
-
|
|
261
|
+
it('reads the family from the model id when the catalog has none (custom provider)', async () => {
|
|
262
|
+
state.models = [
|
|
263
|
+
MODEL({ id: 'my-big-model', modelID: 'my-big-model', providerID: 'my-proxy' }),
|
|
264
|
+
MODEL({ id: 'gemini-3.6-flash', modelID: 'gemini-3.6-flash', providerID: 'my-proxy' }),
|
|
265
|
+
MODEL({ id: 'gpt-5.4-nano', modelID: 'gpt-5.4-nano', providerID: 'my-proxy' }),
|
|
266
|
+
];
|
|
243
267
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
268
|
+
const result = await generateSmallModelText({
|
|
269
|
+
prompt: 'hi',
|
|
270
|
+
directory: '/proj',
|
|
271
|
+
preferredProviderID: 'my-proxy',
|
|
272
|
+
preferredModelID: 'my-big-model',
|
|
273
|
+
restrictToPreferredProvider: true,
|
|
248
274
|
});
|
|
275
|
+
|
|
276
|
+
expect(result).toMatchObject({ providerID: 'my-proxy', modelID: 'gemini-3.6-flash', source: 'session-provider-small' });
|
|
249
277
|
});
|
|
250
278
|
|
|
251
|
-
it('
|
|
252
|
-
|
|
279
|
+
it('never picks Claude Code as the small model of any provider', async () => {
|
|
280
|
+
state.models = [MODEL({ id: 'haiku', modelID: 'haiku', providerID: 'claude-code', family: 'claude-haiku' })];
|
|
253
281
|
|
|
254
|
-
const
|
|
282
|
+
const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
|
|
255
283
|
|
|
256
|
-
|
|
284
|
+
// Only Claude Code has a small family here, so the scan yields nothing
|
|
285
|
+
// and OpenCode's default (the fixture's anthropic haiku) is used.
|
|
286
|
+
expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'default' });
|
|
257
287
|
});
|
|
258
288
|
|
|
259
|
-
it('
|
|
260
|
-
|
|
289
|
+
it('ignores a disabled small model and falls back to the session model', async () => {
|
|
290
|
+
state.models = [
|
|
291
|
+
MODEL({ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', family: 'claude-sonnet' }),
|
|
292
|
+
MODEL({ id: 'claude-haiku-5', modelID: 'claude-haiku-5', family: 'claude-haiku', enabled: false }),
|
|
293
|
+
];
|
|
261
294
|
|
|
262
|
-
const
|
|
295
|
+
const result = await generateSmallModelText({
|
|
296
|
+
prompt: 'hi',
|
|
297
|
+
directory: '/proj',
|
|
298
|
+
preferredProviderID: 'anthropic',
|
|
299
|
+
preferredModelID: 'claude-sonnet-5',
|
|
300
|
+
restrictToPreferredProvider: true,
|
|
301
|
+
});
|
|
263
302
|
|
|
264
|
-
expect(
|
|
303
|
+
expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-sonnet-5', source: 'session-model' });
|
|
265
304
|
});
|
|
266
|
-
});
|
|
267
305
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
getModelCatalog.mockResolvedValue({
|
|
276
|
-
anthropic: {
|
|
277
|
-
id: 'anthropic',
|
|
278
|
-
models: {
|
|
279
|
-
roomy: { id: 'roomy', limit: { context: 100_000, output: 8_000 } },
|
|
280
|
-
unlisted: { id: 'unlisted', limit: { context: 100_000 } },
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
callSmallModel.mockReset();
|
|
285
|
-
callSmallModel.mockResolvedValue('generated');
|
|
306
|
+
it('refuses rather than switch provider when the session has no model of its own', async () => {
|
|
307
|
+
await expect(generateSmallModelText({
|
|
308
|
+
prompt: 'hi',
|
|
309
|
+
directory: '/proj',
|
|
310
|
+
preferredProviderID: 'openai',
|
|
311
|
+
restrictToPreferredProvider: true,
|
|
312
|
+
})).rejects.toMatchObject({ statusCode: 404 });
|
|
286
313
|
});
|
|
287
314
|
|
|
288
|
-
|
|
289
|
-
|
|
315
|
+
it('reports a missing OpenCode as no small model', async () => {
|
|
316
|
+
configureOpenCodeRuntimeProviders(null);
|
|
317
|
+
|
|
318
|
+
await expect(generateSmallModelText({ prompt: 'hi', directory: '/proj' }))
|
|
319
|
+
.rejects.toMatchObject({ statusCode: 404 });
|
|
290
320
|
});
|
|
321
|
+
});
|
|
291
322
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
323
|
+
describe('oversized input', () => {
|
|
324
|
+
// 8k context leaves 4k input tokens after the default output reserve → 16k chars.
|
|
325
|
+
const huge = (overrides = {}) => ({ prompt: 'x'.repeat(20_000), directory: '/proj', ...overrides });
|
|
326
|
+
|
|
327
|
+
it('truncates and flags the response by default', async () => {
|
|
328
|
+
const result = await generateSmallModelText(huge());
|
|
298
329
|
|
|
299
|
-
expect(
|
|
330
|
+
expect(result.inputTruncated).toBe(true);
|
|
331
|
+
const sent = lastGenerate().body.prompt;
|
|
332
|
+
expect(sent.length).toBeLessThan(20_000);
|
|
333
|
+
expect(sent.endsWith('…')).toBe(true);
|
|
300
334
|
});
|
|
301
335
|
|
|
302
|
-
it('
|
|
303
|
-
await generateSmallModelText({
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
336
|
+
it('refuses without calling the model when the caller cannot survive truncation', async () => {
|
|
337
|
+
await expect(generateSmallModelText(huge({ onOverflow: 'error' }))).rejects.toMatchObject({
|
|
338
|
+
statusCode: 413,
|
|
339
|
+
code: 'context-too-small',
|
|
340
|
+
requiredChars: 20_000,
|
|
341
|
+
availableChars: 16_000,
|
|
307
342
|
});
|
|
308
343
|
|
|
309
|
-
expect(
|
|
344
|
+
expect(lastGenerate()).toBeUndefined();
|
|
310
345
|
});
|
|
311
346
|
|
|
312
347
|
it('reserves exactly the requested output budget from the input allowance', async () => {
|
|
313
|
-
|
|
348
|
+
state.models = [MODEL({ limit: { context: 100_000, output: 32_000 } })];
|
|
349
|
+
state.defaultModel = state.models[0];
|
|
350
|
+
|
|
314
351
|
await expect(generateSmallModelText({
|
|
315
352
|
prompt: 'x'.repeat(304_001),
|
|
316
|
-
|
|
353
|
+
directory: '/proj',
|
|
317
354
|
maxOutputTokens: 24_000,
|
|
318
355
|
onOverflow: 'error',
|
|
319
356
|
})).rejects.toMatchObject({ code: 'context-too-small', availableChars: 304_000 });
|
|
357
|
+
});
|
|
320
358
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
359
|
+
it('falls back to a conservative context when OpenCode does not list the model', async () => {
|
|
360
|
+
state.models = [];
|
|
361
|
+
|
|
362
|
+
await expect(generateSmallModelText({ prompt: 'x'.repeat(300_000), directory: '/proj', onOverflow: 'error' }))
|
|
363
|
+
.rejects.toMatchObject({ availableChars: 60_000 * 4 });
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('structured output', () => {
|
|
368
|
+
const schema = { type: 'object', properties: { title: { type: 'string' } } };
|
|
369
|
+
|
|
370
|
+
it('puts the schema in the prompt and returns the parsed JSON text', async () => {
|
|
371
|
+
state.generate = () => ({ text: '{"title":"ok"}' });
|
|
372
|
+
|
|
373
|
+
const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
|
|
374
|
+
|
|
375
|
+
expect(result.text).toBe('{"title":"ok"}');
|
|
376
|
+
expect(lastGenerate().body.prompt).toContain('Reply with JSON matching this schema and nothing else:');
|
|
377
|
+
expect(lastGenerate().body.prompt).toContain('"title"');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('tolerates a json fence', async () => {
|
|
381
|
+
state.generate = () => ({ text: '```json\n{"title":"ok"}\n```' });
|
|
382
|
+
|
|
383
|
+
const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
|
|
384
|
+
|
|
385
|
+
expect(JSON.parse(result.text)).toEqual({ title: 'ok' });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('retries once when the reply is not JSON', async () => {
|
|
389
|
+
let calls = 0;
|
|
390
|
+
state.generate = () => {
|
|
391
|
+
calls += 1;
|
|
392
|
+
return { text: calls === 1 ? 'Sure! Here you go.' : '{"title":"ok"}' };
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const result = await generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema });
|
|
396
|
+
|
|
397
|
+
expect(calls).toBe(2);
|
|
398
|
+
expect(result.text).toBe('{"title":"ok"}');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('gives up with structured-output-unsupported after the retry', async () => {
|
|
402
|
+
state.generate = () => ({ text: 'I cannot do that.' });
|
|
403
|
+
|
|
404
|
+
await expect(generateSmallModelText({ prompt: 'describe', directory: '/proj', responseSchema: schema }))
|
|
405
|
+
.rejects.toMatchObject({ statusCode: 422, code: 'structured-output-unsupported' });
|
|
406
|
+
|
|
407
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(2);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('describeSmallModel', () => {
|
|
412
|
+
it('reports the default model and its budget', async () => {
|
|
413
|
+
const described = await describeSmallModel({ directory: '/proj' });
|
|
414
|
+
|
|
415
|
+
expect(described).toMatchObject({
|
|
416
|
+
providerID: 'anthropic',
|
|
417
|
+
modelID: 'claude-haiku-4-5',
|
|
418
|
+
source: 'small',
|
|
419
|
+
inputCharBudget: 16_000,
|
|
420
|
+
contextTokens: 8_000,
|
|
421
|
+
contextKnown: true,
|
|
422
|
+
hasLogin: true,
|
|
423
|
+
outputTokenLimit: 4_000,
|
|
424
|
+
// /api/generate has no structured-output mode, so the capability is
|
|
425
|
+
// never a settled `false` — callers must try it.
|
|
426
|
+
structuredOutput: null,
|
|
427
|
+
});
|
|
327
428
|
});
|
|
328
429
|
|
|
329
|
-
it('reports the
|
|
330
|
-
|
|
430
|
+
it('reports the override model instead of the default', async () => {
|
|
431
|
+
fs.writeFileSync(
|
|
432
|
+
path.join(TEMP_DATA_DIR, 'settings.json'),
|
|
433
|
+
JSON.stringify({ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-5.6-luna' }),
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const described = await describeSmallModel({ directory: '/proj' });
|
|
438
|
+
expect(described).toMatchObject({ providerID: 'openai', modelID: 'gpt-5.6-luna', source: 'settings' });
|
|
439
|
+
} finally {
|
|
440
|
+
fs.rmSync(path.join(TEMP_DATA_DIR, 'settings.json'), { force: true });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
331
443
|
|
|
332
|
-
|
|
444
|
+
it('reports hasLogin false for a model OpenCode has disabled', async () => {
|
|
445
|
+
state.models = [MODEL({ enabled: false })];
|
|
333
446
|
|
|
334
|
-
expect(
|
|
447
|
+
expect(await describeSmallModel({ directory: '/proj' })).toMatchObject({ hasLogin: false });
|
|
335
448
|
});
|
|
336
449
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/roomy' } });
|
|
450
|
+
it('lets the reserve be decided from the resolved model limits', async () => {
|
|
451
|
+
state.models = [MODEL({ limit: { context: 100_000, output: 8_000 } })];
|
|
452
|
+
state.defaultModel = state.models[0];
|
|
341
453
|
|
|
342
454
|
const described = await describeSmallModel({
|
|
343
455
|
directory: '/proj',
|
|
344
456
|
outputReserveTokens: ({ contextTokens, outputTokenLimit }) => Math.min(contextTokens / 10, outputTokenLimit),
|
|
345
457
|
});
|
|
346
458
|
|
|
347
|
-
// 100k context, 8k output limit
|
|
459
|
+
// 100k context, 8k output limit → 8k reserved, leaving 92k tokens.
|
|
348
460
|
expect(described.outputTokens).toBe(8_000);
|
|
349
461
|
expect(described.inputCharBudget).toBe(92_000 * 4);
|
|
350
462
|
});
|
|
351
463
|
|
|
352
|
-
it('
|
|
353
|
-
|
|
464
|
+
it('answers null when OpenCode is not reachable', async () => {
|
|
465
|
+
configureOpenCodeRuntimeProviders(null);
|
|
466
|
+
|
|
467
|
+
expect(await describeSmallModel({ directory: '/proj' })).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('listAuthenticatedProviders', () => {
|
|
472
|
+
it('offers a provider that has at least one enabled model', async () => {
|
|
473
|
+
expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('hides a provider whose models are all disabled', async () => {
|
|
477
|
+
state.models = [MODEL({ enabled: false })];
|
|
478
|
+
|
|
479
|
+
expect(await listAuthenticatedProviders()).toEqual([]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// The provider list comes back empty on setups where models are perfectly
|
|
483
|
+
// usable, so it can only add names, never remove them.
|
|
484
|
+
it('derives providers from the model list when the provider list is empty', async () => {
|
|
485
|
+
state.providers = [];
|
|
486
|
+
|
|
487
|
+
expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('never offers Claude Code', async () => {
|
|
491
|
+
state.models = [MODEL(), MODEL({ id: 'sonnet', modelID: 'sonnet', providerID: 'claude-code' })];
|
|
354
492
|
|
|
355
|
-
|
|
493
|
+
expect(await listAuthenticatedProviders()).not.toContain('claude-code');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('answers an empty list when OpenCode is not reachable', async () => {
|
|
497
|
+
configureOpenCodeRuntimeProviders(null);
|
|
356
498
|
|
|
357
|
-
expect(
|
|
499
|
+
expect(await listAuthenticatedProviders()).toEqual([]);
|
|
358
500
|
});
|
|
359
501
|
});
|
|
360
502
|
|
|
361
|
-
|
|
362
|
-
|
|
503
|
+
|
|
504
|
+
describe('small model failure response', () => {
|
|
505
|
+
it('preserves the model-specific reason without advice to change settings', async () => {
|
|
506
|
+
let generate;
|
|
507
|
+
registerSmallModelRoutes({
|
|
508
|
+
get() {},
|
|
509
|
+
post(_path, handler) { generate = handler; },
|
|
510
|
+
}, {
|
|
511
|
+
getSmallModelService: async () => ({ generateSmallModelText }),
|
|
512
|
+
});
|
|
513
|
+
state.generateErrors = [
|
|
514
|
+
{ _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
|
|
515
|
+
{ _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
|
|
516
|
+
];
|
|
517
|
+
let status;
|
|
518
|
+
let payload;
|
|
519
|
+
const response = {
|
|
520
|
+
status(value) { status = value; return response; },
|
|
521
|
+
json(value) { payload = value; },
|
|
522
|
+
};
|
|
523
|
+
await generate({ body: { prompt: 'commit', model: 'zai-coding-plan/glm-5.3-flash' } }, response);
|
|
524
|
+
expect(status).toBe(503);
|
|
525
|
+
expect(payload).toEqual({
|
|
526
|
+
error: 'Model unavailable: zai-coding-plan/glm-5.3-flash',
|
|
527
|
+
code: 'small-model-unavailable',
|
|
528
|
+
});
|
|
529
|
+
});
|
|
363
530
|
});
|