pi-hypercharm-provider 1.3.35 → 1.3.36
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 +7 -0
- package/README.md +5 -0
- package/package.json +4 -4
- package/scripts/test-pi-compat.mjs +85 -20
- package/tests/provider.integration.test.ts +23 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.36
|
|
4
|
+
|
|
5
|
+
- Pin Pi SDK development dependencies to 1.0.0 while retaining wildcard host peers.
|
|
6
|
+
- Verify real manifest loading, provider catalogs, startup/shutdown and native/bundled Pi hosts offline.
|
|
7
|
+
- Exercise real transport adapters with Unicode text, tool calls, empty responses, usage, request hooks and cancellation; no live provider calls.
|
|
8
|
+
- Make deprecated-catalog integration fixtures time-independent and emit shutdown before disposing test sessions.
|
|
9
|
+
|
|
3
10
|
## 1.3.35
|
|
4
11
|
|
|
5
12
|
- Validate against Pi 0.99.0, including an offline real-host package-loading probe.
|
package/README.md
CHANGED
|
@@ -11,6 +11,11 @@ _Hyperoptimized coding models — DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, GPT
|
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
+
## Pi 1.0 compatibility
|
|
15
|
+
|
|
16
|
+
Tested with Pi **1.0.0**. Host-provided packages remain wildcard peers; development uses exact SDK pins.
|
|
17
|
+
Run `bun run test:pi` for offline manifest, catalog, lifecycle and streaming checks. To test an installed host, set `PI1_HOST_PACKAGE` to its package directory; add `PI1_HOST_ENTRY=bundle` for its bundled CLI runtime. The probe stubs all network requests and never uses a live provider endpoint.
|
|
18
|
+
|
|
14
19
|
---
|
|
15
20
|
|
|
16
21
|
## Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hypercharm-provider",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.36",
|
|
4
4
|
"description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@earendil-works/pi-coding-agent": "*"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@earendil-works/pi-ai": "0.
|
|
40
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
41
|
-
"@earendil-works/pi-tui": "0.
|
|
39
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
41
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
42
42
|
"@types/node": "^24.10.1",
|
|
43
43
|
"jiti": "2.7.0",
|
|
44
44
|
"typescript": "6.0.3"
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
+
// Offline Pi 1.0 manifest, lifecycle, catalog and real transport regression.
|
|
1
2
|
import assert from 'node:assert/strict';
|
|
2
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
7
|
|
|
7
8
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
-
const home = await mkdtemp(join(tmpdir(), 'pi-
|
|
9
|
+
const home = await mkdtemp(join(tmpdir(), 'pi-provider-compat-'));
|
|
9
10
|
const previousHome = process.env.PI_CODING_AGENT_DIR;
|
|
10
11
|
const previousFetch = globalThis.fetch;
|
|
11
12
|
process.env.PI_CODING_AGENT_DIR = home;
|
|
13
|
+
// Never allow a background catalog/account request to reach a real endpoint.
|
|
12
14
|
globalThis.fetch = async () => new Response('', { status: 503 });
|
|
13
15
|
let session;
|
|
14
16
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
+
const host = process.env.PI1_HOST_PACKAGE;
|
|
18
|
+
const hostEntry = process.env.PI1_HOST_ENTRY === 'bundle' ? 'dist/bundle/index.js' : 'dist/index.js';
|
|
19
|
+
const sdk = await import(host ? pathToFileURL(join(host, hostEntry)).href : '@earendil-works/pi-coding-agent');
|
|
20
|
+
const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, VERSION } = sdk;
|
|
21
|
+
assert.equal(VERSION, '1.0.0', 'executing host version');
|
|
22
|
+
assert.equal((await import('@earendil-works/pi-coding-agent')).VERSION, '1.0.0', 'development host version');
|
|
17
23
|
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
18
24
|
for (const name of ['@earendil-works/pi-ai', '@earendil-works/pi-agent-core', '@earendil-works/pi-coding-agent', '@earendil-works/pi-tui', 'typebox']) {
|
|
19
|
-
assert.equal(manifest.dependencies?.[name], undefined, `${name}:
|
|
25
|
+
assert.equal(manifest.dependencies?.[name], undefined, `${name}: do not bundle host packages`);
|
|
20
26
|
if (manifest.peerDependencies?.[name] !== undefined) assert.equal(manifest.peerDependencies[name], '*');
|
|
27
|
+
if (name !== 'typebox' && manifest.devDependencies?.[name]) assert.equal(manifest.devDependencies[name], '1.0.0');
|
|
21
28
|
}
|
|
22
29
|
const settingsManager = SettingsManager.inMemory({ packages: [root], compaction: { enabled: false }, retry: { enabled: false } });
|
|
23
30
|
const resourceLoader = new DefaultResourceLoader({ cwd: home, agentDir: home, settingsManager,
|
|
@@ -26,25 +33,83 @@ try {
|
|
|
26
33
|
const loaded = resourceLoader.getExtensions();
|
|
27
34
|
assert.deepEqual(loaded.errors, []);
|
|
28
35
|
assert.deepEqual(loaded.warnings ?? [], []);
|
|
29
|
-
assert.
|
|
36
|
+
assert.equal(loaded.extensions.length, manifest.pi.extensions.length, 'all manifest entrypoints load');
|
|
37
|
+
// Endpoint/header-only re-registration (e.g. MCR) retains the prior catalog.
|
|
38
|
+
const registrations = [...loaded.runtime.pendingProviderRegistrations.reduce((byName, { name, config }) =>
|
|
39
|
+
byName.set(name, { name, config: { ...byName.get(name)?.config, ...config } }), new Map()).values()];
|
|
40
|
+
assert(registrations.length > 0, 'providers register before session startup');
|
|
30
41
|
const modelRuntime = await ModelRuntime.create({ authPath: join(home, 'auth.json'), modelsPath: null, modelsStorePath: join(home, 'models-cache'), allowModelNetwork: false });
|
|
31
42
|
({ session } = await createAgentSession({ cwd: home, agentDir: home, resourceLoader, modelRuntime, settingsManager, sessionManager: SessionManager.inMemory(home) }));
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const errors = [];
|
|
44
|
+
await session.bindExtensions({ mode: 'print', onError: error => errors.push(error) });
|
|
45
|
+
await new Promise(done => setImmediate(done));
|
|
46
|
+
const tools = new Set();
|
|
47
|
+
for (const extension of loaded.extensions) for (const [name, { definition }] of extension.tools) {
|
|
48
|
+
assert.equal(definition.name, name); assert.equal(typeof definition.execute, 'function');
|
|
49
|
+
assert.equal(typeof definition.parameters, 'object'); assert(!tools.has(name)); tools.add(name);
|
|
50
|
+
assert(session.getAllTools().some(tool => tool.name === name));
|
|
51
|
+
}
|
|
52
|
+
let modelCount = 0, streamChecks = 0;
|
|
53
|
+
for (const { name, config } of registrations) {
|
|
54
|
+
const models = modelRuntime.getAllModels(name);
|
|
55
|
+
assert.equal(models.length, config.models.length, `${name}: catalog survives registration`);
|
|
56
|
+
assert(models.length > 0);
|
|
57
|
+
assert.equal(new Set(models.map(m => `${m.type}/${m.id}`)).size, models.length);
|
|
58
|
+
for (const model of models) {
|
|
59
|
+
assert.equal(model.provider, name); assert(model.api && model.baseUrl && model.id && model.name);
|
|
60
|
+
if (model.type === 'image') assert(model.output.includes('image'));
|
|
61
|
+
else assert(model.contextWindow > 0);
|
|
62
|
+
if ((model.type ?? 'chat') === 'chat') assert(model.maxTokens > 0);
|
|
63
|
+
assert(model.input.includes('text'));
|
|
64
|
+
for (const cost of Object.values(model.cost)) assert(Number.isFinite(cost) && cost >= 0);
|
|
65
|
+
}
|
|
66
|
+
const chatModels = modelRuntime.getModels(name);
|
|
67
|
+
const model = chatModels.find(m => m.api === 'openai-completions') ?? chatModels[0];
|
|
68
|
+
assert(model, 'a chat model is available for offline transport checks');
|
|
69
|
+
const user = { role: 'user', content: 'hello', timestamp: 1 };
|
|
70
|
+
async function probe(tool = false, empty = false) {
|
|
71
|
+
let payloadCalls = 0, responseCalls = 0, observed = 0, wire;
|
|
72
|
+
const fetch = async (_url, init) => {
|
|
73
|
+
wire = JSON.parse(init.body);
|
|
74
|
+
const delta = tool ? { tool_calls: [{ index: 0, id: 'call_probe', type: 'function', function: { name: 'probe', arguments: '{"value":7}' } }] } : { content: empty ? '' : 'Hello 界' };
|
|
75
|
+
const chunks = [{ id: 'offline', choices: [{ index: 0, delta: { role: 'assistant', ...delta }, finish_reason: null }] }, { id: 'offline', choices: [{ index: 0, delta: {}, finish_reason: tool ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }];
|
|
76
|
+
return new Response(chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } });
|
|
77
|
+
};
|
|
78
|
+
const stream = modelRuntime.streamSimple(model, { messages: [{ role: 'system', content: 'Offline probe', toolsAdded: [{ name: 'probe', description: 'Probe', parameters: { type: 'object', properties: { value: { type: 'integer' } }, required: ['value'] } }], timestamp: 0 }, user] }, {
|
|
79
|
+
apiKey: 'offline-placeholder', maxTokens: 32, maxRetries: 0, fetch,
|
|
80
|
+
onPayload: p => { payloadCalls++; return { ...p, temperature: 0.123 }; },
|
|
81
|
+
onResponse: () => { responseCalls++; }, onProviderStreamEvent: () => { observed++; },
|
|
82
|
+
});
|
|
83
|
+
const events = []; for await (const event of stream) events.push(event.type);
|
|
84
|
+
const result = await stream.result();
|
|
85
|
+
assert.equal(result.stopReason, tool ? 'toolUse' : 'stop', result.errorMessage);
|
|
86
|
+
assert.equal(events.filter(e => e === 'start').length, 1);
|
|
87
|
+
assert.equal(events.filter(e => e === 'done' || e === 'error').length, 1);
|
|
88
|
+
assert.equal(events.at(-1), 'done');
|
|
89
|
+
assert.equal(payloadCalls, 1); assert.equal(responseCalls, 1); assert(observed >= 2);
|
|
90
|
+
assert.equal(wire.temperature, 0.123); assert.equal(wire.tools[0].function.name, 'probe');
|
|
91
|
+
assert.equal(result.usage.totalTokens, 12);
|
|
92
|
+
if (tool) assert.deepEqual(result.content.find(c => c.type === 'toolCall').arguments, { value: 7 });
|
|
93
|
+
else if (!empty) assert.equal(result.content.find(c => c.type === 'text').text, 'Hello 界');
|
|
94
|
+
streamChecks++;
|
|
41
95
|
}
|
|
96
|
+
await probe(); await probe(true); await probe(false, true);
|
|
97
|
+
// Abort after preflight: the SDK's lazy preflight reports an already-aborted
|
|
98
|
+
// auth operation as an error; this checks provider transport cancellation.
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const aborted = await modelRuntime.streamSimple(model, { messages: [user] }, {
|
|
101
|
+
apiKey: 'offline-placeholder', signal: controller.signal, maxRetries: 0,
|
|
102
|
+
onPayload: () => { controller.abort(); },
|
|
103
|
+
fetch: async () => { throw new DOMException('Aborted', 'AbortError'); },
|
|
104
|
+
}).result();
|
|
105
|
+
assert.equal(aborted.stopReason, 'aborted', aborted.errorMessage); streamChecks++;
|
|
106
|
+
modelCount += models.length;
|
|
42
107
|
}
|
|
43
|
-
|
|
108
|
+
await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' });
|
|
109
|
+
assert.deepEqual(errors, []);
|
|
110
|
+
console.log(JSON.stringify({ repo: manifest.name, pi: VERSION, hostEntry, extensions: loaded.extensions.length, tools: tools.size, models: modelCount, streamChecks, lifecycle: 'passed' }));
|
|
44
111
|
} finally {
|
|
45
|
-
session?.dispose();
|
|
46
|
-
|
|
47
|
-
if (previousHome === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
48
|
-
else process.env.PI_CODING_AGENT_DIR = previousHome;
|
|
112
|
+
session?.dispose(); globalThis.fetch = previousFetch;
|
|
113
|
+
if (previousHome === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousHome;
|
|
49
114
|
await rm(home, { recursive: true, force: true });
|
|
50
115
|
}
|
|
@@ -144,7 +144,12 @@ async function load(options = {}) {
|
|
|
144
144
|
noThemes: true,
|
|
145
145
|
noContextFiles: true,
|
|
146
146
|
});
|
|
147
|
-
|
|
147
|
+
// Pin only catalog loading, not timeout clocks or subsequent provider work.
|
|
148
|
+
const now = Date.now;
|
|
149
|
+
try {
|
|
150
|
+
if (options.catalogNow !== undefined) Date.now = () => options.catalogNow;
|
|
151
|
+
await loader.reload();
|
|
152
|
+
} finally { Date.now = now; }
|
|
148
153
|
assert.deepEqual(loader.getExtensions().errors, [], "extension must load without errors");
|
|
149
154
|
|
|
150
155
|
const sessionManager = options.sessionFile
|
|
@@ -163,7 +168,10 @@ async function load(options = {}) {
|
|
|
163
168
|
// the extension session path is part of what this suite verifies.
|
|
164
169
|
const runner = session.extensionRunner;
|
|
165
170
|
await runner.emit({ type: "session_start", reason: "startup" });
|
|
166
|
-
return { credentials, runtime, loader, session, sessionManager, runner
|
|
171
|
+
return { credentials, runtime, loader, session, sessionManager, runner, async shutdown() {
|
|
172
|
+
try { await runner.emit({ type: "session_shutdown", reason: "quit" }); }
|
|
173
|
+
finally { session.dispose(); }
|
|
174
|
+
} };
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
function assistantMessage(overrides = {}) {
|
|
@@ -191,7 +199,9 @@ test("loads offline: embedded catalog, deprecated grace, namespaced registration
|
|
|
191
199
|
const stderr = captureStderr();
|
|
192
200
|
let harness;
|
|
193
201
|
try {
|
|
194
|
-
|
|
202
|
+
const graveyard = Object.values(JSON.parse(readFileSync(deprecatedModelsPath, "utf8")));
|
|
203
|
+
const graceNow = Math.max(...graveyard.map(entry => Date.parse(entry.deprecatedAt))) + 24 * 60 * 60 * 1000;
|
|
204
|
+
harness = await load({ catalogNow: graceNow });
|
|
195
205
|
|
|
196
206
|
const embedded = harness.runtime.getModel("hypercharm", "deepseek-v4-flash");
|
|
197
207
|
assert.ok(embedded, "embedded catalog must register without any network");
|
|
@@ -210,8 +220,8 @@ test("loads offline: embedded catalog, deprecated grace, namespaced registration
|
|
|
210
220
|
).map((model) => model.id),
|
|
211
221
|
);
|
|
212
222
|
const deprecatedOnly = Object.values(deprecated).filter((entry) => !embeddedIds.has(entry.id));
|
|
213
|
-
const fresh = deprecatedOnly.filter((entry) =>
|
|
214
|
-
const stale = deprecatedOnly.filter((entry) =>
|
|
223
|
+
const fresh = deprecatedOnly.filter((entry) => graceNow - Date.parse(entry.deprecatedAt) <= DEPRECATED_TTL_MS);
|
|
224
|
+
const stale = deprecatedOnly.filter((entry) => graceNow - Date.parse(entry.deprecatedAt) > DEPRECATED_TTL_MS);
|
|
215
225
|
assert.ok(fresh.length > 0, "expected at least one model inside the deprecated grace window");
|
|
216
226
|
for (const entry of fresh) {
|
|
217
227
|
assert.ok(harness.runtime.getModel("hypercharm", entry.id), entry.id + " must be served during its grace period");
|
|
@@ -235,7 +245,7 @@ test("loads offline: embedded catalog, deprecated grace, namespaced registration
|
|
|
235
245
|
assert.match(warning, /network disabled/);
|
|
236
246
|
} finally {
|
|
237
247
|
stderr.restore();
|
|
238
|
-
harness?.
|
|
248
|
+
await harness?.shutdown();
|
|
239
249
|
}
|
|
240
250
|
});
|
|
241
251
|
|
|
@@ -268,7 +278,7 @@ test("refreshes from /v1/provider, caches the catalog, and retains it when refre
|
|
|
268
278
|
assert.ok(Array.isArray(cached), "catalog cache written as an array");
|
|
269
279
|
assert.ok(cached.some((entry) => entry.id === "fixture-model"), "cache holds the refreshed catalog");
|
|
270
280
|
} finally {
|
|
271
|
-
first.
|
|
281
|
+
await first.shutdown();
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
// A later session keeps serving the cached catalog even though Hyper is down.
|
|
@@ -280,7 +290,7 @@ test("refreshes from /v1/provider, caches the catalog, and retains it when refre
|
|
|
280
290
|
assert.match(warning, /model catalog/);
|
|
281
291
|
} finally {
|
|
282
292
|
stderr.restore();
|
|
283
|
-
second.
|
|
293
|
+
await second.shutdown();
|
|
284
294
|
}
|
|
285
295
|
});
|
|
286
296
|
|
|
@@ -368,7 +378,7 @@ test("records prism routing as durable session entries", async () => {
|
|
|
368
378
|
// renderer registration both survive.
|
|
369
379
|
const sessionFile = sessionManager.getSessionFile();
|
|
370
380
|
assert.ok(sessionFile);
|
|
371
|
-
harness.
|
|
381
|
+
await harness.shutdown();
|
|
372
382
|
disposed = true;
|
|
373
383
|
|
|
374
384
|
const restored = await load({ sessionFile });
|
|
@@ -389,10 +399,10 @@ test("records prism routing as durable session entries", async () => {
|
|
|
389
399
|
assert.ok(restoredComponent);
|
|
390
400
|
assert.ok(restoredComponent.render(80).join("\n").includes("Prism \u2192 GLM 5.3 Flash"));
|
|
391
401
|
} finally {
|
|
392
|
-
restored.
|
|
402
|
+
await restored.shutdown();
|
|
393
403
|
}
|
|
394
404
|
} finally {
|
|
395
|
-
if (!disposed) harness.
|
|
405
|
+
if (!disposed) await harness.shutdown();
|
|
396
406
|
}
|
|
397
407
|
});
|
|
398
408
|
|
|
@@ -428,7 +438,7 @@ test("co-installs with the official identifier surface without interference", as
|
|
|
428
438
|
}
|
|
429
439
|
assert.equal(ui.statusKeys.includes("hyper") && ownKeys.includes("hyper"), false);
|
|
430
440
|
} finally {
|
|
431
|
-
harness.
|
|
441
|
+
await harness.shutdown();
|
|
432
442
|
}
|
|
433
443
|
});
|
|
434
444
|
|
|
@@ -480,6 +490,6 @@ test("shows the footer widget on model selection and clears it when the provider
|
|
|
480
490
|
"no reinstall while another provider's model is active",
|
|
481
491
|
);
|
|
482
492
|
} finally {
|
|
483
|
-
harness.
|
|
493
|
+
await harness.shutdown();
|
|
484
494
|
}
|
|
485
495
|
});
|