@asobacloud/nehanda 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +444 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { runHooks } from './hookplane.mjs'
|
|
3
|
+
|
|
4
|
+
const MCP_TIMEOUT = 120_000
|
|
5
|
+
const servers = new Map()
|
|
6
|
+
let _elicitationHandler = null
|
|
7
|
+
|
|
8
|
+
/** Set a handler for MCP elicitation requests. Called with (serverName, params) → result. */
|
|
9
|
+
export function setElicitationHandler(handler) { _elicitationHandler = handler }
|
|
10
|
+
|
|
11
|
+
/** Start an MCP server from config. Returns a handle with request(). */
|
|
12
|
+
export function getMcpServer(name, config) {
|
|
13
|
+
if (servers.has(name)) return servers.get(name)
|
|
14
|
+
const handle = startMcpServer(name, config)
|
|
15
|
+
servers.set(name, handle)
|
|
16
|
+
return handle
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function startMcpServer(name, config) {
|
|
20
|
+
const cmd = config.command
|
|
21
|
+
const args = config.args || []
|
|
22
|
+
const env = { ...process.env, ...(config.env || {}) }
|
|
23
|
+
|
|
24
|
+
let child = null
|
|
25
|
+
let reqId = 1
|
|
26
|
+
let pending = new Map()
|
|
27
|
+
let buffer = ''
|
|
28
|
+
let initialized = false
|
|
29
|
+
let initPromise = null
|
|
30
|
+
|
|
31
|
+
function ensureChild() {
|
|
32
|
+
if (child && !child.killed) return
|
|
33
|
+
child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
34
|
+
child.stdout.on('data', chunk => {
|
|
35
|
+
buffer += chunk.toString('utf8')
|
|
36
|
+
const lines = buffer.split('\n')
|
|
37
|
+
buffer = lines.pop() ?? ''
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.trim()) continue
|
|
40
|
+
try {
|
|
41
|
+
const msg = JSON.parse(line)
|
|
42
|
+
if (msg.id != null && pending.has(msg.id)) {
|
|
43
|
+
const { resolve } = pending.get(msg.id)
|
|
44
|
+
pending.delete(msg.id)
|
|
45
|
+
resolve(msg)
|
|
46
|
+
} else if (msg.method === 'elicitation/create' && msg.id != null) {
|
|
47
|
+
// Server-initiated elicitation request
|
|
48
|
+
handleElicitation(name, msg, child).catch(() => {})
|
|
49
|
+
}
|
|
50
|
+
} catch { /* skip non-JSON */ }
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
child.on('error', () => { child = null })
|
|
54
|
+
child.on('close', () => { child = null; for (const [, p] of pending) p.resolve({ error: { code: -1, message: 'MCP server closed' } }); pending.clear() })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sendRequest(method, params, timeoutMs = MCP_TIMEOUT) {
|
|
58
|
+
ensureChild()
|
|
59
|
+
const id = reqId++
|
|
60
|
+
const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }) + '\n'
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
pending.delete(id)
|
|
64
|
+
resolve({ error: { code: -32000, message: 'MCP request timeout' } })
|
|
65
|
+
}, timeoutMs)
|
|
66
|
+
pending.set(id, { resolve: (resp) => { clearTimeout(timer); resolve(resp) } })
|
|
67
|
+
try { child.stdin.write(msg, 'utf8') } catch (e) { clearTimeout(timer); pending.delete(id); resolve({ error: { code: -1, message: String(e) } }) }
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function initialize() {
|
|
72
|
+
if (initialized) return
|
|
73
|
+
if (initPromise) return initPromise
|
|
74
|
+
initPromise = sendRequest('initialize', {
|
|
75
|
+
protocolVersion: '2024-11-05',
|
|
76
|
+
capabilities: {},
|
|
77
|
+
clientInfo: { name: 'ona-sdlc-repl', version: '0.2.0' },
|
|
78
|
+
}).then(resp => {
|
|
79
|
+
if (!resp.error) {
|
|
80
|
+
sendRequest('notifications/initialized', {}).catch(() => {})
|
|
81
|
+
initialized = true
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
return initPromise
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name,
|
|
89
|
+
async request(method, params, timeoutMs) {
|
|
90
|
+
await initialize()
|
|
91
|
+
return sendRequest(method, params, timeoutMs)
|
|
92
|
+
},
|
|
93
|
+
close() {
|
|
94
|
+
if (child && !child.killed) { try { child.kill() } catch {} }
|
|
95
|
+
servers.delete(name)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listMcpServers(settings) {
|
|
101
|
+
const srvs = settings?.mcp_servers || {}
|
|
102
|
+
return Object.keys(srvs)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function mcpListResources(server) {
|
|
106
|
+
const resp = await server.request('resources/list', {})
|
|
107
|
+
if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
|
|
108
|
+
return { content: JSON.stringify(resp.result?.resources || [], null, 2), is_error: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function mcpReadResource(server, uri) {
|
|
112
|
+
const resp = await server.request('resources/read', { uri })
|
|
113
|
+
if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
|
|
114
|
+
const contents = resp.result?.contents || []
|
|
115
|
+
const text = contents.map(c => c.text || `[blob: ${c.uri}]`).join('\n')
|
|
116
|
+
return { content: text || '(empty)', is_error: false }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function mcpCallTool(server, toolName, args) {
|
|
120
|
+
const resp = await server.request('tools/call', { name: toolName, arguments: args })
|
|
121
|
+
if (resp.error) return { content: `MCP error: ${resp.error.message}`, is_error: true }
|
|
122
|
+
const result = resp.result
|
|
123
|
+
const text = Array.isArray(result?.content) ? result.content.map(c => c.text || JSON.stringify(c)).join('\n') : JSON.stringify(result)
|
|
124
|
+
return { content: text, is_error: Boolean(result?.isError) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Handle an MCP elicitation request — fires Elicitation + ElicitationResult hooks. */
|
|
128
|
+
async function handleElicitation(serverName, msg, child) {
|
|
129
|
+
const params = msg.params || {}
|
|
130
|
+
const hookRt = {
|
|
131
|
+
sessionId: '', conversationId: '', runtimeDbPath: process.env.AGENT_SDLC_DB || '',
|
|
132
|
+
cwd: process.cwd(), settings: {},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fire Elicitation hook
|
|
136
|
+
await runHooks(null, hookRt, 'Elicitation', {
|
|
137
|
+
mcp_server_name: serverName,
|
|
138
|
+
message: params.message || '',
|
|
139
|
+
elicitation_id: params.elicitation_id || msg.id,
|
|
140
|
+
mode: params.mode,
|
|
141
|
+
requested_schema: params.schema,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Get user response via handler or default decline
|
|
145
|
+
let action = 'decline', content = null
|
|
146
|
+
if (_elicitationHandler) {
|
|
147
|
+
try {
|
|
148
|
+
const result = await _elicitationHandler(serverName, params)
|
|
149
|
+
action = result?.action || 'decline'
|
|
150
|
+
content = result?.content || null
|
|
151
|
+
} catch { /* decline on error */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fire ElicitationResult hook
|
|
155
|
+
await runHooks(null, hookRt, 'ElicitationResult', {
|
|
156
|
+
mcp_server_name: serverName,
|
|
157
|
+
elicitation_id: params.elicitation_id || msg.id,
|
|
158
|
+
action,
|
|
159
|
+
content,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Send response back to server
|
|
163
|
+
const resp = JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { action, content } }) + '\n'
|
|
164
|
+
try { child.stdin.write(resp, 'utf8') } catch { /* server may have closed */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function closeAllMcpServers() {
|
|
168
|
+
for (const [, s] of servers) s.close()
|
|
169
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** §2.2 — wire model strings */
|
|
2
|
+
const WIRE = {
|
|
3
|
+
claude_code_subscription: {
|
|
4
|
+
claude_opus_4: 'claude-opus-4-20250514',
|
|
5
|
+
claude_sonnet_4: 'claude-sonnet-4-20250514',
|
|
6
|
+
claude_3_5_haiku: 'claude-3-5-haiku-20241022',
|
|
7
|
+
},
|
|
8
|
+
openai_compatible: {
|
|
9
|
+
gpt_4o: 'gpt-4o',
|
|
10
|
+
gpt_4o_mini: 'gpt-4o-mini',
|
|
11
|
+
o3: 'o3',
|
|
12
|
+
o3_mini: 'o3-mini',
|
|
13
|
+
},
|
|
14
|
+
zhipu: {
|
|
15
|
+
glm_4_7_flash: 'glm-4.7-flash',
|
|
16
|
+
},
|
|
17
|
+
ollama: {
|
|
18
|
+
qwen2_5_14b: 'qwen2.5:14b',
|
|
19
|
+
qwen2_5_7b: 'qwen2.5:7b',
|
|
20
|
+
deepseek_coder_v2: 'deepseek-coder-v2',
|
|
21
|
+
codegemma_7b: 'codegemma:7b',
|
|
22
|
+
},
|
|
23
|
+
lm_studio_local: {
|
|
24
|
+
lm_studio_server_routed: 'lm_studio_server_routed',
|
|
25
|
+
},
|
|
26
|
+
nehanda: {
|
|
27
|
+
nehanda_rag_synthesis_27b: 'nehanda-rag-synthesis-27b',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Provider capabilities for custom model support and discovery */
|
|
32
|
+
const PROVIDER_CAPABILITIES = {
|
|
33
|
+
claude_code_subscription: {
|
|
34
|
+
supportsCustomModels: false,
|
|
35
|
+
requiresHardcodedMap: true,
|
|
36
|
+
supportsDiscovery: false,
|
|
37
|
+
},
|
|
38
|
+
openai_compatible: {
|
|
39
|
+
supportsCustomModels: true,
|
|
40
|
+
requiresHardcodedMap: false,
|
|
41
|
+
supportsDiscovery: false,
|
|
42
|
+
},
|
|
43
|
+
zhipu: {
|
|
44
|
+
supportsCustomModels: false,
|
|
45
|
+
requiresHardcodedMap: true,
|
|
46
|
+
supportsDiscovery: false,
|
|
47
|
+
},
|
|
48
|
+
ollama: {
|
|
49
|
+
supportsCustomModels: true,
|
|
50
|
+
requiresHardcodedMap: false,
|
|
51
|
+
supportsDiscovery: true,
|
|
52
|
+
discoveryEndpoint: '/api/tags',
|
|
53
|
+
},
|
|
54
|
+
lm_studio_local: {
|
|
55
|
+
supportsCustomModels: true,
|
|
56
|
+
requiresHardcodedMap: false,
|
|
57
|
+
supportsDiscovery: false,
|
|
58
|
+
},
|
|
59
|
+
nehanda: {
|
|
60
|
+
supportsCustomModels: false,
|
|
61
|
+
requiresHardcodedMap: true,
|
|
62
|
+
supportsDiscovery: false,
|
|
63
|
+
omitToolChoice: true,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function supportsCustomModels(provider) {
|
|
68
|
+
const capabilities = PROVIDER_CAPABILITIES[provider]
|
|
69
|
+
return capabilities ? capabilities.supportsCustomModels : false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function supportsDiscovery(provider) {
|
|
73
|
+
const capabilities = PROVIDER_CAPABILITIES[provider]
|
|
74
|
+
return capabilities ? capabilities.supportsDiscovery : false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getDiscoveryEndpoint(provider) {
|
|
78
|
+
const capabilities = PROVIDER_CAPABILITIES[provider]
|
|
79
|
+
return capabilities?.discoveryEndpoint || null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function omitToolChoice(provider) {
|
|
83
|
+
const capabilities = PROVIDER_CAPABILITIES[provider]
|
|
84
|
+
return capabilities ? !!capabilities.omitToolChoice : false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveWireModel(modelConfig) {
|
|
88
|
+
const { provider, model_id, custom_model_name } = modelConfig || {}
|
|
89
|
+
|
|
90
|
+
if (custom_model_name && custom_model_name.trim()) {
|
|
91
|
+
return custom_model_name.trim()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const map = WIRE[provider]
|
|
95
|
+
if (!map) throw new Error(`Unknown provider: ${provider}`)
|
|
96
|
+
const w = map[model_id]
|
|
97
|
+
if (w === undefined) throw new Error(`Invalid model_id ${model_id} for provider ${provider}`)
|
|
98
|
+
|
|
99
|
+
if (provider === 'lm_studio_local') {
|
|
100
|
+
const name = process.env.LM_STUDIO_MODEL
|
|
101
|
+
if (!name || !name.trim()) throw new Error('LM_STUDIO_MODEL not set. Export it or use /model.')
|
|
102
|
+
return name.trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return w
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DEFAULT_NEHANDA_ENDPOINT = 'https://nehanda-ml.asoba.co/v1'
|
|
109
|
+
const DEFAULT_NEHANDA_MODEL = 'nehanda-rag-synthesis-27b'
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map agents.json agent {model, endpoint} → model_config used by resolveWireModel / orchestrate.
|
|
113
|
+
* Used by nehanda-ui so /model selection actually changes the wire model on the next turn.
|
|
114
|
+
*/
|
|
115
|
+
export function modelConfigFromAgent(agent, { apiKey = '', defaultEndpoint = DEFAULT_NEHANDA_ENDPOINT } = {}) {
|
|
116
|
+
const model = agent?.model || DEFAULT_NEHANDA_MODEL
|
|
117
|
+
const endpoint = agent?.endpoint || defaultEndpoint
|
|
118
|
+
const isNehanda = model === DEFAULT_NEHANDA_MODEL || /nehanda-ml\.asoba\.co/.test(endpoint)
|
|
119
|
+
|
|
120
|
+
if (isNehanda) {
|
|
121
|
+
return {
|
|
122
|
+
provider: 'nehanda',
|
|
123
|
+
model_id: 'nehanda_rag_synthesis_27b',
|
|
124
|
+
custom_model_name: null,
|
|
125
|
+
base_url: endpoint,
|
|
126
|
+
api_key: apiKey,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const provider = /:11434\b/.test(endpoint) ? 'ollama' : 'openai_compatible'
|
|
131
|
+
return {
|
|
132
|
+
provider,
|
|
133
|
+
model_id: model,
|
|
134
|
+
custom_model_name: model,
|
|
135
|
+
base_url: endpoint,
|
|
136
|
+
api_key: provider === 'ollama' ? 'ollama' : apiKey,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function anthropicBaseUrl() {
|
|
141
|
+
return (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function allModelIds() {
|
|
145
|
+
const out = []
|
|
146
|
+
for (const [prov, map] of Object.entries(WIRE)) {
|
|
147
|
+
for (const mid of Object.keys(map)) {
|
|
148
|
+
out.push({ provider: prov, model_id: mid })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Discovery Service
|
|
3
|
+
* Queries Ollama API to discover available models
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Discover available models from an Ollama server
|
|
8
|
+
* @param {string} baseUrl - Base URL of the Ollama server (e.g., "http://localhost:11434")
|
|
9
|
+
* @param {Object} options - Discovery options
|
|
10
|
+
* @param {number} options.timeout - Request timeout in milliseconds (default: 5000)
|
|
11
|
+
* @returns {Promise<DiscoveryResult>} Discovery result with models or error
|
|
12
|
+
*/
|
|
13
|
+
export async function discoverOllamaModels(baseUrl, options = {}) {
|
|
14
|
+
const timeout = options.timeout || 5000
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Construct discovery URL - remove trailing slashes and append endpoint
|
|
18
|
+
const cleanBaseUrl = baseUrl.replace(/\/+$/, '')
|
|
19
|
+
const discoveryUrl = `${cleanBaseUrl}/api/tags`
|
|
20
|
+
|
|
21
|
+
// Make HTTP GET request with timeout
|
|
22
|
+
const controller = new AbortController()
|
|
23
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
24
|
+
|
|
25
|
+
let response
|
|
26
|
+
try {
|
|
27
|
+
response = await fetch(discoveryUrl, {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
headers: {
|
|
31
|
+
'Accept': 'application/json'
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timeoutId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Handle HTTP errors
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const statusText = response.statusText || 'Unknown error'
|
|
41
|
+
if (response.status === 404) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: `Endpoint not found (404). The Ollama server at ${baseUrl} may not support model discovery.`
|
|
45
|
+
}
|
|
46
|
+
} else if (response.status >= 500) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
error: `Server error (${response.status}): ${statusText}. The Ollama server encountered an internal error.`
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
error: `HTTP error (${response.status}): ${statusText}`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Parse JSON response
|
|
60
|
+
let data
|
|
61
|
+
try {
|
|
62
|
+
data = await response.json()
|
|
63
|
+
} catch (parseError) {
|
|
64
|
+
return {
|
|
65
|
+
success: false,
|
|
66
|
+
error: `Invalid JSON response from server: ${parseError.message}`
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate response structure
|
|
71
|
+
if (!data || typeof data !== 'object') {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: 'Invalid response format: expected JSON object'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Array.isArray(data.models)) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: 'Invalid response format: missing or invalid "models" array'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Extract and validate model information
|
|
86
|
+
const models = []
|
|
87
|
+
for (const model of data.models) {
|
|
88
|
+
// Skip invalid model entries
|
|
89
|
+
if (!model || typeof model !== 'object') {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Require non-empty name field
|
|
94
|
+
if (!model.name || typeof model.name !== 'string' || !model.name.trim()) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extract model information
|
|
99
|
+
models.push({
|
|
100
|
+
name: model.name,
|
|
101
|
+
modified_at: model.modified_at || null,
|
|
102
|
+
size: typeof model.size === 'number' ? model.size : 0,
|
|
103
|
+
digest: model.digest || null,
|
|
104
|
+
details: model.details || null
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
models
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
} catch (error) {
|
|
114
|
+
// Handle connection errors and timeouts
|
|
115
|
+
if (error.name === 'AbortError') {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
error: `Request timed out after ${timeout}ms. The Ollama server may be slow or unreachable.`
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Handle network/connection errors
|
|
123
|
+
if (error.code === 'ECONNREFUSED' || error.message.includes('fetch failed')) {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
error: `Cannot connect to Ollama server at ${baseUrl}. Make sure Ollama is running and accessible.`
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generic error
|
|
131
|
+
return {
|
|
132
|
+
success: false,
|
|
133
|
+
error: `Discovery failed: ${error.message}`
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format a list of models for display
|
|
140
|
+
* @param {Array} models - Array of model objects from discovery
|
|
141
|
+
* @param {Object} options - Formatting options
|
|
142
|
+
* @param {string} options.sortBy - Sort by 'date', 'name', or 'size' (default: 'date')
|
|
143
|
+
* @param {boolean} options.verbose - Include detailed information (default: false)
|
|
144
|
+
* @returns {string} Formatted model list
|
|
145
|
+
*/
|
|
146
|
+
export function formatModelList(models, options = {}) {
|
|
147
|
+
const sortBy = options.sortBy || 'date'
|
|
148
|
+
const verbose = options.verbose || false
|
|
149
|
+
|
|
150
|
+
if (!models || models.length === 0) {
|
|
151
|
+
return 'No models found on the server.\n\nTo pull a model, run: ollama pull <model-name>'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort models
|
|
155
|
+
const sorted = [...models]
|
|
156
|
+
if (sortBy === 'name') {
|
|
157
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name))
|
|
158
|
+
} else if (sortBy === 'size') {
|
|
159
|
+
sorted.sort((a, b) => b.size - a.size)
|
|
160
|
+
} else {
|
|
161
|
+
// Sort by modification date (newest first)
|
|
162
|
+
sorted.sort((a, b) => {
|
|
163
|
+
if (!a.modified_at) return 1
|
|
164
|
+
if (!b.modified_at) return -1
|
|
165
|
+
return new Date(b.modified_at) - new Date(a.modified_at)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Format output
|
|
170
|
+
const lines = []
|
|
171
|
+
lines.push('Available models:\n')
|
|
172
|
+
|
|
173
|
+
for (const model of sorted) {
|
|
174
|
+
const size = formatBytes(model.size)
|
|
175
|
+
|
|
176
|
+
if (verbose && model.details) {
|
|
177
|
+
const paramSize = model.details.parameter_size || 'unknown'
|
|
178
|
+
const quantization = model.details.quantization_level || 'unknown'
|
|
179
|
+
lines.push(` ${model.name}`)
|
|
180
|
+
lines.push(` Size: ${size}`)
|
|
181
|
+
lines.push(` Parameters: ${paramSize}`)
|
|
182
|
+
lines.push(` Quantization: ${quantization}`)
|
|
183
|
+
} else {
|
|
184
|
+
lines.push(` ${model.name.padEnd(40)} ${size}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
lines.push('\nUsage: /model ollama/<model-name>')
|
|
189
|
+
lines.push('Example: /model ollama/llama3')
|
|
190
|
+
|
|
191
|
+
return lines.join('\n')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Format bytes in human-readable format
|
|
196
|
+
* @param {number} bytes - Size in bytes
|
|
197
|
+
* @returns {string} Formatted size string
|
|
198
|
+
*/
|
|
199
|
+
function formatBytes(bytes) {
|
|
200
|
+
if (bytes === 0) return '0 B'
|
|
201
|
+
if (bytes < 1024) return `${bytes} B`
|
|
202
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
203
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
204
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
205
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** OpenAI Chat Completions–compatible streaming HTTP client (§2.1 lm_studio_local + openai_compatible). */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strip thinking traces emitted by reasoning-capable models (Nehanda, DeepSeek-R1, Qwen3).
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Tagged blocks: <think>...</think>
|
|
7
|
+
* - Prose header: "Here's a thinking process: ..."
|
|
8
|
+
* - Numbered planning list that ends before the actual answer (```code or plain prose)
|
|
9
|
+
*/
|
|
10
|
+
function stripThinkingTraces(text) {
|
|
11
|
+
// Tagged blocks
|
|
12
|
+
text = text.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
13
|
+
text = text.replace(/<think>[\s\S]*/g, '')
|
|
14
|
+
|
|
15
|
+
// Prose thinking header
|
|
16
|
+
text = text.replace(/^Here's a thinking process:[\s\S]*?(?=\n\n(?!\n)|\n(?=[A-Z0-9`#]))/m, '')
|
|
17
|
+
|
|
18
|
+
// Numbered planning lists: lines starting with "1." or "1. **" that precede a code block
|
|
19
|
+
// or a clean paragraph. We detect: text starts with a numbered step AND contains a code fence.
|
|
20
|
+
const hasCodeFence = text.includes('```')
|
|
21
|
+
const startsWithStep = /^\s*1\.\s/.test(text)
|
|
22
|
+
if (startsWithStep && hasCodeFence) {
|
|
23
|
+
// Keep only from the first code block onward
|
|
24
|
+
const fenceIdx = text.indexOf('```')
|
|
25
|
+
text = text.slice(fenceIdx)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return text.trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function streamOpenAIChatCompletion({ baseUrl, apiKey, model, messages, tools, io, maxTokens = 8192, bufferOutput = true, num_ctx: numCtx, omitToolChoice: skipToolChoice = false, useStream = true }) {
|
|
32
|
+
const url = `${String(baseUrl).replace(/\/$/, '')}/chat/completions`
|
|
33
|
+
const body = { model, messages, stream: useStream, max_tokens: maxTokens }
|
|
34
|
+
// Suppress tool_choice:"auto" for providers that do not support it (e.g. Nehanda
|
|
35
|
+
// vLLM without --enable-auto-tool-choice). When omitToolChoice is true we still
|
|
36
|
+
// send the tools schema so the model can call them — we just omit the directive.
|
|
37
|
+
if (tools?.length) { body.tools = tools; if (!skipToolChoice) body.tool_choice = 'auto' }
|
|
38
|
+
if (numCtx) body.num_ctx = numCtx
|
|
39
|
+
|
|
40
|
+
const res = await fetch(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const t = await res.text()
|
|
48
|
+
throw new Error(`OpenAI-compat ${res.status}: ${t.slice(0, 2000)}`)
|
|
49
|
+
}
|
|
50
|
+
if (!res.body) throw new Error('OpenAI-compat: empty response body')
|
|
51
|
+
|
|
52
|
+
// Non-streaming path: Lambda/proxy returns a single JSON completion object.
|
|
53
|
+
// Used for providers like Nehanda whose Lambda Function URL buffers the full
|
|
54
|
+
// response rather than forwarding SSE chunks.
|
|
55
|
+
if (!useStream) {
|
|
56
|
+
const text = await res.text()
|
|
57
|
+
let json
|
|
58
|
+
try { json = JSON.parse(text) } catch { throw new Error(`OpenAI-compat: non-JSON response: ${text.slice(0, 500)}`) }
|
|
59
|
+
const content = json.choices?.[0]?.message?.content || ''
|
|
60
|
+
const strippedText = stripThinkingTraces(content)
|
|
61
|
+
if (!bufferOutput && strippedText) { if (io.spinner) io.spinner.stop(); io.write(strippedText) }
|
|
62
|
+
const assistantBlocks = []
|
|
63
|
+
if (strippedText) assistantBlocks.push({ type: 'text', text: strippedText })
|
|
64
|
+
return {
|
|
65
|
+
assistantBlocks,
|
|
66
|
+
finishReason: json.choices?.[0]?.finish_reason || 'stop',
|
|
67
|
+
bufferedText: bufferOutput ? strippedText : null,
|
|
68
|
+
usage: json.usage || null,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const reader = res.body.getReader()
|
|
73
|
+
const decoder = new TextDecoder()
|
|
74
|
+
let buffer = '', fullText = '', finishReason = null, firstToken = true, usage = null
|
|
75
|
+
const toolAcc = new Map()
|
|
76
|
+
|
|
77
|
+
while (true) {
|
|
78
|
+
const { done, value } = await reader.read()
|
|
79
|
+
if (done) break
|
|
80
|
+
buffer += decoder.decode(value, { stream: true })
|
|
81
|
+
const parts = buffer.split('\n')
|
|
82
|
+
buffer = parts.pop() ?? ''
|
|
83
|
+
|
|
84
|
+
for (const line of parts) {
|
|
85
|
+
const trimmed = line.trim()
|
|
86
|
+
if (!trimmed.startsWith('data:')) continue
|
|
87
|
+
const data = trimmed.slice(5).trim()
|
|
88
|
+
if (data === '[DONE]') continue
|
|
89
|
+
let json
|
|
90
|
+
try { json = JSON.parse(data) } catch { continue }
|
|
91
|
+
const choice = json.choices?.[0]
|
|
92
|
+
const delta = choice?.delta
|
|
93
|
+
if (delta?.content) {
|
|
94
|
+
if (firstToken) {
|
|
95
|
+
firstToken = false
|
|
96
|
+
// Only stop spinner and write newline when streaming directly to output.
|
|
97
|
+
// When buffering, the caller owns the spinner — don't touch it here.
|
|
98
|
+
if (!bufferOutput) { if (io.spinner) io.spinner.stop(); io.write('\n') }
|
|
99
|
+
}
|
|
100
|
+
fullText += delta.content
|
|
101
|
+
if (!bufferOutput) {
|
|
102
|
+
io.write(delta.content)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (delta?.tool_calls) {
|
|
106
|
+
if (firstToken) {
|
|
107
|
+
firstToken = false
|
|
108
|
+
// Tool calls are always handled by the caller — don't stop spinner here.
|
|
109
|
+
}
|
|
110
|
+
for (const tc of delta.tool_calls) {
|
|
111
|
+
const idx = tc.index ?? 0
|
|
112
|
+
if (!toolAcc.has(idx)) toolAcc.set(idx, { id: '', name: '', arguments: '' })
|
|
113
|
+
const acc = toolAcc.get(idx)
|
|
114
|
+
if (tc.id) acc.id = tc.id
|
|
115
|
+
if (tc.function?.name) acc.name = tc.function.name
|
|
116
|
+
if (tc.function?.arguments != null) acc.arguments += String(tc.function.arguments)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (choice?.finish_reason) finishReason = choice.finish_reason
|
|
120
|
+
if (json.usage) usage = json.usage
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const toolCalls = [...toolAcc.keys()].sort((a, b) => a - b).map(idx => {
|
|
125
|
+
const acc = toolAcc.get(idx)
|
|
126
|
+
if (!acc.name) return null
|
|
127
|
+
let input = {}
|
|
128
|
+
try { input = acc.arguments.trim() ? JSON.parse(acc.arguments) : {} } catch { input = { _parseError: true, _raw: acc.arguments.slice(0, 500) } }
|
|
129
|
+
return { id: acc.id || `call_${idx}_${Date.now()}`, name: acc.name, input }
|
|
130
|
+
}).filter(Boolean)
|
|
131
|
+
|
|
132
|
+
// Strip thinking traces before returning — applies to buffered and non-buffered paths.
|
|
133
|
+
// Non-buffered (live streaming) will have printed raw tokens already; the stripped
|
|
134
|
+
// version is used for the transcript and any downstream processing.
|
|
135
|
+
const strippedText = stripThinkingTraces(fullText)
|
|
136
|
+
|
|
137
|
+
const assistantBlocks = []
|
|
138
|
+
if (strippedText) assistantBlocks.push({ type: 'text', text: strippedText })
|
|
139
|
+
for (const tc of toolCalls) assistantBlocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input })
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
assistantBlocks,
|
|
143
|
+
finishReason: finishReason || (toolCalls.length > 0 ? 'tool_calls' : 'stop'),
|
|
144
|
+
bufferedText: bufferOutput ? strippedText : null,
|
|
145
|
+
usage,
|
|
146
|
+
}
|
|
147
|
+
}
|