nubos-pilot 1.2.4 → 1.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/CHANGELOG.md +17 -1
- package/README.md +2 -1
- package/SECURITY.md +3 -4
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/learnings.cjs +1 -1
- package/bin/np-tools/resolve-model.cjs +55 -1
- package/bin/np-tools/resolve-model.test.cjs +139 -0
- package/bin/np-tools/security.cjs +1 -1
- package/bin/np-tools/spawn-headless.cjs +100 -1
- package/bin/np-tools/spawn-headless.test.cjs +108 -58
- package/bin/np-tools/spawn-offhost.cjs +93 -0
- package/bin/np-tools/spawn-offhost.test.cjs +38 -0
- package/lib/agents.cjs +16 -2
- package/lib/config-schema.cjs +5 -1
- package/lib/learnings/extract.cjs +4 -4
- package/lib/learnings/extract.test.cjs +8 -8
- package/lib/model-providers.cjs +118 -0
- package/lib/model-providers.test.cjs +85 -0
- package/lib/runtime/agent-loop.cjs +64 -0
- package/lib/runtime/agent-loop.test.cjs +135 -0
- package/lib/runtime/dispatch.cjs +174 -0
- package/lib/runtime/dispatch.test.cjs +193 -0
- package/lib/runtime/preflight.cjs +68 -0
- package/lib/runtime/preflight.test.cjs +62 -0
- package/lib/runtime/providers/openai-compat.cjs +102 -0
- package/lib/runtime/providers/openai-compat.test.cjs +103 -0
- package/lib/runtime/tools/index.cjs +415 -0
- package/lib/runtime/tools/index.test.cjs +230 -0
- package/lib/security/review.cjs +4 -4
- package/lib/security/review.test.cjs +6 -6
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/workflows/add-tests.md +41 -0
- package/workflows/architect-phase.md +19 -0
- package/workflows/discuss-phase.md +29 -10
- package/workflows/execute-phase.md +93 -4
- package/workflows/plan-phase.md +57 -16
- package/workflows/research-phase.md +45 -0
- package/workflows/scan-codebase.md +21 -3
- package/workflows/validate-phase.md +30 -13
- package/workflows/verify-work.md +17 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { test, afterEach } = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
|
|
7
|
+
const { dispatchOffHost, _parseTools } = require('./dispatch.cjs');
|
|
8
|
+
|
|
9
|
+
const _dirs = [];
|
|
10
|
+
function _root() {
|
|
11
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'np-dispatch-')));
|
|
12
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
|
|
13
|
+
_dirs.push(root);
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => { while (_dirs.length) { try { fs.rmSync(_dirs.pop(), { recursive: true, force: true }); } catch {} } });
|
|
17
|
+
|
|
18
|
+
const NOW = () => '2026-06-16T00:00:00.000Z';
|
|
19
|
+
|
|
20
|
+
function _deps(over) {
|
|
21
|
+
return Object.assign({
|
|
22
|
+
resolve: () => ({ kind: 'openai-compat', provider: 'ollama', model: 'qwen2.5-coder:32b', baseUrl: 'http://localhost:11434/v1', apiKeyEnv: null, tier: 'sonnet' }),
|
|
23
|
+
preflight: async () => ({ ok: true }),
|
|
24
|
+
loadSource: () => ({ frontmatter: { name: 'np-executor', tier: 'sonnet', tools: 'Read, Write, Bash, Grep' }, body: 'You are the executor.' }),
|
|
25
|
+
runLoop: async () => ({ content: 'done', stopped: 'final', iterations: 2, toolLog: [{ name: 'Read', ok: true }] }),
|
|
26
|
+
now: NOW,
|
|
27
|
+
}, over || {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('DSP-1: happy path returns the envelope and records a metrics row', async () => {
|
|
31
|
+
const cwd = _root();
|
|
32
|
+
const out = await dispatchOffHost({ agent: 'np-architect', task: 'do it', cwd, deps: _deps() });
|
|
33
|
+
assert.equal(out.provider, 'ollama');
|
|
34
|
+
assert.equal(out.model, 'qwen2.5-coder:32b');
|
|
35
|
+
assert.equal(out.content, 'done');
|
|
36
|
+
assert.equal(out.stopped, 'final');
|
|
37
|
+
assert.equal(out.metrics_recorded, true);
|
|
38
|
+
const meta = fs.readFileSync(path.join(cwd, '.nubos-pilot', 'metrics', 'meta.jsonl'), 'utf-8');
|
|
39
|
+
const rec = JSON.parse(meta.trim().split('\n').pop());
|
|
40
|
+
assert.equal(rec.runtime, 'ollama');
|
|
41
|
+
assert.equal(rec.resolved_model, 'qwen2.5-coder:32b');
|
|
42
|
+
assert.equal(rec.status, 'ok');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('DSP-2: a native-kind agent is refused (dispatch-not-offhost)', async () => {
|
|
46
|
+
const cwd = _root();
|
|
47
|
+
const deps = _deps({ resolve: () => ({ kind: 'native', provider: 'claude', model: null, tier: 'opus' }) });
|
|
48
|
+
await assert.rejects(
|
|
49
|
+
dispatchOffHost({ agent: 'np-planner', task: 't', cwd, deps }),
|
|
50
|
+
(e) => e.code === 'dispatch-not-offhost',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('DSP-3: Bash is excluded by default; opt-in (inside a worktree) includes it', async () => {
|
|
55
|
+
const cwd = _root();
|
|
56
|
+
let seen = null;
|
|
57
|
+
const deps = _deps({
|
|
58
|
+
isInWorktree: () => true,
|
|
59
|
+
runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
60
|
+
});
|
|
61
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps });
|
|
62
|
+
assert.deepEqual(seen, ['Read', 'Write', 'Grep']);
|
|
63
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true });
|
|
64
|
+
assert.deepEqual(seen, ['Read', 'Write', 'Bash', 'Grep']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('DSP-3b: readOnly restricts the toolset to read tools', async () => {
|
|
68
|
+
const cwd = _root();
|
|
69
|
+
let seen = null;
|
|
70
|
+
const deps = _deps({ runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; } });
|
|
71
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, readOnly: true });
|
|
72
|
+
assert.deepEqual(seen, ['Read', 'Grep']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('DSP-4: a loop error records an error metrics row and rethrows the loop code', async () => {
|
|
76
|
+
const cwd = _root();
|
|
77
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
78
|
+
const deps = _deps({ runLoop: async () => { throw new NubosPilotError('provider-http-error', 'HTTP 500', {}); } });
|
|
79
|
+
await assert.rejects(dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps }), (e) => e.code === 'provider-http-error');
|
|
80
|
+
const rec = JSON.parse(fs.readFileSync(path.join(cwd, '.nubos-pilot', 'metrics', 'meta.jsonl'), 'utf-8').trim().split('\n').pop());
|
|
81
|
+
assert.equal(rec.status, 'error');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('DSP-5: preflight runs before the loop and a failure aborts before any tool call', async () => {
|
|
85
|
+
const cwd = _root();
|
|
86
|
+
let looped = false;
|
|
87
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
88
|
+
const deps = _deps({
|
|
89
|
+
preflight: async () => { throw new NubosPilotError('preflight-failed', 'unreachable', {}); },
|
|
90
|
+
runLoop: async () => { looped = true; return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
91
|
+
});
|
|
92
|
+
await assert.rejects(dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps }), (e) => e.code === 'preflight-failed');
|
|
93
|
+
assert.equal(looped, false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('DSP-6: missing agent throws dispatch-no-agent', async () => {
|
|
97
|
+
await assert.rejects(dispatchOffHost({ task: 't', deps: _deps() }), (e) => e.code === 'dispatch-no-agent');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('DSP-8: a Rule-9-audited agent without a task context is refused off-host', async () => {
|
|
101
|
+
const cwd = _root();
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps: _deps() }),
|
|
104
|
+
(e) => e.code === 'offhost-audited-agent-unsupported',
|
|
105
|
+
);
|
|
106
|
+
await assert.rejects(
|
|
107
|
+
dispatchOffHost({ agent: 'np-researcher', task: 't', cwd, deps: _deps() }),
|
|
108
|
+
(e) => e.code === 'offhost-audited-agent-unsupported',
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('DSP-9: an audited agent WITH a valid --task-id is allowed; Rule-9 audit rides the envelope', async () => {
|
|
113
|
+
const cwd = _root();
|
|
114
|
+
let seen = null;
|
|
115
|
+
const deps = _deps({ runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }; } });
|
|
116
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId: 'M001-S001-T0001' });
|
|
117
|
+
assert.ok(seen.includes('knowledge-search'), 'knowledge-search must be injected for an audited agent');
|
|
118
|
+
assert.ok(out.rule9 && typeof out.rule9 === 'object', 'audit result must ride the envelope');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('DSP-9b: with recorded search evidence the Rule-9 audit passes', async () => {
|
|
122
|
+
const cwd = _root();
|
|
123
|
+
const taskId = 'M001-S001-T0002';
|
|
124
|
+
require('../nubosloop-audit.cjs').recordSearchEvidence(taskId, 'auth', cwd);
|
|
125
|
+
const deps = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }) });
|
|
126
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId });
|
|
127
|
+
assert.equal(out.rule9.ok, true);
|
|
128
|
+
assert.equal(out.rule9.violation, null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('DSP-12: skipAudit defers Rule-9 to the orchestrator (rule9 not run by dispatch)', async () => {
|
|
132
|
+
const cwd = _root();
|
|
133
|
+
const deps = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [{ name: 'knowledge-search', ok: true }] }) });
|
|
134
|
+
const out = await dispatchOffHost({ agent: 'np-executor', task: 't', cwd, deps, taskId: 'M001-S001-T0003', skipAudit: true });
|
|
135
|
+
assert.equal(out.rule9, null, 'dispatch must not audit when skipAudit is set');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('DSP-10: --allow-bash outside a worktree is refused (offhost-bash-requires-sandbox)', async () => {
|
|
139
|
+
const cwd = _root();
|
|
140
|
+
const deps = _deps({ isInWorktree: () => false });
|
|
141
|
+
await assert.rejects(
|
|
142
|
+
dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true }),
|
|
143
|
+
(e) => e.code === 'offhost-bash-requires-sandbox',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('DSP-11: --allow-bash inside a worktree includes Bash in the toolset', async () => {
|
|
148
|
+
const cwd = _root();
|
|
149
|
+
let seen = null;
|
|
150
|
+
const deps = _deps({
|
|
151
|
+
isInWorktree: () => true,
|
|
152
|
+
runLoop: async ({ toolset }) => { seen = toolset.names.slice(); return { content: 'x', stopped: 'final', iterations: 1, toolLog: [] }; },
|
|
153
|
+
});
|
|
154
|
+
await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps, allowBash: true });
|
|
155
|
+
assert.ok(seen.includes('Bash'), 'Bash must be available inside a worktree');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('DSP-13: outputSchema lints the result and rides the envelope (null when unset)', async () => {
|
|
159
|
+
const cwd = _root();
|
|
160
|
+
const out1 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: _deps() });
|
|
161
|
+
assert.equal(out1.output_lint, null, 'no schema ⇒ no lint');
|
|
162
|
+
const out2 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: _deps(), outputSchema: 'researcher-output' });
|
|
163
|
+
assert.ok(out2.output_lint && out2.output_lint.schema === 'researcher-output', 'lint result rides the envelope');
|
|
164
|
+
assert.equal(typeof out2.output_lint.ok, 'boolean');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('DSP-14: capability flags zero tool-calls despite an advertised toolset (tool-calling unsupported signal)', async () => {
|
|
168
|
+
const cwd = _root();
|
|
169
|
+
// model made no tool calls but tools were advertised → not ok, mutating true (Write/Bash in toolset)
|
|
170
|
+
const noTools = _deps({ runLoop: async () => ({ content: 'just text', stopped: 'final', iterations: 1, toolLog: [] }) });
|
|
171
|
+
const out1 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: noTools });
|
|
172
|
+
assert.equal(out1.capability.ok, false);
|
|
173
|
+
assert.equal(out1.capability.toolCalls, 0);
|
|
174
|
+
assert.ok(out1.capability.toolsAdvertised > 0);
|
|
175
|
+
assert.equal(out1.capability.mutating, true);
|
|
176
|
+
|
|
177
|
+
// model used a tool → ok
|
|
178
|
+
const usedTool = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 2, toolLog: [{ name: 'Read', ok: true }] }) });
|
|
179
|
+
const out2 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: usedTool });
|
|
180
|
+
assert.equal(out2.capability.ok, true);
|
|
181
|
+
|
|
182
|
+
// read-only emitter with no tool calls → not ok but mutating false (softer hint)
|
|
183
|
+
const ro = _deps({ runLoop: async () => ({ content: 'x', stopped: 'final', iterations: 1, toolLog: [] }) });
|
|
184
|
+
const out3 = await dispatchOffHost({ agent: 'np-architect', task: 't', cwd, deps: ro, readOnly: true });
|
|
185
|
+
assert.equal(out3.capability.ok, false);
|
|
186
|
+
assert.equal(out3.capability.mutating, false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('DSP-7: _parseTools accepts a comma string or an array', () => {
|
|
190
|
+
assert.deepEqual(_parseTools('Read, Write , Bash'), ['Read', 'Write', 'Bash']);
|
|
191
|
+
assert.deepEqual(_parseTools(['Read', 'Grep']), ['Read', 'Grep']);
|
|
192
|
+
assert.deepEqual(_parseTools(undefined), []);
|
|
193
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../core.cjs');
|
|
4
|
+
const { _hostOf } = require('./providers/openai-compat.cjs');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 10000;
|
|
7
|
+
|
|
8
|
+
function _isLocal(baseUrl) {
|
|
9
|
+
return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(baseUrl || '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function preflight({ baseUrl, apiKeyEnv, model, fetchImpl, env, timeoutMs }) {
|
|
13
|
+
const out = { ok: false, reachable: false, modelPresent: false, models: [], hint: null, host: 'provider' };
|
|
14
|
+
if (typeof baseUrl !== 'string' || !baseUrl) {
|
|
15
|
+
out.hint = 'provider has no base_url';
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
const f = fetchImpl || globalThis.fetch;
|
|
19
|
+
const e = env || process.env;
|
|
20
|
+
const url = baseUrl.replace(/\/+$/, '') + '/models';
|
|
21
|
+
out.host = _hostOf(url);
|
|
22
|
+
|
|
23
|
+
const headers = {};
|
|
24
|
+
if (apiKeyEnv && e[apiKeyEnv]) headers.authorization = 'Bearer ' + e[apiKeyEnv];
|
|
25
|
+
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await f(url, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs || DEFAULT_PREFLIGHT_TIMEOUT_MS) });
|
|
29
|
+
} catch {
|
|
30
|
+
out.hint = 'cannot reach ' + out.host
|
|
31
|
+
+ (_isLocal(baseUrl) ? ' — is the model server running? (e.g. `ollama serve`)' : ' — check base_url / network');
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
out.hint = out.host + ' returned HTTP ' + res.status + ' for /models';
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
out.reachable = true;
|
|
39
|
+
|
|
40
|
+
let json = null;
|
|
41
|
+
try { json = await res.json(); } catch {}
|
|
42
|
+
const data = json && Array.isArray(json.data) ? json.data : [];
|
|
43
|
+
out.models = data.map((m) => m && m.id).filter((id) => typeof id === 'string');
|
|
44
|
+
|
|
45
|
+
out.modelPresent = !model || out.models.includes(model);
|
|
46
|
+
if (!out.modelPresent) {
|
|
47
|
+
out.hint = 'model "' + model + '" not available on ' + out.host
|
|
48
|
+
+ (_isLocal(baseUrl) ? ' — run: ollama pull ' + model : ' — check the model name / your access');
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
out.ok = true;
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function assertPreflight(args) {
|
|
57
|
+
const r = await preflight(args);
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
throw new NubosPilotError(
|
|
60
|
+
'preflight-failed',
|
|
61
|
+
r.hint || ('preflight failed for ' + r.host),
|
|
62
|
+
{ host: r.host, reachable: r.reachable, modelPresent: r.modelPresent, model: args && args.model },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { preflight, assertPreflight, _isLocal, DEFAULT_PREFLIGHT_TIMEOUT_MS };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const { preflight, assertPreflight, _isLocal } = require('./preflight.cjs');
|
|
5
|
+
|
|
6
|
+
function _res({ ok = true, status = 200, json }) {
|
|
7
|
+
return { ok, status, json: async () => json };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test('PF-1: reachable + model present ⇒ ok', async () => {
|
|
11
|
+
const fetchImpl = async () => _res({ json: { data: [{ id: 'qwen2.5-coder:32b' }, { id: 'llama3' }] } });
|
|
12
|
+
const r = await preflight({ baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5-coder:32b', fetchImpl });
|
|
13
|
+
assert.equal(r.ok, true);
|
|
14
|
+
assert.equal(r.reachable, true);
|
|
15
|
+
assert.equal(r.modelPresent, true);
|
|
16
|
+
assert.deepEqual(r.models, ['qwen2.5-coder:32b', 'llama3']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('PF-2: unreachable server ⇒ not ok with `ollama serve` hint for localhost', async () => {
|
|
20
|
+
const fetchImpl = async () => { throw new Error('ECONNREFUSED'); };
|
|
21
|
+
const r = await preflight({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', fetchImpl });
|
|
22
|
+
assert.equal(r.ok, false);
|
|
23
|
+
assert.equal(r.reachable, false);
|
|
24
|
+
assert.match(r.hint, /ollama serve/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('PF-3: reachable but model missing ⇒ `ollama pull` hint', async () => {
|
|
28
|
+
const fetchImpl = async () => _res({ json: { data: [{ id: 'llama3' }] } });
|
|
29
|
+
const r = await preflight({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5', fetchImpl });
|
|
30
|
+
assert.equal(r.reachable, true);
|
|
31
|
+
assert.equal(r.modelPresent, false);
|
|
32
|
+
assert.equal(r.ok, false);
|
|
33
|
+
assert.match(r.hint, /ollama pull qwen3\.5/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('PF-4: remote host missing model gives a non-ollama hint', async () => {
|
|
37
|
+
const fetchImpl = async () => _res({ json: { data: [{ id: 'gpt-4o' }] } });
|
|
38
|
+
const r = await preflight({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-9', fetchImpl });
|
|
39
|
+
assert.equal(r.ok, false);
|
|
40
|
+
assert.doesNotMatch(r.hint, /ollama/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('PF-5: assertPreflight throws preflight-failed when not ok', async () => {
|
|
44
|
+
const fetchImpl = async () => { throw new Error('down'); };
|
|
45
|
+
let thrown = null;
|
|
46
|
+
try { await assertPreflight({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', fetchImpl }); }
|
|
47
|
+
catch (e) { thrown = e; }
|
|
48
|
+
assert.equal(thrown && thrown.code, 'preflight-failed');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('PF-6: _isLocal classifies localhost/127.0.0.1 as local, public host as remote', () => {
|
|
52
|
+
assert.equal(_isLocal('http://localhost:11434/v1'), true);
|
|
53
|
+
assert.equal(_isLocal('http://127.0.0.1:11434/v1'), true);
|
|
54
|
+
assert.equal(_isLocal('https://api.openai.com/v1'), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('PF-7: HTTP 401 on /models ⇒ not reachable-ok, hint names status', async () => {
|
|
58
|
+
const fetchImpl = async () => _res({ ok: false, status: 401 });
|
|
59
|
+
const r = await preflight({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', fetchImpl });
|
|
60
|
+
assert.equal(r.ok, false);
|
|
61
|
+
assert.match(r.hint, /401/);
|
|
62
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../core.cjs');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
6
|
+
|
|
7
|
+
function _hostOf(url) {
|
|
8
|
+
try { return new URL(url).host; } catch { return 'provider'; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function _parse(json) {
|
|
12
|
+
const choice = json && Array.isArray(json.choices) ? json.choices[0] : null;
|
|
13
|
+
const msg = (choice && choice.message) ? choice.message : {};
|
|
14
|
+
const toolCalls = Array.isArray(msg.tool_calls)
|
|
15
|
+
? msg.tool_calls.map((tc, i) => ({
|
|
16
|
+
id: (tc && tc.id) || ('call_' + i),
|
|
17
|
+
name: tc && tc.function && tc.function.name,
|
|
18
|
+
arguments: tc && tc.function && tc.function.arguments,
|
|
19
|
+
}))
|
|
20
|
+
: [];
|
|
21
|
+
const usage = (json && json.usage) ? {
|
|
22
|
+
tokens_in: typeof json.usage.prompt_tokens === 'number' ? json.usage.prompt_tokens : null,
|
|
23
|
+
tokens_out: typeof json.usage.completion_tokens === 'number' ? json.usage.completion_tokens : null,
|
|
24
|
+
} : null;
|
|
25
|
+
return {
|
|
26
|
+
content: typeof msg.content === 'string' ? msg.content : '',
|
|
27
|
+
toolCalls,
|
|
28
|
+
finishReason: (choice && choice.finish_reason) || null,
|
|
29
|
+
usage,
|
|
30
|
+
raw: msg,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function chat({ baseUrl, apiKeyEnv, model, messages, tools, timeoutMs, fetchImpl, env }) {
|
|
35
|
+
if (typeof baseUrl !== 'string' || !baseUrl) {
|
|
36
|
+
throw new NubosPilotError('provider-no-base-url', 'openai-compat chat requires a base_url', {});
|
|
37
|
+
}
|
|
38
|
+
if (typeof model !== 'string' || !model) {
|
|
39
|
+
throw new NubosPilotError('provider-no-model', 'openai-compat chat requires a model', {});
|
|
40
|
+
}
|
|
41
|
+
const f = fetchImpl || globalThis.fetch;
|
|
42
|
+
if (typeof f !== 'function') {
|
|
43
|
+
throw new NubosPilotError('provider-no-fetch', 'global fetch unavailable (node >=22 required)', {});
|
|
44
|
+
}
|
|
45
|
+
const e = env || process.env;
|
|
46
|
+
const headers = { 'content-type': 'application/json' };
|
|
47
|
+
if (apiKeyEnv) {
|
|
48
|
+
const key = e[apiKeyEnv];
|
|
49
|
+
if (!key) {
|
|
50
|
+
throw new NubosPilotError(
|
|
51
|
+
'provider-missing-api-key',
|
|
52
|
+
'env var ' + apiKeyEnv + ' is empty or unset',
|
|
53
|
+
{ apiKeyEnv },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
headers.authorization = 'Bearer ' + key;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body = { model, messages, stream: false };
|
|
60
|
+
if (Array.isArray(tools) && tools.length) {
|
|
61
|
+
body.tools = tools;
|
|
62
|
+
body.tool_choice = 'auto';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
66
|
+
const host = _hostOf(url);
|
|
67
|
+
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await f(url, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers,
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new NubosPilotError(
|
|
78
|
+
'provider-request-failed',
|
|
79
|
+
'request to ' + host + ' failed (' + ((err && (err.code || err.name)) || 'error') + ')',
|
|
80
|
+
{ host, cause: (err && (err.code || err.name)) || 'unknown' },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
let snippet = '';
|
|
86
|
+
try { snippet = (await res.text()).slice(0, 300); } catch {}
|
|
87
|
+
throw new NubosPilotError(
|
|
88
|
+
'provider-http-error',
|
|
89
|
+
host + ' returned HTTP ' + res.status,
|
|
90
|
+
{ host, status: res.status, body: snippet },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let json;
|
|
95
|
+
try { json = await res.json(); }
|
|
96
|
+
catch {
|
|
97
|
+
throw new NubosPilotError('provider-bad-json', host + ' returned a non-JSON body', { host });
|
|
98
|
+
}
|
|
99
|
+
return _parse(json);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { chat, _parse, _hostOf, DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const { chat, _parse } = require('./openai-compat.cjs');
|
|
5
|
+
|
|
6
|
+
function _res({ ok = true, status = 200, json, text }) {
|
|
7
|
+
return {
|
|
8
|
+
ok, status,
|
|
9
|
+
json: async () => json,
|
|
10
|
+
text: async () => (text != null ? text : JSON.stringify(json || {})),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('OAC-1: _parse extracts content + tool_calls from an OpenAI-shaped response', () => {
|
|
15
|
+
const out = _parse({
|
|
16
|
+
choices: [{
|
|
17
|
+
finish_reason: 'tool_calls',
|
|
18
|
+
message: {
|
|
19
|
+
role: 'assistant', content: 'thinking',
|
|
20
|
+
tool_calls: [{ id: 'c1', function: { name: 'Read', arguments: '{"path":"a.txt"}' } }],
|
|
21
|
+
},
|
|
22
|
+
}],
|
|
23
|
+
});
|
|
24
|
+
assert.equal(out.content, 'thinking');
|
|
25
|
+
assert.equal(out.finishReason, 'tool_calls');
|
|
26
|
+
assert.deepEqual(out.toolCalls, [{ id: 'c1', name: 'Read', arguments: '{"path":"a.txt"}' }]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('OAC-2: _parse on a content-only response yields empty toolCalls', () => {
|
|
30
|
+
const out = _parse({ choices: [{ finish_reason: 'stop', message: { content: 'done' } }] });
|
|
31
|
+
assert.equal(out.content, 'done');
|
|
32
|
+
assert.deepEqual(out.toolCalls, []);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('OAC-3: chat POSTs to <base>/chat/completions with model + tools and parses the reply', async () => {
|
|
36
|
+
let captured = null;
|
|
37
|
+
const fetchImpl = async (url, opts) => {
|
|
38
|
+
captured = { url, opts };
|
|
39
|
+
return _res({ json: { choices: [{ message: { content: 'hi' } }] } });
|
|
40
|
+
};
|
|
41
|
+
const out = await chat({
|
|
42
|
+
baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [{ role: 'user', content: 'x' }],
|
|
43
|
+
tools: [{ type: 'function', function: { name: 'Read' } }], fetchImpl,
|
|
44
|
+
});
|
|
45
|
+
assert.equal(captured.url, 'http://localhost:11434/v1/chat/completions');
|
|
46
|
+
const body = JSON.parse(captured.opts.body);
|
|
47
|
+
assert.equal(body.model, 'qwen');
|
|
48
|
+
assert.equal(body.tool_choice, 'auto');
|
|
49
|
+
assert.equal(out.content, 'hi');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('OAC-4: api_key_env adds a bearer header; missing key throws provider-missing-api-key', async () => {
|
|
53
|
+
let auth = null;
|
|
54
|
+
const fetchImpl = async (_url, opts) => { auth = opts.headers.authorization; return _res({ json: { choices: [{ message: { content: 'ok' } }] } }); };
|
|
55
|
+
await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: { XAI_KEY: 'sk-123' }, fetchImpl });
|
|
56
|
+
assert.equal(auth, 'Bearer sk-123');
|
|
57
|
+
|
|
58
|
+
let thrown = null;
|
|
59
|
+
try { await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: {}, fetchImpl }); }
|
|
60
|
+
catch (e) { thrown = e; }
|
|
61
|
+
assert.equal(thrown && thrown.code, 'provider-missing-api-key');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('OAC-5: non-2xx throws provider-http-error carrying status + host (not full url)', async () => {
|
|
65
|
+
const fetchImpl = async () => _res({ ok: false, status: 500, text: 'boom' });
|
|
66
|
+
let thrown = null;
|
|
67
|
+
try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
|
|
68
|
+
catch (e) { thrown = e; }
|
|
69
|
+
assert.equal(thrown.code, 'provider-http-error');
|
|
70
|
+
assert.equal(thrown.details.status, 500);
|
|
71
|
+
assert.equal(thrown.details.host, 'localhost:11434');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('OAC-6: network failure throws provider-request-failed with host only', async () => {
|
|
75
|
+
const fetchImpl = async () => { const e = new Error('refused'); e.code = 'ECONNREFUSED'; throw e; };
|
|
76
|
+
let thrown = null;
|
|
77
|
+
try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
|
|
78
|
+
catch (e) { thrown = e; }
|
|
79
|
+
assert.equal(thrown.code, 'provider-request-failed');
|
|
80
|
+
assert.equal(thrown.details.host, 'localhost:11434');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('OAC-7: missing base_url / model throw before any fetch', async () => {
|
|
84
|
+
let a = null; try { await chat({ model: 'm', messages: [] }); } catch (e) { a = e; }
|
|
85
|
+
assert.equal(a.code, 'provider-no-base-url');
|
|
86
|
+
let b = null; try { await chat({ baseUrl: 'http://x/v1', messages: [] }); } catch (e) { b = e; }
|
|
87
|
+
assert.equal(b.code, 'provider-no-model');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('OAC-8: _parse synthesizes a stable id when the provider omits tool_calls[].id', () => {
|
|
91
|
+
const out = _parse({ choices: [{ message: { tool_calls: [
|
|
92
|
+
{ function: { name: 'Read', arguments: '{}' } },
|
|
93
|
+
{ function: { name: 'Grep', arguments: '{}' } },
|
|
94
|
+
] } }] });
|
|
95
|
+
assert.deepEqual(out.toolCalls.map((t) => t.id), ['call_0', 'call_1']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('OAC-9: _parse captures token usage when present', () => {
|
|
99
|
+
const out = _parse({ choices: [{ message: { content: 'x' } }], usage: { prompt_tokens: 12, completion_tokens: 5 } });
|
|
100
|
+
assert.deepEqual(out.usage, { tokens_in: 12, tokens_out: 5 });
|
|
101
|
+
const none = _parse({ choices: [{ message: { content: 'x' } }] });
|
|
102
|
+
assert.equal(none.usage, null);
|
|
103
|
+
});
|