@bibobonking/opensol 3.0.1 → 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.
- package/package.json +1 -1
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -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
|
+
}
|