@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.
@@ -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
+ }
@@ -38,12 +38,14 @@ const PROVIDER_CAPABILITIES = {
38
38
  openai_compatible: {
39
39
  supportsCustomModels: true,
40
40
  requiresHardcodedMap: false,
41
- supportsDiscovery: false,
41
+ supportsDiscovery: true,
42
+ discoveryEndpoint: '/models',
42
43
  },
43
44
  zhipu: {
44
- supportsCustomModels: false,
45
- requiresHardcodedMap: true,
46
- supportsDiscovery: false,
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: false,
59
+ supportsDiscovery: true,
60
+ discoveryEndpoint: '/models',
58
61
  },
59
62
  nehanda: {
60
- supportsCustomModels: false,
61
- requiresHardcodedMap: true,
62
- supportsDiscovery: false,
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: null,
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
- const provider = /:11434\b/.test(endpoint) ? 'ollama' : 'openai_compatible'
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,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Model Discovery Service
3
- * Queries Ollama API to discover available models
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
@@ -28,7 +28,16 @@ function stripThinkingTraces(text) {
28
28
  return text.trim()
29
29
  }
30
30
 
31
- export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true }) {
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) {
@@ -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({ model: rt._model, max_tokens: 1024, system, messages, tools: [] })
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({ model: rt._model, max_tokens: 8192, system, messages, tools: filteredTools })
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...')