@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.
- package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
- package/dist/assets/AssistantView-sV1PVQiM.js +2 -0
- package/dist/assets/DiagramView-D2hqP5VV.js +1 -0
- package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
- package/dist/assets/Icon-4My8YZwt.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-Vezjvk9B.js +3 -0
- package/dist/assets/MarkdownRenderer-x091Z--3.js +2 -0
- package/dist/assets/MarkdownRendererImpl-DML8BSro.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-BK-HM2k3.js +1 -0
- package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
- package/dist/assets/MobileDetailNavigation-DcxkWiks.js +1 -0
- package/dist/assets/MobileShareBridge-TAGcOiyb.js +4 -0
- package/dist/assets/MobileSurface-zqlZzEFQ.js +2 -0
- package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-iH1d9S14.js} +1 -1
- package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
- package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
- package/dist/assets/SettingsView-CzW23DZA.js +25 -0
- package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-DZXCWZid.js} +1 -1
- package/dist/assets/TerminalView-pU0wpD8s.js +8 -0
- package/dist/assets/ToolOutputDialog-Bt7JChFG.js +10 -0
- package/dist/assets/appThemeRegistry-BI7WR2Wo.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
- package/dist/assets/clipboard-BXxLshVq.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
- package/dist/assets/context-menu-Dpjla5p4.js +1 -0
- package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
- package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
- package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
- package/dist/assets/fileReferenceActions-Di_DoDex.js +1 -0
- package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-CeFszLvk.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
- package/dist/assets/insertionBoundaries-DDJNusn2.js +4 -0
- package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-CjnOsKDL.js +169 -0
- package/dist/assets/main-j-Ij13jc.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
- package/dist/assets/miniChat-DgxavREE.js +2 -0
- package/dist/assets/mobile-C0rwPMPM.js +2 -0
- package/dist/assets/multirun-ot7-bKg7.js +4 -0
- package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-CSZp1CNB.js +3 -0
- package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-CenlWqxu.js +2 -0
- package/dist/assets/projectMeta-3yOEc0oI.js +3 -0
- package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-BPNvFA_N.js +2 -0
- package/dist/assets/renderMobileApp-CNog29Xy.js +28 -0
- package/dist/assets/runtimeConfig-D28kNEYO.js +2 -0
- package/dist/assets/runtimeEndpointReset-CorvFIRk.js +147 -0
- package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
- package/dist/assets/sessionLookup-CbatbQf7.js +1 -0
- package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
- package/dist/assets/src-Af9cMFKF.css +2 -0
- package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
- package/dist/assets/textarea-CIO34TZz.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
- package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-CE_gAfNa.js +1 -0
- package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-CDpeZWK4.js} +44 -39
- package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-D-xPByG0.js +1 -0
- package/dist/assets/useEffectiveDirectory-Dp-tez_r.js +1 -0
- package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
- package/dist/assets/useMobileNavigationStore-DE1Pe9A9.js +412 -0
- package/dist/assets/useSessionAutoCleanup-jNJ_WC38.js +1 -0
- package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-MDnGj3yv.js +7 -0
- package/dist/assets/utils-B0mQXN9L.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +76 -3
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +761 -0
- package/server/lib/assistants/contact-tools.test.js +328 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +551 -0
- package/server/lib/assistants/harness.test.js +607 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- package/server/lib/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +23 -17
- package/server/lib/notifications/DOCUMENTATION.md +5 -4
- package/server/lib/notifications/apns-runtime.js +139 -20
- package/server/lib/notifications/apns-runtime.test.js +66 -7
- package/server/lib/notifications/apns-titles.js +62 -62
- package/server/lib/notifications/apns-titles.test.js +3 -1
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +177 -86
- package/server/lib/notifications/runtime.test.js +122 -0
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/settings-runtime.js +18 -2
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -0
- package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
- package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
- package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
- package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
- package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
- package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
- package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
- package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
- package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
- package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
- package/dist/assets/SettingsView-DyCZehFG.js +0 -25
- package/dist/assets/TerminalView-XSALfUaH.js +0 -8
- package/dist/assets/ToolOutputDialog-BfuXazW9.js +0 -10
- package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
- package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
- package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
- package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
- package/dist/assets/main-B9HVtgXE.js +0 -2
- package/dist/assets/main-ZNyIs3Sn.js +0 -29
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
- package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
- package/dist/assets/mobile-DEduvLJj.js +0 -2
- package/dist/assets/multirun-DU-wrxlG.js +0 -4
- package/dist/assets/persistence-CmjKImz2.js +0 -3
- package/dist/assets/platform-skqcMARt.js +0 -2
- package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
- package/dist/assets/radio-BEonIX1k.js +0 -1
- package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
- package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
- package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
- package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
- package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
- package/dist/assets/src-2eFAiBLq.css +0 -2
- package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
- package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
- package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
- package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
- package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
- package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
- package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
- 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
|
+
})
|