nubos-pilot 1.2.4 → 1.3.1
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 +3 -0
- package/bin/np-tools/_elision-proxy-entry.cjs +13 -0
- package/bin/np-tools/elision-bench.cjs +67 -0
- package/bin/np-tools/elision-get.cjs +48 -0
- package/bin/np-tools/elision-get.test.cjs +66 -0
- package/bin/np-tools/learnings.cjs +1 -1
- package/bin/np-tools/loop-run-round.cjs +25 -11
- package/bin/np-tools/plan-milestone.cjs +1 -0
- package/bin/np-tools/research-phase.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 +155 -3
- 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/cache-align.cjs +78 -0
- package/lib/cache-align.test.cjs +69 -0
- package/lib/compress.cjs +495 -0
- package/lib/compress.test.cjs +267 -0
- package/lib/config-defaults.cjs +39 -0
- package/lib/config-schema.cjs +45 -5
- package/lib/elision-bench.cjs +409 -0
- package/lib/elision-bench.test.cjs +89 -0
- package/lib/elision-proxy.cjs +158 -0
- package/lib/elision-proxy.test.cjs +243 -0
- package/lib/elision.cjs +163 -0
- package/lib/elision.test.cjs +143 -0
- 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/nubosloop.cjs +1 -1
- package/lib/output-steering.cjs +68 -0
- package/lib/output-steering.test.cjs +74 -0
- package/lib/researcher-swarm.cjs +14 -3
- package/lib/runtime/agent-loop.cjs +94 -0
- package/lib/runtime/agent-loop.test.cjs +240 -0
- package/lib/runtime/dispatch.cjs +174 -0
- package/lib/runtime/dispatch.test.cjs +207 -0
- package/lib/runtime/preflight.cjs +68 -0
- package/lib/runtime/preflight.test.cjs +62 -0
- package/lib/runtime/providers/openai-compat.cjs +103 -0
- package/lib/runtime/providers/openai-compat.test.cjs +112 -0
- package/lib/runtime/tools/index.cjs +447 -0
- package/lib/runtime/tools/index.test.cjs +254 -0
- package/lib/schemas/data/elision-entry.v1.json +16 -0
- package/lib/security/review.cjs +4 -4
- package/lib/security/review.test.cjs +6 -6
- package/lib/token-cost.cjs +46 -0
- package/lib/token-cost.test.cjs +42 -0
- package/np-tools.cjs +3 -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,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,103 @@
|
|
|
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, effort, 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
|
+
if (typeof effort === 'string' && effort) body.reasoning_effort = effort;
|
|
65
|
+
|
|
66
|
+
const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
67
|
+
const host = _hostOf(url);
|
|
68
|
+
|
|
69
|
+
let res;
|
|
70
|
+
try {
|
|
71
|
+
res = await f(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS),
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new NubosPilotError(
|
|
79
|
+
'provider-request-failed',
|
|
80
|
+
'request to ' + host + ' failed (' + ((err && (err.code || err.name)) || 'error') + ')',
|
|
81
|
+
{ host, cause: (err && (err.code || err.name)) || 'unknown' },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
let snippet = '';
|
|
87
|
+
try { snippet = (await res.text()).slice(0, 300); } catch {}
|
|
88
|
+
throw new NubosPilotError(
|
|
89
|
+
'provider-http-error',
|
|
90
|
+
host + ' returned HTTP ' + res.status,
|
|
91
|
+
{ host, status: res.status, body: snippet },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let json;
|
|
96
|
+
try { json = await res.json(); }
|
|
97
|
+
catch {
|
|
98
|
+
throw new NubosPilotError('provider-bad-json', host + ' returned a non-JSON body', { host });
|
|
99
|
+
}
|
|
100
|
+
return _parse(json);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { chat, _parse, _hostOf, DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,112 @@
|
|
|
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-3b: chat forwards effort as reasoning_effort only when set', async () => {
|
|
53
|
+
let captured = null;
|
|
54
|
+
const fetchImpl = async (_url, opts) => { captured = JSON.parse(opts.body); return _res({ json: { choices: [{ message: { content: 'ok' } }] } }); };
|
|
55
|
+
await chat({ baseUrl: 'http://x/v1', model: 'm', messages: [{ role: 'user', content: 'x' }], effort: 'low', fetchImpl });
|
|
56
|
+
assert.equal(captured.reasoning_effort, 'low', 'a set effort reaches the request body');
|
|
57
|
+
await chat({ baseUrl: 'http://x/v1', model: 'm', messages: [{ role: 'user', content: 'x' }], fetchImpl });
|
|
58
|
+
assert.ok(!('reasoning_effort' in captured), 'absent effort is never sent (providers without support unaffected)');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('OAC-4: api_key_env adds a bearer header; missing key throws provider-missing-api-key', async () => {
|
|
62
|
+
let auth = null;
|
|
63
|
+
const fetchImpl = async (_url, opts) => { auth = opts.headers.authorization; return _res({ json: { choices: [{ message: { content: 'ok' } }] } }); };
|
|
64
|
+
await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: { XAI_KEY: 'sk-123' }, fetchImpl });
|
|
65
|
+
assert.equal(auth, 'Bearer sk-123');
|
|
66
|
+
|
|
67
|
+
let thrown = null;
|
|
68
|
+
try { await chat({ baseUrl: 'https://api.x.ai/v1', model: 'grok-2', messages: [], apiKeyEnv: 'XAI_KEY', env: {}, fetchImpl }); }
|
|
69
|
+
catch (e) { thrown = e; }
|
|
70
|
+
assert.equal(thrown && thrown.code, 'provider-missing-api-key');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('OAC-5: non-2xx throws provider-http-error carrying status + host (not full url)', async () => {
|
|
74
|
+
const fetchImpl = async () => _res({ ok: false, status: 500, text: 'boom' });
|
|
75
|
+
let thrown = null;
|
|
76
|
+
try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
|
|
77
|
+
catch (e) { thrown = e; }
|
|
78
|
+
assert.equal(thrown.code, 'provider-http-error');
|
|
79
|
+
assert.equal(thrown.details.status, 500);
|
|
80
|
+
assert.equal(thrown.details.host, 'localhost:11434');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('OAC-6: network failure throws provider-request-failed with host only', async () => {
|
|
84
|
+
const fetchImpl = async () => { const e = new Error('refused'); e.code = 'ECONNREFUSED'; throw e; };
|
|
85
|
+
let thrown = null;
|
|
86
|
+
try { await chat({ baseUrl: 'http://localhost:11434/v1', model: 'qwen', messages: [], fetchImpl }); }
|
|
87
|
+
catch (e) { thrown = e; }
|
|
88
|
+
assert.equal(thrown.code, 'provider-request-failed');
|
|
89
|
+
assert.equal(thrown.details.host, 'localhost:11434');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('OAC-7: missing base_url / model throw before any fetch', async () => {
|
|
93
|
+
let a = null; try { await chat({ model: 'm', messages: [] }); } catch (e) { a = e; }
|
|
94
|
+
assert.equal(a.code, 'provider-no-base-url');
|
|
95
|
+
let b = null; try { await chat({ baseUrl: 'http://x/v1', messages: [] }); } catch (e) { b = e; }
|
|
96
|
+
assert.equal(b.code, 'provider-no-model');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('OAC-8: _parse synthesizes a stable id when the provider omits tool_calls[].id', () => {
|
|
100
|
+
const out = _parse({ choices: [{ message: { tool_calls: [
|
|
101
|
+
{ function: { name: 'Read', arguments: '{}' } },
|
|
102
|
+
{ function: { name: 'Grep', arguments: '{}' } },
|
|
103
|
+
] } }] });
|
|
104
|
+
assert.deepEqual(out.toolCalls.map((t) => t.id), ['call_0', 'call_1']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('OAC-9: _parse captures token usage when present', () => {
|
|
108
|
+
const out = _parse({ choices: [{ message: { content: 'x' } }], usage: { prompt_tokens: 12, completion_tokens: 5 } });
|
|
109
|
+
assert.deepEqual(out.usage, { tokens_in: 12, tokens_out: 5 });
|
|
110
|
+
const none = _parse({ choices: [{ message: { content: 'x' } }] });
|
|
111
|
+
assert.equal(none.usage, null);
|
|
112
|
+
});
|