@openchambery/web 1.19.20 → 2.0.0-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.
Files changed (189) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -76,62 +76,82 @@ describe('modelAcceptsImages', () => {
76
76
  expect(modelAcceptsImages({ attachment: true })).toBe(true)
77
77
  expect(modelAcceptsImages({ id: 'plain' })).toBe(false)
78
78
  })
79
+
80
+ it('accepts v2 list-shaped capabilities.input containing image', () => {
81
+ expect(modelAcceptsImages({
82
+ capabilities: { tools: true, input: ['text', 'image'], output: ['text'] },
83
+ })).toBe(true)
84
+ expect(modelAcceptsImages({
85
+ capabilities: { tools: true, input: ['text'], output: ['text'] },
86
+ })).toBe(false)
87
+ })
79
88
  })
80
89
 
81
90
  describe('projectConnectedModels', () => {
82
- it('projects only connected providers', () => {
91
+ it('projects models from provider.list + model.list using ModelInfo.id as modelID', () => {
83
92
  const catalog = projectConnectedModels({
84
- connected: ['openai'],
85
93
  providers: [
94
+ { id: 'openai', name: 'OpenAI' },
95
+ { id: 'anthropic', name: 'Anthropic' },
96
+ ],
97
+ models: [
86
98
  {
87
- id: 'openai',
88
- name: 'OpenAI',
89
- models: {
90
- 'gpt-5.2': { id: 'gpt-5.2', name: 'GPT-5.2' },
91
- },
99
+ id: 'gpt-5.2',
100
+ modelID: 'internal-gpt-pack',
101
+ providerID: 'openai',
102
+ name: 'GPT-5.2',
103
+ capabilities: { tools: true, input: ['text'], output: ['text'] },
92
104
  },
93
105
  {
94
- id: 'anthropic',
95
- name: 'Anthropic',
96
- models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude' } },
106
+ id: 'claude-sonnet-4',
107
+ modelID: 'internal-claude',
108
+ providerID: 'anthropic',
109
+ name: 'Claude',
110
+ capabilities: { tools: true, input: ['text'], output: ['text'] },
97
111
  },
98
112
  ],
99
113
  })
100
114
  expect(catalog.models).toEqual([
101
115
  { providerID: 'openai', modelID: 'gpt-5.2', name: 'GPT-5.2', acceptsImages: false },
116
+ { providerID: 'anthropic', modelID: 'claude-sonnet-4', name: 'Claude', acceptsImages: false },
102
117
  ])
118
+ expect(catalog.connected).toEqual(['openai', 'anthropic'])
103
119
  })
104
120
 
105
- it('marks vision from SDK capabilities and legacy modalities', () => {
121
+ it('marks vision from SDK capabilities, v2 list input, and legacy modalities', () => {
106
122
  const catalog = projectConnectedModels({
107
- connected: ['openai', 'xai'],
108
123
  providers: [
124
+ { id: 'openai', name: 'OpenAI' },
125
+ { id: 'xai', name: 'xAI' },
126
+ ],
127
+ models: [
109
128
  {
110
- id: 'openai',
111
- name: 'OpenAI',
112
- models: {
113
- 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
114
- 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
115
- },
129
+ id: 'gpt-4o',
130
+ modelID: 'pack-4o',
131
+ providerID: 'openai',
132
+ name: 'GPT-4o',
133
+ capabilities: { tools: true, input: ['text', 'image'], output: ['text'] },
116
134
  },
117
135
  {
118
- id: 'xai',
119
- name: 'xAI',
120
- models: {
121
- 'grok-4.6': {
122
- id: 'grok-4.6',
123
- name: 'Grok 4.6',
124
- // Authoritative SDK shape — no modalities.input array
125
- capabilities: {
126
- temperature: true,
127
- reasoning: true,
128
- attachment: true,
129
- toolcall: true,
130
- input: { text: true, audio: false, image: true, video: false, pdf: false },
131
- output: { text: true, audio: false, image: false, video: false, pdf: false },
132
- interleaved: false,
133
- },
134
- },
136
+ id: 'deepseek-v4-flash',
137
+ modelID: 'pack-ds',
138
+ providerID: 'openai',
139
+ name: 'deepseek-v4-flash',
140
+ capabilities: { tools: true, input: ['text'], output: ['text'] },
141
+ },
142
+ {
143
+ id: 'grok-4.6',
144
+ modelID: 'pack-grok',
145
+ providerID: 'xai',
146
+ name: 'Grok 4.6',
147
+ capabilities: {
148
+ temperature: true,
149
+ reasoning: true,
150
+ attachment: true,
151
+ toolcall: true,
152
+ input: { text: true, audio: false, image: true, video: false, pdf: false },
153
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
154
+ interleaved: false,
135
155
  },
136
156
  },
137
157
  ],
@@ -141,6 +161,17 @@ describe('projectConnectedModels', () => {
141
161
  expect(catalog.models.find((model) => model.modelID === 'deepseek-v4-flash')?.acceptsImages).toBe(false)
142
162
  expect(catalog.connected).toEqual(['openai', 'xai'])
143
163
  })
164
+
165
+ it('ignores models whose provider is not in provider.list when providers are present', () => {
166
+ const catalog = projectConnectedModels({
167
+ providers: [{ id: 'openai', name: 'OpenAI' }],
168
+ models: [
169
+ { id: 'gpt-5.2', providerID: 'openai', name: 'GPT-5.2', capabilities: { input: ['text'], output: ['text'], tools: false } },
170
+ { id: 'other', providerID: 'missing', name: 'Other', capabilities: { input: ['text'], output: ['text'], tools: false } },
171
+ ],
172
+ })
173
+ expect(catalog.models.map((m) => m.modelID)).toEqual(['gpt-5.2'])
174
+ })
144
175
  })
145
176
 
146
177
  describe('loadConnectedCatalog', () => {
@@ -148,42 +179,73 @@ describe('loadConnectedCatalog', () => {
148
179
  vi.restoreAllMocks()
149
180
  })
150
181
 
151
- it('requires both /provider and /config/providers', async () => {
182
+ it('requires provider.list and model.list data arrays', async () => {
152
183
  await expect(loadConnectedCatalog({
153
- provider: { list: async () => ({ data: { connected: ['openai'] } }) },
154
- config: { providers: async () => ({ error: { status: 500 } }) },
184
+ provider: { list: async () => ({ location: {}, data: [{ id: 'openai', name: 'OpenAI' }] }) },
185
+ model: { list: async () => ({ location: {}, data: null }) },
155
186
  })).rejects.toThrow(/provider catalog/)
156
187
  })
157
188
 
158
- it('passes AbortSignal to provider.list and config.providers', async () => {
189
+ it('composes real client-shaped catalog responses', async () => {
190
+ const catalog = await loadConnectedCatalog({
191
+ provider: {
192
+ list: async () => ({
193
+ location: { directory: '/tmp' },
194
+ data: [{ id: 'openai', name: 'OpenAI' }],
195
+ }),
196
+ },
197
+ model: {
198
+ list: async () => ({
199
+ location: { directory: '/tmp' },
200
+ data: [{
201
+ id: 'gpt-4o',
202
+ modelID: 'internal',
203
+ providerID: 'openai',
204
+ name: 'GPT-4o',
205
+ capabilities: { tools: true, input: ['text', 'image'], output: ['text'] },
206
+ }],
207
+ }),
208
+ },
209
+ })
210
+ expect(catalog.models).toEqual([
211
+ { providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
212
+ ])
213
+ })
214
+
215
+ it('surfaces thrown client errors as upstream_error', async () => {
216
+ await expect(loadConnectedCatalog({
217
+ provider: { list: async () => { throw new Error('boom') } },
218
+ model: { list: async () => ({ data: [] }) },
219
+ })).rejects.toMatchObject({ code: 'upstream_error', message: 'boom' })
220
+ })
221
+
222
+ it('passes AbortSignal to provider.list and model.list', async () => {
159
223
  const signals = []
160
224
  const catalog = await loadConnectedCatalog({
161
225
  provider: {
162
226
  list: async (_params, options) => {
163
227
  signals.push(options?.signal)
164
- return { data: { connected: ['xai'] } }
228
+ return {
229
+ location: {},
230
+ data: [{ id: 'xai', name: 'xAI' }],
231
+ }
165
232
  },
166
233
  },
167
- config: {
168
- providers: async (_params, options) => {
234
+ model: {
235
+ list: async (_params, options) => {
169
236
  signals.push(options?.signal)
170
237
  return {
171
- data: {
172
- providers: [{
173
- id: 'xai',
174
- name: 'xAI',
175
- models: {
176
- 'grok-4.6': {
177
- id: 'grok-4.6',
178
- name: 'Grok 4.6',
179
- capabilities: {
180
- input: { text: true, image: true },
181
- attachment: true,
182
- },
183
- },
184
- },
185
- }],
186
- },
238
+ location: {},
239
+ data: [{
240
+ id: 'grok-4.6',
241
+ modelID: 'internal',
242
+ providerID: 'xai',
243
+ name: 'Grok 4.6',
244
+ capabilities: {
245
+ input: { text: true, image: true },
246
+ attachment: true,
247
+ },
248
+ }],
187
249
  }
188
250
  },
189
251
  },
@@ -209,7 +271,7 @@ describe('loadConnectedCatalog', () => {
209
271
  else options?.signal?.addEventListener('abort', onAbort, { once: true })
210
272
  }),
211
273
  },
212
- config: { providers: async () => ({ data: { providers: [] } }) },
274
+ model: { list: async () => ({ data: [] }) },
213
275
  }, { timeoutMs: 30 })).rejects.toMatchObject({
214
276
  code: 'upstream_error',
215
277
  message: expect.stringMatching(/timed out|aborted/i),
@@ -1,5 +1,5 @@
1
1
  import { isConnectedModel, loadConnectedCatalog, parseModelRef } from './catalog.js';
2
- import { generateOpenCodeText } from './generate.js';
2
+ import { generateOpenCodeText, parseDataUrl, MAX_ATTACHMENT_BYTES } from './generate.js';
3
3
 
4
4
  const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
5
5
 
@@ -15,10 +15,29 @@ const normalizeCompletionFilePart = (part) => {
15
15
  if (!isRecord(part) || part.type !== 'file') return null;
16
16
  if (typeof part.mime !== 'string' || !part.mime.trim()) return null;
17
17
  if (typeof part.url !== 'string' || !part.url.trim()) return null;
18
+ const url = part.url.trim();
19
+ if (!url.startsWith('data:')) {
20
+ throw new LlmError('validation_error', 400, 'file part url must be a data URL');
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = parseDataUrl(url);
25
+ } catch (error) {
26
+ if (error?.code === 'validation_error') {
27
+ throw new LlmError('validation_error', 400, error.message || 'attachment too large');
28
+ }
29
+ throw error;
30
+ }
31
+ if (!parsed) {
32
+ throw new LlmError('validation_error', 400, 'file part data URL is malformed');
33
+ }
34
+ if (parsed.byteLength > MAX_ATTACHMENT_BYTES) {
35
+ throw new LlmError('validation_error', 400, `Attachment exceeds ${MAX_ATTACHMENT_BYTES} byte limit`);
36
+ }
18
37
  return {
19
38
  type: 'file',
20
39
  mime: part.mime.trim(),
21
- url: part.url.trim(),
40
+ url,
22
41
  ...(typeof part.filename === 'string' && part.filename.trim() ? { filename: part.filename.trim() } : {}),
23
42
  };
24
43
  };
@@ -57,15 +76,15 @@ export function toOpenAICompletion({ id, model, text, created = Math.floor(Date.
57
76
  }],
58
77
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
59
78
  };
60
- }
79
+ };
61
80
 
62
81
  /**
63
82
  * OpenAI-shaped completions against OpenCode's already-connected providers.
64
83
  *
65
84
  * Public HTTP stays non-streaming (`stream: true` → validation_error).
66
85
  * 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.
86
+ * `globalEventHub`; those are forwarded to generate for real attachment-session
87
+ * `session.text.delta` tokens only — never as fake post-hoc SSE on this route.
69
88
  */
70
89
  export async function createChatCompletion({
71
90
  body,
@@ -75,16 +94,14 @@ export async function createChatCompletion({
75
94
  loadCatalog = loadConnectedCatalog,
76
95
  generateText = generateOpenCodeText,
77
96
  ensureTempDirectory,
78
- fetchImpl,
79
97
  onTextDelta = null,
80
98
  globalEventHub = null,
81
99
  signal = null,
82
100
  }) {
83
101
  signal?.throwIfAborted();
84
102
  if (!isRecord(body)) throw new LlmError('validation_error', 400, 'JSON body is required');
85
- // Bundled OpenCode 1.18.4 generate is a full-turn JSON reply (sessionless
86
- // generate or throwaway session.promptAsync). This HTTP gateway does not
87
- // token-stream. Do not emit fake SSE after the fact.
103
+ // generate.text / attachment-session return a full turn. This HTTP gateway
104
+ // does not token-stream. Do not emit fake SSE after the fact.
88
105
  // In-process onTextDelta (optional) is a separate internal callback path.
89
106
  if (body.stream === true) {
90
107
  throw new LlmError(
@@ -103,8 +120,8 @@ export async function createChatCompletion({
103
120
 
104
121
  const client = clientFactory?.() ?? null;
105
122
  const catalog = await loadCatalog(client ?? {
106
- provider: { list: async () => ({ error: { status: 500 } }) },
107
- config: { providers: async () => ({ error: { status: 500 } }) },
123
+ provider: { list: async () => { throw new Error('no client'); } },
124
+ model: { list: async () => { throw new Error('no client'); } },
108
125
  });
109
126
  signal?.throwIfAborted();
110
127
  if (!isConnectedModel(catalog, resolved.providerID, resolved.modelID)) {
@@ -124,7 +141,6 @@ export async function createChatCompletion({
124
141
  modelID: resolved.modelID,
125
142
  ...(variant ? { variant } : {}),
126
143
  messages,
127
- fetchImpl,
128
144
  clientFactory,
129
145
  ensureTempDirectory,
130
146
  forwardImageParts: Boolean(catalog.models.find((entry) => (
@@ -139,6 +155,15 @@ export async function createChatCompletion({
139
155
  if (error?.code === 'validation_error') {
140
156
  throw new LlmError('validation_error', 400, error.message || 'validation_error');
141
157
  }
158
+ if (error?.code === 'llm_attachment_generation_unavailable') {
159
+ throw new LlmError(
160
+ 'llm_attachment_generation_unavailable',
161
+ 502,
162
+ typeof error?.message === 'string' && error.message.trim()
163
+ ? error.message
164
+ : 'LLM attachment generation is unavailable',
165
+ );
166
+ }
142
167
  throw new LlmError(
143
168
  'upstream_error',
144
169
  502,
@@ -52,7 +52,7 @@ describe('createChatCompletion', () => {
52
52
  })
53
53
 
54
54
  it('returns a non-stream completion from generateText', async () => {
55
- const generateText = vi.fn(async () => ({ text: 'done', source: 'throwaway-session' }))
55
+ const generateText = vi.fn(async () => ({ text: 'done', source: 'generate.text' }))
56
56
  const result = await createChatCompletion({
57
57
  generateText,
58
58
  loadCatalog: async () => ({
@@ -64,10 +64,11 @@ describe('createChatCompletion', () => {
64
64
  expect(result.completion.choices[0].message.content).toBe('done')
65
65
  expect(result.providerID).toBe('openai')
66
66
  expect(result.modelID).toBe('gpt-5.2')
67
+ expect(result.source).toBe('generate.text')
67
68
  })
68
69
 
69
70
  it('forwards in-process onTextDelta and globalEventHub to generateText', async () => {
70
- const generateText = vi.fn(async () => ({ text: 'streamed', source: 'throwaway-session' }))
71
+ const generateText = vi.fn(async () => ({ text: 'streamed', source: 'attachment-session' }))
71
72
  const onTextDelta = vi.fn()
72
73
  const signal = new AbortController().signal
73
74
  const globalEventHub = { subscribeEvent: vi.fn() }
@@ -88,7 +89,7 @@ describe('createChatCompletion', () => {
88
89
  })
89
90
 
90
91
  it('forwards optional file parts on user messages', async () => {
91
- const generateText = vi.fn(async () => ({ text: 'saw it', source: 'throwaway-session' }))
92
+ const generateText = vi.fn(async () => ({ text: 'saw it', source: 'attachment-session' }))
92
93
  const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' }
93
94
  await createChatCompletion({
94
95
  generateText,
@@ -108,7 +109,7 @@ describe('createChatCompletion', () => {
108
109
  })
109
110
 
110
111
  it('asks generate to skip image bytes when the catalog model is not vision-capable', async () => {
111
- const generateText = vi.fn(async () => ({ text: 'ok', source: 'throwaway-session' }))
112
+ const generateText = vi.fn(async () => ({ text: 'ok', source: 'generate.text' }))
112
113
  await createChatCompletion({
113
114
  generateText,
114
115
  loadCatalog: async () => ({
@@ -120,6 +121,26 @@ describe('createChatCompletion', () => {
120
121
  expect(generateText.mock.calls[0][0].forwardImageParts).toBe(false)
121
122
  })
122
123
 
124
+ it('rejects malformed data URL file parts', async () => {
125
+ const generateText = vi.fn()
126
+ await expect(createChatCompletion({
127
+ generateText,
128
+ loadCatalog: async () => ({
129
+ models: [{ providerID: 'openai', modelID: 'gpt-5.2', acceptsImages: true }],
130
+ connected: ['openai'],
131
+ }),
132
+ body: {
133
+ model: 'openai/gpt-5.2',
134
+ messages: [{
135
+ role: 'user',
136
+ content: 'look',
137
+ parts: [{ type: 'file', mime: 'image/png', url: 'https://example.com/x.png' }],
138
+ }],
139
+ },
140
+ })).rejects.toMatchObject({ code: 'validation_error', statusCode: 400 })
141
+ expect(generateText).not.toHaveBeenCalled()
142
+ })
143
+
123
144
  it('LlmError carries a stable code', () => {
124
145
  expect(new LlmError('no_provider', 400, 'missing')).toMatchObject({
125
146
  code: 'no_provider',
@@ -146,4 +167,24 @@ describe('createChatCompletion', () => {
146
167
  message: 'model refused the request',
147
168
  })
148
169
  })
170
+
171
+ it('maps llm_attachment_generation_unavailable before treating it as generic upstream', async () => {
172
+ const generateText = vi.fn(async () => {
173
+ const error = new Error('deny-all missing')
174
+ error.code = 'llm_attachment_generation_unavailable'
175
+ throw error
176
+ })
177
+ await expect(createChatCompletion({
178
+ generateText,
179
+ loadCatalog: async () => ({
180
+ models: [{ providerID: 'openai', modelID: 'gpt-4o', acceptsImages: true }],
181
+ connected: ['openai'],
182
+ }),
183
+ body: { model: 'openai/gpt-4o', messages: [{ role: 'user', content: 'hi' }] },
184
+ })).rejects.toMatchObject({
185
+ code: 'llm_attachment_generation_unavailable',
186
+ statusCode: 502,
187
+ message: 'deny-all missing',
188
+ })
189
+ })
149
190
  })