@openchambery/web 1.19.7-beta.2 → 1.19.7-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
  2. package/dist/assets/AssistantView-sV1PVQiM.js +2 -0
  3. package/dist/assets/DiagramView-D2hqP5VV.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
  5. package/dist/assets/Icon-4My8YZwt.js +1 -0
  6. package/dist/assets/MarkdownLoadingSkeleton-Vezjvk9B.js +3 -0
  7. package/dist/assets/MarkdownRenderer-x091Z--3.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-DML8BSro.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-BK-HM2k3.js +1 -0
  10. package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
  11. package/dist/assets/MobileDetailNavigation-DcxkWiks.js +1 -0
  12. package/dist/assets/MobileShareBridge-TAGcOiyb.js +4 -0
  13. package/dist/assets/MobileSurface-zqlZzEFQ.js +2 -0
  14. package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-iH1d9S14.js} +1 -1
  15. package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
  16. package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
  17. package/dist/assets/SettingsView-CzW23DZA.js +25 -0
  18. package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-DZXCWZid.js} +1 -1
  19. package/dist/assets/TerminalView-pU0wpD8s.js +8 -0
  20. package/dist/assets/ToolOutputDialog-Bt7JChFG.js +10 -0
  21. package/dist/assets/appThemeRegistry-BI7WR2Wo.js +1 -0
  22. package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
  23. package/dist/assets/clipboard-BXxLshVq.js +1 -0
  24. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
  25. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  26. package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
  27. package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
  28. package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
  29. package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
  30. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  31. package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
  32. package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
  33. package/dist/assets/fileReferenceActions-Di_DoDex.js +1 -0
  34. package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
  35. package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-CeFszLvk.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
  37. package/dist/assets/insertionBoundaries-DDJNusn2.js +4 -0
  38. package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
  39. package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
  40. package/dist/assets/main-CjnOsKDL.js +169 -0
  41. package/dist/assets/main-j-Ij13jc.js +2 -0
  42. package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
  43. package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
  44. package/dist/assets/miniChat-DgxavREE.js +2 -0
  45. package/dist/assets/mobile-C0rwPMPM.js +2 -0
  46. package/dist/assets/multirun-ot7-bKg7.js +4 -0
  47. package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
  48. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  49. package/dist/assets/persistence-CSZp1CNB.js +3 -0
  50. package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
  51. package/dist/assets/platform-CenlWqxu.js +2 -0
  52. package/dist/assets/projectMeta-3yOEc0oI.js +3 -0
  53. package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BPNvFA_N.js +2 -0
  55. package/dist/assets/renderMobileApp-CNog29Xy.js +28 -0
  56. package/dist/assets/runtimeConfig-D28kNEYO.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-CorvFIRk.js +147 -0
  58. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  59. package/dist/assets/sessionLookup-CbatbQf7.js +1 -0
  60. package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
  61. package/dist/assets/src-Af9cMFKF.css +2 -0
  62. package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
  63. package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
  64. package/dist/assets/textarea-CIO34TZz.js +1 -0
  65. package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
  66. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
  67. package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
  68. package/dist/assets/url-CE_gAfNa.js +1 -0
  69. package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-CDpeZWK4.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-D-xPByG0.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-Dp-tez_r.js +1 -0
  73. package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-DE1Pe9A9.js +412 -0
  75. package/dist/assets/useSessionAutoCleanup-jNJ_WC38.js +1 -0
  76. package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-MDnGj3yv.js +7 -0
  78. package/dist/assets/utils-B0mQXN9L.js +1 -0
  79. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  80. package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
  81. package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
  82. package/dist/index.html +9 -11
  83. package/dist/mini-chat.html +9 -11
  84. package/dist/mobile.html +15 -17
  85. package/package.json +2 -1
  86. package/server/index.js +2 -0
  87. package/server/lib/assistant-delivery-parts.js +3 -1
  88. package/server/lib/assistant-delivery-parts.test.js +11 -0
  89. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  90. package/server/lib/assistants/assign.js +225 -0
  91. package/server/lib/assistants/assign.test.js +118 -0
  92. package/server/lib/assistants/bubbles.js +45 -0
  93. package/server/lib/assistants/bubbles.test.js +36 -0
  94. package/server/lib/assistants/cards.js +148 -0
  95. package/server/lib/assistants/cards.test.js +116 -0
  96. package/server/lib/assistants/contact-store.js +284 -0
  97. package/server/lib/assistants/contact-store.test.js +131 -0
  98. package/server/lib/assistants/contact-tools.js +761 -0
  99. package/server/lib/assistants/contact-tools.test.js +328 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +551 -0
  102. package/server/lib/assistants/harness.test.js +607 -0
  103. package/server/lib/assistants/routes.js +44 -4
  104. package/server/lib/assistants/service.js +930 -17
  105. package/server/lib/assistants/service.test.js +1163 -45
  106. package/server/lib/llm/DOCUMENTATION.md +84 -0
  107. package/server/lib/llm/catalog.js +100 -0
  108. package/server/lib/llm/catalog.test.js +78 -0
  109. package/server/lib/llm/completions.js +151 -0
  110. package/server/lib/llm/completions.test.js +127 -0
  111. package/server/lib/llm/generate.js +570 -0
  112. package/server/lib/llm/generate.test.js +459 -0
  113. package/server/lib/llm/routes.js +47 -0
  114. package/server/lib/llm/temp-directory.js +37 -0
  115. package/server/lib/notifications/APNS.md +23 -17
  116. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  117. package/server/lib/notifications/apns-runtime.js +139 -20
  118. package/server/lib/notifications/apns-runtime.test.js +66 -7
  119. package/server/lib/notifications/apns-titles.js +62 -62
  120. package/server/lib/notifications/apns-titles.test.js +3 -1
  121. package/server/lib/notifications/routes.js +1 -1
  122. package/server/lib/notifications/runtime.js +177 -86
  123. package/server/lib/notifications/runtime.test.js +122 -0
  124. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  125. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  126. package/server/lib/opencode/settings-runtime.js +18 -2
  127. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  128. package/server/lib/session-goal/runtime.js +20 -0
  129. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  130. package/server/lib/session-index/service.js +10 -4
  131. package/server/lib/session-index/service.test.js +37 -0
  132. package/server/lib/session-title/runtime.js +1 -0
  133. package/server/lib/session-title/runtime.test.js +11 -0
  134. package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
  135. package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
  136. package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
  137. package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
  138. package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
  139. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  140. package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
  141. package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
  142. package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
  143. package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
  144. package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
  145. package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
  146. package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
  147. package/dist/assets/SettingsView-DyCZehFG.js +0 -25
  148. package/dist/assets/TerminalView-XSALfUaH.js +0 -8
  149. package/dist/assets/ToolOutputDialog-BfuXazW9.js +0 -10
  150. package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
  151. package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
  152. package/dist/assets/desktop-CfLlmafK.js +0 -1
  153. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  154. package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
  155. package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
  156. package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
  157. package/dist/assets/main-B9HVtgXE.js +0 -2
  158. package/dist/assets/main-ZNyIs3Sn.js +0 -29
  159. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  160. package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
  161. package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
  162. package/dist/assets/mobile-DEduvLJj.js +0 -2
  163. package/dist/assets/multirun-DU-wrxlG.js +0 -4
  164. package/dist/assets/persistence-CmjKImz2.js +0 -3
  165. package/dist/assets/platform-skqcMARt.js +0 -2
  166. package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
  167. package/dist/assets/radio-BEonIX1k.js +0 -1
  168. package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
  169. package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
  170. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  171. package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
  172. package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
  173. package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
  174. package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
  175. package/dist/assets/src-2eFAiBLq.css +0 -2
  176. package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
  177. package/dist/assets/url-KeihbE0d.js +0 -1
  178. package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
  179. package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
  180. package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
  181. package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
  182. package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
  183. package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
  184. package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
  185. package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
  186. package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
  187. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -0,0 +1,84 @@
1
+ # OpenChamber LLM Gateway
2
+
3
+ OpenAI-shaped Chat Completions for the in-app Assistant contact. This is the
4
+ only model path the Assistant harness (`pi-agent-core` `streamFn`) talks to.
5
+
6
+ ## Contract
7
+
8
+ - `GET /api/openchamber/llm/models` — connected `{providerID, modelID}` catalog
9
+ from OpenCode `GET /provider` (`connected`) plus `GET /config/providers`.
10
+ Plugin adapter formats stay inside OpenCode; this route does not parse them.
11
+ - `POST /api/openchamber/llm/chat/completions` — `{ model, messages, stream?,
12
+ providerID?, modelID? }` → OpenAI `chat.completion` JSON.
13
+ This **public** gateway is **non-streaming**. Bundled OpenCode 1.18.4 generate
14
+ (`POST /generate` when present, otherwise throwaway `session.promptAsync`
15
+ plus idle wait) returns a full assistant turn. `stream: true` is rejected with
16
+ `validation_error` (HTTP 400). Do not emit fake SSE after the fact on this
17
+ route (never typewrite a completed string into tokens over HTTP).
18
+ The contact UI may stream later via a **server-internal** path only (below).
19
+
20
+ `model` may be `providerID/modelID` or a bare `modelID` paired with `providerID`.
21
+
22
+ ## Internals (verified on bundled `@opencode-ai/sdk` 1.18.4)
23
+
24
+ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
25
+ `session.prompt` / `session.promptAsync`. It does **not** expose
26
+ `POST /api/generate` or any other sessionless generate method.
27
+
28
+ 1. Probe `POST /generate` (and SDK `generate` if present). Use it only when
29
+ the response is JSON (`Content-Type` or a JSON object body). SPA / OpenCode
30
+ HTML `200 <!doctype` is not generate — fall through to the throwaway path.
31
+ Bundled 1.18.4 has no sessionless generate.
32
+ 2. Otherwise create a throwaway archived OpenCode session, deny every tool
33
+ (`client.tool.ids()` → `{ [id]: false }`), send our messages as
34
+ `system` + user text via `session.promptAsync` (v2 `session.prompt` only
35
+ forwards `{ id, prompt, delivery, resume }` and drops `model`/`parts` —
36
+ that produced empty assistant text and a 502). Contact file parts reuse the
37
+ existing OpenCode `{ type: 'file', mime, url, filename? }` delivery shape
38
+ (data URLs in the contact SQLite store — not a second attachment store) and
39
+ are forwarded on `promptAsync` only when the connected catalog marks that
40
+ model as image-capable (`modalities.input`, `input`, or `attachment`).
41
+ Non-vision models (for example deepseek-v4-flash) keep the `[image: …]`
42
+ description and any text-file bytes, and skip image data URLs so generate
43
+ cannot stall on unsupported vision parts. Non-image
44
+ text files are also inlined into the flattened prompt. Wait for idle via
45
+ `session.status` + `session.messages`, then delete the session. This is a
46
+ text generator only — never the contact transcript and never a coding
47
+ SessionPrompt loop. Upstream `info.error.message` is forwarded on 502.
48
+
49
+ ### Internal token callback (in-process only)
50
+
51
+ `generateOpenCodeText` / `createChatCompletion` accept optional
52
+ `onTextDelta(text: string)` and `globalEventHub` (the shared
53
+ `globalMessageStreamHub` / `subscribeEvent` surface).
54
+
55
+ - **Throwaway session path:** after archive, before `promptAsync`, subscribe to
56
+ the hub. Forward real OpenCode `message.part.delta` events whose
57
+ `properties.sessionID` matches this throwaway session, `field` is `text`
58
+ (or omitted), and `messageID` matches the first assistant message locked for
59
+ that generate. Still wait for idle and return the **full** text (strip/parse
60
+ needs the complete string). Unsubscribe in `finally` so failed generates
61
+ cannot leak listeners.
62
+ - **Sessionless `/generate` JSON path:** cannot emit live deltas — skip
63
+ `onTextDelta` honestly (no fake typewriter of the completed string).
64
+ - **HTTP `POST .../chat/completions`:** does not pass these options and still
65
+ rejects `stream: true`. Token deltas never leave the server as SSE on that
66
+ route.
67
+
68
+ Bundled OpenCode 1.18.4 emits `message.part.delta` on the global event stream
69
+ for normal session prompts (same events the UI transcript already consumes).
70
+ Throwaway LLM sessions use the same `session.promptAsync` path, so they are
71
+ expected to emit the same delta events when the model streams tokens.
72
+
73
+ Credentials stay in OpenCode. This module does not read `auth.json`, does not
74
+ call Anthropic/OpenAI/plugin SDKs, and does not use the `openai` npm package.
75
+
76
+ No connected provider → `no_provider` (HTTP 400). Upstream failure is never
77
+ an empty success. The 502 body includes `{ error, message }`.
78
+
79
+ ## Ownership
80
+
81
+ The Assistant contact harness owns system prompt, OpenChamber transcript,
82
+ bubble splitting, and OpenChamber API tools (`assign_session`). Those tools
83
+ deliver through contact **cards**, not this completions payload. The gateway
84
+ stays a text generator: OpenCode coding tools stay denied.
@@ -0,0 +1,100 @@
1
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+
3
+ const stringID = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
4
+
5
+ const listIncludesImage = (value) => Array.isArray(value)
6
+ && value.some((item) => String(item).toLowerCase() === 'image');
7
+
8
+ function modelAcceptsImages(model) {
9
+ if (!isRecord(model)) return false;
10
+ if (isRecord(model.modalities) && listIncludesImage(model.modalities.input)) return true;
11
+ if (listIncludesImage(model.input)) return true;
12
+ return model.attachment === true;
13
+ }
14
+
15
+ /**
16
+ * Merge OpenCode `GET /provider` (connected ids) with `GET /config/providers`
17
+ * (model catalog). Do not interpret plugin-specific provider configs.
18
+ *
19
+ * @param {{
20
+ * connected?: unknown,
21
+ * providers?: unknown,
22
+ * }} source
23
+ */
24
+ export function projectConnectedModels(source) {
25
+ const connected = new Set(
26
+ Array.isArray(source?.connected)
27
+ ? source.connected.map(stringID).filter(Boolean)
28
+ : [],
29
+ );
30
+ const models = [];
31
+ const providers = [];
32
+ const list = Array.isArray(source?.providers) ? source.providers : [];
33
+ for (const provider of list) {
34
+ if (!isRecord(provider)) continue;
35
+ const providerID = stringID(provider.id);
36
+ if (!providerID || !connected.has(providerID)) continue;
37
+ const providerModels = [];
38
+ const rawModels = isRecord(provider.models) ? Object.values(provider.models) : [];
39
+ for (const model of rawModels) {
40
+ if (!isRecord(model)) continue;
41
+ const modelID = stringID(model.id);
42
+ if (!modelID) continue;
43
+ const entry = { providerID, modelID, name: stringID(model.name) || modelID, acceptsImages: modelAcceptsImages(model) };
44
+ models.push(entry);
45
+ providerModels.push({ id: modelID, name: entry.name });
46
+ }
47
+ providers.push({
48
+ id: providerID,
49
+ name: stringID(provider.name) || providerID,
50
+ models: providerModels,
51
+ });
52
+ }
53
+ return { connected: [...connected], providers, models };
54
+ }
55
+
56
+ export function parseModelRef(model, providerID, modelID) {
57
+ const explicitProvider = stringID(providerID);
58
+ const explicitModel = stringID(modelID);
59
+ if (explicitProvider && explicitModel) {
60
+ return { providerID: explicitProvider, modelID: explicitModel };
61
+ }
62
+ const raw = stringID(model);
63
+ if (!raw) return null;
64
+ const slash = raw.indexOf('/');
65
+ if (slash > 0) {
66
+ return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) };
67
+ }
68
+ if (explicitProvider && raw) {
69
+ return { providerID: explicitProvider, modelID: raw };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function isConnectedModel(catalog, providerID, modelID) {
75
+ if (!catalog || !stringID(providerID) || !stringID(modelID)) return false;
76
+ return catalog.models.some((entry) => entry.providerID === providerID && entry.modelID === modelID);
77
+ }
78
+
79
+ /**
80
+ * Load the connected catalog from an OpenCode SDK client.
81
+ * Failure is distinct from a successful empty catalog.
82
+ */
83
+ export async function loadConnectedCatalog(client) {
84
+ const listed = await client.provider.list({});
85
+ if (listed?.error || !isRecord(listed?.data)) {
86
+ const error = new Error('OpenCode provider list is unavailable');
87
+ error.code = 'upstream_error';
88
+ throw error;
89
+ }
90
+ const configured = await client.config.providers({});
91
+ if (configured?.error || !isRecord(configured?.data)) {
92
+ const error = new Error('OpenCode provider catalog is unavailable');
93
+ error.code = 'upstream_error';
94
+ throw error;
95
+ }
96
+ return projectConnectedModels({
97
+ connected: listed.data.connected,
98
+ providers: configured.data.providers,
99
+ });
100
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { loadConnectedCatalog, parseModelRef, projectConnectedModels } from './catalog.js'
3
+
4
+ describe('parseModelRef', () => {
5
+ it('splits provider/model refs', () => {
6
+ expect(parseModelRef('openai/gpt-5.2')).toEqual({
7
+ providerID: 'openai',
8
+ modelID: 'gpt-5.2',
9
+ })
10
+ })
11
+
12
+ it('prefers explicit providerID/modelID', () => {
13
+ expect(parseModelRef('ignored', 'anthropic', 'claude-sonnet-4')).toEqual({
14
+ providerID: 'anthropic',
15
+ modelID: 'claude-sonnet-4',
16
+ })
17
+ })
18
+
19
+ it('returns null for invalid refs', () => {
20
+ expect(parseModelRef('')).toBeNull()
21
+ expect(parseModelRef('openai')).toBeNull()
22
+ })
23
+ })
24
+
25
+ describe('projectConnectedModels', () => {
26
+ it('projects only connected providers', () => {
27
+ const catalog = projectConnectedModels({
28
+ connected: ['openai'],
29
+ providers: [
30
+ {
31
+ id: 'openai',
32
+ name: 'OpenAI',
33
+ models: {
34
+ 'gpt-5.2': { id: 'gpt-5.2', name: 'GPT-5.2' },
35
+ },
36
+ },
37
+ {
38
+ id: 'anthropic',
39
+ name: 'Anthropic',
40
+ models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude' } },
41
+ },
42
+ ],
43
+ })
44
+ expect(catalog.models).toEqual([
45
+ { providerID: 'openai', modelID: 'gpt-5.2', name: 'GPT-5.2', acceptsImages: false },
46
+ ])
47
+ })
48
+
49
+ it('marks vision models from modalities or attachment', () => {
50
+ const catalog = projectConnectedModels({
51
+ connected: ['openai'],
52
+ providers: [{
53
+ id: 'openai',
54
+ name: 'OpenAI',
55
+ models: {
56
+ 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
57
+ 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
58
+ },
59
+ }],
60
+ })
61
+ expect(catalog.models.find((model) => model.modelID === 'gpt-4o')?.acceptsImages).toBe(true)
62
+ expect(catalog.models.find((model) => model.modelID === 'deepseek-v4-flash')?.acceptsImages).toBe(false)
63
+ expect(catalog.connected).toEqual(['openai'])
64
+ })
65
+ })
66
+
67
+ describe('loadConnectedCatalog', () => {
68
+ afterEach(() => {
69
+ vi.restoreAllMocks()
70
+ })
71
+
72
+ it('requires both /provider and /config/providers', async () => {
73
+ await expect(loadConnectedCatalog({
74
+ provider: { list: async () => ({ data: { connected: ['openai'] } }) },
75
+ config: { providers: async () => ({ error: { status: 500 } }) },
76
+ })).rejects.toThrow(/provider catalog/)
77
+ })
78
+ })
@@ -0,0 +1,151 @@
1
+ import { isConnectedModel, loadConnectedCatalog, parseModelRef } from './catalog.js';
2
+ import { generateOpenCodeText } from './generate.js';
3
+
4
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+
6
+ export class LlmError extends Error {
7
+ constructor(code, statusCode, message) {
8
+ super(message || code);
9
+ this.code = code;
10
+ this.statusCode = statusCode;
11
+ }
12
+ }
13
+
14
+ const normalizeCompletionFilePart = (part) => {
15
+ if (!isRecord(part) || part.type !== 'file') return null;
16
+ if (typeof part.mime !== 'string' || !part.mime.trim()) return null;
17
+ if (typeof part.url !== 'string' || !part.url.trim()) return null;
18
+ return {
19
+ type: 'file',
20
+ mime: part.mime.trim(),
21
+ url: part.url.trim(),
22
+ ...(typeof part.filename === 'string' && part.filename.trim() ? { filename: part.filename.trim() } : {}),
23
+ };
24
+ };
25
+
26
+ const normalizeMessages = (messages) => {
27
+ if (!Array.isArray(messages) || messages.length === 0) {
28
+ throw new LlmError('validation_error', 400, 'messages is required');
29
+ }
30
+ return messages.map((message) => {
31
+ if (!isRecord(message) || typeof message.content !== 'string') {
32
+ throw new LlmError('validation_error', 400, 'each message needs role and string content');
33
+ }
34
+ const role = message.role;
35
+ if (role !== 'system' && role !== 'user' && role !== 'assistant') {
36
+ throw new LlmError('validation_error', 400, 'message role must be system, user, or assistant');
37
+ }
38
+ const parts = Array.isArray(message.parts)
39
+ ? message.parts.map(normalizeCompletionFilePart).filter(Boolean)
40
+ : [];
41
+ return parts.length > 0 ? { role, content: message.content, parts } : { role, content: message.content };
42
+ });
43
+ };
44
+
45
+ const completionId = () => `chatcmpl_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
46
+
47
+ export function toOpenAICompletion({ id, model, text, created = Math.floor(Date.now() / 1000) }) {
48
+ return {
49
+ id,
50
+ object: 'chat.completion',
51
+ created,
52
+ model,
53
+ choices: [{
54
+ index: 0,
55
+ message: { role: 'assistant', content: text },
56
+ finish_reason: 'stop',
57
+ }],
58
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * OpenAI-shaped completions against OpenCode's already-connected providers.
64
+ *
65
+ * Public HTTP stays non-streaming (`stream: true` → validation_error).
66
+ * In-process callers (Assistant contact harness) may pass `onTextDelta` and
67
+ * `globalEventHub`; those are forwarded to generate for real throwaway-session
68
+ * `message.part.delta` tokens only — never as fake post-hoc SSE on this route.
69
+ */
70
+ export async function createChatCompletion({
71
+ body,
72
+ buildOpenCodeUrl,
73
+ getOpenCodeAuthHeaders,
74
+ clientFactory,
75
+ loadCatalog = loadConnectedCatalog,
76
+ generateText = generateOpenCodeText,
77
+ ensureTempDirectory,
78
+ fetchImpl,
79
+ onTextDelta = null,
80
+ globalEventHub = null,
81
+ }) {
82
+ if (!isRecord(body)) throw new LlmError('validation_error', 400, 'JSON body is required');
83
+ // Bundled OpenCode 1.18.4 generate is a full-turn JSON reply (sessionless
84
+ // generate or throwaway session.promptAsync). This HTTP gateway does not
85
+ // token-stream. Do not emit fake SSE after the fact.
86
+ // In-process onTextDelta (optional) is a separate internal callback path.
87
+ if (body.stream === true) {
88
+ throw new LlmError(
89
+ 'validation_error',
90
+ 400,
91
+ 'This gateway is non-streaming; omit stream or set stream:false',
92
+ );
93
+ }
94
+ const resolved = parseModelRef(body.model, body.providerID, body.modelID);
95
+ if (!resolved) throw new LlmError('validation_error', 400, 'model or providerID/modelID is required');
96
+ const messages = normalizeMessages(body.messages);
97
+
98
+ const client = clientFactory?.() ?? null;
99
+ const catalog = await loadCatalog(client ?? {
100
+ provider: { list: async () => ({ error: { status: 500 } }) },
101
+ config: { providers: async () => ({ error: { status: 500 } }) },
102
+ });
103
+ if (!isConnectedModel(catalog, resolved.providerID, resolved.modelID)) {
104
+ throw new LlmError(
105
+ 'no_provider',
106
+ 400,
107
+ `No connected OpenCode provider for ${resolved.providerID}/${resolved.modelID}`,
108
+ );
109
+ }
110
+
111
+ let generated;
112
+ try {
113
+ generated = await generateText({
114
+ buildOpenCodeUrl,
115
+ getOpenCodeAuthHeaders,
116
+ providerID: resolved.providerID,
117
+ modelID: resolved.modelID,
118
+ messages,
119
+ fetchImpl,
120
+ clientFactory,
121
+ ensureTempDirectory,
122
+ forwardImageParts: Boolean(catalog.models.find((entry) => (
123
+ entry.providerID === resolved.providerID && entry.modelID === resolved.modelID
124
+ ))?.acceptsImages),
125
+ onTextDelta,
126
+ globalEventHub,
127
+ });
128
+ } catch (error) {
129
+ if (error instanceof LlmError) throw error;
130
+ if (error?.code === 'validation_error') {
131
+ throw new LlmError('validation_error', 400, error.message || 'validation_error');
132
+ }
133
+ throw new LlmError(
134
+ 'upstream_error',
135
+ 502,
136
+ typeof error?.message === 'string' && error.message.trim()
137
+ ? error.message
138
+ : 'OpenCode generate failed',
139
+ );
140
+ }
141
+ const text = typeof generated?.text === 'string' ? generated.text.trim() : '';
142
+ if (!text) throw new LlmError('upstream_error', 502, 'OpenCode returned no assistant text');
143
+
144
+ const model = `${resolved.providerID}/${resolved.modelID}`;
145
+ return {
146
+ completion: toOpenAICompletion({ id: completionId(), model, text }),
147
+ providerID: resolved.providerID,
148
+ modelID: resolved.modelID,
149
+ source: generated.source,
150
+ };
151
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { LlmError, createChatCompletion } from './completions.js'
3
+
4
+ describe('createChatCompletion', () => {
5
+ it('rejects when the requested model is not connected', async () => {
6
+ const generateText = vi.fn()
7
+ await expect(createChatCompletion({
8
+ generateText,
9
+ loadCatalog: async () => ({
10
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2' }],
11
+ connected: ['openai'],
12
+ }),
13
+ body: { model: 'anthropic/claude-sonnet-4', messages: [{ role: 'user', content: 'hi' }] },
14
+ })).rejects.toMatchObject({ code: 'no_provider' })
15
+ expect(generateText).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('rejects stream:true instead of faking SSE after a full generate', async () => {
19
+ const generateText = vi.fn()
20
+ await expect(createChatCompletion({
21
+ generateText,
22
+ loadCatalog: async () => ({
23
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2' }],
24
+ connected: ['openai'],
25
+ }),
26
+ body: {
27
+ model: 'openai/gpt-5.2',
28
+ stream: true,
29
+ messages: [{ role: 'user', content: 'hi' }],
30
+ },
31
+ })).rejects.toMatchObject({ code: 'validation_error', statusCode: 400 })
32
+ expect(generateText).not.toHaveBeenCalled()
33
+ })
34
+
35
+ it('returns a non-stream completion from generateText', async () => {
36
+ const generateText = vi.fn(async () => ({ text: 'done', source: 'throwaway-session' }))
37
+ const result = await createChatCompletion({
38
+ generateText,
39
+ loadCatalog: async () => ({
40
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2' }],
41
+ connected: ['openai'],
42
+ }),
43
+ body: { model: 'openai/gpt-5.2', messages: [{ role: 'user', content: 'hi' }] },
44
+ })
45
+ expect(result.completion.choices[0].message.content).toBe('done')
46
+ expect(result.providerID).toBe('openai')
47
+ expect(result.modelID).toBe('gpt-5.2')
48
+ })
49
+
50
+ it('forwards in-process onTextDelta and globalEventHub to generateText', async () => {
51
+ const generateText = vi.fn(async () => ({ text: 'streamed', source: 'throwaway-session' }))
52
+ const onTextDelta = vi.fn()
53
+ const globalEventHub = { subscribeEvent: vi.fn() }
54
+ await createChatCompletion({
55
+ generateText,
56
+ loadCatalog: async () => ({
57
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2' }],
58
+ connected: ['openai'],
59
+ }),
60
+ body: { model: 'openai/gpt-5.2', messages: [{ role: 'user', content: 'hi' }] },
61
+ onTextDelta,
62
+ globalEventHub,
63
+ })
64
+ expect(generateText.mock.calls[0][0].onTextDelta).toBe(onTextDelta)
65
+ expect(generateText.mock.calls[0][0].globalEventHub).toBe(globalEventHub)
66
+ })
67
+
68
+ it('forwards optional file parts on user messages', async () => {
69
+ const generateText = vi.fn(async () => ({ text: 'saw it', source: 'throwaway-session' }))
70
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
71
+ await createChatCompletion({
72
+ generateText,
73
+ loadCatalog: async () => ({
74
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2', acceptsImages: true }],
75
+ connected: ['openai'],
76
+ }),
77
+ body: {
78
+ model: 'openai/gpt-5.2',
79
+ messages: [{ role: 'user', content: 'look', parts: [image] }],
80
+ },
81
+ })
82
+ expect(generateText.mock.calls[0][0].messages).toEqual([
83
+ { role: 'user', content: 'look', parts: [image] },
84
+ ])
85
+ expect(generateText.mock.calls[0][0].forwardImageParts).toBe(true)
86
+ })
87
+
88
+ it('asks generate to skip image bytes when the catalog model is not vision-capable', async () => {
89
+ const generateText = vi.fn(async () => ({ text: 'ok', source: 'throwaway-session' }))
90
+ await createChatCompletion({
91
+ generateText,
92
+ loadCatalog: async () => ({
93
+ models: [{ providerID: 'opencode', modelID: 'deepseek-v4-flash', acceptsImages: false }],
94
+ connected: ['opencode'],
95
+ }),
96
+ body: { model: 'opencode/deepseek-v4-flash', messages: [{ role: 'user', content: 'look' }] },
97
+ })
98
+ expect(generateText.mock.calls[0][0].forwardImageParts).toBe(false)
99
+ })
100
+
101
+ it('LlmError carries a stable code', () => {
102
+ expect(new LlmError('no_provider', 400, 'missing')).toMatchObject({
103
+ code: 'no_provider',
104
+ statusCode: 400,
105
+ })
106
+ })
107
+
108
+ it('wraps generate failures as 502 with the OpenCode error message', async () => {
109
+ const generateText = vi.fn(async () => {
110
+ const error = new Error('model refused the request')
111
+ error.code = 'upstream_error'
112
+ throw error
113
+ })
114
+ await expect(createChatCompletion({
115
+ generateText,
116
+ loadCatalog: async () => ({
117
+ models: [{ providerID: 'opencode', modelID: 'gpt-5-nano' }],
118
+ connected: ['opencode'],
119
+ }),
120
+ body: { model: 'opencode/gpt-5-nano', messages: [{ role: 'user', content: 'hi' }] },
121
+ })).rejects.toMatchObject({
122
+ code: 'upstream_error',
123
+ statusCode: 502,
124
+ message: 'model refused the request',
125
+ })
126
+ })
127
+ })