@openchambery/web 1.19.7-beta.9 → 1.19.8-beta.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/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
- package/dist/assets/AssistantView-DkXW9Fzq.js +2 -0
- package/dist/assets/DiagramView-C8MZY0fA.js +1 -0
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
- package/dist/assets/Icon-BYM02QyU.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
- package/dist/assets/MarkdownRenderer-cxYw8-8G.js +2 -0
- package/dist/assets/MarkdownRendererImpl-B39nwiZX.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-CdYqdejg.js +1 -0
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MobileDetailNavigation-D6ri-ePj.js +1 -0
- package/dist/assets/MobileShareBridge-HRTN71wg.js +4 -0
- package/dist/assets/MobileSurface-D2yHjnZw.js +2 -0
- package/dist/assets/{MultiRunWindow-Ohj6UbWj.js → MultiRunWindow-CTRgfYQD.js} +1 -1
- package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
- package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
- package/dist/assets/SettingsView-ImI02YSp.js +25 -0
- package/dist/assets/{SettingsWindow-B4lzhAbO.js → SettingsWindow-DWo2lUaN.js} +1 -1
- package/dist/assets/TerminalView-BseZ5dR-.js +8 -0
- package/dist/assets/ToolOutputDialog-BIK3cPqo.js +10 -0
- package/dist/assets/appThemeRegistry-4_bwYAWt.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/context-menu-CAp74NfV.js +1 -0
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-CtS4kWoo.js → gitApi-m09MWmzE.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/insertionBoundaries-B0fzEL22.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-BYX7_XVg.js +169 -0
- package/dist/assets/main-BpbNOMNf.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
- package/dist/assets/miniChat-CFBgCVoZ.js +2 -0
- package/dist/assets/mobile-DcZkxRBz.js +2 -0
- package/dist/assets/multirun-CBqJQgS2.js +4 -0
- package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/projectMeta-Ct1qHZhU.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-DxmxYdiE.js +2 -0
- package/dist/assets/renderMobileApp-XXw7Fnrr.js +28 -0
- package/dist/assets/runtimeConfig-CcIqxMFB.js +2 -0
- package/dist/assets/runtimeEndpointReset-CU1HhJfC.js +147 -0
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
- package/dist/assets/sessionLookup-CNMNR-yh.js +1 -0
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-947l7Ra3.js +1 -0
- package/dist/assets/textarea-Cgo_nWUU.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-Pk6tQ1EZ.js +1 -0
- package/dist/assets/{ChatContainer-CDExXwgn.js → useAppFontEffects-CBLEH9it.js} +44 -39
- package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-DMCmF610.js +1 -0
- package/dist/assets/useEffectiveDirectory-CMvw1bLA.js +1 -0
- package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
- package/dist/assets/useMobileNavigationStore-Di9IWKfu.js +413 -0
- package/dist/assets/useSessionAutoCleanup-DMyMP_xI.js +1 -0
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-uNzY5rmU.js +7 -0
- package/dist/assets/utils-BiK3tBGM.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.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 +77 -4
- 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 +764 -0
- package/server/lib/assistants/contact-tools.test.js +337 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +575 -0
- package/server/lib/assistants/harness.test.js +697 -0
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -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/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- 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 +19 -13
- package/server/lib/notifications/DOCUMENTATION.md +4 -3
- package/server/lib/notifications/runtime.js +148 -37
- package/server/lib/notifications/runtime.test.js +100 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- 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/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- 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/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
- package/dist/assets/AssistantView-DSxVAAb_.js +0 -1
- package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
- package/dist/assets/DiagramView-BKQO61y8.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
- package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
- package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
- package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-CiF3r83e.js +0 -25
- package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
- package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
- package/dist/assets/appThemeRegistry-DnwUIQB9.js +0 -2
- package/dist/assets/clipboard-D35XSwID.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
- package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
- package/dist/assets/insertionBoundaries-hK4mQj4W.js +0 -4
- package/dist/assets/main-BmbZFoO4.js +0 -29
- package/dist/assets/main-BwNhn8ml.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-BN89siC3.js +0 -2
- package/dist/assets/mobile-DE9rVo5D.js +0 -2
- package/dist/assets/multirun-BiEuyDq3.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
- package/dist/assets/radio-HD5AB2Qf.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
- package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
- package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
- package/dist/assets/shikiHighlight-BphLZnac.js +0 -1
- package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
- package/dist/assets/src-DZt1nWf5.css +0 -2
- package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects--0dwVTSm.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
- package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
- package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createReasoningOutboundFilter,
|
|
5
|
+
createSseBlockSplitter,
|
|
6
|
+
filterSseBlock,
|
|
7
|
+
projectMessagesPayloadForReasoning,
|
|
8
|
+
projectMessagesPayloadWithoutReasoning,
|
|
9
|
+
readIncludeReasoningFromUrl,
|
|
10
|
+
readIncludeReasoningQuery,
|
|
11
|
+
shouldIncludeReasoning,
|
|
12
|
+
stripIncludeReasoningParam,
|
|
13
|
+
} from './reasoning-projection.js';
|
|
14
|
+
|
|
15
|
+
describe('shouldIncludeReasoning / query helpers', () => {
|
|
16
|
+
it('only treats the strict string false as disabled', () => {
|
|
17
|
+
expect(shouldIncludeReasoning(undefined)).toBe(true);
|
|
18
|
+
expect(shouldIncludeReasoning(null)).toBe(true);
|
|
19
|
+
expect(shouldIncludeReasoning('')).toBe(true);
|
|
20
|
+
expect(shouldIncludeReasoning('true')).toBe(true);
|
|
21
|
+
expect(shouldIncludeReasoning('0')).toBe(true);
|
|
22
|
+
expect(shouldIncludeReasoning('False')).toBe(true);
|
|
23
|
+
expect(shouldIncludeReasoning(false)).toBe(true);
|
|
24
|
+
expect(shouldIncludeReasoning('false')).toBe(false);
|
|
25
|
+
expect(shouldIncludeReasoning(['false'])).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reads Express query and URL forms', () => {
|
|
29
|
+
expect(readIncludeReasoningQuery({ includeReasoning: 'false' })).toBe(false);
|
|
30
|
+
expect(readIncludeReasoningQuery({})).toBe(true);
|
|
31
|
+
expect(readIncludeReasoningFromUrl('/api/event?includeReasoning=false')).toBe(false);
|
|
32
|
+
expect(readIncludeReasoningFromUrl('/api/event')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('strips includeReasoning from upstream paths', () => {
|
|
36
|
+
expect(stripIncludeReasoningParam('/event?directory=/tmp&includeReasoning=false&x=1'))
|
|
37
|
+
.toBe('/event?directory=%2Ftmp&x=1');
|
|
38
|
+
expect(stripIncludeReasoningParam('/event?directory=/tmp')).toBe('/event?directory=/tmp');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('HTTP snapshot projection', () => {
|
|
43
|
+
it('drops reasoning parts and keeps tokens.reasoning', () => {
|
|
44
|
+
const record = {
|
|
45
|
+
info: { id: 'msg_1', tokens: { input: 1, output: 2, reasoning: 99 } },
|
|
46
|
+
parts: [
|
|
47
|
+
{ id: 'p_r', type: 'reasoning', text: 'secret chain' },
|
|
48
|
+
{ id: 'p_t', type: 'text', text: 'hello' },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
const projected = projectMessagesPayloadWithoutReasoning(record);
|
|
52
|
+
expect(projected.info.tokens.reasoning).toBe(99);
|
|
53
|
+
expect(projected.parts).toEqual([{ id: 'p_t', type: 'text', text: 'hello' }]);
|
|
54
|
+
expect(JSON.stringify(projected)).not.toContain('secret chain');
|
|
55
|
+
expect(record.parts).toHaveLength(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('projects list / records / cache envelopes with identity when unchanged', () => {
|
|
59
|
+
const withReasoning = {
|
|
60
|
+
info: { id: 'm1', tokens: { reasoning: 3 } },
|
|
61
|
+
parts: [{ type: 'reasoning', text: 'nope' }, { type: 'text', text: 'ok' }],
|
|
62
|
+
};
|
|
63
|
+
expect(projectMessagesPayloadWithoutReasoning([withReasoning])[0].parts).toEqual([
|
|
64
|
+
{ type: 'text', text: 'ok' },
|
|
65
|
+
]);
|
|
66
|
+
const page = projectMessagesPayloadWithoutReasoning({ records: [withReasoning], turnCount: 1 });
|
|
67
|
+
expect(page.records[0].parts).toHaveLength(1);
|
|
68
|
+
expect(page.turnCount).toBe(1);
|
|
69
|
+
|
|
70
|
+
const cache = projectMessagesPayloadWithoutReasoning({
|
|
71
|
+
available: true,
|
|
72
|
+
record: withReasoning,
|
|
73
|
+
records: [withReasoning],
|
|
74
|
+
});
|
|
75
|
+
expect(cache.record.parts).toHaveLength(1);
|
|
76
|
+
expect(projectMessagesPayloadForReasoning(withReasoning, true)).toBe(withReasoning);
|
|
77
|
+
|
|
78
|
+
const plain = { info: { id: 'm' }, parts: [{ type: 'text', text: 'x' }] };
|
|
79
|
+
const list = [plain];
|
|
80
|
+
expect(projectMessagesPayloadWithoutReasoning(list)).toBe(list);
|
|
81
|
+
expect(projectMessagesPayloadWithoutReasoning(plain)).toBe(plain);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('stateful stream filter', () => {
|
|
86
|
+
it('strips version suffixes for dispatch but keeps original type on kept events', () => {
|
|
87
|
+
const filter = createReasoningOutboundFilter();
|
|
88
|
+
|
|
89
|
+
expect(filter.projectEvent({
|
|
90
|
+
type: 'session.next.reasoning.delta.1',
|
|
91
|
+
properties: { delta: 'think' },
|
|
92
|
+
})).toBeNull();
|
|
93
|
+
expect(filter.projectEvent({
|
|
94
|
+
type: 'session.next.reasoning.delta',
|
|
95
|
+
data: { delta: 'think' },
|
|
96
|
+
})).toBeNull();
|
|
97
|
+
|
|
98
|
+
// properties envelope + versioned type
|
|
99
|
+
expect(filter.projectEvent({
|
|
100
|
+
type: 'message.part.updated.1',
|
|
101
|
+
properties: {
|
|
102
|
+
part: { id: 'p_r', messageID: 'm1', type: 'reasoning', text: 'chain' },
|
|
103
|
+
},
|
|
104
|
+
})).toBeNull();
|
|
105
|
+
|
|
106
|
+
// data envelope + versioned type — learn text, keep original type string
|
|
107
|
+
const textUpdated = {
|
|
108
|
+
type: 'message.part.updated.1',
|
|
109
|
+
data: {
|
|
110
|
+
part: { id: 'p_t', messageID: 'm1', type: 'text', text: 'hi' },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
expect(filter.projectEvent(textUpdated)).toBe(textUpdated);
|
|
114
|
+
expect(textUpdated.type).toBe('message.part.updated.1');
|
|
115
|
+
|
|
116
|
+
const textDelta = {
|
|
117
|
+
type: 'message.part.delta.1',
|
|
118
|
+
data: { messageID: 'm1', partID: 'p_t', field: 'text', delta: '!' },
|
|
119
|
+
};
|
|
120
|
+
expect(filter.projectEvent(textDelta)).toBe(textDelta);
|
|
121
|
+
|
|
122
|
+
// reasoning learned via properties; delta via data still drops
|
|
123
|
+
filter.projectEvent({
|
|
124
|
+
type: 'message.part.updated',
|
|
125
|
+
properties: { part: { id: 'p_r2', messageID: 'm1', type: 'reasoning', text: '' } },
|
|
126
|
+
});
|
|
127
|
+
expect(filter.projectEvent({
|
|
128
|
+
type: 'message.part.delta.1',
|
|
129
|
+
data: { messageID: 'm1', partID: 'p_r2', field: 'text', delta: 'secret' },
|
|
130
|
+
})).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('drops explicit reasoning fields and unknown part deltas', () => {
|
|
134
|
+
const filter = createReasoningOutboundFilter();
|
|
135
|
+
expect(filter.projectEvent({
|
|
136
|
+
type: 'message.part.delta',
|
|
137
|
+
properties: { messageID: 'm1', partID: 'u', field: 'reasoning_content', delta: 'x' },
|
|
138
|
+
})).toBeNull();
|
|
139
|
+
expect(filter.projectEvent({
|
|
140
|
+
type: 'message.part.delta',
|
|
141
|
+
properties: { messageID: 'm1', partID: 'never', field: 'text', delta: 'leak?' },
|
|
142
|
+
})).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('forgets classification and bounds the map', () => {
|
|
146
|
+
const filter = createReasoningOutboundFilter();
|
|
147
|
+
filter.projectEvent({
|
|
148
|
+
type: 'message.part.updated',
|
|
149
|
+
properties: { part: { id: 'p1', messageID: 'm1', type: 'text' } },
|
|
150
|
+
});
|
|
151
|
+
expect(filter.knownPartCount()).toBe(1);
|
|
152
|
+
filter.projectEvent({
|
|
153
|
+
type: 'message.part.removed',
|
|
154
|
+
properties: { messageID: 'm1', partID: 'p1' },
|
|
155
|
+
});
|
|
156
|
+
expect(filter.knownPartCount()).toBe(0);
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < 8200; i += 1) {
|
|
159
|
+
filter.projectEvent({
|
|
160
|
+
type: 'message.part.updated',
|
|
161
|
+
properties: { part: { id: `p_${i}`, messageID: 'm_big', type: 'text' } },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
expect(filter.knownPartCount()).toBeLessThanOrEqual(8192);
|
|
165
|
+
filter.dispose();
|
|
166
|
+
expect(filter.knownPartCount()).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not mutate original objects when stripping embedded parts', () => {
|
|
170
|
+
const filter = createReasoningOutboundFilter();
|
|
171
|
+
const original = {
|
|
172
|
+
type: 'message.updated',
|
|
173
|
+
properties: {
|
|
174
|
+
info: { id: 'm1', tokens: { reasoning: 7 } },
|
|
175
|
+
parts: [
|
|
176
|
+
{ id: 'r', type: 'reasoning', text: 'hidden' },
|
|
177
|
+
{ id: 't', type: 'text', text: 'visible' },
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const projected = filter.projectEvent(original);
|
|
182
|
+
expect(projected).not.toBe(original);
|
|
183
|
+
expect(original.properties.parts).toHaveLength(2);
|
|
184
|
+
expect(projected.properties.parts).toEqual([{ id: 't', type: 'text', text: 'visible' }]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('SSE filter + CR-safe splitter', () => {
|
|
189
|
+
it('preserves wire identity for kept events and drops reasoning', () => {
|
|
190
|
+
const filter = createReasoningOutboundFilter();
|
|
191
|
+
const keptBlock = 'id: evt-1\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p","messageID":"m","type":"text","text":"hi"}}}';
|
|
192
|
+
const droppedBlock = 'id: evt-2\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"no"}}';
|
|
193
|
+
const wrappedDrop = 'id: evt-3\ndata: {"directory":"/repo","payload":{"type":"session.next.reasoning.delta.1","properties":{"delta":"x"}}}';
|
|
194
|
+
const wrappedKeep = 'id: evt-4\ndata: {"directory":"/repo","payload":{"type":"server.connected","properties":{}}}';
|
|
195
|
+
|
|
196
|
+
expect(filterSseBlock(keptBlock, filter)).toBe(`${keptBlock}\n\n`);
|
|
197
|
+
expect(filterSseBlock(droppedBlock, filter)).toBeNull();
|
|
198
|
+
expect(filterSseBlock(wrappedDrop, filter)).toBeNull();
|
|
199
|
+
expect(filterSseBlock(wrappedKeep, filter)).toContain('"directory":"/repo"');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not split one event when CRLF straddles chunk boundaries', () => {
|
|
203
|
+
const splitter = createSseBlockSplitter();
|
|
204
|
+
// Multi-line data event with CRLF, split after lone CR of first line ending.
|
|
205
|
+
const full = 'id: 1\r\ndata: {"type":"text","n":1}\r\ndata: more\r\n\r\n';
|
|
206
|
+
const cut = full.indexOf('\r') + 1; // after first CR of first line's CRLF
|
|
207
|
+
expect(full[cut - 1]).toBe('\r');
|
|
208
|
+
expect(full[cut]).toBe('\n');
|
|
209
|
+
|
|
210
|
+
const first = splitter.push(full.slice(0, cut));
|
|
211
|
+
expect(first).toEqual([]); // must not complete on lone CR→LF
|
|
212
|
+
const second = splitter.push(full.slice(cut));
|
|
213
|
+
expect(second).toHaveLength(1);
|
|
214
|
+
expect(second[0]).toContain('id: 1');
|
|
215
|
+
expect(second[0]).toContain('data: more');
|
|
216
|
+
expect(second[0]).not.toMatch(/^\s*$/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('handles per-character chunking and UTF-8 multi-byte without leaking reasoning', () => {
|
|
220
|
+
const filter = createReasoningOutboundFilter();
|
|
221
|
+
const splitter = createSseBlockSplitter();
|
|
222
|
+
const stream =
|
|
223
|
+
'id: a\ndata: {"type":"message.part.updated","properties":{"part":{"id":"t","messageID":"m","type":"text","text":"你好"}}}\n\n'
|
|
224
|
+
+ 'id: b\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"secret"}}\n\n'
|
|
225
|
+
+ 'id: c\ndata: {"type":"message.part.delta","properties":{"messageID":"m","partID":"t","field":"text","delta":"!"}}\n\n';
|
|
226
|
+
|
|
227
|
+
const bytes = new TextEncoder().encode(stream);
|
|
228
|
+
const out = [];
|
|
229
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
230
|
+
for (const block of splitter.push(bytes.subarray(i, i + 1))) {
|
|
231
|
+
const filtered = filterSseBlock(block, filter);
|
|
232
|
+
if (filtered) out.push(filtered);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const block of splitter.finish()) {
|
|
236
|
+
const filtered = filterSseBlock(block, filter);
|
|
237
|
+
if (filtered) out.push(filtered);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(out).toHaveLength(2);
|
|
241
|
+
expect(out.join('')).toContain('"type":"message.part.updated"');
|
|
242
|
+
expect(out.join('')).toContain('"type":"message.part.delta"');
|
|
243
|
+
expect(out.join('')).toContain('你好');
|
|
244
|
+
expect(out.join('')).not.toContain('reasoning');
|
|
245
|
+
expect(out.join('')).not.toContain('secret');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('keeps event order across mixed CRLF chunk splits', () => {
|
|
249
|
+
const splitter = createSseBlockSplitter();
|
|
250
|
+
const chunks = [
|
|
251
|
+
'id: 1\r\ndata: {"n":1}\r',
|
|
252
|
+
'\n\r\nid: 2\r\ndata: {"n":2}\r\n\r\n',
|
|
253
|
+
];
|
|
254
|
+
const blocks = [];
|
|
255
|
+
for (const chunk of chunks) blocks.push(...splitter.push(chunk));
|
|
256
|
+
blocks.push(...splitter.finish());
|
|
257
|
+
expect(blocks.map((b) => JSON.parse(b.split('\n').find((l) => l.startsWith('data:')).slice(5)).n))
|
|
258
|
+
.toEqual([1, 2]);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('performance: 10k reasoning deltas', () => {
|
|
263
|
+
it('drops 10k reasoning deltas while still emitting known text deltas', () => {
|
|
264
|
+
const disabled = createReasoningOutboundFilter();
|
|
265
|
+
disabled.projectEvent({
|
|
266
|
+
type: 'message.part.updated',
|
|
267
|
+
properties: { part: { id: 'p_text', messageID: 'm1', type: 'text', text: '' } },
|
|
268
|
+
});
|
|
269
|
+
disabled.projectEvent({
|
|
270
|
+
type: 'message.part.updated',
|
|
271
|
+
properties: { part: { id: 'p_reason', messageID: 'm1', type: 'reasoning', text: '' } },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let disabledEvents = 0;
|
|
275
|
+
let disabledBodyBytes = 0;
|
|
276
|
+
for (let i = 0; i < 10_000; i += 1) {
|
|
277
|
+
const out = disabled.projectEvent({
|
|
278
|
+
type: 'message.part.delta',
|
|
279
|
+
properties: {
|
|
280
|
+
messageID: 'm1',
|
|
281
|
+
partID: 'p_reason',
|
|
282
|
+
field: 'text',
|
|
283
|
+
delta: `reasoning-token-${i}-xxxxxxxx`,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
if (out != null) {
|
|
287
|
+
disabledEvents += 1;
|
|
288
|
+
disabledBodyBytes += JSON.stringify(out).length;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const textDelta = {
|
|
293
|
+
type: 'message.part.delta',
|
|
294
|
+
properties: { messageID: 'm1', partID: 'p_text', field: 'text', delta: 'hello-world' },
|
|
295
|
+
};
|
|
296
|
+
expect(disabled.projectEvent(textDelta)).toBe(textDelta);
|
|
297
|
+
expect(disabledEvents).toBe(0);
|
|
298
|
+
expect(disabledBodyBytes).toBe(0);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
import { createGlobalMessageStreamHub } from './global-hub.js';
|
|
11
11
|
import { createGlobalMessageStreamWsBridge } from './global-ws-bridge.js';
|
|
12
12
|
import { acceptDirectoryMessageStreamWsConnection } from './directory-ws-bridge.js';
|
|
13
|
+
import {
|
|
14
|
+
createReasoningOutboundFilter,
|
|
15
|
+
shouldIncludeReasoning,
|
|
16
|
+
} from './reasoning-projection.js';
|
|
13
17
|
import {
|
|
14
18
|
DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
|
|
15
19
|
DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
|
@@ -95,10 +99,15 @@ export function createMessageStreamWsRuntime({
|
|
|
95
99
|
const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
|
|
96
100
|
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
|
|
97
101
|
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
|
|
102
|
+
// OpenChamber projection only — never forwarded to OpenCode upstream URLs.
|
|
103
|
+
// Default (include) path keeps reasoningFilter null for zero send-path overhead.
|
|
104
|
+
const includeReasoning = shouldIncludeReasoning(requestUrl.searchParams.get('includeReasoning'));
|
|
105
|
+
const reasoningFilter = includeReasoning ? null : createReasoningOutboundFilter();
|
|
98
106
|
|
|
99
107
|
if (isGlobalStream) {
|
|
100
108
|
globalBridge.accept(socket, {
|
|
101
109
|
requestedLastEventId,
|
|
110
|
+
reasoningFilter,
|
|
102
111
|
});
|
|
103
112
|
return;
|
|
104
113
|
}
|
|
@@ -107,6 +116,7 @@ export function createMessageStreamWsRuntime({
|
|
|
107
116
|
socket,
|
|
108
117
|
requestedLastEventId,
|
|
109
118
|
requestedDirectory,
|
|
119
|
+
reasoningFilter,
|
|
110
120
|
buildOpenCodeUrl,
|
|
111
121
|
getOpenCodeAuthHeaders,
|
|
112
122
|
processForwardedEventPayload,
|
|
@@ -127,6 +127,59 @@ describe('event stream broadcaster', () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
describe('message stream websocket runtime', () => {
|
|
130
|
+
it('filters reasoning events per connection when includeReasoning=false', async () => {
|
|
131
|
+
const server = new EventEmitter();
|
|
132
|
+
const wsClients = new Set();
|
|
133
|
+
|
|
134
|
+
const runtime = createMessageStreamWsRuntime({
|
|
135
|
+
server,
|
|
136
|
+
uiAuthController: null,
|
|
137
|
+
isRequestOriginAllowed: async () => true,
|
|
138
|
+
rejectWebSocketUpgrade() {
|
|
139
|
+
throw new Error('upgrade should not be used in this test');
|
|
140
|
+
},
|
|
141
|
+
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
|
142
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
143
|
+
processForwardedEventPayload() {},
|
|
144
|
+
wsClients,
|
|
145
|
+
upstreamReconnectDelayMs: 0,
|
|
146
|
+
fetchImpl: async (_url, options) => createSseResponse({
|
|
147
|
+
signal: options.signal,
|
|
148
|
+
holdOpen: true,
|
|
149
|
+
blocks: [
|
|
150
|
+
'id: evt-1\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p_r","messageID":"m1","type":"reasoning","text":"secret"}}}\n\n',
|
|
151
|
+
'id: evt-2\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p_t","messageID":"m1","type":"text","text":"hi"}}}\n\n',
|
|
152
|
+
'id: evt-3\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"nope"}}\n\n',
|
|
153
|
+
'id: evt-4\ndata: {"type":"message.part.delta","properties":{"messageID":"m1","partID":"p_t","field":"text","delta":"!"}}\n\n',
|
|
154
|
+
],
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const filtered = new FakeSocket();
|
|
159
|
+
const full = new FakeSocket();
|
|
160
|
+
runtime.wsServer.emit('connection', filtered, { url: '/api/global/event/ws?includeReasoning=false' });
|
|
161
|
+
runtime.wsServer.emit('connection', full, { url: '/api/global/event/ws' });
|
|
162
|
+
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
164
|
+
|
|
165
|
+
const filteredEvents = filtered.sent.filter((frame) => frame.type === 'event');
|
|
166
|
+
const fullEvents = full.sent.filter((frame) => frame.type === 'event');
|
|
167
|
+
|
|
168
|
+
expect(filteredEvents.some((frame) => frame.payload?.type === 'session.next.reasoning.delta')).toBe(false);
|
|
169
|
+
expect(filteredEvents.some((frame) => frame.payload?.properties?.part?.type === 'reasoning')).toBe(false);
|
|
170
|
+
expect(JSON.stringify(filteredEvents)).not.toContain('secret');
|
|
171
|
+
expect(filteredEvents.some((frame) => frame.payload?.type === 'message.part.updated'
|
|
172
|
+
&& frame.payload?.properties?.part?.type === 'text')).toBe(true);
|
|
173
|
+
expect(filteredEvents.some((frame) => frame.payload?.type === 'message.part.delta')).toBe(true);
|
|
174
|
+
|
|
175
|
+
expect(fullEvents.some((frame) => frame.payload?.properties?.part?.type === 'reasoning')).toBe(true);
|
|
176
|
+
expect(fullEvents.some((frame) => frame.payload?.type === 'session.next.reasoning.delta')).toBe(true);
|
|
177
|
+
|
|
178
|
+
filtered.close();
|
|
179
|
+
full.close();
|
|
180
|
+
await runtime.close();
|
|
181
|
+
});
|
|
182
|
+
|
|
130
183
|
it('shares one global upstream SSE reader across multiple websocket clients', async () => {
|
|
131
184
|
const server = new EventEmitter();
|
|
132
185
|
const wsClients = new Set();
|
|
@@ -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
|
+
})
|