privateer-agent 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/privateer-brand.ts +4 -4
- package/package.json +1 -1
- package/src/channels/run.ts +2 -1
- package/src/cli/chat.ts +6 -2
- package/src/config/hosted.ts +13 -0
- package/src/daemon/index.ts +86 -2
- package/src/providers/account.ts +7 -1
- package/src/providers/defaultModel.ts +119 -0
|
@@ -139,7 +139,7 @@ function shortCwd(): string {
|
|
|
139
139
|
// - signed in → "connected as <account>"
|
|
140
140
|
// - signed out AND the current model bills to a Privateer account → it can't run
|
|
141
141
|
// until they sign in, so say so plainly (warning)
|
|
142
|
-
// - signed out on their own key → a quiet tease that /
|
|
142
|
+
// - signed out on their own key → a quiet tease that /login adds more
|
|
143
143
|
function accountLine(modelProvider?: string): string {
|
|
144
144
|
const u = priv.currentUser();
|
|
145
145
|
if (u) {
|
|
@@ -147,9 +147,9 @@ function accountLine(modelProvider?: string): string {
|
|
|
147
147
|
return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
|
|
148
148
|
}
|
|
149
149
|
if (modelProvider === "privateer") {
|
|
150
|
-
return `${YELLOW}not signed in · /
|
|
150
|
+
return `${YELLOW}not signed in · /login to use this model${RESET}`;
|
|
151
151
|
}
|
|
152
|
-
return `${DIM}not signed in · ${OCEAN_LIGHT}/
|
|
152
|
+
return `${DIM}not signed in · ${OCEAN_LIGHT}/login${DIM} to connect your account${RESET}`;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
|
|
@@ -430,7 +430,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
430
430
|
ctx?.ui?.setTitle?.("Privateer");
|
|
431
431
|
refresh(ctx);
|
|
432
432
|
// No startup notify here: the banner's account line already surfaces the
|
|
433
|
-
// "not signed in · /
|
|
433
|
+
// "not signed in · /login" prompt, so a second line would just be noise.
|
|
434
434
|
});
|
|
435
435
|
|
|
436
436
|
// Keep the header's account line in sync with the picked model (the "this model
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
package/src/channels/run.ts
CHANGED
|
@@ -110,6 +110,7 @@ async function main() {
|
|
|
110
110
|
type GateController = import("../ext/permissionGate.ts").GateController;
|
|
111
111
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
112
112
|
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
113
|
+
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
113
114
|
const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
|
|
114
115
|
const { redactText, collectSecrets } = await import("../util/redact.ts");
|
|
115
116
|
const { MessagingBridge } = await import("./bridge.ts");
|
|
@@ -130,7 +131,7 @@ async function main() {
|
|
|
130
131
|
process.exit(1);
|
|
131
132
|
}
|
|
132
133
|
const ch = cfg.channels ?? {};
|
|
133
|
-
const defaultModel: string = ch.model ?? cfg.defaultModel
|
|
134
|
+
const defaultModel: string = resolveDefaultModel({ explicit: ch.model ?? cfg.defaultModel });
|
|
134
135
|
const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length ? ch.tools : SAFE_TOOLS;
|
|
135
136
|
const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
|
|
136
137
|
const cwd: string = ch.cwd ?? process.cwd();
|
package/src/cli/chat.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// the EngineEvent adapter, and Pi's real session.
|
|
5
5
|
//
|
|
6
6
|
// Run: nvm use && node --env-file=.env --import tsx src/cli/chat.ts
|
|
7
|
-
// Model: PRIVATEER_MODEL=provider/id (default
|
|
7
|
+
// Model: PRIVATEER_MODEL=provider/id (else the account default when signed in, or a
|
|
8
|
+
// BYO-keyed provider — see providers/defaultModel.ts)
|
|
8
9
|
// e.g. tinfoil/llama3-3-70b to watch TEE posture go green.
|
|
9
10
|
|
|
10
11
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
@@ -33,8 +34,11 @@ async function main() {
|
|
|
33
34
|
const priv = await import("../auth/privateer.ts");
|
|
34
35
|
const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
|
|
35
36
|
const { agentVersion } = await import("../config/version.ts");
|
|
37
|
+
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
// resolveDefaultModel() already honours PRIVATEER_MODEL first, then the account
|
|
40
|
+
// default when signed in, then a BYO key — one source of truth (defaultModel.ts).
|
|
41
|
+
const spec = resolveDefaultModel();
|
|
38
42
|
const slash = spec.indexOf("/");
|
|
39
43
|
const provider = spec.slice(0, slash);
|
|
40
44
|
const modelId = spec.slice(slash + 1);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Harbor hosted mode.
|
|
2
|
+
//
|
|
3
|
+
// When true, this daemon is running inside Privateer's confidential-VM fleet
|
|
4
|
+
// (the host orchestrator sets HARBOR_HOSTED=1), not on a user's own machine.
|
|
5
|
+
// Hosted daemons run on-demand: they report their next routine fire time to the
|
|
6
|
+
// server and idle-suspend when there's no work, so the server can wake them
|
|
7
|
+
// again in time. A daemon on a user's laptop leaves this off and keeps running
|
|
8
|
+
// its own cron continuously.
|
|
9
|
+
//
|
|
10
|
+
// Read via process.env at call time, mirroring PRIVATEER_HOME / PRIVATEER_SERVER_URL.
|
|
11
|
+
export function isHosted(): boolean {
|
|
12
|
+
return process.env.HARBOR_HOSTED === "1";
|
|
13
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
|
15
15
|
import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
|
|
16
16
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
17
17
|
import { makeAccountProvider } from "../providers/account.ts";
|
|
18
|
+
import { resolveDefaultModel } from "../providers/defaultModel.ts";
|
|
18
19
|
import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
19
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
20
21
|
import { makeRoutinesControl } from "../remote/routinesControl.ts";
|
|
@@ -49,6 +50,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
49
50
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
50
51
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
51
52
|
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
+
import { isHosted } from "../config/hosted.ts";
|
|
52
54
|
|
|
53
55
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
54
56
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -58,6 +60,10 @@ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
|
58
60
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
59
61
|
|
|
60
62
|
const TICK_MS = 60_000; // scan for due routines once a minute
|
|
63
|
+
// Harbor hosted mode (isHosted): suspend after this much idle time with no work,
|
|
64
|
+
// and stay up if a routine is due within the lead window (avoids suspend→wake churn).
|
|
65
|
+
const HOSTED_IDLE_MS = Number(process.env.HARBOR_IDLE_MS) || 5 * 60_000;
|
|
66
|
+
const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS) || 2 * 60_000;
|
|
61
67
|
const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
62
68
|
// How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
|
|
63
69
|
// answer before it fail-closes to "no response" (the runner then defers the run). Bounds
|
|
@@ -76,12 +82,14 @@ function loadDaemonConfig(): DaemonConfig {
|
|
|
76
82
|
try {
|
|
77
83
|
const raw = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
78
84
|
return {
|
|
79
|
-
|
|
85
|
+
// config.defaultModel is the explicit choice; absent it, resolve (account default
|
|
86
|
+
// when signed in, else BYO) rather than assuming a BYO OpenRouter key.
|
|
87
|
+
defaultModel: resolveDefaultModel({ explicit: typeof raw.defaultModel === "string" ? raw.defaultModel : undefined }),
|
|
80
88
|
webhooks: raw.webhooks,
|
|
81
89
|
providers: raw.providers,
|
|
82
90
|
};
|
|
83
91
|
} catch {
|
|
84
|
-
return { defaultModel:
|
|
92
|
+
return { defaultModel: resolveDefaultModel() };
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -155,6 +163,11 @@ export class Daemon {
|
|
|
155
163
|
private relay?: RelayClient;
|
|
156
164
|
private controllerAttached = false;
|
|
157
165
|
private relayTerminated = false;
|
|
166
|
+
// Harbor hosted mode: last time a controller attached or a routine ran. Drives
|
|
167
|
+
// idle-suspend (no `controller_detached` frame exists, so we gate on inactivity
|
|
168
|
+
// + no live work rather than on controllerAttached, which never resets while the
|
|
169
|
+
// socket stays open).
|
|
170
|
+
private lastActivityAt = Date.now();
|
|
158
171
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
159
172
|
// terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
|
|
160
173
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
@@ -403,6 +416,8 @@ export class Daemon {
|
|
|
403
416
|
|
|
404
417
|
private onControllerAttached(): void {
|
|
405
418
|
this.controllerAttached = true;
|
|
419
|
+
this.markActivity(); // hosted: a driver is present — reset the idle timer
|
|
420
|
+
|
|
406
421
|
this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
|
|
407
422
|
// Version + this terminal's identity public key (so the app can confirm this is
|
|
408
423
|
// the terminal it PINNED at link time before sealing channel tokens to it). No
|
|
@@ -503,6 +518,74 @@ export class Daemon {
|
|
|
503
518
|
await this.runRoutine(r);
|
|
504
519
|
}
|
|
505
520
|
}
|
|
521
|
+
await this.hostedTick();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ── Harbor hosted mode ──────────────────────────────────────────────────────
|
|
525
|
+
// A hosted daemon runs on-demand: it reports its earliest upcoming fire time so
|
|
526
|
+
// the server can wake it while suspended, and idle-suspends when there's no work.
|
|
527
|
+
// No-op on a user's own machine (isHosted() === false).
|
|
528
|
+
|
|
529
|
+
private markActivity(): void { this.lastActivityAt = Date.now(); }
|
|
530
|
+
|
|
531
|
+
// Earliest fire time (ms) across enabled, valid routines — mirrors tick()'s fire
|
|
532
|
+
// filters so we never report/wait on a routine the scheduler won't actually fire.
|
|
533
|
+
private earliestFireMs(): number | null {
|
|
534
|
+
let earliest: number | null = null;
|
|
535
|
+
for (const r of loadRoutines()) {
|
|
536
|
+
if (!r.enabled || triggerError(r)) continue;
|
|
537
|
+
const nrStr = r.nextRun ?? computeNextRun(r)?.toISOString();
|
|
538
|
+
if (!nrStr) continue;
|
|
539
|
+
const t = Date.parse(nrStr);
|
|
540
|
+
if (Number.isNaN(t)) continue;
|
|
541
|
+
if (earliest === null || t < earliest) earliest = t;
|
|
542
|
+
}
|
|
543
|
+
return earliest;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Report our next fire time to the server so its scheduler can wake us. When
|
|
547
|
+
// `suspending`, also flip the server-side status to suspended (we're about to
|
|
548
|
+
// exit) so the sweeper knows to wake us again. Best-effort: an offline instance
|
|
549
|
+
// retries next tick; a suspend report failing means we stay up this tick.
|
|
550
|
+
private async reportSchedule(suspending = false): Promise<boolean> {
|
|
551
|
+
if (!isHosted() || !hasCredentials()) return true;
|
|
552
|
+
const earliest = this.earliestFireMs();
|
|
553
|
+
try {
|
|
554
|
+
const res = await apiRequest("/api/harbor/agent/schedule", {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: { "content-type": "application/json" },
|
|
557
|
+
body: JSON.stringify({
|
|
558
|
+
termId: routineRelayId(),
|
|
559
|
+
nextRoutineAt: earliest !== null ? new Date(earliest).toISOString() : null,
|
|
560
|
+
...(suspending ? { suspended: true } : {}),
|
|
561
|
+
}),
|
|
562
|
+
});
|
|
563
|
+
return res.ok;
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private shouldSuspend(): boolean {
|
|
570
|
+
if (this.running.size > 0 || this.liveTasks.size > 0) return false;
|
|
571
|
+
if (Date.now() - this.lastActivityAt < HOSTED_IDLE_MS) return false;
|
|
572
|
+
const earliest = this.earliestFireMs();
|
|
573
|
+
if (earliest !== null && earliest - Date.now() < HOSTED_SUSPEND_MIN_LEAD_MS) return false;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async hostedTick(): Promise<void> {
|
|
578
|
+
if (!isHosted()) return;
|
|
579
|
+
if (this.shouldSuspend()) {
|
|
580
|
+
// Tell the server we're suspending (+ our next fire) BEFORE exiting; only
|
|
581
|
+
// then tear down. If the report fails, stay up and try again next tick.
|
|
582
|
+
if (!(await this.reportSchedule(true))) return;
|
|
583
|
+
log("hosted: idle — suspending (server will wake for the next routine)");
|
|
584
|
+
this.stop();
|
|
585
|
+
void revokeLocalSessions().finally(() => process.exit(0));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
await this.reportSchedule();
|
|
506
589
|
}
|
|
507
590
|
|
|
508
591
|
// Execute a routine to completion and deliver the result. The one rewired seam:
|
|
@@ -511,6 +594,7 @@ export class Daemon {
|
|
|
511
594
|
async runRoutine(routine: Routine): Promise<IpcResponse> {
|
|
512
595
|
if (this.running.has(routine.id)) return { ok: false, message: "already running" };
|
|
513
596
|
this.running.add(routine.id);
|
|
597
|
+
this.markActivity(); // hosted: work in progress — don't idle-suspend under it
|
|
514
598
|
log(`running routine "${routine.name}"`);
|
|
515
599
|
|
|
516
600
|
const config = loadDaemonConfig();
|
package/src/providers/account.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
notifySignedIn,
|
|
21
21
|
} from "../auth/privateer.ts";
|
|
22
22
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
23
|
+
import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
23
24
|
|
|
24
25
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
25
26
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
@@ -28,7 +29,7 @@ import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } fro
|
|
|
28
29
|
// confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
|
|
29
30
|
// fallback list if the live listing can't be reached.
|
|
30
31
|
const DEFAULT_MODELS = [
|
|
31
|
-
|
|
32
|
+
ACCOUNT_DEFAULT_MODEL_ID,
|
|
32
33
|
"anthropic/claude-sonnet-4.6",
|
|
33
34
|
"openai/gpt-5.5",
|
|
34
35
|
"deepseek/deepseek-v4-flash",
|
|
@@ -101,6 +102,11 @@ export const privateerOAuthProvider = {
|
|
|
101
102
|
}
|
|
102
103
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
103
104
|
const creds = await spawnAccountCredentials();
|
|
105
|
+
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
106
|
+
// to a billable subscription model instead of falling through to a keyless built-in
|
|
107
|
+
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
108
|
+
// chosen default. See providers/defaultModel.ts.
|
|
109
|
+
ensurePiDefaultModel();
|
|
104
110
|
// The fresh path already fired notifySignedIn (pollForToken); fire here for the
|
|
105
111
|
// already-linked path so the header re-renders to "connected" on this terminal too.
|
|
106
112
|
if (wasLinked) notifySignedIn();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The single source of truth for "which model do we default to?" — shared by every
|
|
2
|
+
// entry point that has to pick a model when the user hasn't named one: the REPL
|
|
3
|
+
// (cli/chat.ts), the daemon (routines), the channels runner, and the login-time hook
|
|
4
|
+
// that seeds Pi's TUI default (ensurePiDefaultModel).
|
|
5
|
+
//
|
|
6
|
+
// The bug this fixes: each of those sites used to hardcode `openrouter/openai/gpt-4o-
|
|
7
|
+
// mini`, which assumes a BYO OpenRouter key. A user who is ONLY signed into their
|
|
8
|
+
// Privateer subscription has no such key, so the runtime resolved to OpenRouter and
|
|
9
|
+
// then failed at request time with "No API key found for openrouter". Being signed in
|
|
10
|
+
// never nominated a model. resolveDefaultModel() makes the account channel the default
|
|
11
|
+
// the moment credentials exist, and keeps the legacy BYO behaviour otherwise.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
|
+
import { agentDir } from "../config/paths.ts";
|
|
17
|
+
|
|
18
|
+
// The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
|
|
19
|
+
// strongest privacy tier, and the same id the app shows first. Kept here as the one
|
|
20
|
+
// definition; providers/account.ts imports it so its seed catalog can't drift.
|
|
21
|
+
export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
|
|
22
|
+
export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
|
|
23
|
+
|
|
24
|
+
// Last-resort BYO default, preserved from the pre-resolver code so a user who set an
|
|
25
|
+
// OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
|
|
26
|
+
// either, this still surfaces the familiar "No API key found for openrouter" — a clear
|
|
27
|
+
// signal to run /login or set a key, which is better than an empty/undefined model.
|
|
28
|
+
export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
|
|
29
|
+
|
|
30
|
+
// BYO providers we can positively detect from the environment, in preference order.
|
|
31
|
+
// Each model id matches Pi's own defaultModelPerProvider so it actually resolves once
|
|
32
|
+
// the key is present. OpenRouter stays on the legacy cheap default for continuity.
|
|
33
|
+
const BYO_BY_KEY: Array<{ env: string; spec: string }> = [
|
|
34
|
+
{ env: "ANTHROPIC_API_KEY", spec: "anthropic/claude-opus-4-8" },
|
|
35
|
+
{ env: "OPENAI_API_KEY", spec: "openai/gpt-5.5" },
|
|
36
|
+
{ env: "OPENROUTER_API_KEY", spec: LEGACY_BYO_FALLBACK },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export interface ResolveDefaultModelOptions {
|
|
40
|
+
// An explicit, user-chosen spec (e.g. config.defaultModel, a channel's `model`).
|
|
41
|
+
// Wins over everything when non-empty — it's a deliberate choice, not a fallback.
|
|
42
|
+
explicit?: string | null;
|
|
43
|
+
// Override for testing / non-process callers. Defaults to process.env.
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
// Override the signed-in check (testing). Defaults to hasCredentials().
|
|
46
|
+
signedIn?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
50
|
+
// synchronous (only reads env + the credentials file), so it's safe to call from any
|
|
51
|
+
// entry point at startup. Precedence:
|
|
52
|
+
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
53
|
+
// 2. PRIVATEER_MODEL env — dev/global override
|
|
54
|
+
// 3. signed into Privateer → the account default — the fix: subscription users
|
|
55
|
+
// 4. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
56
|
+
// 5. LEGACY_BYO_FALLBACK — familiar "add a key" signal
|
|
57
|
+
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
58
|
+
const env = opts.env ?? process.env;
|
|
59
|
+
|
|
60
|
+
const explicit = opts.explicit?.trim();
|
|
61
|
+
if (explicit) return explicit;
|
|
62
|
+
|
|
63
|
+
const fromEnv = env.PRIVATEER_MODEL?.trim();
|
|
64
|
+
if (fromEnv) return fromEnv;
|
|
65
|
+
|
|
66
|
+
const signedIn = opts.signedIn ?? hasCredentials();
|
|
67
|
+
if (signedIn) return ACCOUNT_DEFAULT_SPEC;
|
|
68
|
+
|
|
69
|
+
for (const { env: keyName, spec } of BYO_BY_KEY) {
|
|
70
|
+
if (env[keyName]?.trim()) return spec;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return LEGACY_BYO_FALLBACK;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
|
|
77
|
+
// only the first delimiter separates provider from model). Returns null for a spec
|
|
78
|
+
// with no provider prefix.
|
|
79
|
+
function splitSpec(spec: string): { provider: string; modelId: string } | null {
|
|
80
|
+
const slash = spec.indexOf("/");
|
|
81
|
+
if (slash <= 0 || slash === spec.length - 1) return null;
|
|
82
|
+
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The TUI consumer. Pi's own model resolution (findInitialModel) checks its saved
|
|
86
|
+
// settings default BEFORE it falls through to a keyless built-in, but nothing ever
|
|
87
|
+
// pointed that default at the account channel — Pi's provider-default table has no
|
|
88
|
+
// `privateer` entry, so a signed-in-only user landed on OpenRouter and errored. On a
|
|
89
|
+
// successful login we seed Pi's global settings.json (agentDir/settings.json — the
|
|
90
|
+
// same file its SettingsManager reads) with the account default, so the NEXT launch
|
|
91
|
+
// resolves cleanly.
|
|
92
|
+
//
|
|
93
|
+
// Guarded: we only write when the user has NOT already chosen a default (no
|
|
94
|
+
// `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
|
|
95
|
+
// any read/parse/write failure is swallowed; a missing seed just means the user picks
|
|
96
|
+
// a model once via /model. Returns the spec written, or null if we left it alone.
|
|
97
|
+
export function ensurePiDefaultModel(spec: string = ACCOUNT_DEFAULT_SPEC): string | null {
|
|
98
|
+
const parts = splitSpec(spec);
|
|
99
|
+
if (!parts) return null;
|
|
100
|
+
const settingsPath = join(agentDir(), "settings.json");
|
|
101
|
+
try {
|
|
102
|
+
let settings: Record<string, unknown> = {};
|
|
103
|
+
if (existsSync(settingsPath)) {
|
|
104
|
+
const raw = readFileSync(settingsPath, "utf8").trim();
|
|
105
|
+
if (raw) settings = JSON.parse(raw) as Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
// Respect an existing choice — presence of the key means the user (or Pi) already
|
|
108
|
+
// has a default; don't override it.
|
|
109
|
+
if (typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
settings.defaultProvider = parts.provider;
|
|
113
|
+
settings.defaultModel = parts.modelId;
|
|
114
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
115
|
+
return spec;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|