@north-light/crouter 0.3.160 → 0.3.162
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/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/modelauth.d.ts +32 -0
- package/dist/api/dto/modelauth.js +2 -1
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
- package/dist/clients/attach/__tests__/markdown-headings.test.js +8 -0
- package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
- package/dist/clients/attach/render/glyph-codepoints.js +42 -0
- package/dist/clients/attach/render/markdown-headings.js +15 -4
- package/dist/clients/attach/viewer.js +558 -558
- package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
- package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
- package/dist/core/__tests__/model-pin-durability.test.js +7 -5
- package/dist/core/__tests__/model-routes.test.d.ts +1 -0
- package/dist/core/__tests__/model-routes.test.js +41 -0
- package/dist/core/__tests__/review-model-floor.test.js +2 -2
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/labels.d.ts +5 -3
- package/dist/core/canvas/labels.js +7 -4
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +116 -372
- package/dist/core/runtime/launch.d.ts +9 -11
- package/dist/core/runtime/launch.js +15 -21
- package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
- package/dist/core/runtime/managed-provider-cooling.js +0 -19
- package/dist/core/runtime/model-swap.js +7 -1
- package/dist/core/runtime/naming.js +21 -14
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
- package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
- package/dist/daemon/api/handlers/modelauth.js +51 -2
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/types.d.ts +17 -0
- package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +8 -8
- package/runtime.lock.json +161 -161
|
@@ -128,15 +128,18 @@ test('A3: messages append inbox.jsonl; final report writes + notifies subscriber
|
|
|
128
128
|
assert.equal(h.node(B)?.status, 'done', 'row status is done after final');
|
|
129
129
|
});
|
|
130
130
|
// ---------------------------------------------------------------------------
|
|
131
|
-
// A4 — WS attach
|
|
132
|
-
// ?revive=0 on a dormant node → 409 without launching a broker
|
|
131
|
+
// A4 — WS attach reconstructs the same settled broker snapshot as a direct
|
|
132
|
+
// ViewSocketClient; ?revive=0 on a dormant node → 409 without launching a broker
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
134
|
test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketClient snapshot', async () => {
|
|
135
135
|
const root = h.spawnRoot('a4 root');
|
|
136
136
|
const id = (await client.createNode({ kind: 'developer', parent: root, prompt: 'a4 worker' })).node_id;
|
|
137
137
|
await h.awaitBoot(id);
|
|
138
|
-
//
|
|
139
|
-
|
|
138
|
+
// Await the fake engine's startup turn settling before either transport is
|
|
139
|
+
// compared. `awaitBoot()` proves the process started, not that its initial
|
|
140
|
+
// prompt has stopped streaming; comparing it with a later WS welcome races
|
|
141
|
+
// two real broker instants.
|
|
142
|
+
const direct = await kit.attachUntil(id, 'observer', 'a4-direct', (attached) => attached.welcome.snapshot.state.isStreaming === false, 'settled direct broker snapshot');
|
|
140
143
|
// WS capture over the unix socket at the attach path. The node is already
|
|
141
144
|
// live+booted, so ?revive=0 is fine (the socket is listenable).
|
|
142
145
|
const wsWelcome = await new Promise((resolveWelcome, rejectWelcome) => {
|
|
@@ -164,8 +167,8 @@ test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketCl
|
|
|
164
167
|
rejectWelcome(err);
|
|
165
168
|
});
|
|
166
169
|
});
|
|
167
|
-
// Compare the SNAPSHOT field only (role
|
|
168
|
-
//
|
|
170
|
+
// Compare the SNAPSHOT field only (role is outside the snapshot). Both
|
|
171
|
+
// transports captured the same settled broker state.
|
|
169
172
|
assert.equal(JSON.stringify(wsWelcome.snapshot), JSON.stringify(direct.welcome.snapshot), 'WS welcome.snapshot is byte-identical to the direct ViewSocketClient snapshot');
|
|
170
173
|
direct.close();
|
|
171
174
|
});
|
|
@@ -34,7 +34,8 @@ import assert from 'node:assert/strict';
|
|
|
34
34
|
import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
35
35
|
import { tmpdir } from 'node:os';
|
|
36
36
|
import { join } from 'node:path';
|
|
37
|
-
import { setAuthCredential } from '../../../../core/auth-file.js';
|
|
37
|
+
import { readAuthCredentials, setAuthCredential } from '../../../../core/auth-file.js';
|
|
38
|
+
import { readSubscriptionPool, writeSubscriptionPool } from '../../../../core/subscription-state.js';
|
|
38
39
|
import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
|
|
39
40
|
import { createAttachKit } from '../../../../core/__tests__/helpers/broker-clients.js';
|
|
40
41
|
import { CrtrClient } from '../../../../api/index.js';
|
|
@@ -218,3 +219,124 @@ test('B2-modelauth-oauth: installCredential(oauth) preserves extra fields (enter
|
|
|
218
219
|
rmSync(apiDir, { recursive: true, force: true });
|
|
219
220
|
}
|
|
220
221
|
});
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Credential truth/removal — GET exposes only connection state and DELETE
|
|
224
|
+
// clears exactly its provider's durable store.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
test('B2-modelauth-status: list is sanitized and delete clears only the selected provider', async () => {
|
|
227
|
+
const savedPiDir = process.env['PI_CODING_AGENT_DIR'];
|
|
228
|
+
const apiDir = mkdtempSync(join(tmpdir(), 'crtr-b10-status-'));
|
|
229
|
+
process.env['PI_CODING_AGENT_DIR'] = apiDir;
|
|
230
|
+
const expires = 1_900_000_000_000;
|
|
231
|
+
const cooldown = 1_900_000_100_000;
|
|
232
|
+
try {
|
|
233
|
+
await writeSubscriptionPool('anthropic', [
|
|
234
|
+
{
|
|
235
|
+
label: 'Claude One',
|
|
236
|
+
refresh: 'anthropic-refresh-secret',
|
|
237
|
+
access: 'anthropic-access-secret',
|
|
238
|
+
expires,
|
|
239
|
+
rateLimitedUntil: cooldown,
|
|
240
|
+
lastAttemptAt: 0,
|
|
241
|
+
lastRateLimitedAt: 0,
|
|
242
|
+
authFailure: 'invalid_grant',
|
|
243
|
+
accountId: 'anthropic-account-id',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: 'Claude Two',
|
|
247
|
+
refresh: 'second-refresh-secret',
|
|
248
|
+
access: 'second-access-secret',
|
|
249
|
+
expires,
|
|
250
|
+
rateLimitedUntil: 0,
|
|
251
|
+
lastAttemptAt: 0,
|
|
252
|
+
lastRateLimitedAt: 0,
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
await writeSubscriptionPool('openai-codex', [
|
|
256
|
+
{
|
|
257
|
+
label: 'ChatGPT',
|
|
258
|
+
refresh: 'codex-refresh-secret',
|
|
259
|
+
access: 'codex-access-secret',
|
|
260
|
+
expires,
|
|
261
|
+
rateLimitedUntil: 0,
|
|
262
|
+
lastAttemptAt: 0,
|
|
263
|
+
lastRateLimitedAt: 0,
|
|
264
|
+
accountId: 'codex-account-id',
|
|
265
|
+
},
|
|
266
|
+
]);
|
|
267
|
+
await setAuthCredential('github-copilot', {
|
|
268
|
+
type: 'oauth',
|
|
269
|
+
access: 'copilot-access-secret',
|
|
270
|
+
refresh: 'copilot-refresh-secret',
|
|
271
|
+
expires,
|
|
272
|
+
}, join(apiDir, 'auth.json'));
|
|
273
|
+
// A managed provider may also have a hand-configured API key. It remains
|
|
274
|
+
// intentionally absent from status, but DELETE must remove it too.
|
|
275
|
+
await setAuthCredential('anthropic', { type: 'api_key', key: 'anthropic-api-key-secret' }, join(apiDir, 'auth.json'));
|
|
276
|
+
const status = await client.listModelAuth();
|
|
277
|
+
assert.deepEqual(status.providers, [
|
|
278
|
+
{
|
|
279
|
+
provider: 'anthropic',
|
|
280
|
+
managed: true,
|
|
281
|
+
configured: true,
|
|
282
|
+
accounts: [
|
|
283
|
+
{
|
|
284
|
+
label: 'Claude One',
|
|
285
|
+
expires_at: new Date(expires).toISOString(),
|
|
286
|
+
rate_limited_until: new Date(cooldown).toISOString(),
|
|
287
|
+
auth_failure: 'invalid_grant',
|
|
288
|
+
account_id: 'anthropic-account-id',
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
label: 'Claude Two',
|
|
292
|
+
expires_at: new Date(expires).toISOString(),
|
|
293
|
+
rate_limited_until: new Date(0).toISOString(),
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
provider: 'openai-codex',
|
|
299
|
+
managed: true,
|
|
300
|
+
configured: true,
|
|
301
|
+
accounts: [
|
|
302
|
+
{
|
|
303
|
+
label: 'ChatGPT',
|
|
304
|
+
expires_at: new Date(expires).toISOString(),
|
|
305
|
+
rate_limited_until: new Date(0).toISOString(),
|
|
306
|
+
account_id: 'codex-account-id',
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{ provider: 'github-copilot', managed: false, configured: true, kind: 'oauth' },
|
|
311
|
+
]);
|
|
312
|
+
const wire = JSON.stringify(status);
|
|
313
|
+
for (const secret of [
|
|
314
|
+
'anthropic-refresh-secret',
|
|
315
|
+
'anthropic-access-secret',
|
|
316
|
+
'second-refresh-secret',
|
|
317
|
+
'second-access-secret',
|
|
318
|
+
'codex-refresh-secret',
|
|
319
|
+
'codex-access-secret',
|
|
320
|
+
'copilot-refresh-secret',
|
|
321
|
+
'copilot-access-secret',
|
|
322
|
+
'anthropic-api-key-secret',
|
|
323
|
+
]) {
|
|
324
|
+
assert.ok(!wire.includes(secret), `status never exposes ${secret}`);
|
|
325
|
+
}
|
|
326
|
+
const removedManaged = await client.removeCredential('anthropic');
|
|
327
|
+
assert.deepEqual(removedManaged, { provider: 'anthropic', removed_accounts: 3 });
|
|
328
|
+
assert.deepEqual(readSubscriptionPool('anthropic'), [], 'selected managed pool is empty');
|
|
329
|
+
assert.ok(!Object.hasOwn(await readAuthCredentials(join(apiDir, 'auth.json')), 'anthropic'), 'managed API key is removed');
|
|
330
|
+
assert.equal(readSubscriptionPool('openai-codex').length, 1, 'other managed pool is unchanged');
|
|
331
|
+
const removedOauth = await client.removeCredential('github-copilot');
|
|
332
|
+
assert.deepEqual(removedOauth, { provider: 'github-copilot', removed_accounts: 1 });
|
|
333
|
+
assert.deepEqual((await client.listModelAuth()).providers.map((provider) => provider.provider), ['anthropic', 'openai-codex'], 'deleted auth.json credential no longer appears in status');
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
if (savedPiDir === undefined)
|
|
337
|
+
delete process.env['PI_CODING_AGENT_DIR'];
|
|
338
|
+
else
|
|
339
|
+
process.env['PI_CODING_AGENT_DIR'] = savedPiDir;
|
|
340
|
+
rmSync(apiDir, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// fans `reload_auth` to live brokers — a write here is picked up by that watcher
|
|
9
9
|
// (do NOT re-implement the fan).
|
|
10
10
|
//
|
|
11
|
+
// GET /v1/model-auth sanitized credential presence
|
|
11
12
|
// PUT /v1/model-auth/:provider install/rotate a credential (idempotent)
|
|
13
|
+
// DELETE /v1/model-auth/:provider remove all credentials for one provider
|
|
12
14
|
import { homedir } from 'node:os';
|
|
13
15
|
import { join } from 'node:path';
|
|
14
|
-
import { setAuthCredential } from '../../../core/auth-file.js';
|
|
16
|
+
import { deleteAuthCredential, readAuthCredentials, setAuthCredential } from '../../../core/auth-file.js';
|
|
15
17
|
import { ApiError } from '../../../api/index.js';
|
|
16
|
-
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, } from '../../../core/subscription-state.js';
|
|
18
|
+
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, removeAllManagedAccounts, } from '../../../core/subscription-state.js';
|
|
17
19
|
/** Resolve the shared pi credentials file, matching the daemon's auth.json
|
|
18
20
|
* mtime watcher exactly (`PI_CODING_AGENT_DIR` env, else `~/.pi/agent`) so a
|
|
19
21
|
* write here is the file that watcher fans a `reload_auth` for. */
|
|
@@ -43,6 +45,37 @@ function requireField(kind, field, value) {
|
|
|
43
45
|
}
|
|
44
46
|
return value;
|
|
45
47
|
}
|
|
48
|
+
function toManagedAccountStatus(account) {
|
|
49
|
+
return {
|
|
50
|
+
label: account.label,
|
|
51
|
+
expires_at: new Date(account.expires).toISOString(),
|
|
52
|
+
rate_limited_until: new Date(account.rateLimitedUntil).toISOString(),
|
|
53
|
+
...(account.authFailure ? { auth_failure: account.authFailure } : {}),
|
|
54
|
+
...(account.accountId ? { account_id: account.accountId } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function managedStatus(provider) {
|
|
58
|
+
const accounts = readSubscriptionPool(provider).map(toManagedAccountStatus);
|
|
59
|
+
return { provider, managed: true, configured: accounts.length > 0, accounts };
|
|
60
|
+
}
|
|
61
|
+
/** `GET /v1/model-auth` — credential truth projected without tokens, keys, or
|
|
62
|
+
* other auth-file material. Managed providers always reflect their complete
|
|
63
|
+
* pool rows, while non-managed presence reflects supported auth.json kinds. */
|
|
64
|
+
async function handleList() {
|
|
65
|
+
const credentials = await readAuthCredentials(authPath());
|
|
66
|
+
const providers = [
|
|
67
|
+
managedStatus(ANTHROPIC_PROVIDER_ID),
|
|
68
|
+
managedStatus(OPENAI_CODEX_PROVIDER_ID),
|
|
69
|
+
];
|
|
70
|
+
for (const [provider, credential] of Object.entries(credentials)) {
|
|
71
|
+
if (isManagedProvider(provider))
|
|
72
|
+
continue;
|
|
73
|
+
if (credential.type !== 'api_key' && credential.type !== 'oauth')
|
|
74
|
+
continue;
|
|
75
|
+
providers.push({ provider, managed: false, configured: true, kind: credential.type });
|
|
76
|
+
}
|
|
77
|
+
return { status: 200, body: { providers } };
|
|
78
|
+
}
|
|
46
79
|
/** `PUT /v1/model-auth/:provider` — idempotent upsert/rotate keyed by provider
|
|
47
80
|
* (and `account_id` for a managed account). */
|
|
48
81
|
async function handleInstall(ctx) {
|
|
@@ -111,6 +144,22 @@ async function handleInstall(ctx) {
|
|
|
111
144
|
throw new ApiError(400, 'invalid_request', `unknown credential kind: ${String(req.kind)}`);
|
|
112
145
|
}
|
|
113
146
|
}
|
|
147
|
+
/** `DELETE /v1/model-auth/:provider` — remove all durable user credentials for
|
|
148
|
+
* one provider. Managed OAuth accounts are cleared through their pool lock and
|
|
149
|
+
* any managed-provider API key through the auth.json lock; other providers use
|
|
150
|
+
* the auth.json owner alone. */
|
|
151
|
+
async function handleDelete(ctx) {
|
|
152
|
+
const provider = ctx.params['provider'];
|
|
153
|
+
if (isManagedProvider(provider)) {
|
|
154
|
+
const removedAccounts = await removeAllManagedAccounts(provider);
|
|
155
|
+
const removedApiKey = await deleteAuthCredential(provider, authPath());
|
|
156
|
+
return { status: 200, body: { provider, removed_accounts: removedAccounts + (removedApiKey ? 1 : 0) } };
|
|
157
|
+
}
|
|
158
|
+
const removedAccounts = await deleteAuthCredential(provider, authPath()) ? 1 : 0;
|
|
159
|
+
return { status: 200, body: { provider, removed_accounts: removedAccounts } };
|
|
160
|
+
}
|
|
114
161
|
export const modelAuthRoutes = [
|
|
162
|
+
{ method: 'GET', pattern: '/v1/model-auth', handler: handleList },
|
|
115
163
|
{ method: 'PUT', pattern: '/v1/model-auth/:provider', handler: handleInstall },
|
|
164
|
+
{ method: 'DELETE', pattern: '/v1/model-auth/:provider', handler: handleDelete },
|
|
116
165
|
];
|
|
@@ -365,6 +365,7 @@ function handleDemote(ctx) {
|
|
|
365
365
|
...(meta.launch?.modelProviderPinned !== undefined
|
|
366
366
|
? { modelProviderPinned: meta.launch.modelProviderPinned }
|
|
367
367
|
: {}),
|
|
368
|
+
...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
|
|
368
369
|
cwd: meta.cwd,
|
|
369
370
|
profileId: meta.profile_id ?? null,
|
|
370
371
|
});
|
|
@@ -485,6 +486,7 @@ async function handleConfig(ctx) {
|
|
|
485
486
|
hasManager: meta.parent !== null,
|
|
486
487
|
...(meta.model_override != null ? { model: meta.model_override } : {}),
|
|
487
488
|
...(meta.launch?.modelProviderPinned !== undefined ? { modelProviderPinned: meta.launch.modelProviderPinned } : {}),
|
|
489
|
+
...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
|
|
488
490
|
cwd: meta.cwd,
|
|
489
491
|
profileId: meta.profile_id ?? null,
|
|
490
492
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -99,6 +99,19 @@ export interface ModelLaddersConfig {
|
|
|
99
99
|
anthropic: Record<ModelStrength, string>;
|
|
100
100
|
openai: Record<ModelStrength, string>;
|
|
101
101
|
}
|
|
102
|
+
/** A declared provider/model route. Route ids are the keys of `modelRoutes` so
|
|
103
|
+
* scopes can override one route without restating the registry. */
|
|
104
|
+
export interface ModelRouteConfig {
|
|
105
|
+
family: string;
|
|
106
|
+
credentialSource: string;
|
|
107
|
+
providerId: string;
|
|
108
|
+
models: Partial<Record<ModelStrength, string>>;
|
|
109
|
+
}
|
|
110
|
+
export interface ModelRoutingConfig {
|
|
111
|
+
credentialSourceOrder?: string[];
|
|
112
|
+
familyOrder?: string[];
|
|
113
|
+
strengthFallback?: ModelStrength[];
|
|
114
|
+
}
|
|
102
115
|
/** Launch metadata for one kind (a top-level kind like `developer`, or a full
|
|
103
116
|
* sub-kind string like `plan/reviewers/security`) — the settings-file
|
|
104
117
|
* replacement for per-kind persona frontmatter. `model`/`tools`/`extensions`
|
|
@@ -159,6 +172,10 @@ export interface ScopeConfig {
|
|
|
159
172
|
fold_finished_tools: boolean;
|
|
160
173
|
keybindings: KeybindingOverrides;
|
|
161
174
|
modelLadders: ModelLaddersConfig;
|
|
175
|
+
/** Declarative provider/model routes. Absent uses the synthesized ladder registry. */
|
|
176
|
+
modelRoutes?: Record<string, ModelRouteConfig>;
|
|
177
|
+
/** Ordering overrides for declared model routes. */
|
|
178
|
+
modelRouting?: ModelRoutingConfig;
|
|
162
179
|
/** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
|
|
163
180
|
* full kind string (top-level e.g. `developer`, or sub-kind e.g.
|
|
164
181
|
* `plan/reviewers/security`). Builtins ship a default registry via
|