privateer-agent 0.12.47 → 0.12.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/bin/headless-flags.d.mts +25 -0
- package/bin/headless-flags.mjs +166 -0
- package/bin/privateer-launch.mjs +93 -3
- package/extensions/privateer-gate.ts +192 -5
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +24 -11
- package/src/acp/run.ts +3 -1
- package/src/bridge/engineAdapter.ts +1 -1
- package/src/cli/chat.ts +6 -2
- package/src/config/privacyDisabled.ts +6 -1
- package/src/config/privacyPolicy.ts +3 -1
- package/src/engine/errors.ts +57 -3
- package/src/ext/permissionGate.ts +23 -4
- package/src/permissions/cliSpend.ts +163 -0
- package/src/permissions/modeGate.ts +9 -3
- package/src/permissions/noQuarter.ts +21 -2
- package/src/providers/account.ts +89 -1
- package/src/remote/headlessApproval.ts +146 -0
- package/src/tools/media.ts +151 -12
package/src/providers/account.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { join } from "node:path";
|
|
|
27
27
|
import { globalDir } from "../config/paths.ts";
|
|
28
28
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
|
|
29
29
|
import { installGzipRequestBodies } from "../util/gzipRequestBody.ts";
|
|
30
|
-
import { describeAccountBalanceError } from "../engine/errors.ts";
|
|
30
|
+
import { describeAccountBalanceError, isConnectionFailureText } from "../engine/errors.ts";
|
|
31
31
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
32
32
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
33
33
|
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
sealedProviderFor,
|
|
39
39
|
sealedShimBase,
|
|
40
40
|
ensureSealedShim,
|
|
41
|
+
stopSealedShim,
|
|
41
42
|
attestSealed,
|
|
42
43
|
} from "./sealedShim.ts";
|
|
43
44
|
import type { PhalaEnclaveIdentity } from "./phalaSeal.ts";
|
|
@@ -897,6 +898,10 @@ export function makeAccountProvider() {
|
|
|
897
898
|
// An auth failure, unlike a balance failure, needs a fresh child session.
|
|
898
899
|
// Pi has no reactive-401 refresh; see recoverAccountSession.
|
|
899
900
|
void recoverAccountSession(ctx, msg.errorMessage);
|
|
901
|
+
// A request that got no response at all. See recoverAccountConnection.
|
|
902
|
+
if (isConnectionFailureText(msg.errorMessage)) {
|
|
903
|
+
void recoverAccountConnection(ctx, (msg as { model?: string }).model ?? "", () => register(lastIds));
|
|
904
|
+
}
|
|
900
905
|
});
|
|
901
906
|
};
|
|
902
907
|
}
|
|
@@ -1125,6 +1130,89 @@ const ACCOUNT_AUTH_FAILURE =
|
|
|
1125
1130
|
// plenty, and the user gets a clear message instead of a retry loop.
|
|
1126
1131
|
const RECOVERY_COOLDOWN_MS = 30_000;
|
|
1127
1132
|
|
|
1133
|
+
// Ask the server whether an account access token is still accepted. The status, or
|
|
1134
|
+
// null when there was no answer at all (which says nothing about the token).
|
|
1135
|
+
async function probeAccountToken(access: string): Promise<number | null> {
|
|
1136
|
+
try {
|
|
1137
|
+
const res = await fetch(`${serverBaseUrl()}/auth/me`, {
|
|
1138
|
+
headers: { Authorization: `Bearer ${access}` },
|
|
1139
|
+
signal: AbortSignal.timeout(8_000),
|
|
1140
|
+
});
|
|
1141
|
+
void res.body?.cancel().catch(() => {});
|
|
1142
|
+
return res.status;
|
|
1143
|
+
} catch {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Get a session that answers "Connection error." on every message working again.
|
|
1150
|
+
*
|
|
1151
|
+
* The report this exists for: a session started while a sign-in was failing kept
|
|
1152
|
+
* failing with the SDK's bare "Connection error." on every retry, while a fresh
|
|
1153
|
+
* `privateer` on the same machine worked. Nothing noticed, because the only recovery
|
|
1154
|
+
* the account channel had keyed on a 401 (recoverAccountSession), and a request that
|
|
1155
|
+
* never gets an HTTP response carries no status at all. Two pieces of per-SESSION state
|
|
1156
|
+
* can strand requests that way while the machine's login is fine, and both are cheap
|
|
1157
|
+
* to renew:
|
|
1158
|
+
*
|
|
1159
|
+
* • the sealed shim (tinfoil/phala): the model's baseUrl points at a loopback port
|
|
1160
|
+
* for the life of the process. Restart the shim and re-register — Pi re-reads the
|
|
1161
|
+
* live model's baseUrl from the registry on registration — so the next request
|
|
1162
|
+
* goes to a listener that exists;
|
|
1163
|
+
* • the credential: probe the token this process holds; if the server now refuses it,
|
|
1164
|
+
* mint a fresh session exactly as a 401 would. A probe that gets NO answer is a real
|
|
1165
|
+
* outage, and replacing a good session over it would only leak a Linked Devices row.
|
|
1166
|
+
*
|
|
1167
|
+
* Bounded by the same cooldown as recoverAccountSession, so a dead network costs one
|
|
1168
|
+
* probe per window, not one per retry. Returns what it renewed.
|
|
1169
|
+
*/
|
|
1170
|
+
export async function recoverAccountConnection(
|
|
1171
|
+
ctx: unknown,
|
|
1172
|
+
modelId: string,
|
|
1173
|
+
reRegister: () => void,
|
|
1174
|
+
probe: (access: string) => Promise<number | null> = probeAccountToken,
|
|
1175
|
+
): Promise<{ shim: boolean; session: boolean }> {
|
|
1176
|
+
const renewed = { shim: false, session: false };
|
|
1177
|
+
if (!hasCredentials()) return renewed;
|
|
1178
|
+
const slot = armSlot() as ReturnType<typeof armSlot> & { connectionRecoveredAt?: number };
|
|
1179
|
+
const now = Date.now();
|
|
1180
|
+
if (slot.connectionRecoveredAt !== undefined && now - slot.connectionRecoveredAt < RECOVERY_COOLDOWN_MS) return renewed;
|
|
1181
|
+
slot.connectionRecoveredAt = now;
|
|
1182
|
+
|
|
1183
|
+
if (sealedEnabled() && sealedProviderFor(modelId)) {
|
|
1184
|
+
try {
|
|
1185
|
+
await stopSealedShim();
|
|
1186
|
+
await ensureSealedShim();
|
|
1187
|
+
reRegister();
|
|
1188
|
+
renewed.shim = true;
|
|
1189
|
+
} catch {
|
|
1190
|
+
/* the shim won't start → sealed models fall back to the cleartext path on re-register */
|
|
1191
|
+
try {
|
|
1192
|
+
reRegister();
|
|
1193
|
+
} catch {
|
|
1194
|
+
/* nothing more to do from here */
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const held = slot.cred;
|
|
1200
|
+
const status = held ? await probe(held.access) : null;
|
|
1201
|
+
if (status === 401 || status === 403) {
|
|
1202
|
+
slot.cred = undefined;
|
|
1203
|
+
renewed.session = await armAccountCredential(ctx, { notify: false });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const c = ctx as SeedContext;
|
|
1207
|
+
if (c?.hasUI && (renewed.shim || renewed.session)) {
|
|
1208
|
+
c.ui?.notify?.(
|
|
1209
|
+
`Couldn't reach Privateer — renewed this session's ${[renewed.shim ? "sealed connection" : "", renewed.session ? "credential" : ""].filter(Boolean).join(" and ")}. Send that message again.`,
|
|
1210
|
+
"warning",
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
return renewed;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1128
1216
|
// Replace a dead account session after an auth failure, so the NEXT prompt works.
|
|
1129
1217
|
//
|
|
1130
1218
|
// This is the client-side answer to a gap in Pi's OAuth contract: inference goes out
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// APPROVALS FOR A RUN WITH NO SCREEN, answered from the Privateer app.
|
|
2
|
+
//
|
|
3
|
+
// privateer -p --approve-in-app "make the trailer"
|
|
4
|
+
//
|
|
5
|
+
// A `-p` run's gate has nobody to ask, so it denies. With --approve-in-app it instead
|
|
6
|
+
// brings up the same E2EE relay `/remote-access` uses, waits for the app to attach to
|
|
7
|
+
// this terminal, and puts the question there — "Generate a video (billed…) · about
|
|
8
|
+
// $0.50" — and denies only if nobody answers before the timeout. The relay is started
|
|
9
|
+
// LAZILY, on the first ask: a run that never needs an approval never opens a socket.
|
|
10
|
+
//
|
|
11
|
+
// WHAT THIS IS NOT. The relay reaches an app that is running (foreground, or
|
|
12
|
+
// backgrounded with its socket alive — the app raises its own local notification for an
|
|
13
|
+
// approval then). An app that is fully closed is only reachable by a server-sent push,
|
|
14
|
+
// which does not exist yet (docs/push-on-gate-server.md). So the waiting line says to
|
|
15
|
+
// OPEN the app, rather than promising a notification that may never arrive.
|
|
16
|
+
//
|
|
17
|
+
// POSTURE. Nothing here widens what the gate allows: this is only the asker the gate
|
|
18
|
+
// calls when it has already decided to ask. Fail-closed throughout — not signed in, no
|
|
19
|
+
// relay, timeout, abort: deny. "Allow and remember" from the app counts as allow-once,
|
|
20
|
+
// because a headless run has no later turn for a remembered rule to help and the gate
|
|
21
|
+
// never remembers the always-ask kinds anyway. A PROMPT sent from the app is refused:
|
|
22
|
+
// this terminal takes approvals, not instructions, for the length of the run.
|
|
23
|
+
|
|
24
|
+
import { RemoteBridge, type RelayLike } from "./remoteBridge.ts";
|
|
25
|
+
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
26
|
+
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
27
|
+
import type { RelayCallbacks } from "./relayClient.ts";
|
|
28
|
+
|
|
29
|
+
/** The relay surface this needs beyond the bridge's: lifecycle and presence. */
|
|
30
|
+
export interface HeadlessRelay extends RelayLike {
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
stop(): void;
|
|
33
|
+
hasController(): boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HeadlessApprovalOptions {
|
|
37
|
+
/** Per ask: how long to wait for the app to attach AND answer. */
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
/** Where the waiting / outcome lines go (stderr — stdout may be the answer or JSON). */
|
|
40
|
+
log: (line: string) => void;
|
|
41
|
+
signedIn: () => boolean;
|
|
42
|
+
makeRelay: (callbacks: RelayCallbacks) => HeadlessRelay;
|
|
43
|
+
/** Label for this terminal in the app. */
|
|
44
|
+
label?: string;
|
|
45
|
+
/** Presence poll interval; tests shorten it. */
|
|
46
|
+
pollMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mins = (ms: number): string => (ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 1000)}s`);
|
|
50
|
+
|
|
51
|
+
export class HeadlessAppApprover {
|
|
52
|
+
private bridge?: RemoteBridge;
|
|
53
|
+
private relay?: HeadlessRelay;
|
|
54
|
+
private starting?: Promise<void>;
|
|
55
|
+
private closed = false;
|
|
56
|
+
// How many times the app has left. A deny that coincides with a departure is the
|
|
57
|
+
// bridge failing the ask closed, not a person pressing Deny — and presence alone
|
|
58
|
+
// can't tell, because the app may already be back by the time we look.
|
|
59
|
+
private departures = 0;
|
|
60
|
+
|
|
61
|
+
constructor(private readonly opts: HeadlessApprovalOptions) {}
|
|
62
|
+
|
|
63
|
+
private ensureRelay(): Promise<void> {
|
|
64
|
+
if (this.starting) return this.starting;
|
|
65
|
+
const bridge = new RemoteBridge({
|
|
66
|
+
onPrompt: () => bridge.sendNotice("This terminal is a headless run waiting for approvals only — it can't take a prompt."),
|
|
67
|
+
onCommand: () => bridge.sendNotice("This terminal is a headless run waiting for approvals only — it can't run commands."),
|
|
68
|
+
onControllerDetached: () => this.departures++,
|
|
69
|
+
});
|
|
70
|
+
this.bridge = bridge;
|
|
71
|
+
this.relay = this.opts.makeRelay(bridge.callbacks);
|
|
72
|
+
bridge.attachRelay(this.relay);
|
|
73
|
+
this.starting = this.relay.start();
|
|
74
|
+
return this.starting;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async waitForController(deadline: number, signal?: AbortSignal): Promise<boolean> {
|
|
78
|
+
const poll = this.opts.pollMs ?? 500;
|
|
79
|
+
while (!this.closed && !signal?.aborted && Date.now() < deadline) {
|
|
80
|
+
if (this.relay?.hasController()) return true;
|
|
81
|
+
await new Promise((r) => setTimeout(r, Math.min(poll, Math.max(0, deadline - Date.now()))));
|
|
82
|
+
}
|
|
83
|
+
return !!this.relay?.hasController() && !signal?.aborted;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The gate's asker for this run. `priceNote` (e.g. "about $0.50") rides on the prompt. */
|
|
87
|
+
async ask(req: PermissionRequest, signal?: AbortSignal, priceNote?: string): Promise<AskOutcome> {
|
|
88
|
+
const what = `${req.title}${priceNote ? ` · ${priceNote}` : ""}`;
|
|
89
|
+
if (!this.opts.signedIn()) {
|
|
90
|
+
this.opts.log(`⚓ ${what} — denied: --approve-in-app needs this machine signed in to Privateer (run \`privateer\`, then /login).`);
|
|
91
|
+
return "deny";
|
|
92
|
+
}
|
|
93
|
+
const deadline = Date.now() + this.opts.timeoutMs;
|
|
94
|
+
try {
|
|
95
|
+
await this.ensureRelay();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
this.opts.log(`⚓ ${what} — denied: couldn't reach the Privateer relay (${e instanceof Error ? e.message : String(e)}).`);
|
|
98
|
+
return "deny";
|
|
99
|
+
}
|
|
100
|
+
const asked: PermissionRequest = priceNote ? { ...req, detail: `${req.detail} · ${priceNote}` } : req;
|
|
101
|
+
|
|
102
|
+
this.opts.log(
|
|
103
|
+
`⚓ Approval needed: ${what}\n` +
|
|
104
|
+
` Waiting up to ${mins(this.opts.timeoutMs)} — open the Privateer app and approve it there` +
|
|
105
|
+
(this.opts.label ? ` (terminal "${this.opts.label}")` : "") +
|
|
106
|
+
".",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Loop, because the app coming and going is normal (a phone locks, a user switches
|
|
110
|
+
// apps): a detach fails the pending ask closed, and the next attach gets it again.
|
|
111
|
+
while (Date.now() < deadline && !signal?.aborted && !this.closed) {
|
|
112
|
+
if (!(await this.waitForController(deadline, signal))) break;
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const onAbort = () => controller.abort();
|
|
115
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
|
|
117
|
+
let outcome: AskOutcome;
|
|
118
|
+
const departuresBefore = this.departures;
|
|
119
|
+
try {
|
|
120
|
+
outcome = await this.bridge!.remoteAsk(asked, controller.signal);
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
signal?.removeEventListener("abort", onAbort);
|
|
124
|
+
}
|
|
125
|
+
if (outcome !== "deny") {
|
|
126
|
+
this.opts.log(`⚓ Approved in the app: ${what}`);
|
|
127
|
+
return "allow";
|
|
128
|
+
}
|
|
129
|
+
// A real "Deny" is the answer. A deny because the app left is not — wait for it
|
|
130
|
+
// to come back and ask again.
|
|
131
|
+
// A dropped socket fails it closed the same way, with no detach to count.
|
|
132
|
+
if (controller.signal.aborted || (this.departures === departuresBefore && this.relay?.isConnected())) break;
|
|
133
|
+
}
|
|
134
|
+
const why = signal?.aborted ? "the run was cancelled" : `no answer from the app in ${mins(this.opts.timeoutMs)}`;
|
|
135
|
+
const deniedInApp = !signal?.aborted && Date.now() < deadline;
|
|
136
|
+
this.opts.log(`⚓ Denied: ${what} — ${deniedInApp ? "denied in the app" : why}.`);
|
|
137
|
+
return "deny";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Drop the relay. Safe to call twice, and before anything started. */
|
|
141
|
+
close(): void {
|
|
142
|
+
this.closed = true;
|
|
143
|
+
this.relay?.stop();
|
|
144
|
+
this.bridge?.dispose();
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/tools/media.ts
CHANGED
|
@@ -947,9 +947,32 @@ export const generateSfxToolDefinition = {
|
|
|
947
947
|
|
|
948
948
|
// ── Capabilities ─────────────────────────────────────────────────────────────
|
|
949
949
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
950
|
+
/** One priced video call shape. `resolution: null` is a call that sends none. */
|
|
951
|
+
export interface VideoPriceRow {
|
|
952
|
+
seconds: number | null;
|
|
953
|
+
resolution: string | null;
|
|
954
|
+
audio: boolean;
|
|
955
|
+
usd: number;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export interface CapabilitiesResponse {
|
|
959
|
+
/** `priceUsdEach` is what one image costs the account; null when the server couldn't price it. */
|
|
960
|
+
image?: { model?: string; blockedByZdr?: boolean; maxPerCall?: number; priceUsdEach?: number | null };
|
|
961
|
+
/** `priceUsd`/`priceTable` are the server's RESERVATION figures — worst-case-biased, so
|
|
962
|
+
* a budget built on them can only come in under. Absent on a server older than them. */
|
|
963
|
+
video?: {
|
|
964
|
+
model?: string;
|
|
965
|
+
blockedByZdr?: boolean;
|
|
966
|
+
durations?: number[] | null;
|
|
967
|
+
aspectRatios?: string[] | null;
|
|
968
|
+
priceUsd?: { min?: number; max?: number } | null;
|
|
969
|
+
priceTable?: VideoPriceRow[] | null;
|
|
970
|
+
priceSource?: "table" | "fallback";
|
|
971
|
+
};
|
|
972
|
+
sprites?: {
|
|
973
|
+
available?: boolean;
|
|
974
|
+
directionSets?: { id: string; billedClips?: number; billedTurnStills?: number }[];
|
|
975
|
+
};
|
|
953
976
|
model3d?: {
|
|
954
977
|
model?: string;
|
|
955
978
|
configured?: boolean;
|
|
@@ -1066,6 +1089,119 @@ function describeAxis(a: MeshAxis): string {
|
|
|
1066
1089
|
return ` ${a.name}: ${(a.values ?? []).join(" | ")} (default ${String(a.default)})${cost}`;
|
|
1067
1090
|
}
|
|
1068
1091
|
|
|
1092
|
+
const usd = (n: number | undefined | null): string => `$${(n ?? 0).toFixed(2)}`;
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The image and video sections of the capability report.
|
|
1096
|
+
*
|
|
1097
|
+
* Exported and pure for the reason describeModel3d is. The prices are the point: an
|
|
1098
|
+
* agent planning a film used to see what a mesh or an effect cost and nothing at all
|
|
1099
|
+
* for a clip — the dearest call it can make — so it could neither weigh the job nor
|
|
1100
|
+
* set a sensible `--max-spend`. A server too old to report a price gets that said
|
|
1101
|
+
* plainly rather than a $0.00 that reads as free.
|
|
1102
|
+
*/
|
|
1103
|
+
export function describeImageVideo(image: CapabilitiesResponse["image"], video: CapabilitiesResponse["video"]): string[] {
|
|
1104
|
+
const imagePrice =
|
|
1105
|
+
typeof image?.priceUsdEach === "number" ? `, about ${usd(image.priceUsdEach)} each` : ", price not reported";
|
|
1106
|
+
const lines = [
|
|
1107
|
+
`Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
1108
|
+
` up to ${image?.maxPerCall ?? 1} image(s) per call${imagePrice}`,
|
|
1109
|
+
`Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
1110
|
+
` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
|
|
1111
|
+
` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`,
|
|
1112
|
+
];
|
|
1113
|
+
const table = video?.priceTable ?? [];
|
|
1114
|
+
if (video?.priceUsd && table.length) {
|
|
1115
|
+
const fallback = video.priceSource === "fallback" ? " (no price row for this model — a conservative estimate)" : "";
|
|
1116
|
+
lines.push(` cost: ${usd(video.priceUsd.min)}-${usd(video.priceUsd.max)} a clip${fallback}, reserved up front and settled at the real cost`);
|
|
1117
|
+
// Per length at the call's default (no resolution sent, no audio): that is the
|
|
1118
|
+
// call an agent makes unless it asks for more, and the line it budgets from.
|
|
1119
|
+
const plain = table.filter((r) => r.resolution === null && !r.audio && r.seconds !== null);
|
|
1120
|
+
if (plain.length) lines.push(` by length (default resolution, no audio): ${plain.map((r) => `${r.seconds}s ${usd(r.usd)}`).join(", ")}`);
|
|
1121
|
+
const resolutions = [...new Set(table.map((r) => r.resolution).filter((r): r is string => !!r))];
|
|
1122
|
+
if (resolutions.length) lines.push(` resolution changes the price: ${resolutions.join(", ")} (pass generate_video's \`resolution\`)`);
|
|
1123
|
+
if (table.some((r) => r.audio)) lines.push(" audio: true costs more on this model");
|
|
1124
|
+
} else {
|
|
1125
|
+
lines.push(" cost: not reported by this server — roughly $0.10-$1 a clip, more for long or high-resolution clips");
|
|
1126
|
+
}
|
|
1127
|
+
return lines;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* What one call of a billed tool is expected to cost, in USD — or null when it can't
|
|
1132
|
+
* be priced from what the server reports. Pure over a capability report, for the
|
|
1133
|
+
* `--max-spend` budget (permissions/cliSpend.ts): it prices the call the model is
|
|
1134
|
+
* ABOUT to make from that call's own arguments, and it errs high everywhere it has
|
|
1135
|
+
* to choose, because a cap that under-counts is not a cap.
|
|
1136
|
+
*/
|
|
1137
|
+
export function quoteMediaCallUsd(tool: string, input: unknown, caps: CapabilitiesResponse): number | null {
|
|
1138
|
+
const args = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
1139
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
1140
|
+
const clip = (seconds: number | undefined, resolution: string | undefined, audio: boolean): number | null => {
|
|
1141
|
+
const table = caps.video?.priceTable ?? [];
|
|
1142
|
+
if (!table.length) return null;
|
|
1143
|
+
const exact = table.find(
|
|
1144
|
+
(r) => r.seconds === (seconds ?? r.seconds) && r.resolution === (resolution ?? null) && r.audio === audio,
|
|
1145
|
+
);
|
|
1146
|
+
// An exact row when the call names a legal length; otherwise the dearest row that
|
|
1147
|
+
// still matches what we do know — never a cheaper guess.
|
|
1148
|
+
if (exact && seconds !== undefined) return exact.usd;
|
|
1149
|
+
// `resolution: null` rows are the server's price for a call that sends none, so
|
|
1150
|
+
// that's the set an unspecified resolution is quoted from.
|
|
1151
|
+
const candidates = table.filter((r) => r.resolution === (resolution ?? null) && (audio || !r.audio));
|
|
1152
|
+
const pool = candidates.length ? candidates : table;
|
|
1153
|
+
return Math.max(...pool.map((r) => r.usd));
|
|
1154
|
+
};
|
|
1155
|
+
switch (tool) {
|
|
1156
|
+
case "generate_image": {
|
|
1157
|
+
// The report prices the ACCOUNT's image model; a call naming another one would
|
|
1158
|
+
// be quoted at the wrong model's rate, so it isn't quoted at all.
|
|
1159
|
+
if (typeof args.model === "string" && args.model) return null;
|
|
1160
|
+
const each = caps.image?.priceUsdEach;
|
|
1161
|
+
if (typeof each !== "number") return null;
|
|
1162
|
+
const n = Math.min(Math.max(Math.trunc(num(args.count) ?? 1), 1), caps.image?.maxPerCall ?? 4);
|
|
1163
|
+
return each * n;
|
|
1164
|
+
}
|
|
1165
|
+
case "generate_video":
|
|
1166
|
+
return clip(num(args.seconds), typeof args.resolution === "string" ? args.resolution : undefined, args.audio === true);
|
|
1167
|
+
case "generate_model":
|
|
1168
|
+
return typeof caps.model3d?.priceUsd?.max === "number" ? caps.model3d.priceUsd.max : null;
|
|
1169
|
+
case "generate_sprite": {
|
|
1170
|
+
const set = typeof args.directions === "string" ? args.directions : "one";
|
|
1171
|
+
const row = caps.sprites?.directionSets?.find((d) => d.id === set);
|
|
1172
|
+
// The sprite pipeline picks each clip's length and resolution itself, so every
|
|
1173
|
+
// clip is quoted at the dearest silent row.
|
|
1174
|
+
const silent = (caps.video?.priceTable ?? []).filter((r) => !r.audio);
|
|
1175
|
+
const clipUsd = silent.length ? Math.max(...silent.map((r) => r.usd)) : null;
|
|
1176
|
+
if (!row || clipUsd === null) return null;
|
|
1177
|
+
const stills = row.billedTurnStills ?? 0;
|
|
1178
|
+
const each = caps.image?.priceUsdEach;
|
|
1179
|
+
if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model))) return null;
|
|
1180
|
+
return clipUsd * (row.billedClips ?? 1) + stills * (each ?? 0);
|
|
1181
|
+
}
|
|
1182
|
+
case "generate_sfx":
|
|
1183
|
+
// No server figure; the tool's own documented ceiling for a single effect.
|
|
1184
|
+
return 0.02;
|
|
1185
|
+
default:
|
|
1186
|
+
// Speech and music: the server reports no price, and a guess would be a cap in
|
|
1187
|
+
// name only.
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Read the capability report (optionally describing a specific video / 3D model). */
|
|
1193
|
+
export async function readMediaCapabilities(
|
|
1194
|
+
query: { videoModel?: string; model?: string } = {},
|
|
1195
|
+
signal?: AbortSignal,
|
|
1196
|
+
): Promise<{ ok: true; data: CapabilitiesResponse } | { ok: false; message: string }> {
|
|
1197
|
+
const q = new URLSearchParams();
|
|
1198
|
+
if (query.videoModel) q.set("videoModel", query.videoModel);
|
|
1199
|
+
if (query.model) q.set("model", query.model);
|
|
1200
|
+
const qs = q.toString();
|
|
1201
|
+
const r = await callAccount<CapabilitiesResponse>(`/api/agent/media/capabilities${qs ? `?${qs}` : ""}`, { method: "GET", signal });
|
|
1202
|
+
return r.ok ? { ok: true, data: r.data } : { ok: false, message: r.message };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1069
1205
|
/**
|
|
1070
1206
|
* The sound-effect section of the capability report.
|
|
1071
1207
|
*
|
|
@@ -1137,7 +1273,7 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1137
1273
|
label: "Media Capabilities",
|
|
1138
1274
|
description:
|
|
1139
1275
|
"Report what this Privateer account can generate right now: which image and video models it " +
|
|
1140
|
-
"resolves to, the clip lengths and aspect ratios that video model accepts, and whether the " +
|
|
1276
|
+
"resolves to, what an image and a clip cost, the clip lengths and aspect ratios that video model accepts, and whether the " +
|
|
1141
1277
|
"account's privacy settings currently block media generation. Free and instant. Call it before " +
|
|
1142
1278
|
"planning a multi-clip video so you pick a legal clip length instead of discovering it through a " +
|
|
1143
1279
|
"rejected — or worse, billed — call.\n" +
|
|
@@ -1157,6 +1293,14 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1157
1293
|
"their legal values and the price — is per-model.",
|
|
1158
1294
|
}),
|
|
1159
1295
|
),
|
|
1296
|
+
videoModel: Type.Optional(
|
|
1297
|
+
Type.String({
|
|
1298
|
+
description:
|
|
1299
|
+
"A video model id to describe instead of the account default (e.g. 'bytedance/seedance-2.0'). " +
|
|
1300
|
+
"Clip lengths, aspect ratios and the price per clip are all per-model — check the one you will " +
|
|
1301
|
+
"pass as generate_video's `model` before you spend on it.",
|
|
1302
|
+
}),
|
|
1303
|
+
),
|
|
1160
1304
|
ttsModel: Type.Optional(
|
|
1161
1305
|
Type.String({
|
|
1162
1306
|
description:
|
|
@@ -1166,9 +1310,10 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1166
1310
|
}),
|
|
1167
1311
|
),
|
|
1168
1312
|
}),
|
|
1169
|
-
async execute(_toolCallId: string, params: { model?: string; ttsModel?: string }, signal?: AbortSignal) {
|
|
1313
|
+
async execute(_toolCallId: string, params: { model?: string; ttsModel?: string; videoModel?: string }, signal?: AbortSignal) {
|
|
1170
1314
|
const query = new URLSearchParams();
|
|
1171
1315
|
if (params?.model) query.set("model", params.model);
|
|
1316
|
+
if (params?.videoModel) query.set("videoModel", params.videoModel);
|
|
1172
1317
|
if (params?.ttsModel) query.set("ttsModel", params.ttsModel);
|
|
1173
1318
|
const qs = query.toString();
|
|
1174
1319
|
const r = await callAccount<CapabilitiesResponse>(
|
|
@@ -1178,13 +1323,7 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1178
1323
|
if (!r.ok) return text(`Could not read media capabilities: ${r.message}`);
|
|
1179
1324
|
|
|
1180
1325
|
const { image, video, model3d, sfx, speech, privacy } = r.data;
|
|
1181
|
-
const lines =
|
|
1182
|
-
`Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
1183
|
-
` up to ${image?.maxPerCall ?? 1} image(s) per call`,
|
|
1184
|
-
`Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
|
|
1185
|
-
` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
|
|
1186
|
-
` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`,
|
|
1187
|
-
];
|
|
1326
|
+
const lines = describeImageVideo(image, video);
|
|
1188
1327
|
|
|
1189
1328
|
lines.push(...describeModel3d(model3d));
|
|
1190
1329
|
lines.push(...describeSfx(sfx));
|