@asobacloud/nehanda 0.6.1 → 0.8.0
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/README.md +36 -29
- package/bin/agent.mjs +70 -59
- package/bin/nehanda-ui.mjs +147 -143
- package/lib/configureProvider.mjs +254 -0
- package/lib/modelConfig.mjs +20 -10
- package/lib/modelDiscovery.mjs +125 -1
- package/lib/openaiCompat.mjs +11 -1
- package/lib/orchestrate.mjs +32 -9
- package/lib/slashCommands.mjs +128 -0
- package/lib/ui.mjs +15 -10
- package/package.json +1 -1
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goose-style provider configure wizard (#8).
|
|
3
|
+
* Single flow: provider → API key → host → keyed model discovery → persist.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
discoverOpenAiCompatModels,
|
|
8
|
+
discoverOllamaModels,
|
|
9
|
+
} from './modelDiscovery.mjs'
|
|
10
|
+
|
|
11
|
+
/** @typedef {{ id: string, label: string, provider: string, defaultHost: string, needsKey: boolean, keySlot: string|null, discovery: 'openai'|'ollama' }} ProviderPreset */
|
|
12
|
+
|
|
13
|
+
/** @type {ProviderPreset[]} */
|
|
14
|
+
export const PROVIDER_PRESETS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'nehanda',
|
|
17
|
+
label: 'Nehanda Cloud',
|
|
18
|
+
provider: 'nehanda',
|
|
19
|
+
defaultHost: 'https://nehanda-ml.asoba.co/v1',
|
|
20
|
+
needsKey: true,
|
|
21
|
+
keySlot: 'nehanda',
|
|
22
|
+
discovery: 'openai',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'openrouter',
|
|
26
|
+
label: 'OpenRouter',
|
|
27
|
+
provider: 'openai_compatible',
|
|
28
|
+
defaultHost: 'https://openrouter.ai/api/v1',
|
|
29
|
+
needsKey: true,
|
|
30
|
+
keySlot: 'openai_compat',
|
|
31
|
+
discovery: 'openai',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'openai_compatible',
|
|
35
|
+
label: 'OpenAI-compatible',
|
|
36
|
+
provider: 'openai_compatible',
|
|
37
|
+
defaultHost: 'https://api.openai.com/v1',
|
|
38
|
+
needsKey: true,
|
|
39
|
+
keySlot: 'openai_compat',
|
|
40
|
+
discovery: 'openai',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'zhipu',
|
|
44
|
+
label: 'Zhipu (z.ai)',
|
|
45
|
+
provider: 'zhipu',
|
|
46
|
+
defaultHost: 'https://open.bigmodel.cn/api/paas/v4',
|
|
47
|
+
needsKey: true,
|
|
48
|
+
keySlot: 'zhipu',
|
|
49
|
+
discovery: 'openai',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'ollama',
|
|
53
|
+
label: 'Ollama (local / LAN)',
|
|
54
|
+
provider: 'ollama',
|
|
55
|
+
defaultHost: 'http://localhost:11434',
|
|
56
|
+
needsKey: false,
|
|
57
|
+
keySlot: null,
|
|
58
|
+
discovery: 'ollama',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'lm_studio',
|
|
62
|
+
label: 'LM Studio',
|
|
63
|
+
provider: 'lm_studio_local',
|
|
64
|
+
defaultHost: 'http://localhost:1234/v1',
|
|
65
|
+
needsKey: false,
|
|
66
|
+
keySlot: 'openai_compat',
|
|
67
|
+
discovery: 'openai',
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export function normalizeOpenAiBaseUrl(host) {
|
|
72
|
+
const h = String(host || '').trim().replace(/\/+$/, '')
|
|
73
|
+
if (!h) return ''
|
|
74
|
+
return h
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ollama tags API wants host without trailing /v1. */
|
|
78
|
+
export function normalizeOllamaHost(host) {
|
|
79
|
+
return String(host || '').trim().replace(/\/+$/, '').replace(/\/v1$/i, '')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Persist endpoint for agents.json — OpenAI-compat style /v1 for Ollama. */
|
|
83
|
+
export function endpointForAgent(preset, host) {
|
|
84
|
+
if (preset.discovery === 'ollama') {
|
|
85
|
+
const base = normalizeOllamaHost(host)
|
|
86
|
+
return base ? `${base}/v1` : ''
|
|
87
|
+
}
|
|
88
|
+
return normalizeOpenAiBaseUrl(host)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Discover models for a preset using host + optional API key.
|
|
93
|
+
* @param {ProviderPreset} preset
|
|
94
|
+
* @param {{ host: string, apiKey?: string, timeout?: number }} opts
|
|
95
|
+
*/
|
|
96
|
+
export async function discoverModelsForPreset(preset, opts = {}) {
|
|
97
|
+
const timeout = opts.timeout || 5000
|
|
98
|
+
if (preset.discovery === 'ollama') {
|
|
99
|
+
const host = normalizeOllamaHost(opts.host)
|
|
100
|
+
return discoverOllamaModels(host, { timeout })
|
|
101
|
+
}
|
|
102
|
+
const host = normalizeOpenAiBaseUrl(opts.host)
|
|
103
|
+
return discoverOpenAiCompatModels(host, { apiKey: opts.apiKey || '', timeout })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Interactive configure wizard.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} deps
|
|
110
|
+
* @param {(q: string) => Promise<string>} deps.ask
|
|
111
|
+
* @param {(title: string, items: object[]) => Promise<object|null>} deps.select
|
|
112
|
+
* @param {(msg: string) => void} [deps.notify]
|
|
113
|
+
* @param {(ctx: {preset, host, apiKey}) => Promise<{success, models?, error?}>} [deps.discover]
|
|
114
|
+
* @param {(args: {slot: string, key: string}) => Promise<void>} [deps.saveKey]
|
|
115
|
+
* @param {(preset: ProviderPreset) => Promise<string>} [deps.resolveExistingKey]
|
|
116
|
+
* @param {(cfg: object) => Promise<void>} [deps.persist]
|
|
117
|
+
* @param {{ presetId?: string, prefilledKey?: string }} [opts]
|
|
118
|
+
* @returns {Promise<object|null>} configured result or null if cancelled
|
|
119
|
+
*/
|
|
120
|
+
export async function runConfigureWizard(deps, opts = {}) {
|
|
121
|
+
const ask = deps.ask
|
|
122
|
+
const select = deps.select
|
|
123
|
+
const notify = deps.notify || (() => {})
|
|
124
|
+
const discover = deps.discover || ((ctx) => discoverModelsForPreset(ctx.preset, ctx))
|
|
125
|
+
const saveKey = deps.saveKey || (async () => {})
|
|
126
|
+
const resolveExistingKey = deps.resolveExistingKey || (async () => '')
|
|
127
|
+
const persist = deps.persist || (async () => {})
|
|
128
|
+
|
|
129
|
+
let preset
|
|
130
|
+
if (opts.presetId) {
|
|
131
|
+
preset = PROVIDER_PRESETS.find(p => p.id === opts.presetId)
|
|
132
|
+
if (!preset) throw new Error(`Unknown provider preset: ${opts.presetId}`)
|
|
133
|
+
} else {
|
|
134
|
+
const items = PROVIDER_PRESETS.map(p => ({
|
|
135
|
+
id: p.id,
|
|
136
|
+
label: p.label,
|
|
137
|
+
desc: p.defaultHost,
|
|
138
|
+
...p,
|
|
139
|
+
}))
|
|
140
|
+
const picked = await select('Which provider?', items)
|
|
141
|
+
if (!picked) return null
|
|
142
|
+
preset = PROVIDER_PRESETS.find(p => p.id === picked.id) || picked
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── API key ────────────────────────────────────────────────
|
|
146
|
+
let apiKey = typeof opts.prefilledKey === 'string' ? opts.prefilledKey.trim() : ''
|
|
147
|
+
let keyDirty = false
|
|
148
|
+
|
|
149
|
+
if (preset.needsKey && !apiKey) {
|
|
150
|
+
const existing = (await resolveExistingKey(preset)) || ''
|
|
151
|
+
if (existing) {
|
|
152
|
+
const choice = await select('Update API key?', [
|
|
153
|
+
{ id: 'keep', label: 'Keep existing key', desc: 'Use the key already in secure storage' },
|
|
154
|
+
{ id: 'replace', label: 'Replace key', desc: 'Enter a new API key' },
|
|
155
|
+
])
|
|
156
|
+
if (!choice) return null
|
|
157
|
+
if (choice.id === 'replace') {
|
|
158
|
+
const entered = await ask(`${preset.label} API key:`)
|
|
159
|
+
if (!entered) return null
|
|
160
|
+
apiKey = entered.trim()
|
|
161
|
+
keyDirty = true
|
|
162
|
+
} else {
|
|
163
|
+
apiKey = existing
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
const entered = await ask(`${preset.label} API key:`)
|
|
167
|
+
if (!entered) return null
|
|
168
|
+
apiKey = entered.trim()
|
|
169
|
+
keyDirty = true
|
|
170
|
+
}
|
|
171
|
+
} else if (preset.needsKey && apiKey && opts.prefilledKey) {
|
|
172
|
+
// Key already saved by /key — do not re-save
|
|
173
|
+
keyDirty = false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Host ───────────────────────────────────────────────────
|
|
177
|
+
const hostPrompt = preset.discovery === 'ollama'
|
|
178
|
+
? `Ollama host [${preset.defaultHost}]:`
|
|
179
|
+
: `API host [${preset.defaultHost}]:`
|
|
180
|
+
const hostAnswer = await ask(hostPrompt)
|
|
181
|
+
const host = (hostAnswer && hostAnswer.trim()) || preset.defaultHost
|
|
182
|
+
if (!host) return null
|
|
183
|
+
|
|
184
|
+
const endpoint = endpointForAgent(preset, host)
|
|
185
|
+
if (!endpoint) return null
|
|
186
|
+
|
|
187
|
+
// ── Discovery ──────────────────────────────────────────────
|
|
188
|
+
notify('Fetching models from provider…')
|
|
189
|
+
let discovered = []
|
|
190
|
+
let discoveryError = null
|
|
191
|
+
try {
|
|
192
|
+
const result = await discover({
|
|
193
|
+
preset,
|
|
194
|
+
host: preset.discovery === 'ollama' ? normalizeOllamaHost(host) : normalizeOpenAiBaseUrl(host),
|
|
195
|
+
apiKey,
|
|
196
|
+
})
|
|
197
|
+
if (result?.success && Array.isArray(result.models)) {
|
|
198
|
+
discovered = result.models
|
|
199
|
+
} else {
|
|
200
|
+
discoveryError = result?.error || 'Discovery failed'
|
|
201
|
+
notify(`Discovery failed: ${discoveryError}`)
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
discoveryError = err.message
|
|
205
|
+
notify(`Discovery failed: ${discoveryError}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const modelItems = discovered.map(m => ({
|
|
209
|
+
id: m.name,
|
|
210
|
+
name: m.name,
|
|
211
|
+
label: m.name,
|
|
212
|
+
desc: 'Discovered',
|
|
213
|
+
endpoint,
|
|
214
|
+
}))
|
|
215
|
+
modelItems.push({
|
|
216
|
+
id: '__custom__',
|
|
217
|
+
name: '__custom__',
|
|
218
|
+
label: '(enter custom name…)',
|
|
219
|
+
desc: discoveryError ? 'Discovery unavailable — type a model id' : 'Type a model id not listed above',
|
|
220
|
+
endpoint,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const modelPick = await select('Select model', modelItems)
|
|
224
|
+
if (!modelPick) return null
|
|
225
|
+
|
|
226
|
+
let modelName = modelPick.name
|
|
227
|
+
if (modelPick.id === '__custom__' || modelName === '__custom__') {
|
|
228
|
+
const custom = await ask('Custom model name:')
|
|
229
|
+
if (!custom || !custom.trim()) return null
|
|
230
|
+
modelName = custom.trim()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (keyDirty && preset.keySlot && apiKey) {
|
|
234
|
+
await saveKey({ slot: preset.keySlot, key: apiKey })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cfg = {
|
|
238
|
+
presetId: preset.id,
|
|
239
|
+
provider: preset.provider,
|
|
240
|
+
model: modelName,
|
|
241
|
+
endpoint,
|
|
242
|
+
apiKey: preset.needsKey ? apiKey : (preset.provider === 'ollama' ? 'ollama' : apiKey),
|
|
243
|
+
model_config: {
|
|
244
|
+
provider: preset.provider,
|
|
245
|
+
model_id: modelName,
|
|
246
|
+
custom_model_name: modelName,
|
|
247
|
+
base_url: endpoint,
|
|
248
|
+
api_key: preset.provider === 'ollama' ? 'ollama' : apiKey,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await persist(cfg)
|
|
253
|
+
return cfg
|
|
254
|
+
}
|
package/lib/modelConfig.mjs
CHANGED
|
@@ -38,12 +38,14 @@ const PROVIDER_CAPABILITIES = {
|
|
|
38
38
|
openai_compatible: {
|
|
39
39
|
supportsCustomModels: true,
|
|
40
40
|
requiresHardcodedMap: false,
|
|
41
|
-
supportsDiscovery:
|
|
41
|
+
supportsDiscovery: true,
|
|
42
|
+
discoveryEndpoint: '/models',
|
|
42
43
|
},
|
|
43
44
|
zhipu: {
|
|
44
|
-
supportsCustomModels:
|
|
45
|
-
requiresHardcodedMap:
|
|
46
|
-
supportsDiscovery:
|
|
45
|
+
supportsCustomModels: true,
|
|
46
|
+
requiresHardcodedMap: false,
|
|
47
|
+
supportsDiscovery: true,
|
|
48
|
+
discoveryEndpoint: '/models',
|
|
47
49
|
},
|
|
48
50
|
ollama: {
|
|
49
51
|
supportsCustomModels: true,
|
|
@@ -54,12 +56,14 @@ const PROVIDER_CAPABILITIES = {
|
|
|
54
56
|
lm_studio_local: {
|
|
55
57
|
supportsCustomModels: true,
|
|
56
58
|
requiresHardcodedMap: false,
|
|
57
|
-
supportsDiscovery:
|
|
59
|
+
supportsDiscovery: true,
|
|
60
|
+
discoveryEndpoint: '/models',
|
|
58
61
|
},
|
|
59
62
|
nehanda: {
|
|
60
|
-
supportsCustomModels:
|
|
61
|
-
requiresHardcodedMap:
|
|
62
|
-
supportsDiscovery:
|
|
63
|
+
supportsCustomModels: true,
|
|
64
|
+
requiresHardcodedMap: false,
|
|
65
|
+
supportsDiscovery: true,
|
|
66
|
+
discoveryEndpoint: '/models',
|
|
63
67
|
omitToolChoice: true,
|
|
64
68
|
},
|
|
65
69
|
}
|
|
@@ -118,16 +122,22 @@ export function modelConfigFromAgent(agent, { apiKey = '', defaultEndpoint = DEF
|
|
|
118
122
|
const isNehanda = model === DEFAULT_NEHANDA_MODEL || /nehanda-ml\.asoba\.co/.test(endpoint)
|
|
119
123
|
|
|
120
124
|
if (isNehanda) {
|
|
125
|
+
const isDefault = model === DEFAULT_NEHANDA_MODEL
|
|
121
126
|
return {
|
|
122
127
|
provider: 'nehanda',
|
|
123
128
|
model_id: 'nehanda_rag_synthesis_27b',
|
|
124
|
-
custom_model_name
|
|
129
|
+
// Discovered / non-default names ride as custom_model_name (#8)
|
|
130
|
+
custom_model_name: isDefault ? null : model,
|
|
125
131
|
base_url: endpoint,
|
|
126
132
|
api_key: apiKey,
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
let provider = 'openai_compatible'
|
|
137
|
+
if (/:11434\b/.test(endpoint)) provider = 'ollama'
|
|
138
|
+
else if (/bigmodel\.cn|\.z\.ai\b/.test(endpoint)) provider = 'zhipu'
|
|
139
|
+
else if (/:1234\b|:8000\b/.test(endpoint)) provider = 'lm_studio_local'
|
|
140
|
+
|
|
131
141
|
return {
|
|
132
142
|
provider,
|
|
133
143
|
model_id: model,
|
package/lib/modelDiscovery.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model Discovery Service
|
|
3
|
-
* Queries Ollama
|
|
3
|
+
* Queries Ollama (/api/tags) and OpenAI-compatible (/v1/models) endpoints.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import http from 'node:http'
|
|
@@ -232,6 +232,130 @@ export async function discoverOllamaModels(baseUrl, options = {}) {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Normalize an OpenAI-compatible base URL so `{base}/models` resolves correctly.
|
|
237
|
+
* Accepts either `https://host/v1` or `https://host`.
|
|
238
|
+
*/
|
|
239
|
+
export function normalizeOpenAiCompatBase(baseUrl) {
|
|
240
|
+
if (!baseUrl || typeof baseUrl !== 'string') return ''
|
|
241
|
+
// Do not force /v1 — Zhipu and similar hosts use paths like /api/paas/v4.
|
|
242
|
+
return baseUrl.trim().replace(/\/+$/, '')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Strip trailing /v1 so Ollama `/api/tags` is hit on the root host.
|
|
247
|
+
*/
|
|
248
|
+
export function normalizeOllamaBase(baseUrl) {
|
|
249
|
+
if (!baseUrl || typeof baseUrl !== 'string') return ''
|
|
250
|
+
return baseUrl.trim().replace(/\/+$/, '').replace(/\/v1$/i, '')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isEmbeddingModelId(id) {
|
|
254
|
+
return /embedding|whisper|tts|dall-e|moderation/i.test(id)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Discover models from an OpenAI-compatible `/v1/models` endpoint (keyed).
|
|
259
|
+
* @param {string} baseUrl - e.g. https://openrouter.ai/api/v1
|
|
260
|
+
* @param {Object} options
|
|
261
|
+
* @param {string} [options.apiKey] - Bearer token (optional for local servers)
|
|
262
|
+
* @param {number} [options.timeout=5000]
|
|
263
|
+
* @returns {Promise<{success:boolean, models?:Array<{name:string}>, error?:string}>}
|
|
264
|
+
*/
|
|
265
|
+
export async function discoverOpenAiCompatModels(baseUrl, options = {}) {
|
|
266
|
+
const timeout = options.timeout || 5000
|
|
267
|
+
const apiKey = typeof options.apiKey === 'string' ? options.apiKey.trim() : ''
|
|
268
|
+
const cleanBase = normalizeOpenAiCompatBase(baseUrl)
|
|
269
|
+
if (!cleanBase) {
|
|
270
|
+
return { success: false, error: 'Base URL is required for model discovery.' }
|
|
271
|
+
}
|
|
272
|
+
const discoveryUrl = `${cleanBase}/models`
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const controller = new AbortController()
|
|
276
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
277
|
+
let response
|
|
278
|
+
try {
|
|
279
|
+
const headers = { Accept: 'application/json' }
|
|
280
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
|
281
|
+
response = await fetch(discoveryUrl, { method: 'GET', signal: controller.signal, headers })
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timeoutId)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!response.ok) {
|
|
287
|
+
const statusText = response.statusText || 'Unknown error'
|
|
288
|
+
if (response.status === 401 || response.status === 403) {
|
|
289
|
+
return {
|
|
290
|
+
success: false,
|
|
291
|
+
error: `Authentication failed (${response.status}): ${statusText}. Check the API key.`,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
error: `HTTP error (${response.status}): ${statusText}`,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let data
|
|
301
|
+
try {
|
|
302
|
+
data = await response.json()
|
|
303
|
+
} catch (parseError) {
|
|
304
|
+
return { success: false, error: `Invalid JSON response from server: ${parseError.message}` }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const rows = Array.isArray(data?.data) ? data.data
|
|
308
|
+
: Array.isArray(data?.models) ? data.models
|
|
309
|
+
: null
|
|
310
|
+
if (!rows) {
|
|
311
|
+
return { success: false, error: 'Invalid response format: expected OpenAI-style { data: [...] }' }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const models = []
|
|
315
|
+
const seen = new Set()
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const id = typeof row?.id === 'string' ? row.id.trim()
|
|
318
|
+
: typeof row?.name === 'string' ? row.name.trim()
|
|
319
|
+
: ''
|
|
320
|
+
if (!id || isEmbeddingModelId(id) || seen.has(id)) continue
|
|
321
|
+
seen.add(id)
|
|
322
|
+
models.push({ name: id })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { success: true, models }
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error.name === 'AbortError') {
|
|
328
|
+
return {
|
|
329
|
+
success: false,
|
|
330
|
+
error: `Request timed out after ${timeout}ms. The server may be slow or unreachable.`,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (error.code === 'ECONNREFUSED' || (error.message && error.message.includes('fetch failed'))) {
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
error: `Cannot connect to ${cleanBase}. Check the host and network.`,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return { success: false, error: `Discovery failed: ${error.message}` }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Route discovery by provider id (used by bin/agent.mjs `/models`).
|
|
345
|
+
* @param {string} provider
|
|
346
|
+
* @param {{ baseUrl: string, apiKey?: string, timeout?: number }} opts
|
|
347
|
+
*/
|
|
348
|
+
export async function discoverModelsForProvider(provider, opts = {}) {
|
|
349
|
+
const timeout = opts.timeout || 5000
|
|
350
|
+
if (provider === 'ollama') {
|
|
351
|
+
return discoverOllamaModels(normalizeOllamaBase(opts.baseUrl), { timeout })
|
|
352
|
+
}
|
|
353
|
+
return discoverOpenAiCompatModels(opts.baseUrl, {
|
|
354
|
+
apiKey: opts.apiKey || '',
|
|
355
|
+
timeout,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
235
359
|
/**
|
|
236
360
|
* Format a list of models for display
|
|
237
361
|
* @param {Array} models - Array of model objects from discovery
|
package/lib/openaiCompat.mjs
CHANGED
|
@@ -28,7 +28,16 @@ function stripThinkingTraces(text) {
|
|
|
28
28
|
return text.trim()
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** True for DOM AbortError and Anthropic SDK APIUserAbortError. */
|
|
32
|
+
export function isAbortError(e) {
|
|
33
|
+
if (!e) return false
|
|
34
|
+
if (e.name === 'AbortError' || e.name === 'APIUserAbortError') return true
|
|
35
|
+
if (e.constructor?.name === 'APIUserAbortError') return true
|
|
36
|
+
const msg = String(e.message || '')
|
|
37
|
+
return msg === 'Request was aborted.' || /operation was aborted/i.test(msg)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true, signal } = {}) {
|
|
32
41
|
const url = `${String(baseUrl).replace(/\/$/, '')}/chat/completions`
|
|
33
42
|
const body = { model, messages, stream: useStream, max_tokens: maxTokens }
|
|
34
43
|
// Suppress tool_choice:"auto" for providers that do not support it (e.g. Nehanda
|
|
@@ -41,6 +50,7 @@ export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messa
|
|
|
41
50
|
method: 'POST',
|
|
42
51
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
43
52
|
body: JSON.stringify(body),
|
|
53
|
+
signal,
|
|
44
54
|
})
|
|
45
55
|
|
|
46
56
|
if (!res.ok) {
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { runHooks } from './hookplane.mjs'
|
|
|
8
8
|
import { appendEntry, transcriptToAnthropicMessages, transcriptToOpenAIMessages, makeUserPayload, makeAssistantPayload, makeToolResultPayload, applyEpistemicFilter } from './transcript.mjs'
|
|
9
9
|
import { evaluatePermission } from './permissions.mjs'
|
|
10
10
|
import { executeBuiltinTool, anthropicToolDefinitions, openAICompatToolDefinitions, LOCAL_CORE_TOOLS, EXPLORE_ONLY_TOOLS, dynamicTools, getMcpToolDefinitions } from './tools.mjs'
|
|
11
|
-
import { streamOpenAIChatCompletion } from './openaiCompat.mjs'
|
|
11
|
+
import { streamOpenAIChatCompletion, isAbortError } from './openaiCompat.mjs'
|
|
12
12
|
import { withTransaction } from './store.mjs'
|
|
13
13
|
import { getPhase, setPhase, canTransition } from './workflow.mjs'
|
|
14
14
|
import { buildJevSystem2Messages } from './compact.mjs'
|
|
@@ -425,6 +425,16 @@ function classifyError(e) {
|
|
|
425
425
|
return 'unknown'
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/** Re-throw user aborts so Esc is not swallowed as a soft model failure (issue #7). */
|
|
429
|
+
function rethrowIfAborted(e) {
|
|
430
|
+
if (!isAbortError(e)) return
|
|
431
|
+
if (e.name === 'AbortError') throw e
|
|
432
|
+
const err = new Error('abort')
|
|
433
|
+
err.name = 'AbortError'
|
|
434
|
+
err.cause = e
|
|
435
|
+
throw err
|
|
436
|
+
}
|
|
437
|
+
|
|
428
438
|
async function askHuman(io, q) {
|
|
429
439
|
if (!process.stdin.isTTY) return true
|
|
430
440
|
if (io?.ask) return (await io.ask(q)).trim().toLowerCase().startsWith('y')
|
|
@@ -637,13 +647,14 @@ If this file is not relevant to the task, say "Not relevant" and nothing else.`
|
|
|
637
647
|
model: rt._model,
|
|
638
648
|
max_tokens: 512,
|
|
639
649
|
messages: [{ role: 'user', content: prompt }],
|
|
640
|
-
})
|
|
650
|
+
}, { signal: rt.signal })
|
|
641
651
|
const summary = sanitizeModelText(response.content?.[0]?.text || '')
|
|
642
652
|
trackTokens(rt.sessionId, response.usage?.input_tokens, response.usage?.output_tokens)
|
|
643
653
|
if (summary && !/^not relevant/i.test(summary)) {
|
|
644
654
|
summaries.push({ path, header, summary })
|
|
645
655
|
}
|
|
646
|
-
} catch {
|
|
656
|
+
} catch (e) {
|
|
657
|
+
rethrowIfAborted(e)
|
|
647
658
|
summaries.push({ path, header, summary: '(summarization failed — see header)' })
|
|
648
659
|
}
|
|
649
660
|
if (io.spinner) io.spinner.stop()
|
|
@@ -681,13 +692,15 @@ If this file is not relevant to the task, say "Not relevant" and nothing else.`
|
|
|
681
692
|
bufferOutput: true,
|
|
682
693
|
maxTokens: 512,
|
|
683
694
|
numCtx: rt.settings?.model_config?.num_ctx,
|
|
695
|
+
signal: rt.signal,
|
|
684
696
|
})
|
|
685
697
|
const summary = sanitizeModelText(out.bufferedText || '')
|
|
686
698
|
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
687
699
|
if (summary && !/^not relevant/i.test(summary)) {
|
|
688
700
|
summaries.push({ path, header, summary })
|
|
689
701
|
}
|
|
690
|
-
} catch {
|
|
702
|
+
} catch (e) {
|
|
703
|
+
rethrowIfAborted(e)
|
|
691
704
|
summaries.push({ path, header, summary: '(summarization failed — see header)' })
|
|
692
705
|
}
|
|
693
706
|
if (io.spinner) io.spinner.stop()
|
|
@@ -829,7 +842,10 @@ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt
|
|
|
829
842
|
|
|
830
843
|
let bufferedText = ''
|
|
831
844
|
try {
|
|
832
|
-
const stream = await client.messages.stream(
|
|
845
|
+
const stream = await client.messages.stream(
|
|
846
|
+
{ model: rt._model, max_tokens: 1024, system, messages, tools: [] },
|
|
847
|
+
{ signal: rt.signal },
|
|
848
|
+
)
|
|
833
849
|
for await (const ev of stream) {
|
|
834
850
|
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
|
|
835
851
|
bufferedText += ev.delta.text
|
|
@@ -839,6 +855,7 @@ async function runAnthropicSummaryTurn(db, rt, io, hookRt, client, summaryPrompt
|
|
|
839
855
|
trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
|
|
840
856
|
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(mapAnthropicContent(final.content || [])))
|
|
841
857
|
} catch (e) {
|
|
858
|
+
rethrowIfAborted(e)
|
|
842
859
|
if (io.spinner) io.spinner.stop()
|
|
843
860
|
return null
|
|
844
861
|
}
|
|
@@ -857,11 +874,12 @@ async function runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl,
|
|
|
857
874
|
|
|
858
875
|
let result = null
|
|
859
876
|
try {
|
|
860
|
-
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx })
|
|
877
|
+
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: [], io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, signal: rt.signal })
|
|
861
878
|
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
862
879
|
appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(out.assistantBlocks))
|
|
863
880
|
result = sanitizeModelText(out.bufferedText)
|
|
864
|
-
} catch {
|
|
881
|
+
} catch (e) {
|
|
882
|
+
rethrowIfAborted(e)
|
|
865
883
|
}
|
|
866
884
|
if (io.spinner) io.spinner.stop()
|
|
867
885
|
return result
|
|
@@ -878,7 +896,10 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
|
|
|
878
896
|
|
|
879
897
|
let assistantBlocks = [], stopReason = null, bufferedText = ''
|
|
880
898
|
try {
|
|
881
|
-
const stream = await client.messages.stream(
|
|
899
|
+
const stream = await client.messages.stream(
|
|
900
|
+
{ model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools },
|
|
901
|
+
{ signal: rt.signal },
|
|
902
|
+
)
|
|
882
903
|
for await (const ev of stream) {
|
|
883
904
|
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) {
|
|
884
905
|
bufferedText += ev.delta.text
|
|
@@ -893,6 +914,7 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
|
|
|
893
914
|
assistantBlocks = mapAnthropicContent(final.content || [])
|
|
894
915
|
trackTokens(rt.sessionId, final.usage?.input_tokens, final.usage?.output_tokens)
|
|
895
916
|
} catch (e) {
|
|
917
|
+
rethrowIfAborted(e)
|
|
896
918
|
if (io.spinner) io.spinner.stop()
|
|
897
919
|
await runHooks(db, hookRt, 'StopFailure', { error: classifyError(e), error_details: e.message })
|
|
898
920
|
io.println(`\n[model error] ${e.message}`)
|
|
@@ -942,7 +964,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
|
|
|
942
964
|
// on top of the prompt-injected instructions, producing malformed hybrid output
|
|
943
965
|
// like {"Glob", "arguments": {...}} instead of {"name": "Glob", "arguments": {...}}.
|
|
944
966
|
const toolsForRequest = xmlInstructions ? [] : filteredTools
|
|
945
|
-
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider) })
|
|
967
|
+
const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider), signal: rt.signal })
|
|
946
968
|
assistantBlocks = out.assistantBlocks
|
|
947
969
|
bufferedText = out.bufferedText
|
|
948
970
|
finishReason = out.finishReason
|
|
@@ -962,6 +984,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
|
|
|
962
984
|
|
|
963
985
|
if (out.usage) trackTokens(rt.sessionId, out.usage.prompt_tokens, out.usage.completion_tokens)
|
|
964
986
|
} catch (e) {
|
|
987
|
+
rethrowIfAborted(e)
|
|
965
988
|
if (e.message?.includes('does not support tools') && activeTools.length > 0) {
|
|
966
989
|
if (io.spinner) io.spinner.stop()
|
|
967
990
|
io.println('\n[info] Model switching to manual tool injection...')
|