privateer-agent 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,149 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { ProviderConfig } from "../config/schema.ts";
3
+ import { NEARAI_BASE_URL } from "./registry.ts";
4
+ import { authedFetch, serverBaseUrl } from "../auth/privateer.ts";
5
+
6
+ // ── NEAR AI TEE attestation ──────────────────────────────────────────────────
7
+ // Every NEAR AI Cloud model runs inside a Trusted Execution Environment (Intel TDX
8
+ // confidential VM + NVIDIA confidential-computing GPU). On request, the gateway
9
+ // returns a cryptographic attestation report proving the model is running on
10
+ // genuine TEE hardware, with a signing key that never leaves the enclave bound to
11
+ // a caller-supplied nonce (report_data = signing_address || nonce). That lets us
12
+ // surface a live "this inference is confidential and verifiable" signal.
13
+ //
14
+ // We do a *pragmatic* check suited to a TUI: fetch a fresh report bound to our
15
+ // nonce and confirm it carries a TEE signing key plus hardware evidence. We do NOT
16
+ // re-validate the raw NVIDIA/Intel quote chains here — that's the job of the full
17
+ // verifier (github.com/nearai/cloud-verifier). The /verify command prints the raw
18
+ // report so a user can take it to that verifier.
19
+
20
+ const TIMEOUT_MS = 12_000;
21
+
22
+ export type TeePosture = "green" | "yellow" | "red";
23
+
24
+ export interface Attestation {
25
+ model: string;
26
+ nonce: string; // the 32-byte hex nonce we sent (freshness / anti-replay)
27
+ signingAddress?: string; // TEE-bound key that signs inference responses
28
+ nonceEchoed: boolean; // our nonce appears in the report → it's fresh, not replayed
29
+ hardware: string[]; // detected evidence, e.g. ["NVIDIA", "Intel TDX"]
30
+ raw: unknown; // full report, for /verify display + external verification
31
+ }
32
+
33
+ function baseFor(cfg: ProviderConfig): string {
34
+ return (cfg.baseURL ?? NEARAI_BASE_URL).replace(/\/+$/, "");
35
+ }
36
+
37
+ // A 32-byte (64 hex char) random nonce, per NEAR's attestation API guidance.
38
+ export function randomNonce(): string {
39
+ return randomBytes(32).toString("hex");
40
+ }
41
+
42
+ // Recursively find the first string value under any of `keys` (case-insensitive).
43
+ function deepFindString(obj: unknown, keys: string[]): string | undefined {
44
+ const want = new Set(keys.map((k) => k.toLowerCase()));
45
+ const stack: unknown[] = [obj];
46
+ while (stack.length) {
47
+ const cur = stack.pop();
48
+ if (Array.isArray(cur)) {
49
+ stack.push(...cur);
50
+ } else if (cur && typeof cur === "object") {
51
+ for (const [k, v] of Object.entries(cur)) {
52
+ if (typeof v === "string" && want.has(k.toLowerCase()) && v.trim()) return v.trim();
53
+ if (v && typeof v === "object") stack.push(v);
54
+ }
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ // Fetch and interpret the attestation report for a model. Throws a readable error
61
+ // (mirroring listModels) on missing key / network / HTTP failure so the UI can
62
+ // show a dim "unverified" affordance rather than a colored verdict.
63
+ export async function fetchAttestation(cfg: ProviderConfig, modelId: string): Promise<Attestation> {
64
+ if (!cfg.apiKey) throw new Error("no API key");
65
+ const nonce = randomNonce();
66
+ const url =
67
+ `${baseFor(cfg)}/attestation/report` +
68
+ `?model=${encodeURIComponent(modelId)}&signing_algo=ecdsa&nonce=${nonce}`;
69
+
70
+ const ac = new AbortController();
71
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
72
+ let raw: unknown;
73
+ try {
74
+ const res = await fetch(url, {
75
+ headers: { authorization: `Bearer ${cfg.apiKey}` },
76
+ signal: ac.signal,
77
+ });
78
+ if (!res.ok) {
79
+ const body = await res.text().catch(() => "");
80
+ const hint = body.slice(0, 200).trim();
81
+ throw new Error(`HTTP ${res.status} ${res.statusText}${hint ? ` — ${hint}` : ""}`);
82
+ }
83
+ raw = await res.json();
84
+ } catch (err) {
85
+ if (err instanceof Error && err.name === "AbortError") {
86
+ throw new Error(`timed out after ${TIMEOUT_MS / 1000}s`);
87
+ }
88
+ throw err;
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+
93
+ return interpretReport(modelId, nonce, raw);
94
+ }
95
+
96
+ // Turn a raw NEAR attestation report into our Attestation posture, independent of
97
+ // how it was fetched (direct nearai gateway, or via the Privateer server proxy).
98
+ function interpretReport(modelId: string, nonce: string, raw: unknown): Attestation {
99
+ const signingAddress = deepFindString(raw, ["signing_address", "signingAddress", "address"]);
100
+ // Hardware evidence is detected by scanning the serialized report for the quote
101
+ // markers each vendor uses — robust to the exact response shape.
102
+ const blob = JSON.stringify(raw).toLowerCase();
103
+ const hardware: string[] = [];
104
+ if (/nvidia|gpu/.test(blob)) hardware.push("NVIDIA");
105
+ if (/intel|tdx/.test(blob)) hardware.push("Intel TDX");
106
+ const nonceEchoed = blob.includes(nonce.toLowerCase());
107
+
108
+ return { model: modelId, nonce, signingAddress, nonceEchoed, hardware, raw };
109
+ }
110
+
111
+ // Fetch attestation for an account-billed `privateer:near/...` model through the
112
+ // Privateer server proxy (the NEAR key stays server-side). The server generates
113
+ // the nonce and returns { model, nonce, report, has* booleans }; we interpret the
114
+ // report exactly like the direct path so /verify renders identically. `modelId`
115
+ // is the bare id, still `near/`-prefixed (the server strips it upstream).
116
+ export async function fetchAttestationViaServer(modelId: string): Promise<Attestation> {
117
+ const ac = new AbortController();
118
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
119
+ try {
120
+ const res = await authedFetch(
121
+ `${serverBaseUrl()}/api/models/near/attestation?model=${encodeURIComponent(modelId)}`,
122
+ { signal: ac.signal },
123
+ );
124
+ if (!res.ok) {
125
+ const body = await res.text().catch(() => "");
126
+ const hint = body.slice(0, 200).trim();
127
+ throw new Error(`HTTP ${res.status} ${res.statusText}${hint ? ` — ${hint}` : ""}`);
128
+ }
129
+ const data = (await res.json()) as { nonce?: string; report?: unknown };
130
+ return interpretReport(modelId, data.nonce ?? "", data.report ?? {});
131
+ } catch (err) {
132
+ if (err instanceof Error && err.name === "AbortError") {
133
+ throw new Error(`timed out after ${TIMEOUT_MS / 1000}s`);
134
+ }
135
+ throw err;
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+
141
+ // Map an attestation to a status color. GREEN: fresh report bound to our nonce with
142
+ // a TEE signing key and hardware evidence (confidential + verifiable). YELLOW: a
143
+ // report came back but it's missing the signing key, hardware evidence, or nonce
144
+ // echo (attested but not fully confirmable here). RED: no attestation material.
145
+ export function teePosture(att: Attestation): TeePosture {
146
+ if (!att.signingAddress && att.hardware.length === 0) return "red";
147
+ if (att.signingAddress && att.hardware.length > 0 && att.nonceEchoed) return "green";
148
+ return "yellow";
149
+ }
@@ -0,0 +1,104 @@
1
+ import type { Config } from "../config/schema.ts";
2
+ import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
3
+ import { PROVIDER_META } from "./catalog.ts";
4
+ import { configuredProviders, parseModelSpec } from "./resolve.ts";
5
+ import type { Modality } from "../util/images.ts";
6
+
7
+ // Map a provider-reported input modality string (OpenRouter exposes these) onto our
8
+ // taxonomy. "file" is treated as document support since that's what providers use for
9
+ // PDFs; unknown strings are ignored.
10
+ function fromReported(reported: string[]): Set<Modality> {
11
+ const out = new Set<Modality>();
12
+ for (const r of reported) {
13
+ const m = r.toLowerCase();
14
+ if (m === "image") out.add("image");
15
+ else if (m === "audio") out.add("audio");
16
+ else if (m === "video") out.add("video");
17
+ else if (m === "file" || m === "document" || m === "pdf") out.add("document");
18
+ }
19
+ return out;
20
+ }
21
+
22
+ // Static heuristics per modality, keyed off the model id. Intentionally generous: a
23
+ // false positive surfaces a provider error, a false negative silently drops input.
24
+ function heuristic(modality: Modality, id: string): boolean {
25
+ switch (modality) {
26
+ case "image":
27
+ if (/vision|-vl\b|\bvl-|pixtral|multimodal|maverick|scout/.test(id)) return true;
28
+ if (/claude-3|claude-(opus|sonnet|haiku)-4|claude-4/.test(id)) return true;
29
+ if (/gpt-4o|gpt-4\.1|gpt-4\.5|gpt-4-turbo|gpt-5|chatgpt-4o|\bo[134](?:-|$)/.test(id)) return true;
30
+ if (/gemini/.test(id)) return true;
31
+ if (/llama-3\.2.*vision|llama-4/.test(id)) return true;
32
+ if (/grok.*vision|grok-4/.test(id)) return true;
33
+ return false;
34
+ case "document": // native PDF input
35
+ if (/claude-3|claude-(opus|sonnet|haiku)-4|claude-4/.test(id)) return true;
36
+ if (/gemini/.test(id)) return true;
37
+ return false;
38
+ case "audio":
39
+ if (/gpt-4o-audio|gpt-4o-realtime|gpt-audio|whisper/.test(id)) return true;
40
+ if (/gemini/.test(id)) return true;
41
+ return false;
42
+ case "video":
43
+ if (/gemini/.test(id)) return true;
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Whether a model accepts a given input modality. Reported modalities (when the
49
+ // provider supplies them) win over the static heuristic.
50
+ export function modelSupports(
51
+ modality: Modality,
52
+ _provider: string,
53
+ modelId: string,
54
+ reported?: string[],
55
+ ): boolean {
56
+ if (reported) return fromReported(reported).has(modality);
57
+ return heuristic(modality, modelId.toLowerCase());
58
+ }
59
+
60
+ // The full set of input modalities a model accepts.
61
+ export function modalitiesFor(provider: string, modelId: string, reported?: string[]): Set<Modality> {
62
+ if (reported) return fromReported(reported);
63
+ const out = new Set<Modality>();
64
+ for (const m of ["image", "document", "audio", "video"] as Modality[]) {
65
+ if (heuristic(m, modelId.toLowerCase())) out.add(m);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // Providers to consider for auto-pick, in preference order: the provider behind the
71
+ // configured default model first, then any other configured providers.
72
+ function providerOrder(config: Config): ProviderName[] {
73
+ const ready = new Set(configuredProviders(config).filter((p) => p.ready).map((p) => p.name));
74
+ let primary: ProviderName | undefined;
75
+ try {
76
+ const p = parseModelSpec(config.defaultModel).provider;
77
+ if ((KNOWN_PROVIDERS as readonly string[]).includes(p)) primary = p as ProviderName;
78
+ } catch {
79
+ /* malformed defaultModel → no primary */
80
+ }
81
+ const rest = KNOWN_PROVIDERS.filter((p) => p !== primary && ready.has(p));
82
+ return [...(primary && ready.has(primary) ? [primary] : []), ...rest];
83
+ }
84
+
85
+ // Hybrid auto-pick for a modality route: when no model is configured for `modality`
86
+ // and the default can't handle it, return a known capable "provider:model" from a
87
+ // configured provider, or null if none qualifies. Reuses each provider's catalog
88
+ // default — no network call.
89
+ export function suggestModelFor(modality: Modality, config: Config): string | null {
90
+ for (const provider of providerOrder(config)) {
91
+ const spec = PROVIDER_META[provider].defaultModel;
92
+ const { modelId } = parseModelSpec(spec);
93
+ if (modelSupports(modality, provider, modelId)) return spec;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // Back-compat shims for the original vision-only API.
99
+ export function modelSupportsVision(provider: string, modelId: string, reported?: string[]): boolean {
100
+ return modelSupports("image", provider, modelId, reported);
101
+ }
102
+ export function suggestVisionModel(config: Config): string | null {
103
+ return suggestModelFor("image", config);
104
+ }
@@ -0,0 +1,66 @@
1
+ import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
2
+ import { providerRequiresKey } from "./registry.ts";
3
+
4
+ // Human-facing metadata for each provider, used by the onboarding flow: a display
5
+ // label, where to get an API key, and the model to default to when the provider is
6
+ // chosen first. Keeps the registry (wiring) separate from presentation.
7
+ export interface ProviderMeta {
8
+ name: ProviderName;
9
+ label: string;
10
+ requiresKey: boolean;
11
+ defaultModel: string; // "provider:model" picked when this provider is selected first
12
+ keyHint: string; // where to obtain a key, or a note for keyless providers
13
+ baseURLDefault?: string; // shown as the placeholder for keyless/local providers
14
+ }
15
+
16
+ export const PROVIDER_META: Record<ProviderName, ProviderMeta> = {
17
+ anthropic: {
18
+ name: "anthropic",
19
+ label: "Anthropic",
20
+ requiresKey: providerRequiresKey("anthropic"),
21
+ defaultModel: "anthropic:claude-opus-4-8",
22
+ keyHint: "console.anthropic.com/settings/keys",
23
+ },
24
+ openai: {
25
+ name: "openai",
26
+ label: "OpenAI",
27
+ requiresKey: providerRequiresKey("openai"),
28
+ defaultModel: "openai:gpt-4o",
29
+ keyHint: "platform.openai.com/api-keys",
30
+ },
31
+ openrouter: {
32
+ name: "openrouter",
33
+ label: "OpenRouter",
34
+ requiresKey: providerRequiresKey("openrouter"),
35
+ defaultModel: "openrouter:anthropic/claude-opus-4.8",
36
+ keyHint: "openrouter.ai/keys",
37
+ },
38
+ ollama: {
39
+ name: "ollama",
40
+ label: "Ollama (local)",
41
+ requiresKey: providerRequiresKey("ollama"),
42
+ defaultModel: "ollama:llama3.1",
43
+ keyHint: "runs locally — no key needed",
44
+ baseURLDefault: "http://localhost:11434/api",
45
+ },
46
+ nearai: {
47
+ name: "nearai",
48
+ label: "NEAR AI (private TEE inference)",
49
+ requiresKey: providerRequiresKey("nearai"),
50
+ defaultModel: "nearai:zai-org/GLM-5.1-FP8",
51
+ keyHint: "cloud.near.ai → API Keys",
52
+ },
53
+ privateer: {
54
+ name: "privateer",
55
+ // Default to a NEAR confidential-compute (TEE) model: it's the strongest
56
+ // privacy guarantee, runs through the same billed agent endpoint, and was
57
+ // verified to pass agent tool_calls. Switch to any listed model with /model.
58
+ label: "Privateer account (billed to your subscription)",
59
+ requiresKey: providerRequiresKey("privateer"),
60
+ defaultModel: "privateer:near/deepseek-ai/DeepSeek-V4-Flash",
61
+ keyHint: "sign in with /login — no API key needed",
62
+ },
63
+ };
64
+
65
+ // Provider metadata in display order.
66
+ export const PROVIDER_LIST: ProviderMeta[] = KNOWN_PROVIDERS.map((n) => PROVIDER_META[n]);
@@ -0,0 +1,183 @@
1
+ import type { ProviderConfig, ProviderName } from "../config/schema.ts";
2
+ import { NEARAI_BASE_URL } from "./registry.ts";
3
+ import { authedFetch, serverBaseUrl, DEFAULT_SERVER_URL } from "../auth/privateer.ts";
4
+
5
+ // A model offered by a provider, as surfaced in the picker. `id` is the bare model
6
+ // id (no "provider:" prefix); `label` is an optional human-friendly name.
7
+ // `inputModalities` (when the provider reports it) lists accepted input kinds —
8
+ // e.g. ["text", "image"] — and lets the router know a model can actually see images.
9
+ export interface ModelInfo {
10
+ id: string;
11
+ label?: string;
12
+ inputModalities?: string[];
13
+ }
14
+
15
+ const TIMEOUT_MS = 12_000;
16
+
17
+ // Default API roots per provider. These mirror each SDK's default so the listing
18
+ // endpoint and the actual chat endpoint stay in sync when no baseURL is configured.
19
+ const DEFAULT_BASE: Record<ProviderName, string> = {
20
+ anthropic: "https://api.anthropic.com",
21
+ openai: "https://api.openai.com/v1",
22
+ openrouter: "https://openrouter.ai/api/v1",
23
+ ollama: "http://localhost:11434/api",
24
+ nearai: NEARAI_BASE_URL,
25
+ privateer: DEFAULT_SERVER_URL, // unused by the privateer branch (authed endpoint), kept for type completeness
26
+ };
27
+
28
+ function baseFor(name: ProviderName, cfg: ProviderConfig): string {
29
+ return (cfg.baseURL ?? DEFAULT_BASE[name]).replace(/\/+$/, "");
30
+ }
31
+
32
+ async function getJson(url: string, headers: Record<string, string>): Promise<unknown> {
33
+ const ac = new AbortController();
34
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
35
+ try {
36
+ const res = await fetch(url, { headers, signal: ac.signal });
37
+ if (!res.ok) {
38
+ const body = await res.text().catch(() => "");
39
+ const hint = body.slice(0, 200).trim();
40
+ throw new Error(`HTTP ${res.status} ${res.statusText}${hint ? ` — ${hint}` : ""}`);
41
+ }
42
+ return await res.json();
43
+ } catch (err) {
44
+ if (err instanceof Error && err.name === "AbortError") {
45
+ throw new Error(`timed out after ${TIMEOUT_MS / 1000}s`);
46
+ }
47
+ throw err;
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+
53
+ // Pull the list of models a provider currently offers, using the credentials the user
54
+ // supplied. Each provider exposes a different listing endpoint and response shape, so
55
+ // this is the second provider-specific seam (alongside the model factory in registry.ts).
56
+ // Throws with a readable message on auth/network failure so the picker can surface it.
57
+ export async function listModels(name: ProviderName, cfg: ProviderConfig): Promise<ModelInfo[]> {
58
+ const base = baseFor(name, cfg);
59
+ switch (name) {
60
+ case "anthropic": {
61
+ if (!cfg.apiKey) throw new Error("no API key");
62
+ const json = (await getJson(`${base}/v1/models?limit=1000`, {
63
+ "x-api-key": cfg.apiKey,
64
+ "anthropic-version": "2023-06-01",
65
+ })) as { data?: { id: string; display_name?: string }[] };
66
+ return (json.data ?? []).map((m) => ({ id: m.id, label: m.display_name }));
67
+ }
68
+ case "openai": {
69
+ if (!cfg.apiKey) throw new Error("no API key");
70
+ const json = (await getJson(`${base}/models`, {
71
+ authorization: `Bearer ${cfg.apiKey}`,
72
+ })) as { data?: { id: string }[] };
73
+ // Keep chat-capable families; the listing also includes embeddings/tts/whisper.
74
+ const chat = (json.data ?? []).filter((m) => /^(gpt|o\d|chatgpt)/i.test(m.id));
75
+ return (chat.length ? chat : (json.data ?? []))
76
+ .map((m) => ({ id: m.id }))
77
+ .sort((a, b) => a.id.localeCompare(b.id));
78
+ }
79
+ case "openrouter": {
80
+ // OpenRouter's model list is public; the key is sent when present but optional.
81
+ const json = (await getJson(
82
+ `${base}/models`,
83
+ cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {},
84
+ )) as {
85
+ data?: { id: string; name?: string; architecture?: { input_modalities?: string[] } }[];
86
+ };
87
+ return (json.data ?? [])
88
+ .map((m) => ({ id: m.id, label: m.name, inputModalities: m.architecture?.input_modalities }))
89
+ .sort((a, b) => a.id.localeCompare(b.id));
90
+ }
91
+ case "ollama": {
92
+ // Locally installed models, via the Ollama daemon's tags endpoint.
93
+ const json = (await getJson(`${base}/tags`, {})) as {
94
+ models?: { name: string }[];
95
+ };
96
+ return (json.models ?? []).map((m) => ({ id: m.name }));
97
+ }
98
+ case "nearai": {
99
+ // OpenAI-compatible model list. Every NEAR model runs in a TEE, so there's
100
+ // no per-model capability to surface here beyond the id itself.
101
+ if (!cfg.apiKey) throw new Error("no API key");
102
+ const json = (await getJson(`${base}/models`, {
103
+ authorization: `Bearer ${cfg.apiKey}`,
104
+ })) as { data?: { id: string }[] };
105
+ return (json.data ?? [])
106
+ .map((m) => ({ id: m.id }))
107
+ .sort((a, b) => a.id.localeCompare(b.id));
108
+ }
109
+ case "privateer": {
110
+ // The server proxies OpenRouter and bills to the account; list what the
111
+ // account can use via the authed models endpoint (the JWT is injected by
112
+ // authedFetch — no API key ever touches the client).
113
+ const res = await authedFetch(`${serverBaseUrl()}/api/models/openrouter`);
114
+ if (!res.ok) throw new Error(`HTTP ${res.status} listing account models`);
115
+ const json = (await res.json()) as {
116
+ models?: { id: string; name?: string; inputModalities?: string[]; architecture?: { input_modalities?: string[] } }[];
117
+ };
118
+ return (json.models ?? [])
119
+ .map((m) => ({ id: m.id, label: m.name, inputModalities: m.inputModalities ?? m.architecture?.input_modalities }))
120
+ .sort((a, b) => a.id.localeCompare(b.id));
121
+ }
122
+ }
123
+ }
124
+
125
+ // ── OpenRouter Zero-Data-Retention (ZDR) posture ─────────────────────────────
126
+ // OpenRouter exposes a model's retention story through two authenticated REST
127
+ // endpoints (both need the user's API key). We fold them into a per-account
128
+ // snapshot that the status-bar shield reads against the selected model.
129
+
130
+ export type ZdrPosture = "green" | "yellow" | "red";
131
+
132
+ export interface ZdrAccountData {
133
+ // Models with at least one zero-data-retention endpoint (from /endpoints/zdr).
134
+ zdrModelIds: Set<string>;
135
+ // Models usable under the account's privacy settings + guardrails (from /models/user).
136
+ userModelIds: Set<string>;
137
+ }
138
+
139
+ // Model ids may carry a variant suffix (":free", ":thinking") that the ZDR/user
140
+ // listings don't use on their permaslug. Strip it and lowercase so the sets,
141
+ // and the lookup against them, compare on the same canonical id.
142
+ function normalizeModelId(id: string): string {
143
+ const i = id.indexOf(":");
144
+ return (i === -1 ? id : id.slice(0, i)).trim().toLowerCase();
145
+ }
146
+
147
+ // Fetch the account's ZDR snapshot. Issues the two authed calls concurrently and
148
+ // reuses getJson's timeout + readable error message. Throws "no API key" (matching
149
+ // listModels) when no key is configured, since both endpoints require auth.
150
+ export async function fetchZdrAccount(cfg: ProviderConfig): Promise<ZdrAccountData> {
151
+ if (!cfg.apiKey) throw new Error("no API key");
152
+ const base = baseFor("openrouter", cfg);
153
+ const headers = { authorization: `Bearer ${cfg.apiKey}` };
154
+ const [zdr, user] = await Promise.all([
155
+ getJson(`${base}/endpoints/zdr`, headers) as Promise<{ data?: { model_id?: string }[] }>,
156
+ getJson(`${base}/models/user`, headers) as Promise<{ data?: { id?: string }[] }>,
157
+ ]);
158
+ const zdrModelIds = new Set(
159
+ (zdr.data ?? []).flatMap((e) => (e.model_id ? [normalizeModelId(e.model_id)] : [])),
160
+ );
161
+ const userModelIds = new Set(
162
+ (user.data ?? []).flatMap((m) => (m.id ? [normalizeModelId(m.id)] : [])),
163
+ );
164
+ return { zdrModelIds, userModelIds };
165
+ }
166
+
167
+ // Decide the shield color for a model against an account snapshot. Pure/synchronous
168
+ // so model switches re-evaluate without a network round-trip. `enforced` is the
169
+ // client's own ZDR-enforcement setting (config.providers.openrouter.enforceZdr):
170
+ // when on, Privateer pins requests to ZDR endpoints, so a ZDR-capable model is
171
+ // guaranteed zero-retention (green) rather than merely able to be (yellow).
172
+ export function zdrPosture(modelId: string, acct: ZdrAccountData, enforced: boolean): ZdrPosture {
173
+ const id = normalizeModelId(modelId);
174
+ const inUser = acct.userModelIds.has(id);
175
+ const inZdr = acct.zdrModelIds.has(id);
176
+ // RED: blocked by the account's privacy settings (request would 404), or no
177
+ // zero-retention endpoint exists for the model (data will be retained — and
178
+ // under enforcement the request would be rejected outright).
179
+ if (!inUser || !inZdr) return "red";
180
+ // Usable and a ZDR endpoint exists: GREEN when we force ZDR routing, YELLOW when
181
+ // ZDR is merely available (a request may still hit a retaining endpoint).
182
+ return enforced ? "green" : "yellow";
183
+ }
@@ -0,0 +1,71 @@
1
+ import type { LanguageModel } from "ai";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createOpenAI } from "@ai-sdk/openai";
4
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
5
+ import { createOllama } from "ollama-ai-provider-v2";
6
+ import type { ProviderConfig, ProviderName } from "../config/schema.ts";
7
+ import { authedFetch, serverBaseUrl } from "../auth/privateer.ts";
8
+
9
+ // NEAR AI Cloud's OpenAI-compatible gateway. Every model behind it runs inside a
10
+ // Trusted Execution Environment (TEE), so requests are confidential and each one
11
+ // can be cryptographically attested (see ./attestation.ts). It only implements the
12
+ // Chat Completions API, so the factory below pins `.chat()` rather than the SDK's
13
+ // default Responses transport, and supplies this base when the user hasn't set one.
14
+ export const NEARAI_BASE_URL = "https://cloud-api.near.ai/v1";
15
+
16
+ // Each factory turns provider credentials + a model id into an AI SDK LanguageModel.
17
+ // This is the single seam that makes Privateer provider-agnostic: the agent loop,
18
+ // tools, and UI never know or care which provider is behind the model.
19
+ type Factory = (cfg: ProviderConfig, modelId: string) => LanguageModel;
20
+
21
+ // Whether a provider requires an API key to be usable (Ollama is local, so it doesn't).
22
+ const REQUIRES_KEY: Record<ProviderName, boolean> = {
23
+ openrouter: true,
24
+ anthropic: true,
25
+ openai: true,
26
+ ollama: false,
27
+ nearai: true,
28
+ // Privateer authenticates via a stored account session, not a typed key, so
29
+ // there's no key to prompt for. Readiness is "are you logged in?" — see
30
+ // providers/resolve.ts, which special-cases this against hasCredentials().
31
+ privateer: false,
32
+ };
33
+
34
+ const FACTORIES: Record<ProviderName, Factory> = {
35
+ openrouter: (cfg, modelId) =>
36
+ // When the user enforces ZDR, pin routing to zero-data-retention endpoints so
37
+ // prompts can't be retained upstream; OpenRouter rejects models that have none.
38
+ createOpenRouter({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(
39
+ modelId,
40
+ cfg.enforceZdr ? { provider: { zdr: true } } : {},
41
+ ),
42
+ anthropic: (cfg, modelId) =>
43
+ createAnthropic({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(modelId),
44
+ openai: (cfg, modelId) =>
45
+ createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(modelId),
46
+ ollama: (cfg, modelId) =>
47
+ createOllama({ baseURL: cfg.baseURL })(modelId),
48
+ nearai: (cfg, modelId) =>
49
+ // OpenAI-compatible, but Chat-Completions-only — `.chat()` avoids the SDK's
50
+ // default Responses transport, which NEAR's TEE endpoints don't implement.
51
+ createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL ?? NEARAI_BASE_URL }).chat(modelId),
52
+ privateer: (cfg, modelId) =>
53
+ // Routes to the Privateer server's billed, OpenAI-compatible agent endpoint.
54
+ // `authedFetch` injects the account JWT and refreshes it on 401, so no key is
55
+ // configured here (apiKey is a placeholder the SDK requires). Chat-Completions
56
+ // only, hence `.chat()`. The modelId is a normal OpenRouter id, resolved and
57
+ // billed server-side. Base URL: cfg override → account server → default.
58
+ createOpenAI({
59
+ apiKey: "privateer-session",
60
+ baseURL: `${(cfg.baseURL ?? serverBaseUrl()).replace(/\/$/, "")}/api/agent/v1`,
61
+ fetch: authedFetch as typeof fetch,
62
+ }).chat(modelId),
63
+ };
64
+
65
+ export function providerRequiresKey(name: ProviderName): boolean {
66
+ return REQUIRES_KEY[name];
67
+ }
68
+
69
+ export function buildModel(name: ProviderName, cfg: ProviderConfig, modelId: string): LanguageModel {
70
+ return FACTORIES[name](cfg, modelId);
71
+ }
@@ -0,0 +1,78 @@
1
+ import type { LanguageModel } from "ai";
2
+ import type { Config } from "../config/schema.ts";
3
+ import { KNOWN_PROVIDERS, type ProviderName } from "../config/schema.ts";
4
+ import { buildModel, providerRequiresKey } from "./registry.ts";
5
+ import { hasCredentials } from "../auth/privateer.ts";
6
+
7
+ // Privateer's readiness is session-based, not key-based: ready iff logged in.
8
+ function providerReady(name: ProviderName, cfg: { apiKey?: string }): boolean {
9
+ if (name === "privateer") return hasCredentials();
10
+ return providerRequiresKey(name) ? Boolean(cfg.apiKey) : true;
11
+ }
12
+
13
+ export interface ResolvedModel {
14
+ spec: string; // original "provider:model" string
15
+ provider: ProviderName;
16
+ modelId: string;
17
+ model: LanguageModel;
18
+ }
19
+
20
+ function isKnownProvider(name: string): name is ProviderName {
21
+ return (KNOWN_PROVIDERS as readonly string[]).includes(name);
22
+ }
23
+
24
+ // Parse a "provider:model" spec. The model id itself may contain ":" or "/"
25
+ // (e.g. "openrouter:anthropic/claude-opus-4.8"), so only the first ":" splits.
26
+ export function parseModelSpec(spec: string): { provider: string; modelId: string } {
27
+ const idx = spec.indexOf(":");
28
+ if (idx === -1) {
29
+ throw new Error(
30
+ `Invalid model "${spec}". Use "provider:model", e.g. openrouter:anthropic/claude-opus-4.8`,
31
+ );
32
+ }
33
+ return { provider: spec.slice(0, idx).trim(), modelId: spec.slice(idx + 1).trim() };
34
+ }
35
+
36
+ // A Privateer account model is served over one of two privacy channels: NEAR's
37
+ // confidential-compute TEE (model ids prefixed "near/", cryptographically
38
+ // attestable) or the account's zero-data-retention OpenRouter proxy (every other
39
+ // id, pinned to ZDR endpoints server-side). The picker and the status-bar shield
40
+ // both surface this so the active privacy channel is always visible.
41
+ export type PrivateerChannel = "tee" | "zdr";
42
+
43
+ export function privateerChannel(modelId: string): PrivateerChannel {
44
+ return modelId.startsWith("near/") ? "tee" : "zdr";
45
+ }
46
+
47
+ // Turn a model spec + config into a ready-to-use AI SDK model, validating that the
48
+ // provider is known and configured. Construction does not hit the network.
49
+ export function resolveModel(spec: string, config: Config): ResolvedModel {
50
+ const { provider, modelId } = parseModelSpec(spec);
51
+
52
+ if (!isKnownProvider(provider)) {
53
+ throw new Error(
54
+ `Unknown provider "${provider}". Known: ${KNOWN_PROVIDERS.join(", ")}.`,
55
+ );
56
+ }
57
+ if (!modelId) throw new Error(`Missing model id in "${spec}".`);
58
+
59
+ const cfg = config.providers[provider] ?? {};
60
+ if (provider === "privateer" && !hasCredentials()) {
61
+ throw new Error(`Not signed in to your Privateer account. Run /login first.`);
62
+ }
63
+ if (provider !== "privateer" && providerRequiresKey(provider) && !cfg.apiKey) {
64
+ throw new Error(
65
+ `No API key for "${provider}". Set ${provider.toUpperCase()}_API_KEY or add it to ~/.privateer/config.json.`,
66
+ );
67
+ }
68
+
69
+ return { spec, provider, modelId, model: buildModel(provider, cfg, modelId) };
70
+ }
71
+
72
+ // Which providers currently have working credentials — used by /doctor and provider listing.
73
+ export function configuredProviders(config: Config): { name: ProviderName; ready: boolean }[] {
74
+ return KNOWN_PROVIDERS.map((name) => {
75
+ const cfg = config.providers[name] ?? {};
76
+ return { name, ready: providerReady(name, cfg) };
77
+ });
78
+ }