privateer-agent 0.2.1 → 0.3.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 (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,127 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
3
- import { join } from "node:path";
4
- import type { ModelMessage } from "ai";
5
- import { globalDir } from "../config/load.ts";
6
- import type { UsageTotals } from "../engine/events.ts";
7
-
8
- // Persisted conversation for a project. Each session is stored under its own id so the
9
- // `/resume` picker can browse history; `latest.json` mirrors the newest write so the
10
- // `--continue` flag keeps working without enumerating the sessions directory.
11
- export interface SessionData {
12
- id: string;
13
- updatedAt: string;
14
- modelSpec: string;
15
- messages: ModelMessage[];
16
- usage: UsageTotals;
17
- }
18
-
19
- // Lightweight summary used by the session picker, without loading every message.
20
- export interface SessionMeta {
21
- id: string;
22
- updatedAt: string;
23
- modelSpec: string;
24
- messageCount: number;
25
- preview: string;
26
- }
27
-
28
- // A stable per-project key derived from the absolute cwd. Exported so other memory
29
- // modules (e.g. auto-memory) can resolve the same per-project directory.
30
- export function projectKey(cwd: string): string {
31
- return createHash("sha1").update(cwd).digest("hex").slice(0, 12);
32
- }
33
-
34
- function projectDir(cwd: string): string {
35
- return join(globalDir(), "projects", projectKey(cwd));
36
- }
37
-
38
- function sessionsDir(cwd: string): string {
39
- return join(projectDir(cwd), "sessions");
40
- }
41
-
42
- function latestPath(cwd: string): string {
43
- return join(projectDir(cwd), "latest.json");
44
- }
45
-
46
- function sessionPath(cwd: string, id: string): string {
47
- return join(sessionsDir(cwd), `${id}.json`);
48
- }
49
-
50
- // Per-session checkpoint directory (index + content-addressed blobs), kept alongside
51
- // the session file so `/rewind` survives a restart when that session is resumed.
52
- export function checkpointsDir(cwd: string, id: string): string {
53
- return join(projectDir(cwd), "checkpoints", id);
54
- }
55
-
56
- // A fresh, time-ordered session id minted once per run.
57
- export function newSessionId(): string {
58
- return `s-${Date.now()}`;
59
- }
60
-
61
- // Pull a short, single-line preview from the first user message for the picker.
62
- function previewOf(messages: ModelMessage[]): string {
63
- const first = messages.find((m) => m.role === "user");
64
- if (!first) return "(empty session)";
65
- const text =
66
- typeof first.content === "string"
67
- ? first.content
68
- : Array.isArray(first.content)
69
- ? first.content
70
- .map((p) => (p.type === "text" ? p.text : ""))
71
- .join(" ")
72
- : "";
73
- return text.replace(/\s+/g, " ").trim().slice(0, 72) || "(no text)";
74
- }
75
-
76
- export function saveSession(
77
- cwd: string,
78
- id: string,
79
- data: Omit<SessionData, "updatedAt" | "id">,
80
- ): void {
81
- mkdirSync(sessionsDir(cwd), { recursive: true });
82
- const payload: SessionData = { ...data, id, updatedAt: new Date().toISOString() };
83
- const json = JSON.stringify(payload);
84
- writeFileSync(sessionPath(cwd, id), json, "utf8");
85
- writeFileSync(latestPath(cwd), json, "utf8"); // back-compat for --continue
86
- }
87
-
88
- function readSessionFile(path: string): SessionData | null {
89
- if (!existsSync(path)) return null;
90
- try {
91
- const data = JSON.parse(readFileSync(path, "utf8")) as SessionData;
92
- // Tolerate older files written before sessions had ids.
93
- if (!data.id) data.id = newSessionId();
94
- return data;
95
- } catch {
96
- return null;
97
- }
98
- }
99
-
100
- export function loadLatest(cwd: string): SessionData | null {
101
- return readSessionFile(latestPath(cwd));
102
- }
103
-
104
- export function loadSession(cwd: string, id: string): SessionData | null {
105
- return readSessionFile(sessionPath(cwd, id));
106
- }
107
-
108
- // Summaries of every stored session for this project, newest first.
109
- export function listSessions(cwd: string): SessionMeta[] {
110
- const dir = sessionsDir(cwd);
111
- if (!existsSync(dir)) return [];
112
- const metas: SessionMeta[] = [];
113
- for (const file of readdirSync(dir)) {
114
- if (!file.endsWith(".json")) continue;
115
- const data = readSessionFile(join(dir, file));
116
- if (!data) continue;
117
- metas.push({
118
- id: data.id,
119
- updatedAt: data.updatedAt,
120
- modelSpec: data.modelSpec,
121
- messageCount: data.messages.length,
122
- preview: previewOf(data.messages),
123
- });
124
- }
125
- // Newest first; fall back to id when two writes share a millisecond.
126
- return metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id));
127
- }
@@ -1,149 +0,0 @@
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
- }
@@ -1,104 +0,0 @@
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
- }
@@ -1,183 +0,0 @@
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
- }
@@ -1,71 +0,0 @@
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
- }