privateer-agent 0.5.0 → 0.5.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/bin/privateer-tui CHANGED
@@ -58,7 +58,7 @@ mkdir -p "$EXT_DIR"
58
58
  # ABSOLUTE path so the target's own relative imports resolve from the repo (a plain
59
59
  # symlink would resolve them relative to the shim's location and break). We remove
60
60
  # any shim we previously managed first, so a dropped package can't linger and reload.
61
- MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
61
+ MANAGED="privateer-brand privateer-context privateer-gate privateer-account privateer-models privateer-posture privateer-tools privateer-privacy pi-privacy pi-web-access rpiv-web-tools pi-mcp-adapter pi-hypa pi-subagents"
62
62
  for name in $MANAGED; do rm -f "$EXT_DIR/$name.ts"; done
63
63
  shim() { printf 'export { default } from "%s";\n' "$2" > "$EXT_DIR/$1.ts"; }
64
64
  # Branding + the account sign-in surface (banner, ⚓ badge, /signin /signout).
@@ -67,6 +67,10 @@ shim privateer-brand "$REPO/extensions/privateer-brand.ts"
67
67
  shim privateer-context "$REPO/extensions/privateer-context.ts"
68
68
  shim privateer-gate "$REPO/extensions/privateer-gate.ts"
69
69
  shim privateer-account "$REPO/extensions/privateer-account.ts"
70
+ # The /models picker — searchable model selector with per-row privacy shields
71
+ # (TEE / ZDR / standard). Pi's built-in /model is redirected here by the
72
+ # patch-package patch (patches/@earendil-works+pi-coding-agent+*.patch).
73
+ shim privateer-models "$REPO/extensions/privateer-models.ts"
70
74
  shim privateer-posture "$REPO/extensions/privateer-posture.ts"
71
75
  shim privateer-tools "$REPO/extensions/privateer-tools.ts"
72
76
  # pi-privacy wrapped with the account-channel tier resolver (privateer/near… = TEE),
@@ -0,0 +1,429 @@
1
+ // The Privateer `/models` picker — a searchable model selector that puts the
2
+ // PRIVACY POSTURE of every model on screen. Pi's built-in `/model` picker (which
3
+ // this shadows via the redirect patch in patches/@earendil-works+pi-coding-agent)
4
+ // shows only `id [provider]`; it has no hook for a privacy shield and can't be
5
+ // overridden from an extension (its command name is a reserved builtin, dispatched
6
+ // before extension commands). So we ship our OWN picker through `ctx.ui.custom`,
7
+ // which renders a full pi-tui component: fuzzy search + a per-row shield (⛉ TEE /
8
+ // ◈ ZDR / · standard) + tier grouping + a legend, ranked strongest-privacy-first.
9
+ //
10
+ // Honest-labeling contract (pi-privacy posture/tiers.ts): a TEE row is only a
11
+ // *claim* (tee-unverified, yellow) until a live attestation confirms it
12
+ // (tee-verified, green). The picker seeds each row with the server's baseline tier
13
+ // (GET /api/models `privacy.tier`, via account.ts) and then attests the TEE-capable
14
+ // rows in the background, upgrading the shield in place. ZDR-enforced rows are never
15
+ // downgraded by attestation — we only attest rows that claim a TEE.
16
+
17
+ import {
18
+ Container,
19
+ fuzzyFilter,
20
+ getKeybindings,
21
+ Input,
22
+ Spacer,
23
+ Text,
24
+ } from "@earendil-works/pi-tui";
25
+ import { TIERS, tierRank, type PrivacyTier } from "pi-privacy";
26
+ import { verifyModelPosture } from "pi-privacy";
27
+ import {
28
+ accountBaselineTier,
29
+ accountCatalogLoaded,
30
+ accountPosture,
31
+ fetchAccountCatalog,
32
+ } from "../src/providers/account.ts";
33
+
34
+ // A minimal view of Pi's theme (passed to the ui.custom factory) — enough to color
35
+ // text without pulling Pi's internal theme types into an extension.
36
+ interface ThemeLike {
37
+ fg(color: string, text: string): string;
38
+ bold(text: string): string;
39
+ }
40
+
41
+ // A pi-tui TUI handle (the first arg the ui.custom factory receives). We only use
42
+ // requestRender; keep the surface tiny so this doesn't couple to Pi internals.
43
+ interface TuiLike {
44
+ requestRender(): void;
45
+ }
46
+
47
+ interface ModelLike {
48
+ provider: string;
49
+ id: string;
50
+ name?: string;
51
+ }
52
+
53
+ interface Row {
54
+ provider: string;
55
+ id: string;
56
+ name: string;
57
+ model: ModelLike;
58
+ tier: PrivacyTier;
59
+ // true once a live attestation has resolved this row (so we don't re-attest).
60
+ attested?: boolean;
61
+ }
62
+
63
+ // posture bucket → theme color name (Pi's palette) for the shield glyph.
64
+ const POSTURE_COLOR: Record<string, string> = {
65
+ green: "success",
66
+ yellow: "warning",
67
+ red: "error",
68
+ neutral: "muted",
69
+ };
70
+
71
+ // The shield/marker glyph per tier family. TEE → shield; ZDR → diamond; local →
72
+ // house; standard → dot. Color comes from the tier's posture bucket, so verified
73
+ // (green) and merely-claimed (yellow) read differently at a glance — the same
74
+ // distinction the status-bar badge (privateer-posture.ts) draws.
75
+ function glyphFor(tier: PrivacyTier): string {
76
+ if (tier === "tee-verified" || tier === "tee-unverified") return "⛉";
77
+ if (tier === "local") return "⌂";
78
+ if (tier === "zdr-enforced" || tier === "zdr-policy") return "◈";
79
+ return "·";
80
+ }
81
+
82
+ function shield(theme: ThemeLike, tier: PrivacyTier): string {
83
+ const info = TIERS[tier];
84
+ const color = POSTURE_COLOR[info.posture] ?? "muted";
85
+ return theme.fg(color, glyphFor(tier));
86
+ }
87
+
88
+ // Server/prefix baseline tier for a provider+model, before any live attestation.
89
+ function baselineTier(provider: string, id: string): PrivacyTier {
90
+ if (provider === "privateer") {
91
+ return (
92
+ accountBaselineTier(id) ??
93
+ (id.startsWith("near/") || id.startsWith("tinfoil/") ? "tee-unverified" : "standard")
94
+ );
95
+ }
96
+ if (provider === "tinfoil" || provider === "nearai") return "tee-unverified";
97
+ if (provider === "ollama") return "local";
98
+ return "standard";
99
+ }
100
+
101
+ // A row claims a TEE and is therefore worth attesting live (to go green — or to
102
+ // honestly drop to standard if the attestation fails). ZDR/standard rows are left
103
+ // on their server baseline; attesting them would only ever weaken an honest label.
104
+ function isTeeCandidate(row: Row): boolean {
105
+ if (row.tier !== "tee-verified" && row.tier !== "tee-unverified") return false;
106
+ if (row.provider === "privateer") return row.id.startsWith("near/") || row.id.startsWith("tinfoil/");
107
+ return row.provider === "tinfoil" || row.provider === "nearai";
108
+ }
109
+
110
+ // Run the live attestation for one TEE-candidate row. privateer/* goes through the
111
+ // account server-proxy path (accountPosture); other providers use pi-privacy's
112
+ // direct client attestation. Returns undefined on any failure (keep the baseline).
113
+ async function attestRow(row: Row): Promise<PrivacyTier | undefined> {
114
+ try {
115
+ if (row.provider === "privateer") {
116
+ const res = await accountPosture(row.id);
117
+ return res.tier;
118
+ }
119
+ const apiKey =
120
+ row.provider === "nearai"
121
+ ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY
122
+ : undefined;
123
+ const res = await verifyModelPosture(row.provider, row.id, { apiKey });
124
+ return res.tier;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ // The search text a row is matched against — id, provider, name, and the tier label
131
+ // so a user can type "tee" or "zdr" to filter by posture.
132
+ function searchText(row: Row): string {
133
+ return `${row.provider} ${row.provider}/${row.id} ${row.id} ${row.name} ${TIERS[row.tier].label}`;
134
+ }
135
+
136
+ // Strongest-privacy-first, then provider, then id — so the safest options surface at
137
+ // the top of an unfiltered list.
138
+ function sortRows(rows: Row[]): Row[] {
139
+ return [...rows].sort((a, b) => {
140
+ const r = tierRank(a.tier) - tierRank(b.tier);
141
+ if (r !== 0) return r;
142
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
143
+ return a.id.localeCompare(b.id);
144
+ });
145
+ }
146
+
147
+ const MAX_VISIBLE = 12;
148
+
149
+ class ModelsPicker extends Container {
150
+ private tui: TuiLike;
151
+ private theme: ThemeLike;
152
+ private rows: Row[];
153
+ private filtered: Row[];
154
+ private selectedIndex = 0;
155
+ private searchInput: Input;
156
+ private listContainer: Container;
157
+ private legendText: Text;
158
+ private blurbText: Text;
159
+ private currentId: string | undefined;
160
+ private onSelect: (row: Row) => void;
161
+ private onCancel: () => void;
162
+ private _focused = false;
163
+
164
+ constructor(opts: {
165
+ tui: TuiLike;
166
+ theme: ThemeLike;
167
+ rows: Row[];
168
+ current: ModelLike | undefined;
169
+ onSelect: (row: Row) => void;
170
+ onCancel: () => void;
171
+ }) {
172
+ super();
173
+ this.tui = opts.tui;
174
+ this.theme = opts.theme;
175
+ this.rows = sortRows(opts.rows);
176
+ this.filtered = this.rows;
177
+ this.currentId = opts.current ? `${opts.current.provider}/${opts.current.id}` : undefined;
178
+ this.onSelect = opts.onSelect;
179
+ this.onCancel = opts.onCancel;
180
+
181
+ const t = this.theme;
182
+ this.addChild(new Text(t.fg("accent", t.bold("Select a model")), 1, 0));
183
+ this.legendText = new Text(this.legend(), 1, 0);
184
+ this.addChild(this.legendText);
185
+ this.addChild(new Spacer(1));
186
+
187
+ this.searchInput = new Input();
188
+ this.searchInput.onSubmit = () => {
189
+ const row = this.filtered[this.selectedIndex];
190
+ if (row) this.onSelect(row);
191
+ };
192
+ this.addChild(this.searchInput);
193
+ this.addChild(new Spacer(1));
194
+
195
+ this.listContainer = new Container();
196
+ this.addChild(this.listContainer);
197
+ this.addChild(new Spacer(1));
198
+
199
+ this.blurbText = new Text("", 1, 0);
200
+ this.addChild(this.blurbText);
201
+ this.addChild(
202
+ new Text(
203
+ t.fg("muted", "↑↓ navigate ⏎ select type to search esc cancel"),
204
+ 1,
205
+ 0,
206
+ ),
207
+ );
208
+
209
+ // Keep selection on the current model if it's in the list.
210
+ const cur = this.rows.findIndex((r) => `${r.provider}/${r.id}` === this.currentId);
211
+ if (cur >= 0) this.selectedIndex = cur;
212
+
213
+ this.updateList();
214
+ void this.attestTeeRows();
215
+ }
216
+
217
+ get focused(): boolean {
218
+ return this._focused;
219
+ }
220
+ set focused(v: boolean) {
221
+ this._focused = v;
222
+ this.searchInput.focused = v;
223
+ }
224
+
225
+ private legend(): string {
226
+ const t = this.theme;
227
+ return (
228
+ `${shield(t, "tee-verified")} ${t.fg("muted", "TEE")} ` +
229
+ `${shield(t, "zdr-enforced")} ${t.fg("muted", "ZDR")} ` +
230
+ `${shield(t, "standard")} ${t.fg("muted", "Standard")} ` +
231
+ t.fg("muted", "— green = verified/enforced, yellow = claimed")
232
+ );
233
+ }
234
+
235
+ // Fire attestation for every TEE-candidate row in parallel; upgrade each shield in
236
+ // place as its result lands. Cheap in practice (the enabled catalog is mostly
237
+ // ZDR/standard), and never blocks the picker — the baseline renders immediately.
238
+ private async attestTeeRows(): Promise<void> {
239
+ const candidates = this.rows.filter((r) => isTeeCandidate(r) && !r.attested);
240
+ if (!candidates.length) return;
241
+ await Promise.allSettled(
242
+ candidates.map(async (row) => {
243
+ const tier = await attestRow(row);
244
+ row.attested = true;
245
+ if (tier && tier !== row.tier) {
246
+ row.tier = tier;
247
+ }
248
+ }),
249
+ );
250
+ // Re-sort (a newly-verified TEE may move up) and re-render once, preserving the
251
+ // highlighted model across the reshuffle.
252
+ const selected = this.filtered[this.selectedIndex];
253
+ this.rows = sortRows(this.rows);
254
+ this.applyFilter(this.searchInput.getValue(), selected);
255
+ this.legendText.setText(this.legend());
256
+ this.tui.requestRender();
257
+ }
258
+
259
+ private applyFilter(query: string, keep?: Row): void {
260
+ this.filtered = query
261
+ ? fuzzyFilter(this.rows, query, (r: Row) => searchText(r))
262
+ : this.rows;
263
+ if (keep) {
264
+ const i = this.filtered.findIndex((r) => r.provider === keep.provider && r.id === keep.id);
265
+ this.selectedIndex = i >= 0 ? i : 0;
266
+ } else {
267
+ this.selectedIndex = 0;
268
+ }
269
+ this.updateList();
270
+ }
271
+
272
+ private updateList(): void {
273
+ const t = this.theme;
274
+ this.listContainer.clear();
275
+ if (this.filtered.length === 0) {
276
+ this.listContainer.addChild(new Text(t.fg("muted", " No matching models"), 1, 0));
277
+ this.blurbText.setText("");
278
+ return;
279
+ }
280
+ if (this.selectedIndex >= this.filtered.length) this.selectedIndex = this.filtered.length - 1;
281
+
282
+ const start = Math.max(
283
+ 0,
284
+ Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filtered.length - MAX_VISIBLE),
285
+ );
286
+ const end = Math.min(start + MAX_VISIBLE, this.filtered.length);
287
+ for (let i = start; i < end; i++) {
288
+ const row = this.filtered[i];
289
+ const isSel = i === this.selectedIndex;
290
+ const isCur = `${row.provider}/${row.id}` === this.currentId;
291
+ const mark = shield(t, row.tier);
292
+ const label = TIERS[row.tier].label;
293
+ const idText = isSel ? t.fg("accent", row.id) : t.fg("text", row.id);
294
+ const prov = t.fg("muted", `[${row.provider}]`);
295
+ const tierText = t.fg(POSTURE_COLOR[TIERS[row.tier].posture] ?? "muted", label);
296
+ const check = isCur ? t.fg("success", " ✓") : "";
297
+ const prefix = isSel ? t.fg("accent", "→ ") : " ";
298
+ this.listContainer.addChild(
299
+ new Text(`${prefix}${mark} ${idText} ${prov} ${tierText}${check}`, 1, 0),
300
+ );
301
+ }
302
+ if (start > 0 || end < this.filtered.length) {
303
+ this.listContainer.addChild(
304
+ new Text(t.fg("muted", ` (${this.selectedIndex + 1}/${this.filtered.length})`), 1, 0),
305
+ );
306
+ }
307
+ // Honest one-liner for the highlighted model — states the LIMIT of the claim.
308
+ const sel = this.filtered[this.selectedIndex];
309
+ if (sel) this.blurbText.setText(t.fg("muted", ` ${TIERS[sel.tier].blurb}`));
310
+ }
311
+
312
+ // Seed the search box (from `/models <query>`) and filter to match.
313
+ setQuery(q: string): void {
314
+ this.searchInput.setValue(q);
315
+ this.applyFilter(q);
316
+ }
317
+
318
+ handleInput(keyData: string): void {
319
+ const kb = getKeybindings();
320
+ if (kb.matches(keyData, "tui.select.up")) {
321
+ if (!this.filtered.length) return;
322
+ this.selectedIndex =
323
+ this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1;
324
+ this.updateList();
325
+ } else if (kb.matches(keyData, "tui.select.down")) {
326
+ if (!this.filtered.length) return;
327
+ this.selectedIndex =
328
+ this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
329
+ this.updateList();
330
+ } else if (kb.matches(keyData, "tui.select.confirm")) {
331
+ const row = this.filtered[this.selectedIndex];
332
+ if (row) this.onSelect(row);
333
+ } else if (kb.matches(keyData, "tui.select.cancel")) {
334
+ this.onCancel();
335
+ } else {
336
+ // Everything else edits the search box, then re-filters.
337
+ this.searchInput.handleInput(keyData);
338
+ this.applyFilter(this.searchInput.getValue());
339
+ }
340
+ }
341
+ }
342
+
343
+ // The extension entrypoint. Registers `/models`; the redirect patch also routes
344
+ // `/model` here so the shielded picker is the single model surface.
345
+ export default function privateerModels(pi: {
346
+ registerCommand?: (name: string, opts: unknown) => void;
347
+ setModel?: (model: ModelLike) => Promise<boolean> | boolean;
348
+ }): void {
349
+ if (typeof pi.registerCommand !== "function") return;
350
+
351
+ pi.registerCommand("models", {
352
+ description: "Pick a model with its privacy posture (TEE / ZDR / standard) — searchable",
353
+ argumentHint: "[search]",
354
+ handler: async (args: string, ctx: any): Promise<void> => {
355
+ const ui = ctx?.ui;
356
+ if (!ui?.custom) {
357
+ ctx?.ui?.notify?.("The model picker needs an interactive terminal.", "warning");
358
+ return;
359
+ }
360
+ const registry = ctx.modelRegistry;
361
+ // Refresh so a just-fetched account catalog / models.json edit is reflected.
362
+ try {
363
+ registry?.refresh?.();
364
+ } catch {
365
+ /* non-fatal */
366
+ }
367
+ // Make sure the account baseline tiers are loaded (first-open may race the
368
+ // provider's background fetch). Best-effort; falls back to prefix heuristics.
369
+ if (!accountCatalogLoaded()) {
370
+ try {
371
+ await fetchAccountCatalog();
372
+ } catch {
373
+ /* keep heuristics */
374
+ }
375
+ }
376
+
377
+ let available: ModelLike[] = [];
378
+ try {
379
+ available = (await registry.getAvailable()) as ModelLike[];
380
+ } catch (e) {
381
+ ui.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
382
+ return;
383
+ }
384
+ if (!available.length) {
385
+ ui.notify?.("No models available. Use /login to add a provider.", "warning");
386
+ return;
387
+ }
388
+
389
+ const rows: Row[] = available.map((m) => ({
390
+ provider: m.provider,
391
+ id: m.id,
392
+ name: m.name ?? m.id,
393
+ model: m,
394
+ tier: baselineTier(m.provider, m.id),
395
+ }));
396
+
397
+ const initialQuery = String(args ?? "").trim();
398
+ const chosen: Row | undefined = await ui.custom(
399
+ (tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (result?: Row) => void) => {
400
+ const picker = new ModelsPicker({
401
+ tui,
402
+ theme,
403
+ rows,
404
+ current: ctx.model,
405
+ onSelect: (row) => close(row),
406
+ onCancel: () => close(undefined),
407
+ });
408
+ if (initialQuery) picker.setQuery(initialQuery);
409
+ return picker;
410
+ },
411
+ );
412
+
413
+ if (!chosen) return;
414
+ try {
415
+ const ok = await pi.setModel!(chosen.model);
416
+ if (ok === false) {
417
+ ui.notify?.(`No API key for ${chosen.provider}/${chosen.id}.`, "warning");
418
+ return;
419
+ }
420
+ ui.notify?.(
421
+ `Model: ${chosen.provider}/${chosen.id} · ${TIERS[chosen.tier].label}`,
422
+ "info",
423
+ );
424
+ } catch (e) {
425
+ ui.notify?.(`Couldn't switch model: ${(e as Error).message}`, "error");
426
+ }
427
+ },
428
+ });
429
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "bin",
38
38
  "src",
39
39
  "extensions",
40
+ "patches",
40
41
  "README.md",
41
42
  "LICENSE"
42
43
  ],
@@ -46,7 +47,8 @@
46
47
  "channels": "node --env-file=.env --import tsx src/channels/run.ts",
47
48
  "dev": "tsx watch src/main.ts",
48
49
  "typecheck": "tsc --noEmit",
49
- "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
50
+ "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done",
51
+ "postinstall": "patch-package"
50
52
  },
51
53
  "engines": {
52
54
  "node": ">=22.19.0"
@@ -60,11 +62,12 @@
60
62
  "@noble/ciphers": "^2.1.1",
61
63
  "@noble/curves": "^1.9.7",
62
64
  "@noble/hashes": "^1.7.1",
65
+ "patch-package": "^8.0.1",
63
66
  "pi-mcp-adapter": "^2.11.0",
64
67
  "pi-privacy": "^0.3.0",
65
68
  "pi-subagents": "^0.34.0",
66
- "privateer-workflow": "^0.1.0",
67
69
  "picomatch": "^4.0.4",
70
+ "privateer-workflow": "^0.1.0",
68
71
  "tsx": "^4.16.0",
69
72
  "typebox": "^1.3.4",
70
73
  "undici": "^7.28.0",
@@ -0,0 +1,23 @@
1
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
2
+ index 5d65200..a997ad7 100644
3
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
4
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
5
+ @@ -2042,7 +2042,17 @@ export class InteractiveMode {
6
+ if (text === "/model" || text.startsWith("/model ")) {
7
+ const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
8
+ this.editor.setText("");
9
+ - await this.handleModelCommand(searchTerm);
10
+ + // Privateer redirect: when the shielded picker extension is loaded
11
+ + // (registers `/models`), route `/model` to it so users get the
12
+ + // privacy-posture picker via muscle memory. Falls back to Pi's
13
+ + // built-in picker when the extension isn't present.
14
+ + const privateerModels = searchTerm ? `/models ${searchTerm}` : "/models";
15
+ + if (this.isExtensionCommand(privateerModels)) {
16
+ + await this.session.prompt(privateerModels);
17
+ + }
18
+ + else {
19
+ + await this.handleModelCommand(searchTerm);
20
+ + }
21
+ return;
22
+ }
23
+ if (text === "/export" || text.startsWith("/export ")) {
@@ -47,20 +47,88 @@ function seedModel(id: string) {
47
47
  };
48
48
  }
49
49
 
50
- // Fetch the account channel's enabled model catalog. `GET /api/models` is the server's
51
- // public list of billable models (`{ models: [{ modelId }] }`) — the same set the app
52
- // shows. (The `/api/agent/v1` base only implements chat/completions, no /models route.)
53
- // Falls back to DEFAULT_MODELS on any failure.
54
- export async function fetchAccountModels(): Promise<string[]> {
50
+ // One catalog entry with its server-asserted baseline privacy tier.
51
+ export interface AccountModelInfo {
52
+ id: string;
53
+ tier: PrivacyTier;
54
+ }
55
+
56
+ // The set of tier strings pi-privacy defines (posture/tiers.ts). We only trust a
57
+ // server-supplied tier if it's one of these — anything else falls back to a prefix
58
+ // heuristic, so a server typo or older/newer server can never inject a bogus tier.
59
+ const VALID_TIERS = new Set<PrivacyTier>([
60
+ "tee-verified",
61
+ "tee-unverified",
62
+ "local",
63
+ "zdr-enforced",
64
+ "zdr-policy",
65
+ "standard",
66
+ ]);
67
+
68
+ // Baseline tier when the server doesn't (yet) send one. Honest-labeling rule: a
69
+ // confidential-compute model is only *claimed* here (tee-unverified) — the picker
70
+ // upgrades it to tee-verified live via attestation (accountPosture). Everything
71
+ // else with no server signal is "standard": we don't assert ZDR we can't back.
72
+ function tierFromPrefix(modelId: string): PrivacyTier {
73
+ return modelId.startsWith("near/") || modelId.startsWith("tinfoil/") ? "tee-unverified" : "standard";
74
+ }
75
+
76
+ function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
77
+ return tier && VALID_TIERS.has(tier as PrivacyTier) ? (tier as PrivacyTier) : tierFromPrefix(modelId);
78
+ }
79
+
80
+ // Baseline tiers for the account catalog, keyed by modelId. Populated by
81
+ // fetchAccountCatalog() so the /models picker can shield each row without re-fetching.
82
+ // A live NEAR attestation (accountPosture) can still upgrade a row to tee-verified.
83
+ const accountTierMap = new Map<string, PrivacyTier>();
84
+
85
+ // The server-asserted baseline tier for an account model, or undefined if we haven't
86
+ // seen it in a catalog fetch. Used by the /models picker (privateer-models.ts).
87
+ export function accountBaselineTier(modelId: string): PrivacyTier | undefined {
88
+ return accountTierMap.get(modelId);
89
+ }
90
+
91
+ // Whether a catalog fetch has populated the tier map at least once. The /models
92
+ // picker uses this to decide if it must fetch before opening (first-open race with
93
+ // the provider's background fetch) vs. render immediately from the cached tiers.
94
+ export function accountCatalogLoaded(): boolean {
95
+ return accountTierMap.size > 0;
96
+ }
97
+
98
+ // Fetch the account channel's enabled model catalog WITH per-model privacy tiers.
99
+ // `GET /api/models` is the server's public list of billable models
100
+ // (`{ models: [{ modelId, privacy: { tier } }] }`) — the same set the app shows.
101
+ // (The `/api/agent/v1` base only implements chat/completions, no /models route.)
102
+ // Falls back to DEFAULT_MODELS (prefix-derived tiers) on any failure. Side effect:
103
+ // refreshes accountTierMap.
104
+ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
105
+ const fallback = (): AccountModelInfo[] =>
106
+ DEFAULT_MODELS.map((id) => ({ id, tier: tierFromPrefix(id) }));
107
+ let infos: AccountModelInfo[];
55
108
  try {
56
109
  const res = await fetch(`${serverBaseUrl()}/api/models`);
57
- if (!res.ok) return DEFAULT_MODELS;
58
- const data = (await res.json()) as { models?: { modelId?: string }[] };
59
- const ids = (data.models ?? []).map((m) => m.modelId).filter((x): x is string => !!x);
60
- return ids.length ? ids : DEFAULT_MODELS;
110
+ if (!res.ok) {
111
+ infos = fallback();
112
+ } else {
113
+ const data = (await res.json()) as {
114
+ models?: { modelId?: string; privacy?: { tier?: string } }[];
115
+ };
116
+ const parsed = (data.models ?? [])
117
+ .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
118
+ .filter((x): x is AccountModelInfo => !!x);
119
+ infos = parsed.length ? parsed : fallback();
120
+ }
61
121
  } catch {
62
- return DEFAULT_MODELS;
122
+ infos = fallback();
63
123
  }
124
+ accountTierMap.clear();
125
+ for (const info of infos) accountTierMap.set(info.id, info.tier);
126
+ return infos;
127
+ }
128
+
129
+ // Back-compat id-only view over fetchAccountCatalog (registerProvider only needs ids).
130
+ export async function fetchAccountModels(): Promise<string[]> {
131
+ return (await fetchAccountCatalog()).map((m) => m.id);
64
132
  }
65
133
 
66
134
  // The Pi OAuth provider (Omit<OAuthProviderInterface, "id"> — Pi supplies the id from
@@ -199,8 +267,10 @@ export function makeAccountProvider() {
199
267
  models: ids.map(seedModel),
200
268
  });
201
269
  register(DEFAULT_MODELS); // immediate: provider exists this tick
202
- void fetchAccountModels()
203
- .then((ids) => ids.length && register(ids)) // refine to the live catalog
270
+ // Refine to the live catalog. fetchAccountCatalog also populates accountTierMap
271
+ // as a side effect, so the /models picker can shield each row without re-fetching.
272
+ void fetchAccountCatalog()
273
+ .then((infos) => infos.length && register(infos.map((m) => m.id)))
204
274
  .catch(() => {
205
275
  /* keep the fallback model */
206
276
  });