anon-pi 0.5.0 → 0.7.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/README.md +11 -4
- package/dist/anon-pi.d.ts +179 -13
- package/dist/anon-pi.d.ts.map +1 -1
- package/dist/anon-pi.js +281 -15
- package/dist/anon-pi.js.map +1 -1
- package/dist/cli.js +374 -42
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/src/anon-pi.ts +372 -16
- package/src/cli.ts +457 -41
package/src/anon-pi.ts
CHANGED
|
@@ -90,12 +90,30 @@ export const CONTAINER_STAGE_DIR = '/opt/anon-pi-seed/agent';
|
|
|
90
90
|
*/
|
|
91
91
|
export const CONTAINER_MODELS_SEED = '/anon-pi-seed/models.json';
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Where anon-pi mounts the generated settings SEED (the local-model default
|
|
95
|
+
* selection: defaultProvider/defaultModel/enabledModels) read-only, so the
|
|
96
|
+
* first-launch seed can MERGE it into the fresh home's settings.json (never
|
|
97
|
+
* clobbering image-staged packages/extensions).
|
|
98
|
+
*/
|
|
99
|
+
export const CONTAINER_SETTINGS_SEED = '/anon-pi-seed/settings.json';
|
|
100
|
+
|
|
93
101
|
/** Marker file written into the agent dir after seeding; holds the seed version. */
|
|
94
102
|
export const SEED_MARKER = '.anon-pi-seed';
|
|
95
103
|
|
|
96
|
-
/** The
|
|
104
|
+
/** The file the host-side seed carries: pi's model/provider registry. */
|
|
97
105
|
export const MODELS_FILE = 'models.json';
|
|
98
106
|
|
|
107
|
+
/** pi's settings file (holds defaultModel/defaultProvider/enabledModels + more). */
|
|
108
|
+
export const SETTINGS_FILE = 'settings.json';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The settings SEED file anon-pi writes next to a machine (the local-model
|
|
112
|
+
* selection fragment). Distinct name so it never collides with a real
|
|
113
|
+
* settings.json; the seed MERGES it into the home's settings on first launch.
|
|
114
|
+
*/
|
|
115
|
+
export const SETTINGS_SEED_FILE = 'settings-seed.json';
|
|
116
|
+
|
|
99
117
|
/**
|
|
100
118
|
* containerRunCmd builds the container command: on a FRESH home (no seed
|
|
101
119
|
* marker), promote the image's staged defaults + the mounted models.json into
|
|
@@ -145,6 +163,12 @@ export interface AnonPiEnv {
|
|
|
145
163
|
llmDirect?: string;
|
|
146
164
|
/** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
|
|
147
165
|
xdgConfigHome?: string;
|
|
166
|
+
/**
|
|
167
|
+
* The host pi agent dir (PI_CODING_AGENT_DIR), used ONLY to locate the host
|
|
168
|
+
* `~/.pi/agent/models.json` that `init` reads the matching local provider
|
|
169
|
+
* from. Defaults to ~/.pi/agent. Never written.
|
|
170
|
+
*/
|
|
171
|
+
piAgentDir?: string;
|
|
148
172
|
/**
|
|
149
173
|
* Absolute path to the Dockerfile.pi that ships with anon-pi, used only to
|
|
150
174
|
* make the missing-image error's build command concrete. cli.ts resolves it
|
|
@@ -213,6 +237,67 @@ export function machineJsonPath(env: AnonPiEnv, name: string): string {
|
|
|
213
237
|
return join(machineDir(env, name), 'machine.json');
|
|
214
238
|
}
|
|
215
239
|
|
|
240
|
+
/**
|
|
241
|
+
* The GLOBAL local-model models.json seed: `<home>/models.json`. The local model
|
|
242
|
+
* is a WORKSPACE-level thing (config.json holds ONE global `llm`, the single
|
|
243
|
+
* `--allow-direct` hole shared by every machine), so its generated models.json
|
|
244
|
+
* lives once at the workspace root and seeds EVERY machine's fresh home. A
|
|
245
|
+
* machine may override it with its own `machines/<M>/models.json` (see
|
|
246
|
+
* resolveModelsSeedPath) for the rare "this machine uses a different local
|
|
247
|
+
* model" case; by default all machines share this one.
|
|
248
|
+
*/
|
|
249
|
+
export function globalModelsSeedPath(env: AnonPiEnv): string {
|
|
250
|
+
return join(resolveAnonPiHome(env), MODELS_FILE);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The GLOBAL settings seed (the default-model selection): `<home>/settings-seed.json`. */
|
|
254
|
+
export function globalSettingsSeedPath(env: AnonPiEnv): string {
|
|
255
|
+
return join(resolveAnonPiHome(env), SETTINGS_SEED_FILE);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A machine's OPTIONAL per-machine models.json override: `machines/<M>/models.json`. */
|
|
259
|
+
export function machineModelsSeedPath(env: AnonPiEnv, name: string): string {
|
|
260
|
+
return join(machineDir(env, name), MODELS_FILE);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A machine's OPTIONAL per-machine settings seed override: `machines/<M>/settings-seed.json`. */
|
|
264
|
+
export function machineSettingsSeedPath(env: AnonPiEnv, name: string): string {
|
|
265
|
+
return join(machineDir(env, name), SETTINGS_SEED_FILE);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* PURE: resolve the models.json SEED path for a machine, per-machine override
|
|
270
|
+
* first, else the global one. `exists` is injected (the CLI passes existsSync)
|
|
271
|
+
* so this stays pure/testable. Returns the chosen path, or undefined when
|
|
272
|
+
* NEITHER exists (a machine with no local-model seed at all — pi starts with no
|
|
273
|
+
* models). The precedence is: `machines/<M>/models.json` (a deliberate
|
|
274
|
+
* per-machine override) > `<home>/models.json` (the global default).
|
|
275
|
+
*/
|
|
276
|
+
export function resolveModelsSeedPath(
|
|
277
|
+
env: AnonPiEnv,
|
|
278
|
+
machine: string,
|
|
279
|
+
exists: (p: string) => boolean,
|
|
280
|
+
): string | undefined {
|
|
281
|
+
const perMachine = machineModelsSeedPath(env, machine);
|
|
282
|
+
if (exists(perMachine)) return perMachine;
|
|
283
|
+
const global = globalModelsSeedPath(env);
|
|
284
|
+
if (exists(global)) return global;
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** PURE: the settings-seed path for a machine (per-machine override > global), or undefined. */
|
|
289
|
+
export function resolveSettingsSeedPath(
|
|
290
|
+
env: AnonPiEnv,
|
|
291
|
+
machine: string,
|
|
292
|
+
exists: (p: string) => boolean,
|
|
293
|
+
): string | undefined {
|
|
294
|
+
const perMachine = machineSettingsSeedPath(env, machine);
|
|
295
|
+
if (exists(perMachine)) return perMachine;
|
|
296
|
+
const global = globalSettingsSeedPath(env);
|
|
297
|
+
if (exists(global)) return global;
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
216
301
|
/** The sessions dirname pi keeps its per-cwd conversation dirs under (in the agent dir). */
|
|
217
302
|
export const SESSIONS_DIRNAME = 'sessions';
|
|
218
303
|
|
|
@@ -628,6 +713,12 @@ export interface LaunchIntent {
|
|
|
628
713
|
* starts with no models; you add them in-session).
|
|
629
714
|
*/
|
|
630
715
|
modelsSeed?: string;
|
|
716
|
+
/**
|
|
717
|
+
* The settings SEED to mount read-only for the first-launch seed (the
|
|
718
|
+
* local-model default selection, e.g. <machine-dir>/settings-seed.json).
|
|
719
|
+
* Omitted => no settings seed (no default model is pre-selected).
|
|
720
|
+
*/
|
|
721
|
+
settingsSeed?: string;
|
|
631
722
|
/** The seed version stamped into a fresh home's marker. Default SEED_VERSION. */
|
|
632
723
|
seedVersion?: string;
|
|
633
724
|
}
|
|
@@ -903,6 +994,12 @@ export function resolveRunPlan(
|
|
|
903
994
|
if (modelsSeed !== undefined) {
|
|
904
995
|
netcageArgs.push('-v', `${modelsSeed}:${CONTAINER_MODELS_SEED}:ro`);
|
|
905
996
|
}
|
|
997
|
+
// The generated settings SEED (the local-model default selection) read-only,
|
|
998
|
+
// when present; the seed-if-fresh MERGES it into the home's settings.json.
|
|
999
|
+
const settingsSeed = nonEmpty(intent.settingsSeed);
|
|
1000
|
+
if (settingsSeed !== undefined) {
|
|
1001
|
+
netcageArgs.push('-v', `${settingsSeed}:${CONTAINER_SETTINGS_SEED}:ro`);
|
|
1002
|
+
}
|
|
906
1003
|
// The jail cwd.
|
|
907
1004
|
netcageArgs.push('-w', cwd);
|
|
908
1005
|
// The image, then the command: a marker-guarded seed-if-fresh then the tool.
|
|
@@ -943,11 +1040,25 @@ export function resolveRunPlan(
|
|
|
943
1040
|
function containerSeedThen(seedVersion: string, exec: string): string {
|
|
944
1041
|
const agent = CONTAINER_AGENT_DIR;
|
|
945
1042
|
const marker = `${agent}/${SEED_MARKER}`;
|
|
1043
|
+
const settings = `${agent}/${SETTINGS_FILE}`;
|
|
1044
|
+
// Merge the settings SEED (the local-model default selection) into the home's
|
|
1045
|
+
// settings.json, overwriting ONLY the three selection keys so any staged
|
|
1046
|
+
// packages/extensions survive. Done with a node one-liner (pi is a node app,
|
|
1047
|
+
// so node is on PATH). The seed path + target are shell-quoted single args.
|
|
1048
|
+
const mergeSettings =
|
|
1049
|
+
`{ [ -f "${CONTAINER_SETTINGS_SEED}" ] && node -e '` +
|
|
1050
|
+
`const fs=require("fs");` +
|
|
1051
|
+
`const seed=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));` +
|
|
1052
|
+
`let cur={};try{cur=JSON.parse(fs.readFileSync(process.argv[2],"utf8"))}catch(e){}` +
|
|
1053
|
+
`cur.defaultProvider=seed.defaultProvider;cur.defaultModel=seed.defaultModel;cur.enabledModels=seed.enabledModels;` +
|
|
1054
|
+
`fs.writeFileSync(process.argv[2],JSON.stringify(cur,null,"\\t")+"\\n")` +
|
|
1055
|
+
`' "${CONTAINER_SETTINGS_SEED}" "${settings}" || true; }`;
|
|
946
1056
|
return (
|
|
947
1057
|
`mkdir -p "${agent}" && ` +
|
|
948
1058
|
`if [ ! -f "${marker}" ]; then ` +
|
|
949
1059
|
`{ [ -d "${CONTAINER_STAGE_DIR}" ] && cp -a "${CONTAINER_STAGE_DIR}/." "${agent}/" || true; } && ` +
|
|
950
1060
|
`{ [ -f "${CONTAINER_MODELS_SEED}" ] && cp "${CONTAINER_MODELS_SEED}" "${agent}/${MODELS_FILE}" || true; } && ` +
|
|
1061
|
+
`${mergeSettings} && ` +
|
|
951
1062
|
`printf '%s\\n' "${seedVersion}" > "${marker}"; ` +
|
|
952
1063
|
`fi && ` +
|
|
953
1064
|
`${exec}`
|
|
@@ -1347,31 +1458,275 @@ export const LOCAL_PROVIDER_API = 'openai-completions';
|
|
|
1347
1458
|
export const LOCAL_PROVIDER_API_KEY = 'none';
|
|
1348
1459
|
|
|
1349
1460
|
/**
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1461
|
+
* apiKey values that are NOT real secrets (safe to carry into the anonymized
|
|
1462
|
+
* seed verbatim). Anything else is treated as a REAL secret: `init` refuses to
|
|
1463
|
+
* seed it (which would put a host credential into the anon home) unless the
|
|
1464
|
+
* operator passes `--force-allow-local-llm-api-key`.
|
|
1465
|
+
*/
|
|
1466
|
+
export const BENIGN_API_KEYS: ReadonlySet<string> = new Set([
|
|
1467
|
+
'',
|
|
1468
|
+
'none',
|
|
1469
|
+
'ollama',
|
|
1470
|
+
'no-key',
|
|
1471
|
+
'nokey',
|
|
1472
|
+
'local',
|
|
1473
|
+
'dummy',
|
|
1474
|
+
'sk-no-key-required',
|
|
1475
|
+
]);
|
|
1476
|
+
|
|
1477
|
+
/** PURE: whether an apiKey looks like a REAL secret (i.e. not in the benign set). */
|
|
1478
|
+
export function apiKeyLooksReal(apiKey: string | undefined): boolean {
|
|
1479
|
+
if (apiKey === undefined) return false;
|
|
1480
|
+
return !BENIGN_API_KEYS.has(apiKey.trim().toLowerCase());
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* A pi model entry as anon-pi seeds it for the local provider. pi keys a model
|
|
1485
|
+
* by `id`; `name` is the display label and `cost` is all-zero (a LAN model is
|
|
1486
|
+
* free). A "server"-sourced entry is minimal (id/name/cost); a "configured"
|
|
1487
|
+
* entry (imported from the host models.json) preserves whatever extra fields it
|
|
1488
|
+
* carried (`contextWindow`, `maxTokens`, `reasoning`, `input`, ...) via the
|
|
1489
|
+
* index signature.
|
|
1490
|
+
*/
|
|
1491
|
+
export interface GeneratedModel {
|
|
1492
|
+
id: string;
|
|
1493
|
+
name: string;
|
|
1494
|
+
cost?: {input: number; output: number; cacheRead: number; cacheWrite: number};
|
|
1495
|
+
[k: string]: unknown;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* PURE: a candidate model for the `init` picker. `configured` means it came from
|
|
1500
|
+
* the host `~/.pi/agent/models.json` provider that matches the endpoint (a
|
|
1501
|
+
* well-tuned entry with its real config); otherwise it was only reported by the
|
|
1502
|
+
* endpoint's `/v1/models` (a bare id we synthesize a minimal entry for). The
|
|
1503
|
+
* picker marks configured ones so the user knows which are more likely correct.
|
|
1504
|
+
*/
|
|
1505
|
+
export interface ModelCandidate {
|
|
1506
|
+
id: string;
|
|
1507
|
+
configured: boolean;
|
|
1508
|
+
/** The full pi model entry to seed (rich for configured, minimal otherwise). */
|
|
1509
|
+
entry: GeneratedModel;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* PURE: turn a discovered model `id` into a minimal-but-valid pi model entry.
|
|
1514
|
+
* `name` defaults to the id; a LAN model is free, so every cost is 0.
|
|
1515
|
+
*/
|
|
1516
|
+
export function localModelEntry(id: string): GeneratedModel {
|
|
1517
|
+
return {
|
|
1518
|
+
id,
|
|
1519
|
+
name: id,
|
|
1520
|
+
cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* PURE: extract the model ids from a parsed OpenAI-compatible `/v1/models`
|
|
1526
|
+
* response (`{ data: [{ id }, ...] }`, as llama.cpp / vLLM / LM Studio serve).
|
|
1527
|
+
* Tolerates a bare array, a `models` key, missing/garbage input (returns []), so
|
|
1528
|
+
* `init` can feed whatever the endpoint returned straight in.
|
|
1529
|
+
*/
|
|
1530
|
+
export function parseModelsListing(raw: unknown): string[] {
|
|
1531
|
+
const rows: unknown[] = Array.isArray(raw)
|
|
1532
|
+
? raw
|
|
1533
|
+
: raw && typeof raw === 'object'
|
|
1534
|
+
? (((raw as Record<string, unknown>).data as unknown[]) ??
|
|
1535
|
+
((raw as Record<string, unknown>).models as unknown[]) ??
|
|
1536
|
+
[])
|
|
1537
|
+
: [];
|
|
1538
|
+
if (!Array.isArray(rows)) return [];
|
|
1539
|
+
const ids: string[] = [];
|
|
1540
|
+
for (const r of rows) {
|
|
1541
|
+
if (typeof r === 'string') {
|
|
1542
|
+
if (r.trim() !== '') ids.push(r.trim());
|
|
1543
|
+
} else if (r && typeof r === 'object') {
|
|
1544
|
+
const id = (r as Record<string, unknown>).id;
|
|
1545
|
+
if (typeof id === 'string' && id.trim() !== '') ids.push(id.trim());
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return ids;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** The result of scanning a host models.json for the endpoint's provider. */
|
|
1552
|
+
export interface HostProviderMatch {
|
|
1553
|
+
/** The matching provider's models as full pi entries (verbatim host config). */
|
|
1554
|
+
models: GeneratedModel[];
|
|
1555
|
+
/** The matching provider's apiKey (verbatim), for the benign/real check. */
|
|
1556
|
+
apiKey?: string;
|
|
1557
|
+
/** True iff that apiKey looks like a REAL secret (init refuses without --force). */
|
|
1558
|
+
apiKeyLooksReal: boolean;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* PURE: find, in a parsed host `~/.pi/agent/models.json`, the provider whose
|
|
1563
|
+
* `baseUrl` points at `llmEndpoint` (matched via hostPortKey), and return ONLY
|
|
1564
|
+
* that provider's models + apiKey. This is the anonymity-critical scoping: the
|
|
1565
|
+
* ONLY provider considered is the one served by the `--allow-direct` endpoint,
|
|
1566
|
+
* so no other provider (etherplay/google/a paid API) — and no other provider's
|
|
1567
|
+
* key — can ever enter the seed. Returns undefined when no provider matches.
|
|
1359
1568
|
*
|
|
1360
|
-
* The
|
|
1361
|
-
*
|
|
1362
|
-
|
|
1569
|
+
* The `--allow-direct` target and this match both go through hostPortKey, so a
|
|
1570
|
+
* URL / ip:port / bare-ip host config all match the same endpoint.
|
|
1571
|
+
*/
|
|
1572
|
+
export function pickLocalProviderModels(
|
|
1573
|
+
hostModels: PiModelsFile,
|
|
1574
|
+
llmEndpoint: string,
|
|
1575
|
+
): HostProviderMatch | undefined {
|
|
1576
|
+
const providers = hostModels.providers ?? {};
|
|
1577
|
+
const want = hostPortKey(llmEndpoint);
|
|
1578
|
+
for (const p of Object.values(providers)) {
|
|
1579
|
+
if (!p || typeof p !== 'object' || !p.baseUrl) continue;
|
|
1580
|
+
if (hostPortKey(p.baseUrl) !== want) continue;
|
|
1581
|
+
const models: GeneratedModel[] = [];
|
|
1582
|
+
for (const m of p.models ?? []) {
|
|
1583
|
+
if (m && typeof m === 'object') {
|
|
1584
|
+
const id = (m as Record<string, unknown>).id;
|
|
1585
|
+
if (typeof id === 'string' && id.trim() !== '') {
|
|
1586
|
+
models.push({...(m as GeneratedModel), id: id.trim()});
|
|
1587
|
+
}
|
|
1588
|
+
} else if (typeof m === 'string' && m.trim() !== '') {
|
|
1589
|
+
models.push(localModelEntry(m.trim()));
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return {
|
|
1593
|
+
models,
|
|
1594
|
+
apiKey: p.apiKey,
|
|
1595
|
+
apiKeyLooksReal: apiKeyLooksReal(p.apiKey),
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* PURE: merge the host-config models (rich, `configured: true`) with the
|
|
1603
|
+
* endpoint's live `/v1/models` ids (`configured: false` for any the host did not
|
|
1604
|
+
* already carry), into ONE deduped, sorted candidate list. Host config wins on
|
|
1605
|
+
* an id present in both (it has the real config). Every candidate here is served
|
|
1606
|
+
* by the endpoint, so every one is `--allow-direct`-reachable; the merge just
|
|
1607
|
+
* unions "what you already configured" with "what the server also offers".
|
|
1363
1608
|
*/
|
|
1364
|
-
export function
|
|
1609
|
+
export function mergeModelSources(
|
|
1610
|
+
hostModels: readonly GeneratedModel[],
|
|
1611
|
+
serverIds: readonly string[],
|
|
1612
|
+
): ModelCandidate[] {
|
|
1613
|
+
const byId = new Map<string, ModelCandidate>();
|
|
1614
|
+
for (const m of hostModels) {
|
|
1615
|
+
const id = m.id.trim();
|
|
1616
|
+
if (id === '') continue;
|
|
1617
|
+
byId.set(id, {id, configured: true, entry: {...m, id}});
|
|
1618
|
+
}
|
|
1619
|
+
for (const raw of serverIds) {
|
|
1620
|
+
const id = raw.trim();
|
|
1621
|
+
if (id === '' || byId.has(id)) continue;
|
|
1622
|
+
byId.set(id, {id, configured: false, entry: localModelEntry(id)});
|
|
1623
|
+
}
|
|
1624
|
+
return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* PURE: synthesize a pi `models.json` for the local provider from an endpoint
|
|
1629
|
+
* and the CHOSEN model entries. It normalises the endpoint with hostPortKey and
|
|
1630
|
+
* returns a models.json carrying exactly ONE provider (named LOCAL_PROVIDER_NAME
|
|
1631
|
+
* — a neutral name, no host fingerprint) pointed at that endpoint.
|
|
1632
|
+
*
|
|
1633
|
+
* `apiKey` defaults to the benign LOCAL_PROVIDER_API_KEY. A caller may pass the
|
|
1634
|
+
* host provider's real key ONLY under an explicit force flag; the benign/real
|
|
1635
|
+
* decision (and the refusal) lives in `init`, not here — this pure function just
|
|
1636
|
+
* writes what it is given.
|
|
1637
|
+
*
|
|
1638
|
+
* Accepts either full model entries (from the host config) or bare id strings
|
|
1639
|
+
* (which it turns into minimal entries). Empty models => a provider pointed at
|
|
1640
|
+
* the endpoint with no pickable model (the degraded fallback).
|
|
1641
|
+
*/
|
|
1642
|
+
export function generateModelsJson(
|
|
1643
|
+
llmEndpoint: string,
|
|
1644
|
+
models: readonly (GeneratedModel | string)[] = [],
|
|
1645
|
+
apiKey: string = LOCAL_PROVIDER_API_KEY,
|
|
1646
|
+
): PiModelsFile {
|
|
1365
1647
|
const hostPort = hostPortKey(llmEndpoint);
|
|
1648
|
+
const entries: GeneratedModel[] = [];
|
|
1649
|
+
const seen = new Set<string>();
|
|
1650
|
+
for (const m of models) {
|
|
1651
|
+
const entry = typeof m === 'string' ? localModelEntry(m.trim()) : m;
|
|
1652
|
+
const id = entry.id.trim();
|
|
1653
|
+
if (id === '' || seen.has(id)) continue;
|
|
1654
|
+
seen.add(id);
|
|
1655
|
+
entries.push({...entry, id});
|
|
1656
|
+
}
|
|
1657
|
+
entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
1366
1658
|
const provider: PiProvider = {
|
|
1367
1659
|
api: LOCAL_PROVIDER_API,
|
|
1368
|
-
apiKey
|
|
1660
|
+
apiKey,
|
|
1369
1661
|
baseUrl: `http://${hostPort}/v1`,
|
|
1370
|
-
models:
|
|
1662
|
+
models: entries,
|
|
1371
1663
|
};
|
|
1372
1664
|
return {providers: {[LOCAL_PROVIDER_NAME]: provider}};
|
|
1373
1665
|
}
|
|
1374
1666
|
|
|
1667
|
+
/** The pi settings.json keys anon-pi sets for the local-model default selection. */
|
|
1668
|
+
export interface ModelSelection {
|
|
1669
|
+
defaultProvider: string;
|
|
1670
|
+
defaultModel: string;
|
|
1671
|
+
enabledModels: string[];
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* PURE: the model-selection settings.json fragment for the seeded local
|
|
1676
|
+
* provider: `defaultProvider` = LOCAL_PROVIDER_NAME, `defaultModel` = the chosen
|
|
1677
|
+
* default id, `enabledModels` = `local/<id>` for each imported model (pi's
|
|
1678
|
+
* `<provider>/<id>` convention). The caller MERGES this into any existing
|
|
1679
|
+
* settings so image-staged settings (packages/extensions) are preserved.
|
|
1680
|
+
*/
|
|
1681
|
+
export function generateModelSelection(
|
|
1682
|
+
modelIds: readonly string[],
|
|
1683
|
+
defaultId: string,
|
|
1684
|
+
): ModelSelection {
|
|
1685
|
+
const ids = Array.from(
|
|
1686
|
+
new Set(modelIds.map((m) => m.trim()).filter((m) => m !== '')),
|
|
1687
|
+
).sort((a, b) => a.localeCompare(b));
|
|
1688
|
+
return {
|
|
1689
|
+
defaultProvider: LOCAL_PROVIDER_NAME,
|
|
1690
|
+
defaultModel: defaultId.trim(),
|
|
1691
|
+
enabledModels: ids.map((id) => `${LOCAL_PROVIDER_NAME}/${id}`),
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* PURE: shallow-merge the local-model selection into an existing (parsed)
|
|
1697
|
+
* settings.json object, returning the merged object. Only the three selection
|
|
1698
|
+
* keys are overwritten; every other key the user/image had (packages,
|
|
1699
|
+
* extensions, thinking level, ...) is preserved. `existing` undefined/garbage is
|
|
1700
|
+
* treated as `{}`.
|
|
1701
|
+
*/
|
|
1702
|
+
export function mergeModelSelection(
|
|
1703
|
+
existing: unknown,
|
|
1704
|
+
selection: ModelSelection,
|
|
1705
|
+
): Record<string, unknown> {
|
|
1706
|
+
const base: Record<string, unknown> =
|
|
1707
|
+
existing && typeof existing === 'object'
|
|
1708
|
+
? {...(existing as Record<string, unknown>)}
|
|
1709
|
+
: {};
|
|
1710
|
+
base.defaultProvider = selection.defaultProvider;
|
|
1711
|
+
base.defaultModel = selection.defaultModel;
|
|
1712
|
+
base.enabledModels = selection.enabledModels;
|
|
1713
|
+
return base;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* The host `~/.pi/agent/models.json` path `init` reads the matching local
|
|
1718
|
+
* provider from. Uses the container-less host HOME (or PI_CODING_AGENT_DIR when
|
|
1719
|
+
* the user relocated pi's agent dir). This is READ-ONLY (init copies only the
|
|
1720
|
+
* ONE matching provider's models); it is never written.
|
|
1721
|
+
*/
|
|
1722
|
+
export function resolveHostModelsPath(env: AnonPiEnv): string {
|
|
1723
|
+
const agentDir =
|
|
1724
|
+
env.piAgentDir && env.piAgentDir.trim() !== ''
|
|
1725
|
+
? env.piAgentDir
|
|
1726
|
+
: join(env.home, '.pi', 'agent');
|
|
1727
|
+
return join(agentDir, MODELS_FILE);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1375
1730
|
/**
|
|
1376
1731
|
* A pi provider entry (as it appears under models.json `providers[name]`). Only
|
|
1377
1732
|
* the fields anon-pi reads are typed; the rest is preserved verbatim.
|
|
@@ -1914,6 +2269,7 @@ export function envFromProcess(
|
|
|
1914
2269
|
image: penv.ANON_PI_IMAGE,
|
|
1915
2270
|
llmDirect: penv.ANON_PI_LLM,
|
|
1916
2271
|
xdgConfigHome: penv.XDG_CONFIG_HOME,
|
|
2272
|
+
piAgentDir: penv.PI_CODING_AGENT_DIR,
|
|
1917
2273
|
dockerfilePath: shippedDockerfilePath(),
|
|
1918
2274
|
webveilDockerfilePath: shippedWebveilDockerfilePath(),
|
|
1919
2275
|
};
|