@asobacloud/nehanda 0.6.1 → 0.7.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 +27 -22
- package/bin/agent.mjs +23 -23
- package/bin/nehanda-ui.mjs +88 -95
- 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/ui.mjs +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,31 +51,32 @@ ona-code
|
|
|
51
51
|
|
|
52
52
|
### 3. Provider Setup
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
The CLI defaults to the primary Nehanda endpoint (`https://nehanda-ml.asoba.co/v1`). If an API key is required:
|
|
57
|
-
|
|
58
|
-
```
|
|
59
|
-
❯ /key
|
|
60
|
-
New Nehanda API key: <your-key>
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
#### Option B: Local LM Studio
|
|
64
|
-
|
|
65
|
-
Start LM Studio locally on port `1234` or `8000`, then start the CLI. Select or switch models via:
|
|
54
|
+
Use a single Goose-style wizard — provider, API key, host, and model in one flow. The CLI discovers available models from the provider (`GET /v1/models` with your key, or Ollama `/api/tags`).
|
|
66
55
|
|
|
67
56
|
```
|
|
68
57
|
❯ /model
|
|
69
|
-
```
|
|
70
58
|
|
|
71
|
-
|
|
59
|
+
◆ Which provider?
|
|
60
|
+
│ ● OpenRouter
|
|
61
|
+
└
|
|
62
|
+
◆ API key
|
|
63
|
+
│ sk-or-v1-…
|
|
64
|
+
└
|
|
65
|
+
◆ API host
|
|
66
|
+
│ https://openrouter.ai/api/v1
|
|
67
|
+
└
|
|
68
|
+
Fetching models from provider…
|
|
69
|
+
◆ Model
|
|
70
|
+
│ ● openai/gpt-4o
|
|
71
|
+
└
|
|
72
|
+
✓ Provider / host / model saved — session active
|
|
73
|
+
```
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
Shortcuts:
|
|
76
|
+
- `/key openrouter sk-…` — saves the key, then continues into the same host → discover → model steps
|
|
77
|
+
- `/config` — read-only status (provider, model, endpoint, which keys are set)
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
❯ /config base_url http://AsobaCorp-1.local:11434/v1
|
|
77
|
-
❯ /model ollama/deepseek-coder-v2:latest
|
|
78
|
-
```
|
|
79
|
+
Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the host, then choose from the discovered catalog (or enter a custom name if discovery fails).
|
|
79
80
|
|
|
80
81
|
---
|
|
81
82
|
|
|
@@ -326,9 +327,9 @@ Place the script at `lib/scripts/my-new-tool.py`. It will receive arguments inte
|
|
|
326
327
|
| Command | Description |
|
|
327
328
|
|---|---|
|
|
328
329
|
| `/help` | Display available commands |
|
|
329
|
-
| `/model
|
|
330
|
-
| `/key` | Save
|
|
331
|
-
| `/config` |
|
|
330
|
+
| `/model` | Configure provider → API key → host → discover models → select (single wizard) |
|
|
331
|
+
| `/key [provider] [value]` | Save API key then continue the configure wizard (`openrouter`, `zhipu`, `openai`, or Nehanda default) |
|
|
332
|
+
| `/config` | Show provider, model, endpoint, and key status (read-only) |
|
|
332
333
|
| `/mcp` | Manage MCP server connections (see below) |
|
|
333
334
|
| `/clear` | Clear conversation history and reset transcript state |
|
|
334
335
|
| `/retry` | Resend the last failed request |
|
|
@@ -497,6 +498,10 @@ node tests/unit/ui_legibility_behavioral.mjs
|
|
|
497
498
|
|
|
498
499
|
## Changelog
|
|
499
500
|
|
|
501
|
+
### 0.7.0
|
|
502
|
+
|
|
503
|
+
- **Unified `/model` configure wizard with keyed discovery (`lib/configureProvider.mjs`, `lib/modelDiscovery.mjs`, `bin/nehanda-ui.mjs`):** Provider setup is a single Goose-style flow — provider → API key → host → live model catalog → select → persist. OpenRouter / OpenAI-compat / Zhipu / Nehanda / LM Studio discover via authenticated `GET {base}/models`; Ollama keeps `/api/tags`. Discovery failure falls back to custom model name. `/key <provider>` chains into the same wizard tail. `/config` is read-only status. Behavioral coverage in `tests/unit/configure_provider_behavioral.mjs`. Closes #8.
|
|
504
|
+
|
|
500
505
|
### 0.6.1
|
|
501
506
|
|
|
502
507
|
- **Fix: Laya provisioning smoke test (`lib/scripts/ensure-laya-env.sh`):** The `Router()` constructibility check was missing `import sys`, causing `npm install` to fail with `NameError: name 'sys' is not defined` even when all packages installed successfully. The marker file was never written, leaving the CLI in a permanently broken state despite a complete venv. Fixed.
|
package/bin/agent.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { getPhase, canTransition, setPhase } from '../lib/workflow.mjs'
|
|
|
20
20
|
import { executeBuiltinTool, invalidateMcpToolCache } from '../lib/tools.mjs'
|
|
21
21
|
import { appendEntry, makeUserPayload, makeToolResultPayload } from '../lib/transcript.mjs'
|
|
22
22
|
import { loadInstructions } from '../lib/instructions.mjs'
|
|
23
|
-
import {
|
|
23
|
+
import { discoverModelsForProvider, formatModelList } from '../lib/modelDiscovery.mjs'
|
|
24
24
|
import { compactConversation } from '../lib/compact.mjs'
|
|
25
25
|
import { createTeam, getTeam, listTeams, deleteTeam } from '../lib/team.mjs'
|
|
26
26
|
import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
|
|
@@ -259,25 +259,26 @@ Session:
|
|
|
259
259
|
app.addSystemMessage('No provider configured. Use /model to set a provider first.')
|
|
260
260
|
return
|
|
261
261
|
}
|
|
262
|
-
|
|
262
|
+
|
|
263
263
|
if (!supportsDiscovery(provider)) {
|
|
264
|
-
app.addSystemMessage(`Model discovery is not supported for provider: ${provider}\n\
|
|
264
|
+
app.addSystemMessage(`Model discovery is not supported for provider: ${provider}\n\nUse /model to configure a discoverable provider.`)
|
|
265
265
|
return
|
|
266
266
|
}
|
|
267
|
-
|
|
268
|
-
// Get base URL for the provider
|
|
267
|
+
|
|
269
268
|
const baseUrl = getBaseUrlForProvider(provider, settings)
|
|
270
|
-
|
|
269
|
+
const apiKey = settings.model_config?.api_key
|
|
270
|
+
|| (provider === 'zhipu' ? resolveZhipuApiKey() : resolveOpenAiCompatKey())
|
|
271
|
+
|
|
271
272
|
app.addSystemMessage(`Discovering models from ${baseUrl}...`)
|
|
272
|
-
|
|
273
|
+
|
|
273
274
|
try {
|
|
274
|
-
const result = await
|
|
275
|
-
|
|
275
|
+
const result = await discoverModelsForProvider(provider, { baseUrl, apiKey, timeout: 5000 })
|
|
276
|
+
|
|
276
277
|
if (!result.success) {
|
|
277
|
-
app.addSystemMessage(`\nDiscovery failed: ${result.error}\n\nTroubleshooting:\n •
|
|
278
|
+
app.addSystemMessage(`\nDiscovery failed: ${result.error}\n\nTroubleshooting:\n • Check the API key (/key)\n • Check the base URL in settings: /config\n • Verify network connectivity to ${baseUrl}`)
|
|
278
279
|
return
|
|
279
280
|
}
|
|
280
|
-
|
|
281
|
+
|
|
281
282
|
const formatted = formatModelList(result.models)
|
|
282
283
|
app.addSystemMessage(`\n${formatted}`)
|
|
283
284
|
} catch (error) {
|
|
@@ -842,32 +843,31 @@ async function mainPipe(opts) {
|
|
|
842
843
|
io.println(ui.colors.error(' No provider configured. Use /model to set a provider first.'))
|
|
843
844
|
continue
|
|
844
845
|
}
|
|
845
|
-
|
|
846
|
+
|
|
846
847
|
if (!supportsDiscovery(provider)) {
|
|
847
848
|
io.println(ui.colors.error(` Model discovery is not supported for provider: ${provider}`))
|
|
848
|
-
io.println(`\n ${ui.colors.
|
|
849
|
-
io.println(' ollama')
|
|
850
|
-
io.println(`\n ${ui.colors.dim('For other providers, use /model <provider>/<model-name> directly.')}`)
|
|
849
|
+
io.println(`\n ${ui.colors.dim('Use /model to configure a discoverable provider.')}`)
|
|
851
850
|
continue
|
|
852
851
|
}
|
|
853
|
-
|
|
854
|
-
// Get base URL for the provider
|
|
852
|
+
|
|
855
853
|
const baseUrl = getBaseUrlForProvider(provider, settings)
|
|
856
|
-
|
|
854
|
+
const apiKey = settings.model_config?.api_key
|
|
855
|
+
|| (provider === 'zhipu' ? resolveZhipuApiKey() : resolveOpenAiCompatKey())
|
|
856
|
+
|
|
857
857
|
io.println(`\n Discovering models from ${baseUrl}...\n`)
|
|
858
|
-
|
|
858
|
+
|
|
859
859
|
try {
|
|
860
|
-
const result = await
|
|
861
|
-
|
|
860
|
+
const result = await discoverModelsForProvider(provider, { baseUrl, apiKey, timeout: 5000 })
|
|
861
|
+
|
|
862
862
|
if (!result.success) {
|
|
863
863
|
io.println(ui.colors.error(` Discovery failed: ${result.error}`))
|
|
864
864
|
io.println(`\n ${ui.colors.header('Troubleshooting:')}`)
|
|
865
|
-
io.println(' •
|
|
865
|
+
io.println(' • Check the API key (/key)')
|
|
866
866
|
io.println(' • Check the base URL in settings: /config')
|
|
867
867
|
io.println(` • Verify network connectivity to ${baseUrl}\n`)
|
|
868
868
|
continue
|
|
869
869
|
}
|
|
870
|
-
|
|
870
|
+
|
|
871
871
|
const formatted = formatModelList(result.models)
|
|
872
872
|
io.println(formatted)
|
|
873
873
|
} catch (error) {
|
package/bin/nehanda-ui.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
18
18
|
import { runUserTurn } from '../lib/orchestrate.mjs'
|
|
19
19
|
import { loadInstructions } from '../lib/instructions.mjs'
|
|
20
20
|
import { modelConfigFromAgent } from '../lib/modelConfig.mjs'
|
|
21
|
+
import { runConfigureWizard } from '../lib/configureProvider.mjs'
|
|
21
22
|
import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfig, globalMcpConfigPath, updateMcpServerEnv } from '../lib/mcp-config.mjs'
|
|
22
23
|
import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
|
|
23
24
|
import { invalidateMcpToolCache } from '../lib/tools.mjs'
|
|
@@ -137,8 +138,8 @@ function renderMd(text) {
|
|
|
137
138
|
// ── Constants ────────────────────────────────────────────────
|
|
138
139
|
const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']
|
|
139
140
|
const SLASH_COMMANDS = [
|
|
140
|
-
{ name: '/model', desc: '
|
|
141
|
-
{ name: '/key', desc: 'Set API key
|
|
141
|
+
{ name: '/model', desc: 'Configure provider, key, host, and model (single wizard)' },
|
|
142
|
+
{ name: '/key', desc: 'Set API key then continue configure (/key openrouter|zhipu|openai)' },
|
|
142
143
|
{ name: '/config', desc: 'Show current config' },
|
|
143
144
|
{ name: '/mcp', desc: 'MCP server & tool management (/mcp list)' },
|
|
144
145
|
{ name: '/clear', desc: 'New conversation' },
|
|
@@ -147,41 +148,6 @@ const SLASH_COMMANDS = [
|
|
|
147
148
|
{ name: '/exit', desc: 'Quit' },
|
|
148
149
|
]
|
|
149
150
|
|
|
150
|
-
// ── Model registry ────────────────────────────────────────────
|
|
151
|
-
const DEFAULT_REGISTRY = {
|
|
152
|
-
ollama_hosts: ['http://AsobaCorp-1.local:11434'],
|
|
153
|
-
models: [
|
|
154
|
-
{ name: 'nehanda-rag-synthesis-27b', endpoint: 'https://nehanda-ml.asoba.co/v1', label: 'Nehanda 27B (EC2)', desc: 'Primary — fine-tuned Qwen3.6 27B, vLLM, af-south-1' },
|
|
155
|
-
{ name: 'deepseek-coder-v2:latest', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'DeepSeek Coder V2 (LAN)', desc: 'Windows LAN delegate — coding specialist' },
|
|
156
|
-
{ name: 'qwen2.5:14b', endpoint: 'http://AsobaCorp-1.local:11434/v1', label: 'Qwen 2.5 14B (LAN)', desc: 'Windows LAN delegate — general reasoning' },
|
|
157
|
-
],
|
|
158
|
-
}
|
|
159
|
-
function readRegistry() {
|
|
160
|
-
try { return JSON.parse(fs.readFileSync(path.join(AIMEE_DIR, 'model-registry.json'), 'utf8')) }
|
|
161
|
-
catch { return DEFAULT_REGISTRY }
|
|
162
|
-
}
|
|
163
|
-
async function discoverOllamaModels(hosts) {
|
|
164
|
-
const out = []
|
|
165
|
-
await Promise.all(hosts.map(async host => {
|
|
166
|
-
const base = host.replace(/\/$/, '')
|
|
167
|
-
try {
|
|
168
|
-
const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(2000) })
|
|
169
|
-
if (!res.ok) return
|
|
170
|
-
const data = await res.json()
|
|
171
|
-
for (const m of (data.models || []))
|
|
172
|
-
out.push({ name: m.name, endpoint: `${base}/v1`, label: `${m.name} (${new URL(base).hostname})`, desc: `Discovered — ${m.name}`, discovered: true })
|
|
173
|
-
} catch {}
|
|
174
|
-
}))
|
|
175
|
-
return out
|
|
176
|
-
}
|
|
177
|
-
async function buildModelList() {
|
|
178
|
-
const reg = readRegistry()
|
|
179
|
-
const base = reg.models || []
|
|
180
|
-
const disc = await discoverOllamaModels(reg.ollama_hosts || [])
|
|
181
|
-
const keys = new Set(base.map(m => `${m.name}|${m.endpoint}`))
|
|
182
|
-
return [...base, ...disc.filter(m => !keys.has(`${m.name}|${m.endpoint}`))]
|
|
183
|
-
}
|
|
184
|
-
|
|
185
151
|
// ── Asoba lock-up (compact) ───────────────────────────────────
|
|
186
152
|
function AsobaMark() {
|
|
187
153
|
return e(Box, { flexDirection: 'column', alignItems: 'center' },
|
|
@@ -390,6 +356,7 @@ function BlockConfirmMenu({ payload, onDecide }) {
|
|
|
390
356
|
}
|
|
391
357
|
|
|
392
358
|
function TokenFooter({ calls, model }) {
|
|
359
|
+
const cols = process.stdout.columns || 80
|
|
393
360
|
return e(Box, { flexDirection: 'column' },
|
|
394
361
|
e(Text, { dimColor: true }, '─'.repeat(cols)),
|
|
395
362
|
e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
|
|
@@ -517,6 +484,58 @@ function stripThink(text) {
|
|
|
517
484
|
.trim()
|
|
518
485
|
}
|
|
519
486
|
|
|
487
|
+
/**
|
|
488
|
+
* UI adapter for Goose-style configure wizard (#8).
|
|
489
|
+
* Persists agents.json + runtimeSettings.model_config atomically at the end.
|
|
490
|
+
*/
|
|
491
|
+
async function runUiConfigureWizard(bridge, opts = {}) {
|
|
492
|
+
try {
|
|
493
|
+
const result = await runConfigureWizard({
|
|
494
|
+
ask: q => bridge.ask(q),
|
|
495
|
+
select: (title, items) => bridge.select(title, items),
|
|
496
|
+
notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
|
|
497
|
+
resolveExistingKey: async (preset) => {
|
|
498
|
+
if (preset.keySlot === 'nehanda') return readApiKey()
|
|
499
|
+
if (preset.keySlot === 'zhipu') return resolveZhipuApiKey()
|
|
500
|
+
if (preset.keySlot === 'openai_compat') return resolveOpenAiCompatKey()
|
|
501
|
+
return ''
|
|
502
|
+
},
|
|
503
|
+
saveKey: async ({ slot, key }) => {
|
|
504
|
+
if (slot === 'nehanda') writeApiKey(key)
|
|
505
|
+
else if (slot === 'zhipu') saveSecureCredentials({ zhipuApiKey: key })
|
|
506
|
+
else if (slot === 'openai_compat') saveOpenAiCompatKey(key)
|
|
507
|
+
},
|
|
508
|
+
persist: async (cfg) => {
|
|
509
|
+
const data = readAgents()
|
|
510
|
+
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
511
|
+
if (ag) {
|
|
512
|
+
ag.model = cfg.model
|
|
513
|
+
ag.endpoint = cfg.endpoint
|
|
514
|
+
if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
|
|
515
|
+
}
|
|
516
|
+
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
517
|
+
runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
|
|
518
|
+
{ model: cfg.model, endpoint: cfg.endpoint },
|
|
519
|
+
{ apiKey: cfg.apiKey || readApiKey() },
|
|
520
|
+
)
|
|
521
|
+
},
|
|
522
|
+
}, opts)
|
|
523
|
+
|
|
524
|
+
if (!result) {
|
|
525
|
+
bridge.addMessage({ role: 'system', text: ' Configure cancelled.' })
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
bridge.refreshModel()
|
|
529
|
+
bridge.addMessage({ role: 'system', text: [
|
|
530
|
+
` ${chalk.green('✓')} Provider → ${chalk.bold(result.provider)}`,
|
|
531
|
+
` ${chalk.green('✓')} Host → ${chalk.dim(result.endpoint)}`,
|
|
532
|
+
` ${chalk.green('✓')} Model → ${chalk.bold(result.model)}`,
|
|
533
|
+
].join('\n') })
|
|
534
|
+
} catch (err) {
|
|
535
|
+
bridge.addMessage({ role: 'error', text: 'Configure failed: ' + err.message })
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
520
539
|
// ── Command handler ───────────────────────────────────────────
|
|
521
540
|
async function handleCommand(cmd, bridge) {
|
|
522
541
|
const [name] = cmd.split(/\s+/)
|
|
@@ -529,14 +548,17 @@ async function handleCommand(cmd, bridge) {
|
|
|
529
548
|
if (name === '/config') {
|
|
530
549
|
const data = readAgents()
|
|
531
550
|
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
551
|
+
const mc = runtimeSettings?.model_config
|
|
532
552
|
const compatKeySet = !!resolveOpenAiCompatKey()
|
|
533
553
|
const zhipuKeySet = !!resolveZhipuApiKey()
|
|
534
554
|
bridge.addMessage({ role: 'system', text: [
|
|
535
|
-
'', `
|
|
536
|
-
`
|
|
555
|
+
'', ` Provider: ${chalk.hex(BRAND.accent)(mc?.provider || '—')}`,
|
|
556
|
+
` Model: ${chalk.hex(BRAND.accent)(ag?.model || mc?.custom_model_name || '—')}`,
|
|
557
|
+
` Endpoint: ${chalk.dim(ag?.endpoint || mc?.base_url || '—')}`,
|
|
537
558
|
` Nehanda key: ${readApiKey() ? chalk.green('set') : chalk.hex(BRAND.red)('not set')}`,
|
|
538
559
|
` OpenAI-compat key: ${compatKeySet ? chalk.green('set') : chalk.dim('not set')}`,
|
|
539
|
-
` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`,
|
|
560
|
+
` Zhipu key: ${zhipuKeySet ? chalk.green('set') : chalk.dim('not set')}`,
|
|
561
|
+
` ${chalk.dim('Use /model to configure provider, key, host, and model in one flow.')}`, '',
|
|
540
562
|
].join('\n') })
|
|
541
563
|
return
|
|
542
564
|
}
|
|
@@ -552,74 +574,43 @@ async function handleCommand(cmd, bridge) {
|
|
|
552
574
|
}
|
|
553
575
|
if (name === '/exit' || name === '/quit') { bridge.exit(); process.exit(0) }
|
|
554
576
|
if (name === '/key') {
|
|
555
|
-
//
|
|
577
|
+
// /key [openrouter|zhipu|openai|nehanda] [value] — save key then continue configure wizard
|
|
556
578
|
const parts = cmd.trim().split(/\s+/)
|
|
557
|
-
const sub = parts[1] || ''
|
|
558
|
-
const
|
|
579
|
+
const sub = (parts[1] || '').toLowerCase()
|
|
580
|
+
const KEY_TO_PRESET = {
|
|
581
|
+
openrouter: 'openrouter',
|
|
582
|
+
openai: 'openai_compatible',
|
|
583
|
+
zhipu: 'zhipu',
|
|
584
|
+
nehanda: 'nehanda',
|
|
585
|
+
}
|
|
559
586
|
|
|
560
|
-
if (!sub || !
|
|
561
|
-
// Default: Nehanda API key (existing behaviour, unchanged)
|
|
587
|
+
if (!sub || !KEY_TO_PRESET[sub]) {
|
|
562
588
|
const answer = await bridge.ask('New Nehanda API key:')
|
|
563
589
|
if (!answer) return
|
|
564
|
-
try {
|
|
565
|
-
|
|
590
|
+
try {
|
|
591
|
+
writeApiKey(answer)
|
|
592
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Nehanda API key saved — continuing configure…` })
|
|
593
|
+
await runUiConfigureWizard(bridge, { presetId: 'nehanda', prefilledKey: answer })
|
|
594
|
+
} catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
|
|
566
595
|
return
|
|
567
596
|
}
|
|
568
597
|
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
const answer = inlineValue || await bridge.ask(`New ${provider} API key:`)
|
|
598
|
+
const presetId = KEY_TO_PRESET[sub]
|
|
599
|
+
const inlineValue = parts.slice(2).join(' ').trim()
|
|
600
|
+
const answer = inlineValue || await bridge.ask(`New ${sub} API key:`)
|
|
573
601
|
if (!answer) return
|
|
574
602
|
|
|
575
603
|
try {
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
saveOpenAiCompatKey(answer)
|
|
582
|
-
if (provider === 'openrouter') {
|
|
583
|
-
// Auto-configure base_url and provider for OpenRouter
|
|
584
|
-
runtimeSettings.model_config = {
|
|
585
|
-
...runtimeSettings.model_config,
|
|
586
|
-
base_url: 'https://openrouter.ai/api/v1',
|
|
587
|
-
provider: 'openai_compatible',
|
|
588
|
-
}
|
|
589
|
-
bridge.addMessage({ role: 'system', text: [
|
|
590
|
-
` ${chalk.green('✓')} OpenRouter API key saved to secure storage.`,
|
|
591
|
-
` ${chalk.green('✓')} Base URL set to ${chalk.dim('https://openrouter.ai/api/v1')}`,
|
|
592
|
-
` ${chalk.dim('Use /model to select an OpenRouter model (e.g. openai_compatible/anthropic/claude-3.5-sonnet)')}`,
|
|
593
|
-
].join('\n') })
|
|
594
|
-
} else {
|
|
595
|
-
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} OpenAI-compat API key saved to secure storage.` })
|
|
596
|
-
}
|
|
597
|
-
}
|
|
604
|
+
if (sub === 'zhipu') saveSecureCredentials({ zhipuApiKey: answer })
|
|
605
|
+
else if (sub === 'nehanda') writeApiKey(answer)
|
|
606
|
+
else saveOpenAiCompatKey(answer)
|
|
607
|
+
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} ${sub} API key saved — continuing configure…` })
|
|
608
|
+
await runUiConfigureWizard(bridge, { presetId, prefilledKey: answer })
|
|
598
609
|
} catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
|
|
599
610
|
return
|
|
600
611
|
}
|
|
601
612
|
if (name === '/model') {
|
|
602
|
-
bridge
|
|
603
|
-
const all = await buildModelList()
|
|
604
|
-
const data0 = readAgents()
|
|
605
|
-
const curAg = data0.agents?.find(a => a.name === data0.default_agent) || data0.agents?.[0]
|
|
606
|
-
const cur = curAg?.model || ''
|
|
607
|
-
const items = all.map(m => ({ ...m, label: m.name === cur ? `${m.label} ✓` : m.label }))
|
|
608
|
-
const sel = await bridge.select('Select model', items)
|
|
609
|
-
if (!sel) return
|
|
610
|
-
try {
|
|
611
|
-
const data = readAgents()
|
|
612
|
-
const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
|
|
613
|
-
if (ag) { ag.model = sel.name; ag.endpoint = sel.endpoint }
|
|
614
|
-
fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
|
|
615
|
-
// Keep in-session model_config in sync so the next turn uses the selected wire name
|
|
616
|
-
runtimeSettings.model_config = modelConfigFromAgent(
|
|
617
|
-
ag || { model: sel.name, endpoint: sel.endpoint },
|
|
618
|
-
{ apiKey: readApiKey() },
|
|
619
|
-
)
|
|
620
|
-
bridge.refreshModel()
|
|
621
|
-
bridge.addMessage({ role: 'system', text: ` ${chalk.green('✓')} Model → ${chalk.bold(sel.name)}` })
|
|
622
|
-
} catch (err) { bridge.addMessage({ role: 'error', text: 'Failed: ' + err.message }) }
|
|
613
|
+
await runUiConfigureWizard(bridge)
|
|
623
614
|
return
|
|
624
615
|
}
|
|
625
616
|
if (name === '/mcp') {
|
|
@@ -862,6 +853,8 @@ bridge.onSubmit = async (text) => {
|
|
|
862
853
|
// field belongs to aimee and was silently reintroducing conflicting tool-call
|
|
863
854
|
// syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
|
|
864
855
|
onaInstructions: onaInstructionsContent || null,
|
|
856
|
+
// Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
|
|
857
|
+
signal: controller.signal,
|
|
865
858
|
settings: {
|
|
866
859
|
...runtimeSettings,
|
|
867
860
|
model_config: modelConfig,
|
|
@@ -914,7 +907,7 @@ bridge.onSubmit = async (text) => {
|
|
|
914
907
|
bridge.lastFailedText = null
|
|
915
908
|
|
|
916
909
|
} catch (err) {
|
|
917
|
-
if (err.name === 'AbortError') {
|
|
910
|
+
if (err.name === 'AbortError' || err.name === 'APIUserAbortError') {
|
|
918
911
|
bridge.endAssistant({ role: 'assistant', text: lastPartial, cancelled: true })
|
|
919
912
|
} else {
|
|
920
913
|
bridge.discardAssistant()
|
|
@@ -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...')
|
package/lib/ui.mjs
CHANGED
|
@@ -120,12 +120,12 @@ export function formatHelp(provider, dbPath) {
|
|
|
120
120
|
cmd('/compact', 'Compact conversation'),
|
|
121
121
|
cmd('/team', 'Manage teams'),
|
|
122
122
|
'',
|
|
123
|
-
cmd('/model
|
|
123
|
+
cmd('/model', 'Configure provider, key, host, model'),
|
|
124
|
+
cmd('/key [provider] [value]', 'Save API key then continue configure'),
|
|
124
125
|
cmd('/login', 'Store credentials'),
|
|
125
126
|
cmd('/logout', 'Clear credentials'),
|
|
126
127
|
cmd('/status', 'Auth status'),
|
|
127
|
-
cmd('/config', 'Show /
|
|
128
|
-
cmd('/config set <key> <value>', 'Set any config value'),
|
|
128
|
+
cmd('/config', 'Show provider / model / key status'),
|
|
129
129
|
cmd('/mcp', 'MCP server management & guide'),
|
|
130
130
|
cmd('/mcp list', 'List all discovered tools & descriptions'),
|
|
131
131
|
cmd('/mcp status', 'Show configured MCP servers'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asobacloud/nehanda",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agentic terminal REPL and single-process engine for governed AI deep research and software development. Connects to Nehanda v3, OpenRouter, Ollama, LM Studio, ZhipuAI, or any OpenAI-compatible API. Includes Laya System-1 control plane for memory management, pre-execution safety gating, and intent-driven instruction shaping.",
|
|
6
6
|
"keywords": [
|