@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,594 @@
1
+ import { HttpError } from './http.mjs'
2
+ import {
3
+ loadModelsCache,
4
+ loadSecretsSafe,
5
+ providerKey,
6
+ saveModelsCache,
7
+ } from './store.mjs'
8
+
9
+ export const PROVIDERS = {
10
+ openai: {
11
+ name: 'OpenAI',
12
+ format: 'openai',
13
+ baseUrl: 'https://api.openai.com/v1',
14
+ },
15
+ anthropic: {
16
+ name: 'Anthropic',
17
+ format: 'anthropic',
18
+ baseUrl: 'https://api.anthropic.com/v1',
19
+ },
20
+ google: {
21
+ name: 'Google',
22
+ format: 'gemini',
23
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
24
+ },
25
+ opencode: {
26
+ name: 'OpenCode',
27
+ format: 'opencode',
28
+ baseUrl: 'https://opencode.ai/inference/v1',
29
+ listUrl: 'https://opencode.ai/inference/v1/models',
30
+ bases: {
31
+ 'openai-chat': 'https://opencode.ai/inference/openai/v1',
32
+ 'openai-responses': 'https://opencode.ai/inference/openai/v1',
33
+ anthropic: 'https://opencode.ai/inference/anthropic/v1',
34
+ gemini: 'https://opencode.ai/inference/google/v1beta',
35
+ },
36
+ },
37
+ deepseek: { name: 'DeepSeek', format: 'openai', baseUrl: 'https://api.deepseek.com' },
38
+ openrouter: {
39
+ name: 'OpenRouter',
40
+ format: 'openai',
41
+ baseUrl: 'https://openrouter.ai/api/v1',
42
+ },
43
+ groq: { name: 'Groq', format: 'openai', baseUrl: 'https://api.groq.com/openai/v1' },
44
+ mistral: { name: 'Mistral', format: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
45
+ xai: { name: 'xAI', format: 'openai', baseUrl: 'https://api.x.ai/v1' },
46
+ together: {
47
+ name: 'Together AI',
48
+ format: 'openai',
49
+ baseUrl: 'https://api.together.xyz/v1',
50
+ },
51
+ ollama: {
52
+ name: 'Ollama',
53
+ format: 'openai',
54
+ baseUrl: 'http://localhost:11434/v1',
55
+ keyless: true,
56
+ },
57
+ custom: { name: 'Custom endpoint', format: 'openai', baseUrl: '', keyless: true },
58
+ }
59
+
60
+ const EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default'])
61
+
62
+ // Obvious provider/key mismatches are caught before they produce a confusing 401.
63
+ // Only stable, well-known prefixes are listed; providers with ambiguous formats are not checked.
64
+ export const KEY_FORMATS = {
65
+ openrouter: { prefix: 'sk-or-', label: 'OpenRouter' },
66
+ anthropic: { prefix: 'sk-ant-', label: 'Anthropic' },
67
+ google: { prefix: 'AIza', label: 'Google' },
68
+ groq: { prefix: 'gsk_', label: 'Groq' },
69
+ xai: { prefix: 'xai-', label: 'xAI' },
70
+ }
71
+
72
+ export function validateKeyFormat(providerId, apiKey) {
73
+ const clean = typeof apiKey === 'string' ? apiKey.trim() : ''
74
+ if (!clean) return null
75
+
76
+ for (const [otherId, format] of Object.entries(KEY_FORMATS)) {
77
+ if (otherId !== providerId && clean.startsWith(format.prefix)) {
78
+ return `This API key starts with "${format.prefix}", which belongs to ${format.label}. Please configure it under ${format.label} in Settings → Models.`
79
+ }
80
+ }
81
+
82
+ const expected = KEY_FORMATS[providerId]
83
+ if (expected && !clean.startsWith(expected.prefix)) {
84
+ let hint = ''
85
+ if (providerId === 'openrouter' && clean.startsWith('sk-')) {
86
+ hint = ' (OpenRouter keys start with "sk-or-v1-". The key you entered looks like a DeepSeek or OpenAI key)'
87
+ }
88
+ return `An API key for ${expected.label} must start with "${expected.prefix}".${hint}`
89
+ }
90
+
91
+ return null
92
+ }
93
+ const NON_CHAT =
94
+ /(^|[/_-])(embedding|embeddings|embed|whisper|tts|dall-e|moderation|transcribe|realtime|sora|audio|image|rerank|ocr)([/_-]|$)/i
95
+ const OPENAI_CHAT = /^(gpt|o[1-9]|chatgpt|codex)/i
96
+ const CATALOG_TTL = 12 * 60 * 60 * 1000
97
+ const CATALOG_URL = 'https://models.dev/api.json'
98
+
99
+ function record(value) {
100
+ return value && typeof value === 'object' ? value : {}
101
+ }
102
+
103
+ function text(value) {
104
+ return typeof value === 'string' ? value.trim() : ''
105
+ }
106
+
107
+ function number(value) {
108
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
109
+ }
110
+
111
+ function strings(value) {
112
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []
113
+ }
114
+
115
+ function providerDef(providerId) {
116
+ const def = PROVIDERS[providerId]
117
+ if (!def) throw new HttpError(400, `Unknown provider: ${providerId}`, 'unknown_provider')
118
+ return def
119
+ }
120
+
121
+ export function isKnownProvider(providerId) {
122
+ return Boolean(PROVIDERS[providerId])
123
+ }
124
+
125
+ function trimCatalog(raw) {
126
+ const providers = {}
127
+ for (const [providerId, value] of Object.entries(raw ?? {})) {
128
+ const models = {}
129
+ for (const [modelId, modelValue] of Object.entries(record(record(value).models))) {
130
+ const model = record(modelValue)
131
+ models[modelId] = {
132
+ id: modelId,
133
+ name: text(model.name) || modelId,
134
+ family: text(model.family),
135
+ reasoning: model.reasoning === true,
136
+ reasoning_options: Array.isArray(model.reasoning_options) ? model.reasoning_options : [],
137
+ tool_call: model.tool_call === true,
138
+ temperature: model.temperature !== false,
139
+ modalities: record(model.modalities),
140
+ limit: record(model.limit),
141
+ provider: record(model.provider),
142
+ canonical_model_id: text(model.canonical_model_id),
143
+ }
144
+ }
145
+ if (Object.keys(models).length > 0) providers[providerId] = { models }
146
+ }
147
+ return { fetchedAt: Date.now(), providers }
148
+ }
149
+
150
+ export async function loadCatalog({ force = false } = {}) {
151
+ const cached = await loadModelsCache()
152
+ const fresh = cached && Date.now() - Number(cached.fetchedAt ?? 0) < CATALOG_TTL
153
+ if (cached && fresh && !force) return { catalog: cached, stale: false, error: '' }
154
+ try {
155
+ const response = await fetch(CATALOG_URL, {
156
+ headers: { Accept: 'application/json' },
157
+ signal: AbortSignal.timeout(30_000),
158
+ })
159
+ if (!response.ok) throw new Error(`models.dev returned ${response.status}.`)
160
+ const raw = await response.json()
161
+ const catalog = trimCatalog(raw)
162
+ await saveModelsCache(catalog)
163
+ return { catalog, stale: false, error: '' }
164
+ } catch (error) {
165
+ if (cached) {
166
+ return {
167
+ catalog: cached,
168
+ stale: true,
169
+ error: error instanceof Error ? error.message : 'models.dev is unavailable.',
170
+ }
171
+ }
172
+ return {
173
+ catalog: { fetchedAt: 0, providers: {} },
174
+ stale: true,
175
+ error: error instanceof Error ? error.message : 'models.dev is unavailable.',
176
+ }
177
+ }
178
+ }
179
+
180
+ function catalogModel(catalog, providerId, modelId) {
181
+ return catalog.providers?.[providerId]?.models?.[modelId] ?? null
182
+ }
183
+
184
+ function normalizeReasoning(model) {
185
+ const options = Array.isArray(model.reasoning_options) ? model.reasoning_options : []
186
+ const result = []
187
+ for (const option of options) {
188
+ const type = text(record(option).type)
189
+ if (type === 'effort') {
190
+ const values = strings(record(option).values)
191
+ .map((value) => value.toLowerCase())
192
+ .filter((value) => EFFORTS.has(value))
193
+ if (values.length > 0) result.push({ type: 'effort', values })
194
+ } else if (type === 'toggle') {
195
+ result.push({ type: 'toggle' })
196
+ } else if (type === 'budget_tokens') {
197
+ result.push({
198
+ type: 'budget_tokens',
199
+ ...(number(record(option).min) !== undefined ? { min: number(record(option).min) } : {}),
200
+ ...(number(record(option).max) !== undefined ? { max: number(record(option).max) } : {}),
201
+ })
202
+ }
203
+ }
204
+ return result
205
+ }
206
+
207
+ function opencodeKindFromId(modelId, catalogEntry) {
208
+ const api = text(catalogEntry?.provider?.api)
209
+ if (api.includes('/anthropic/')) return 'anthropic'
210
+ if (api.includes('/google/')) return 'gemini'
211
+ if (api.includes('/openai/')) {
212
+ const family = `${text(catalogEntry?.family)} ${text(catalogEntry?.canonical_model_id)} ${modelId}`.toLowerCase()
213
+ if (/\b(gpt|grok|muse|codex)\b/.test(family)) return 'openai-responses'
214
+ return 'openai-chat'
215
+ }
216
+ const id = modelId.toLowerCase()
217
+ if (/gemini/.test(id)) return 'gemini'
218
+ if (/claude/.test(id) || /qwen.*(flash|plus)/.test(id)) return 'anthropic'
219
+ if (/gpt|grok|muse|codex/.test(id)) return 'openai-responses'
220
+ return 'openai-chat'
221
+ }
222
+
223
+ function modelInfo({ providerId, id, name, context, maxOutput, vision, catalogEntry, fallbackKind }) {
224
+ const catalogDef = catalogEntry ?? {}
225
+ const modalities = record(catalogDef.modalities)
226
+ const limit = record(catalogDef.limit)
227
+ const reasoningOptions = normalizeReasoning(catalogDef)
228
+ const apiKind =
229
+ providerId === 'opencode'
230
+ ? opencodeKindFromId(id, catalogDef)
231
+ : providerDef(providerId).format === 'anthropic'
232
+ ? 'anthropic'
233
+ : providerDef(providerId).format === 'gemini'
234
+ ? 'gemini'
235
+ : fallbackKind ?? 'openai-chat'
236
+ const free =
237
+ providerId === 'opencode' &&
238
+ (/(^|[-_/])free($|[-_/])/i.test(id) ||
239
+ /big-pickle|systemone|^jev/i.test(id) ||
240
+ /(^|[-_/])test($|[-_/])/i.test(id))
241
+ return {
242
+ id,
243
+ name: name || text(catalogDef.name) || id.replace(/^models\//, ''),
244
+ context: context ?? number(limit.context),
245
+ maxOutput: number(limit.output),
246
+ vision: Boolean(
247
+ vision ||
248
+ strings(modalities.input).includes('image') ||
249
+ strings(modalities.input).includes('pdf'),
250
+ ),
251
+ reasoningOptions,
252
+ toolCall: catalogDef.tool_call !== false,
253
+ temperature: catalogDef.temperature !== false,
254
+ family: text(catalogDef.family) || undefined,
255
+ apiKind,
256
+ free,
257
+ canonicalId: text(catalogDef.canonical_model_id) || undefined,
258
+ note:
259
+ providerId === 'opencode' && free
260
+ ? 'OpenCode free-tier models only work inside OpenCode. Add credits or use a paid model.'
261
+ : undefined,
262
+ }
263
+ }
264
+
265
+ function dedupe(models) {
266
+ const seen = new Set()
267
+ return models.filter((model) => {
268
+ if (!model.id || seen.has(model.id)) return false
269
+ seen.add(model.id)
270
+ return true
271
+ })
272
+ }
273
+
274
+ function fromOpenAI(payload, providerId, catalog) {
275
+ const entries = Array.isArray(record(payload).data) ? record(payload).data : []
276
+ const models = []
277
+ for (const raw of entries) {
278
+ const item = record(raw)
279
+ const id = text(item.id)
280
+ if (!id) continue
281
+ if (providerId === 'openai' && !OPENAI_CHAT.test(id)) continue
282
+ if (NON_CHAT.test(id)) continue
283
+ const architecture = record(item.architecture)
284
+ const output = strings(architecture.output_modalities)
285
+ if (output.length > 0 && !output.includes('text')) continue
286
+ const supported = strings(item.supported_parameters)
287
+ if (providerId === 'openrouter' && supported.length > 0 && !supported.includes('tools')) {
288
+ continue
289
+ }
290
+ const entry = catalogModel(catalog, providerId, id)
291
+ models.push(
292
+ modelInfo({
293
+ providerId,
294
+ id,
295
+ name: text(item.name) || text(entry?.name),
296
+ context: number(item.context_length) ?? number(item.context_window),
297
+ vision: strings(architecture.input_modalities).includes('image'),
298
+ catalogEntry: entry,
299
+ }),
300
+ )
301
+ }
302
+ return dedupe(models)
303
+ }
304
+
305
+ function fromAnthropic(payload, providerId, catalog) {
306
+ const entries = Array.isArray(record(payload).data) ? record(payload).data : []
307
+ return dedupe(
308
+ entries
309
+ .map((raw) => {
310
+ const item = record(raw)
311
+ const id = text(item.id)
312
+ if (!id) return null
313
+ return modelInfo({
314
+ providerId,
315
+ id,
316
+ name: text(item.display_name),
317
+ context: number(item.context_window),
318
+ catalogEntry: catalogModel(catalog, providerId, id),
319
+ })
320
+ })
321
+ .filter(Boolean),
322
+ )
323
+ }
324
+
325
+ function fromGemini(payload, providerId, catalog) {
326
+ const entries = Array.isArray(record(payload).models) ? record(payload).models : []
327
+ return dedupe(
328
+ entries
329
+ .map((raw) => {
330
+ const item = record(raw)
331
+ const id = text(item.name).replace(/^models\//, '')
332
+ if (!id) return null
333
+ const methods = strings(item.supportedGenerationMethods)
334
+ if (methods.length > 0 && !methods.includes('generateContent')) return null
335
+ return modelInfo({
336
+ providerId,
337
+ id,
338
+ name: text(item.displayName),
339
+ context: number(item.inputTokenLimit),
340
+ maxOutput: number(item.outputTokenLimit),
341
+ vision: true,
342
+ catalogEntry: catalogModel(catalog, providerId, id),
343
+ })
344
+ })
345
+ .filter(Boolean),
346
+ )
347
+ }
348
+
349
+ function fromCatalog(providerId, catalog) {
350
+ const entries = Object.values(catalog.providers?.[providerId]?.models ?? {})
351
+ const models = []
352
+ for (const raw of entries) {
353
+ const entry = record(raw)
354
+ const id = text(entry.id)
355
+ if (!id) continue
356
+ if (providerId === 'openai' && !OPENAI_CHAT.test(id)) continue
357
+ if (NON_CHAT.test(id)) continue
358
+ if (providerId === 'opencode' && /big-pickle|systemone|^jev/i.test(id)) continue
359
+ models.push(modelInfo({ providerId, id, catalogEntry: entry }))
360
+ }
361
+ return dedupe(models)
362
+ }
363
+
364
+ export function filterUsable(providerId, models) {
365
+ if (providerId !== 'opencode') return models
366
+ return models.filter((model) => !model.free && !/big-pickle|systemone|^jev/i.test(model.id))
367
+ }
368
+
369
+ function baseUrlFor(providerId, settings) {
370
+ const def = providerDef(providerId)
371
+ const custom = text(settings?.providers?.[providerId]?.baseUrl)
372
+ const base = (custom || def.baseUrl || '').replace(/\/+$/, '')
373
+ if (!base) {
374
+ throw new HttpError(
375
+ 400,
376
+ providerId === 'custom'
377
+ ? 'Set a base URL for the custom endpoint in Settings → Models.'
378
+ : `No base URL is configured for ${def.name}.`,
379
+ 'missing_base_url',
380
+ )
381
+ }
382
+ return base
383
+ }
384
+
385
+ async function authHeaders(providerId, { settings, key } = {}) {
386
+ const def = providerDef(providerId)
387
+ const headers = { 'Content-Type': 'application/json' }
388
+ const apiKey = key ?? (await providerKey(providerId))
389
+ if (providerId === 'opencode') {
390
+ if (!apiKey) {
391
+ throw new HttpError(
392
+ 400,
393
+ 'Add an OpenCode API key in Settings → Models. OpenCode free-tier models cannot be used outside OpenCode.',
394
+ 'missing_key',
395
+ )
396
+ }
397
+ headers.Authorization = `Bearer ${apiKey}`
398
+ return headers
399
+ }
400
+ if (providerId === 'custom' || providerId === 'ollama') {
401
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
402
+ return headers
403
+ }
404
+ if (def.format === 'anthropic') {
405
+ if (!apiKey) throw new HttpError(400, 'Add an Anthropic API key in Settings → Models.', 'missing_key')
406
+ headers['x-api-key'] = apiKey
407
+ headers['anthropic-version'] = '2023-06-01'
408
+ return headers
409
+ }
410
+ if (def.format === 'gemini') {
411
+ if (!apiKey) throw new HttpError(400, 'Add a Google API key in Settings → Models.', 'missing_key')
412
+ headers['x-goog-api-key'] = apiKey
413
+ return headers
414
+ }
415
+ if (!apiKey) {
416
+ throw new HttpError(400, `Add an API key for ${def.name} in Settings → Models.`, 'missing_key')
417
+ }
418
+ headers.Authorization = `Bearer ${apiKey}`
419
+ if (providerId === 'openrouter') {
420
+ if (!apiKey.startsWith('sk-or-')) {
421
+ throw new HttpError(
422
+ 400,
423
+ `The configured OpenRouter key does not match the OpenRouter key format (starts with "${apiKey.slice(0, 5)}…", but OpenRouter keys start with "sk-or-v1-"). If this is a DeepSeek key, please select the DeepSeek provider in Settings → Models.`,
424
+ 'invalid_key_format',
425
+ )
426
+ }
427
+ headers['HTTP-Referer'] = 'http://127.0.0.1'
428
+ headers['X-Title'] = 'OpenSol'
429
+ }
430
+ return headers
431
+ }
432
+
433
+ function listUrl(providerId, settings) {
434
+ const def = providerDef(providerId)
435
+ if (providerId === 'opencode') return def.listUrl
436
+ const base = baseUrlFor(providerId, settings)
437
+ if (def.format === 'anthropic') return `${base}/models?limit=1000`
438
+ if (def.format === 'gemini') return `${base}/models?pageSize=1000`
439
+ return `${base}/models`
440
+ }
441
+
442
+ async function listHeaders(providerId, settings) {
443
+ const headers = await authHeaders(providerId, { settings })
444
+ delete headers['Content-Type']
445
+ return headers
446
+ }
447
+
448
+ async function responseDetail(response) {
449
+ const raw = await response.text().catch(() => '')
450
+ if (!raw) return ''
451
+ try {
452
+ const parsed = JSON.parse(raw)
453
+ const error = parsed.error
454
+ if (typeof error === 'string') return error
455
+ if (error && typeof error.message === 'string') return error.message
456
+ if (typeof parsed.message === 'string') return parsed.message
457
+ } catch {
458
+ // Fall through to the raw text.
459
+ }
460
+ return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400)
461
+ }
462
+
463
+ export async function listModels(providerId, settings) {
464
+ const def = providerDef(providerId)
465
+ const { catalog, stale, error: catalogError } = await loadCatalog()
466
+ const url = listUrl(providerId, settings)
467
+ try {
468
+ const response = await fetch(url, {
469
+ headers: await listHeaders(providerId, settings),
470
+ signal: AbortSignal.timeout(25_000),
471
+ })
472
+ if (!response.ok) {
473
+ const detail = await responseDetail(response)
474
+ const friendly =
475
+ response.status === 401
476
+ ? `${def.name} rejected this API key (401).${detail ? ` ${detail}` : ''}`
477
+ : response.status === 403
478
+ ? `${def.name} refused to list models for this key (403).${detail ? ` ${detail}` : ''}`
479
+ : `${def.name} returned HTTP ${response.status} while listing models.${detail ? ` ${detail}` : ''}`
480
+ throw new HttpError(response.status, friendly, 'model_list_failed')
481
+ }
482
+ const payload = await response.json()
483
+ const models =
484
+ def.format === 'anthropic'
485
+ ? fromAnthropic(payload, providerId, catalog)
486
+ : def.format === 'gemini'
487
+ ? fromGemini(payload, providerId, catalog)
488
+ : fromOpenAI(payload, providerId, catalog)
489
+ return {
490
+ provider: providerId,
491
+ models: filterUsable(providerId, models),
492
+ source: 'live',
493
+ fetchedAt: Date.now(),
494
+ note:
495
+ providerId === 'opencode'
496
+ ? 'OpenCode free-tier models are not available outside OpenCode and are hidden here. Paid models work with an API key.'
497
+ : '',
498
+ catalogError: catalogError || (stale ? 'Using a cached model metadata snapshot.' : ''),
499
+ }
500
+ } catch (error) {
501
+ const status = error instanceof HttpError ? error.status : 0
502
+ const fallback = filterUsable(providerId, fromCatalog(providerId, catalog))
503
+ if (fallback.length > 0 && (status === 0 || status === 404 || status >= 500)) {
504
+ return {
505
+ provider: providerId,
506
+ models: fallback,
507
+ source: 'catalog',
508
+ fetchedAt: Date.now(),
509
+ note: `The live model list could not be loaded. Showing the models.dev catalog instead. ${
510
+ error instanceof Error ? error.message : ''
511
+ }`.trim(),
512
+ catalogError: catalogError || '',
513
+ }
514
+ }
515
+ if (status === 0) {
516
+ const base = baseUrlFor(providerId, settings)
517
+ throw new HttpError(
518
+ 502,
519
+ providerId === 'ollama'
520
+ ? `Ollama is not running at ${base}. Start Ollama, then refresh, or choose another provider in Settings → Models.`
521
+ : `${def.name} could not be reached at ${base}. Check that the server is running and the base URL is correct.`,
522
+ 'provider_unreachable',
523
+ )
524
+ }
525
+ throw error
526
+ }
527
+ }
528
+
529
+ export async function listModelStatus(providerId, settings) {
530
+ const def = providerDef(providerId)
531
+ const { secrets } = await loadSecretsSafe()
532
+ const key = secrets.providers?.[providerId]?.apiKey ?? ''
533
+ return {
534
+ id: providerId,
535
+ name: def.name,
536
+ format: def.format,
537
+ baseUrl: text(settings?.providers?.[providerId]?.baseUrl) || def.baseUrl,
538
+ keyless: Boolean(def.keyless),
539
+ configured:
540
+ providerId === 'custom'
541
+ ? Boolean(text(settings?.providers?.custom?.baseUrl))
542
+ : Boolean(def.keyless || key),
543
+ hint: key ? `••••${key.slice(-4)}` : '',
544
+ }
545
+ }
546
+
547
+ export async function llmTarget(providerId, kind, model, settings) {
548
+ const def = providerDef(providerId)
549
+ const allowed =
550
+ providerId === 'opencode'
551
+ ? ['openai-chat', 'openai-responses', 'anthropic', 'gemini']
552
+ : def.format === 'anthropic'
553
+ ? ['anthropic']
554
+ : def.format === 'gemini'
555
+ ? ['gemini']
556
+ : ['openai-chat', 'openai-responses']
557
+ if (!allowed.includes(kind)) {
558
+ throw new HttpError(400, `${def.name} does not support the ${kind} transport.`, 'bad_transport')
559
+ }
560
+ if (!text(model)) throw new HttpError(400, 'Choose a model before sending a message.', 'missing_model')
561
+ const base =
562
+ providerId === 'opencode'
563
+ ? def.bases[kind]
564
+ : baseUrlFor(providerId, settings)
565
+ const headers = await authHeaders(providerId, { settings })
566
+ headers.Accept = 'text/event-stream'
567
+ let pathname
568
+ if (kind === 'openai-chat') pathname = '/chat/completions'
569
+ else if (kind === 'openai-responses') pathname = '/responses'
570
+ else if (kind === 'anthropic') pathname = '/messages'
571
+ else pathname = `/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`
572
+ return {
573
+ url: `${base}${pathname}`,
574
+ headers,
575
+ provider: providerId,
576
+ kind,
577
+ }
578
+ }
579
+
580
+ export async function providerList(settings) {
581
+ const result = []
582
+ for (const id of Object.keys(PROVIDERS)) result.push(await listModelStatus(id, settings))
583
+ return result
584
+ }
585
+
586
+ export async function searchKey() {
587
+ const { secrets } = await loadSecretsSafe()
588
+ return secrets.search?.apiKey ?? ''
589
+ }
590
+
591
+ export async function deploySecrets() {
592
+ const { secrets } = await loadSecretsSafe()
593
+ return secrets.deploy ?? {}
594
+ }