@pyai/sdk 0.3.1 → 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/AGENT_GUIDE.md +296 -0
- package/CLI.md +609 -0
- package/CLI.schema.json +1085 -0
- package/README.md +49 -22
- package/dist/cli-config.d.ts +47 -0
- package/dist/cli-config.js +265 -0
- package/dist/cli-dx.d.ts +11 -0
- package/dist/cli-dx.js +32 -0
- package/dist/cli-http.d.ts +38 -0
- package/dist/cli-http.js +286 -0
- package/dist/cli-init.d.ts +27 -0
- package/dist/cli-init.js +430 -0
- package/dist/cli-routes.d.ts +15 -0
- package/dist/cli-routes.js +52 -0
- package/dist/cli-runtime.d.ts +10 -0
- package/dist/cli-runtime.js +23 -0
- package/dist/cli-web-auth.d.ts +33 -0
- package/dist/cli-web-auth.js +154 -0
- package/dist/cli.d.ts +1 -17
- package/dist/cli.js +674 -270
- package/dist/index.d.ts +206 -26
- package/dist/index.js +98 -12
- package/package.json +7 -2
- package/src/cli-config.ts +283 -0
- package/src/cli-dx.ts +33 -0
- package/src/cli-http.ts +273 -0
- package/src/cli-init.ts +430 -0
- package/src/cli-routes.ts +72 -0
- package/src/cli-runtime.ts +30 -0
- package/src/cli-web-auth.ts +148 -0
- package/src/cli.ts +431 -295
- package/src/index.ts +259 -32
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, unlink, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BASE_URL = "https://api.pyai.com";
|
|
8
|
+
|
|
9
|
+
export class CliConfigError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
|
|
12
|
+
constructor(code: string, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CliConfigError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CliProfile {
|
|
20
|
+
api_key?: string;
|
|
21
|
+
base_url?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliConfig {
|
|
25
|
+
version: 1;
|
|
26
|
+
active_profile: string | null;
|
|
27
|
+
profiles: Record<string, CliProfile>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedConfig {
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
profile: string;
|
|
34
|
+
/** Provenance of the API key. The base URL is resolved independently. */
|
|
35
|
+
source: "explicit" | "environment" | "profile" | "none";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProfileSummary {
|
|
39
|
+
name: string;
|
|
40
|
+
active: boolean;
|
|
41
|
+
base_url: string;
|
|
42
|
+
has_api_key: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Keys are opaque: validation only ensures a bounded, header-safe value. */
|
|
46
|
+
export function validateApiKey(input: string): string {
|
|
47
|
+
if (typeof input !== "string" || input.length === 0 || input.length > 512 || /[\s\p{Cc}]/u.test(input)) {
|
|
48
|
+
throw new CliConfigError("invalid_api_key", "API keys must contain 1–512 characters without whitespace or control characters.");
|
|
49
|
+
}
|
|
50
|
+
return input;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The SDK adds /v1, so accept both documented API base URL forms. */
|
|
54
|
+
export function normalizeBaseURL(input: string): string {
|
|
55
|
+
const invalid = () => new CliConfigError("invalid_base_url", "Base URL must use HTTPS (or HTTP on localhost), without credentials, a query, or a fragment.");
|
|
56
|
+
if (typeof input !== "string" || !/^https?:\/\//i.test(input) || /[\s\p{Cc}?#]/u.test(input)) throw invalid();
|
|
57
|
+
let url: URL;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(input);
|
|
60
|
+
} catch {
|
|
61
|
+
throw invalid();
|
|
62
|
+
}
|
|
63
|
+
const authority = input.slice(input.indexOf("://") + 3).split("/")[0]!;
|
|
64
|
+
if (url.username || url.password || authority.includes("@")) throw invalid();
|
|
65
|
+
const hostname = url.hostname.toLowerCase();
|
|
66
|
+
const loopback = hostname === "localhost" || hostname === "localhost." || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
67
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw invalid();
|
|
68
|
+
const path = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
69
|
+
return `${url.origin}${path}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateProfileName(name: string): string {
|
|
73
|
+
if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) {
|
|
74
|
+
throw new CliConfigError("invalid_profile", "Profile names must be 1–64 letters, numbers, dots, underscores, or hyphens, starting with a letter or number.");
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function configDirectory(): string {
|
|
80
|
+
if (process.env.PYAI_CONFIG_DIR) return resolve(process.env.PYAI_CONFIG_DIR);
|
|
81
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "pyai");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emptyConfig(): CliConfig {
|
|
85
|
+
return { version: 1, active_profile: null, profiles: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
89
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function invalidConfig(): CliConfigError {
|
|
97
|
+
return new CliConfigError("config_invalid", "PyAI config.json is invalid or uses an unsupported version. Fix it or move it aside before retrying.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseConfig(text: string): CliConfig {
|
|
101
|
+
try {
|
|
102
|
+
const parsed: unknown = JSON.parse(text);
|
|
103
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.profiles)) throw invalidConfig();
|
|
104
|
+
if (parsed.active_profile !== null && typeof parsed.active_profile !== "string") throw invalidConfig();
|
|
105
|
+
const profiles: Record<string, CliProfile> = {};
|
|
106
|
+
for (const [name, value] of Object.entries(parsed.profiles)) {
|
|
107
|
+
validateProfileName(name);
|
|
108
|
+
if (!isRecord(value) || Object.keys(value).some((key) => key !== "api_key" && key !== "base_url")) throw invalidConfig();
|
|
109
|
+
const profile: CliProfile = {};
|
|
110
|
+
if ("api_key" in value) profile.api_key = validateApiKey(value.api_key as string);
|
|
111
|
+
if ("base_url" in value) profile.base_url = normalizeBaseURL(value.base_url as string);
|
|
112
|
+
profiles[name] = profile;
|
|
113
|
+
}
|
|
114
|
+
if (parsed.active_profile !== null) {
|
|
115
|
+
validateProfileName(parsed.active_profile);
|
|
116
|
+
if (!Object.hasOwn(profiles, parsed.active_profile)) throw invalidConfig();
|
|
117
|
+
}
|
|
118
|
+
return { version: 1, active_profile: parsed.active_profile, profiles };
|
|
119
|
+
} catch {
|
|
120
|
+
// Never include JSON parser errors: they can contain the credential itself.
|
|
121
|
+
throw invalidConfig();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function verifyOwner(uid: number): void {
|
|
126
|
+
if (typeof process.getuid === "function" && uid !== process.getuid()) {
|
|
127
|
+
throw new CliConfigError("config_unsafe", "PyAI configuration must be owned by the current user.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function inspectDirectory(directory: string): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
const stat = await lstat(directory);
|
|
134
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
135
|
+
throw new CliConfigError("config_unsafe", "PyAI configuration directory must be a real directory, not a symbolic link.");
|
|
136
|
+
}
|
|
137
|
+
verifyOwner(stat.uid);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (hasCode(error, "ENOENT")) return false;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readConfigAt(directory: string): Promise<CliConfig> {
|
|
146
|
+
let file: FileHandle | undefined;
|
|
147
|
+
try {
|
|
148
|
+
if (!(await inspectDirectory(directory))) return emptyConfig();
|
|
149
|
+
const path = join(directory, "config.json");
|
|
150
|
+
const stat = await lstat(path);
|
|
151
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
152
|
+
throw new CliConfigError("config_unsafe", "PyAI config.json must be a regular file, not a symbolic link.");
|
|
153
|
+
}
|
|
154
|
+
file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
155
|
+
const openedStat = await file.stat();
|
|
156
|
+
verifyOwner(openedStat.uid);
|
|
157
|
+
if (!openedStat.isFile() || openedStat.size > 1024 * 1024) throw invalidConfig();
|
|
158
|
+
return parseConfig(await file.readFile("utf8"));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (hasCode(error, "ENOENT")) return emptyConfig();
|
|
161
|
+
if (error instanceof CliConfigError) throw error;
|
|
162
|
+
throw new CliConfigError("config_read_failed", "Unable to read PyAI config.json. Check the configuration directory and file permissions.");
|
|
163
|
+
} finally {
|
|
164
|
+
await file?.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Raw configuration is for internal use; use listProfiles for display. */
|
|
169
|
+
export async function readConfig(): Promise<CliConfig> {
|
|
170
|
+
return readConfigAt(configDirectory());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function writeConfigAt(directory: string, config: CliConfig): Promise<void> {
|
|
174
|
+
const temporary = join(directory, `.config-${randomUUID()}.tmp`);
|
|
175
|
+
let file: FileHandle | undefined;
|
|
176
|
+
try {
|
|
177
|
+
file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
178
|
+
await file.writeFile(`${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
179
|
+
await file.sync();
|
|
180
|
+
await file.close();
|
|
181
|
+
file = undefined;
|
|
182
|
+
await rename(temporary, join(directory, "config.json"));
|
|
183
|
+
} finally {
|
|
184
|
+
await file?.close();
|
|
185
|
+
await unlink(temporary).catch((error: unknown) => {
|
|
186
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function updateConfig(update: (config: CliConfig) => void): Promise<void> {
|
|
192
|
+
const directory = configDirectory();
|
|
193
|
+
const lockPath = join(directory, "config.json.lock");
|
|
194
|
+
let lock: FileHandle | undefined;
|
|
195
|
+
try {
|
|
196
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
197
|
+
await inspectDirectory(directory);
|
|
198
|
+
await chmod(directory, 0o700);
|
|
199
|
+
// Every writer holds an exclusive lock across read, modification, and rename.
|
|
200
|
+
// A crashed writer leaves a visible lock instead of risking lost updates.
|
|
201
|
+
const deadline = Date.now() + 5000;
|
|
202
|
+
while (!lock) {
|
|
203
|
+
try {
|
|
204
|
+
lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
207
|
+
if (Date.now() >= deadline) {
|
|
208
|
+
throw new CliConfigError("config_busy", "PyAI configuration is locked by another process. Retry shortly; remove config.json.lock only if no PyAI configuration command is running.");
|
|
209
|
+
}
|
|
210
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const config = await readConfigAt(directory);
|
|
214
|
+
update(config);
|
|
215
|
+
await writeConfigAt(directory, config);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof CliConfigError) throw error;
|
|
218
|
+
throw new CliConfigError("config_write_failed", "Unable to save PyAI configuration. Check the configuration directory and file permissions.");
|
|
219
|
+
} finally {
|
|
220
|
+
if (lock) {
|
|
221
|
+
await lock.close();
|
|
222
|
+
await unlink(lockPath);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Merge supplied fields and make this the active profile. */
|
|
228
|
+
export async function saveProfile(name: string, fields: CliProfile): Promise<void> {
|
|
229
|
+
validateProfileName(name);
|
|
230
|
+
const normalized: CliProfile = {};
|
|
231
|
+
if (fields.api_key !== undefined) normalized.api_key = validateApiKey(fields.api_key);
|
|
232
|
+
if (fields.base_url !== undefined) normalized.base_url = normalizeBaseURL(fields.base_url);
|
|
233
|
+
await updateConfig((config) => {
|
|
234
|
+
const previous = Object.hasOwn(config.profiles, name) ? config.profiles[name] : undefined;
|
|
235
|
+
config.profiles[name] = { ...previous, ...normalized };
|
|
236
|
+
config.active_profile = name;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function removeProfile(name: string): Promise<void> {
|
|
241
|
+
validateProfileName(name);
|
|
242
|
+
await updateConfig((config) => {
|
|
243
|
+
if (!Object.hasOwn(config.profiles, name)) throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
244
|
+
delete config.profiles[name];
|
|
245
|
+
if (config.active_profile === name) {
|
|
246
|
+
config.active_profile = Object.hasOwn(config.profiles, "default") ? "default" : Object.keys(config.profiles).sort()[0] ?? null;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function useProfile(name: string): Promise<void> {
|
|
252
|
+
validateProfileName(name);
|
|
253
|
+
await updateConfig((config) => {
|
|
254
|
+
if (!Object.hasOwn(config.profiles, name)) throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
255
|
+
config.active_profile = name;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function resolveConfig(options: { apiKey?: string; baseURL?: string; profile?: string; allowMissingProfile?: boolean; ignoreApiKey?: boolean } = {}): Promise<ResolvedConfig> {
|
|
260
|
+
const config = await readConfig();
|
|
261
|
+
const selected = options.profile ?? (process.env.PYAI_PROFILE || undefined);
|
|
262
|
+
const profile = validateProfileName(selected ?? config.active_profile ?? "default");
|
|
263
|
+
const stored = Object.hasOwn(config.profiles, profile) ? config.profiles[profile] : undefined;
|
|
264
|
+
if (selected !== undefined && !stored && !options.allowMissingProfile) {
|
|
265
|
+
throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
266
|
+
}
|
|
267
|
+
const environmentKey = process.env.PYAI_API_KEY || undefined;
|
|
268
|
+
const apiKey = options.ignoreApiKey ? undefined : options.apiKey ?? environmentKey ?? stored?.api_key;
|
|
269
|
+
const baseURL = normalizeBaseURL(options.baseURL ?? (process.env.PYAI_BASE_URL || undefined) ?? stored?.base_url ?? DEFAULT_BASE_URL);
|
|
270
|
+
const source = options.ignoreApiKey ? "none" : options.apiKey !== undefined ? "explicit" : environmentKey !== undefined ? "environment" : stored?.api_key !== undefined ? "profile" : "none";
|
|
271
|
+
return { ...(apiKey !== undefined ? { apiKey: validateApiKey(apiKey) } : {}), baseURL, profile, source };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Never reveal a key, including its prefix or suffix, in profile listings. */
|
|
275
|
+
export async function listProfiles(): Promise<ProfileSummary[]> {
|
|
276
|
+
const config = await readConfig();
|
|
277
|
+
return Object.keys(config.profiles).sort().map((name) => ({
|
|
278
|
+
name,
|
|
279
|
+
active: config.active_profile === name,
|
|
280
|
+
base_url: config.profiles[name]!.base_url ?? DEFAULT_BASE_URL,
|
|
281
|
+
has_api_key: config.profiles[name]!.api_key !== undefined,
|
|
282
|
+
}));
|
|
283
|
+
}
|
package/src/cli-dx.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Offline discovery and copyable workflows; recipes never execute commands. */
|
|
2
|
+
export const aliases: Record<string, string> = Object.assign(Object.create(null), {
|
|
3
|
+
login: "auth login", logout: "auth logout", whoami: "auth status", use: "profiles use",
|
|
4
|
+
say: "speak", hear: "transcribe",
|
|
5
|
+
});
|
|
6
|
+
export const shortFlags: Record<string, string> = Object.assign(Object.create(null), {
|
|
7
|
+
"-h": "--help", "-v": "--version", "-o": "--out", "-f": "--file",
|
|
8
|
+
"-t": "--text", "-p": "--profile", "-j": "--json",
|
|
9
|
+
});
|
|
10
|
+
export interface Recipe { name: string; title: string; description: string; commands: string[]; notes: string[] }
|
|
11
|
+
export const recipes: Recipe[] = [
|
|
12
|
+
{ name: "auth", title: "Sign in and check access", description: "Save a named profile and inspect the current credential.",
|
|
13
|
+
commands: ["pyai login --profile work", "pyai whoami --profile work --json", "pyai use work"],
|
|
14
|
+
notes: ["Browser login requires the CLI login API and console deployment. Use --no-browser on remote terminals.", "For unattended runs, supply PYAI_API_KEY through the environment. Environment values override saved profiles."] },
|
|
15
|
+
{ name: "speak", title: "Turn text into audio", description: "Speak a line, narrate a text file, or pipe text from another command.",
|
|
16
|
+
commands: ['pyai speak "Hello from PyAI" -o hello.wav', "pyai speak --text-file script.txt -o narration.wav", 'printf "Build complete.\\n" | pyai speak -o build.wav'],
|
|
17
|
+
notes: ["Files must have a new output name unless you pass --force. Default output is pyai-speak.wav.", "Use pyai voices --json to choose a current voice; add --voice ID."] },
|
|
18
|
+
{ name: "transcribe", title: "Get a transcript", description: "Print just the words or retain structured output for automation.",
|
|
19
|
+
commands: ["pyai hear call.wav --text-only > transcript.txt", "pyai transcribe call.wav --json > transcript.json", 'pyai transcribe "https://example.com/call.wav" --wait --text-only'],
|
|
20
|
+
notes: ["Replace the example URL with a reachable audio URL. Hosted audio uses an asynchronous job.", "--text-only requires --wait for URLs; large offloaded results retain a job reference instead of following an external URL with credentials."] },
|
|
21
|
+
{ name: "dub", title: "Dub audio in one command", description: "Submit a Dub job, wait for completion, and download the audio.",
|
|
22
|
+
commands: ["pyai request GET /healthz/dub --json", "pyai dub original.wav --from en --to hi -o dubbed.wav --wait-timeout 600"],
|
|
23
|
+
notes: ["Check the live health response for available input and output languages before choosing them. Requires dub:render.", "On a timeout, resume with pyai dub wait JOB_ID and pyai dub audio JOB_ID -o dubbed.wav; do not resubmit the source."] },
|
|
24
|
+
{ name: "agent", title: "Start a project for a coding agent", description: "Create local context and request examples without a network call.",
|
|
25
|
+
commands: ["pyai init voice-demo --template agent", "pyai schema agents create --json", "pyai agents create --data @voice-demo/agent.json --dry-run --json"],
|
|
26
|
+
notes: ["The destination directory must be new. The starter writes no credentials and installs no dependencies.", "Read the generated PYAI.md first. Fetch live OpenAPI before adding fields or endpoints; remove --dry-run only when ready to create the profile."] },
|
|
27
|
+
{ name: "inspect", title: "Discover before you call", description: "Inspect commands offline and fetch the current API contract.",
|
|
28
|
+
commands: ["pyai schema speak --json", "pyai schema agents --json", "pyai schema --openapi --json > openapi.json", "pyai request GET /v1/me --json"],
|
|
29
|
+
notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
|
|
30
|
+
{ name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
|
|
31
|
+
commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
|
|
32
|
+
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.5.0 and pin that version in repeatable builds."] },
|
|
33
|
+
];
|
package/src/cli-http.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/** HTTP transport for the CLI. SDK request behavior remains independent. */
|
|
2
|
+
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly exitCode: number;
|
|
6
|
+
readonly details?: Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
constructor(code: string, message: string, exitCode = 1, details?: Record<string, unknown>) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CliError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CliHttpOptions {
|
|
18
|
+
baseURL: string;
|
|
19
|
+
apiKey?: string;
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliRequestOptions {
|
|
25
|
+
json?: unknown;
|
|
26
|
+
body?: BodyInit;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
auth?: boolean;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function record(value: unknown): Record<string, unknown> {
|
|
33
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
34
|
+
? value as Record<string, unknown> : {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function string(value: unknown): string | undefined {
|
|
38
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function timeout(value: number): number {
|
|
42
|
+
if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
|
|
43
|
+
throw new CliError("invalid_timeout", "Request timeout must be a positive number of milliseconds (at most 2147483647).", 2);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function retryDelay(header: string | null, attempt: number): number {
|
|
49
|
+
if (header !== null) {
|
|
50
|
+
const seconds = Number(header);
|
|
51
|
+
if (header.trim() !== "" && Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
52
|
+
const date = Date.parse(header);
|
|
53
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
54
|
+
}
|
|
55
|
+
return Math.min(200 * 2 ** attempt, 2000);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pause(ms: number, signal: AbortSignal): Promise<void> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
if (signal.aborted) { reject(signal.reason); return; }
|
|
61
|
+
const finish = () => { signal.removeEventListener("abort", abort); resolve(); };
|
|
62
|
+
const timer = setTimeout(finish, ms);
|
|
63
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
64
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class CliHttp {
|
|
69
|
+
private readonly baseURL: URL;
|
|
70
|
+
private readonly apiKey?: string;
|
|
71
|
+
private readonly timeoutMs: number;
|
|
72
|
+
private readonly maxRetries: number;
|
|
73
|
+
|
|
74
|
+
constructor(options: CliHttpOptions) {
|
|
75
|
+
try { this.baseURL = new URL(options.baseURL); }
|
|
76
|
+
catch { throw new CliError("invalid_base_url", "API base URL must be an absolute HTTP or HTTPS URL.", 2); }
|
|
77
|
+
if (!["http:", "https:"].includes(this.baseURL.protocol) || this.baseURL.username || this.baseURL.password
|
|
78
|
+
|| this.baseURL.search || this.baseURL.hash) {
|
|
79
|
+
throw new CliError("invalid_base_url", "API base URL must use HTTP or HTTPS without credentials, a query, or a fragment.", 2);
|
|
80
|
+
}
|
|
81
|
+
this.apiKey = options.apiKey;
|
|
82
|
+
this.timeoutMs = timeout(options.timeoutMs ?? 30_000);
|
|
83
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
84
|
+
if (!Number.isSafeInteger(this.maxRetries) || this.maxRetries < 0) {
|
|
85
|
+
throw new CliError("invalid_retries", "Maximum retries must be a non-negative integer.", 2);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
request(method: string, path: string, options: CliRequestOptions = {}): Promise<Response> {
|
|
90
|
+
return this.execute(method, path, options, false) as Promise<Response>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
json(method: string, path: string, options: CliRequestOptions = {}): Promise<any> {
|
|
94
|
+
return this.execute(method, path, options, true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Validate previews through the same URL rules used by live requests. */
|
|
98
|
+
validatePath(path: string): string {
|
|
99
|
+
return this.url(path).toString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private redact(value: string): string {
|
|
103
|
+
let result = value;
|
|
104
|
+
if (this.apiKey) {
|
|
105
|
+
result = result.split(this.apiKey).join("[REDACTED]");
|
|
106
|
+
result = result.split(encodeURIComponent(this.apiKey)).join("[REDACTED]");
|
|
107
|
+
}
|
|
108
|
+
return result.replace(/\bBearer\s+[^\s"',;<>]+/gi, "Bearer [REDACTED]")
|
|
109
|
+
.replace(/\bpyai_(?:test|live)_[A-Za-z0-9._~-]+/g, "[REDACTED]");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private url(path: string): URL {
|
|
113
|
+
if (!/^\/(?![\/\\])/.test(path) || /[\\\u0000-\u0020\u007f#]/.test(path)) {
|
|
114
|
+
throw new CliError("invalid_path", "API path must begin with a single / and contain no backslashes, whitespace, or fragment.", 2);
|
|
115
|
+
}
|
|
116
|
+
if (path.split("?", 1)[0]!.split("/").some((segment) => [".", ".."].includes(segment.replace(/%2e/gi, ".")))) {
|
|
117
|
+
throw new CliError("invalid_path", "API path must not contain . or .. traversal segments.", 2);
|
|
118
|
+
}
|
|
119
|
+
const url = new URL(this.baseURL.toString().replace(/\/+$/, "") + path);
|
|
120
|
+
if (url.origin !== this.baseURL.origin || url.username || url.password) {
|
|
121
|
+
throw new CliError("invalid_path", "API path must remain on the configured API origin.", 2);
|
|
122
|
+
}
|
|
123
|
+
for (const name of url.searchParams.keys()) {
|
|
124
|
+
if (name.toLowerCase() === "api_key") {
|
|
125
|
+
throw new CliError("unsafe_query", "Pass API keys through authentication, never through a URL query.", 2);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return url;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private transportError(error: unknown, signal: AbortSignal): CliError {
|
|
132
|
+
if (signal.aborted && signal.reason instanceof CliError) return signal.reason;
|
|
133
|
+
if (error instanceof CliError) return error;
|
|
134
|
+
const message = error instanceof Error ? error.message : "Connection failed.";
|
|
135
|
+
return new CliError("network_error", this.redact(`Network request failed: ${message}`), 4);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async apiError(response: Response): Promise<CliError> {
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
let payload: Record<string, unknown> = {};
|
|
141
|
+
try { payload = record(JSON.parse(text)); } catch { /* Non-JSON gateways still expose their HTTP status. */ }
|
|
142
|
+
const nested = record(payload.error);
|
|
143
|
+
const problemType = string(payload.type);
|
|
144
|
+
const problemCode = problemType?.match(/^https?:\/\/[^/]+\/problems\/([^/?#]+)$/)?.[1];
|
|
145
|
+
const fallbackCode = response.status === 401 ? "unauthorized" : response.status === 403 ? "forbidden" : `http_${response.status}`;
|
|
146
|
+
const code = this.redact(string(nested.code) ?? string(payload.code) ?? problemCode ?? fallbackCode);
|
|
147
|
+
const message = this.redact(string(nested.message) ?? string(payload.detail) ?? string(payload.message)
|
|
148
|
+
?? string(payload.title) ?? `API request failed (HTTP ${response.status}).`);
|
|
149
|
+
const details: Record<string, unknown> = { status: response.status };
|
|
150
|
+
const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id")
|
|
151
|
+
?? string(payload.request_id) ?? string(nested.request_id);
|
|
152
|
+
const retryAfter = response.headers.get("retry-after") ?? string(payload.retry_after);
|
|
153
|
+
if (requestId) details.request_id = this.redact(requestId);
|
|
154
|
+
if (retryAfter !== undefined && retryAfter !== null) details.retry_after = this.redact(retryAfter);
|
|
155
|
+
if (string(nested.type)) details.type = this.redact(nested.type as string);
|
|
156
|
+
if (typeof nested.param === "string") details.param = this.redact(nested.param);
|
|
157
|
+
return new CliError(code, message, response.status === 401 || response.status === 403 ? 3 : 1, details);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Preserve the request deadline after headers, including paused or stalled downloads. */
|
|
161
|
+
private stream(response: Response, abort: AbortController, cleanup: () => void): Response {
|
|
162
|
+
if (!response.body) { cleanup(); return response; }
|
|
163
|
+
const reader = response.body.getReader();
|
|
164
|
+
let finished = false;
|
|
165
|
+
let stop: () => void;
|
|
166
|
+
const finish = () => {
|
|
167
|
+
finished = true;
|
|
168
|
+
abort.signal.removeEventListener("abort", stop);
|
|
169
|
+
cleanup();
|
|
170
|
+
};
|
|
171
|
+
const body = new ReadableStream<Uint8Array>({
|
|
172
|
+
start: (controller) => {
|
|
173
|
+
stop = () => {
|
|
174
|
+
if (finished) return;
|
|
175
|
+
finish();
|
|
176
|
+
controller.error(this.transportError(abort.signal.reason, abort.signal));
|
|
177
|
+
void reader.cancel().catch(() => {});
|
|
178
|
+
};
|
|
179
|
+
abort.signal.addEventListener("abort", stop, { once: true });
|
|
180
|
+
if (abort.signal.aborted) stop();
|
|
181
|
+
},
|
|
182
|
+
pull: async (controller) => {
|
|
183
|
+
try {
|
|
184
|
+
const result = await reader.read();
|
|
185
|
+
if (finished) return;
|
|
186
|
+
if (result.done) { finish(); controller.close(); }
|
|
187
|
+
else controller.enqueue(result.value);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (finished) return;
|
|
190
|
+
finish();
|
|
191
|
+
controller.error(this.transportError(error, abort.signal));
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
cancel: async (reason) => {
|
|
195
|
+
finish();
|
|
196
|
+
abort.abort();
|
|
197
|
+
await reader.cancel(reason).catch(() => {});
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
const result = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
201
|
+
Object.defineProperties(result, {
|
|
202
|
+
url: { value: response.url }, redirected: { value: response.redirected }, type: { value: response.type },
|
|
203
|
+
});
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async execute(method: string, path: string, options: CliRequestOptions, readJson: boolean): Promise<unknown> {
|
|
208
|
+
const url = this.url(path);
|
|
209
|
+
method = method.toUpperCase();
|
|
210
|
+
if (!/^[A-Z]+$/.test(method) || ["CONNECT", "TRACE", "TRACK"].includes(method)) {
|
|
211
|
+
throw new CliError("invalid_method", "Unsupported HTTP method.", 2);
|
|
212
|
+
}
|
|
213
|
+
if (options.auth !== false && !this.apiKey) {
|
|
214
|
+
throw new CliError("missing_api_key", "No API key. Set PYAI_API_KEY, configure a profile, or pass --api-key.", 3);
|
|
215
|
+
}
|
|
216
|
+
if (options.json !== undefined && options.body !== undefined) {
|
|
217
|
+
throw new CliError("invalid_body", "Provide either a JSON body or a raw body, not both.", 2);
|
|
218
|
+
}
|
|
219
|
+
let headers: Headers;
|
|
220
|
+
let body: BodyInit | undefined;
|
|
221
|
+
try {
|
|
222
|
+
headers = new Headers(options.headers);
|
|
223
|
+
headers.delete("authorization");
|
|
224
|
+
headers.delete("x-api-key");
|
|
225
|
+
if (options.auth !== false) headers.set("authorization", `Bearer ${this.apiKey}`);
|
|
226
|
+
body = options.json === undefined ? options.body : JSON.stringify(options.json);
|
|
227
|
+
if (options.json !== undefined) headers.set("content-type", "application/json");
|
|
228
|
+
} catch { throw new CliError("invalid_request", "Request headers or JSON body are invalid.", 2); }
|
|
229
|
+
if ((method === "GET" || method === "HEAD") && body !== undefined) {
|
|
230
|
+
throw new CliError("invalid_body", `${method} requests cannot have a body.`, 2);
|
|
231
|
+
}
|
|
232
|
+
const duration = timeout(options.timeoutMs ?? this.timeoutMs);
|
|
233
|
+
const deadline = Date.now() + duration;
|
|
234
|
+
const abort = new AbortController();
|
|
235
|
+
const timer = setTimeout(() => abort.abort(new CliError("timeout", `Request timed out after ${duration} ms.`, 4)), duration);
|
|
236
|
+
const cleanup = () => clearTimeout(timer);
|
|
237
|
+
const retries = method === "GET" || method === "HEAD" ? this.maxRetries : 0;
|
|
238
|
+
let streaming = false;
|
|
239
|
+
try {
|
|
240
|
+
for (let attempt = 0; ; attempt++) {
|
|
241
|
+
let delay = retryDelay(null, attempt);
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetch(url, { method, headers, body, signal: abort.signal, redirect: "manual" });
|
|
244
|
+
if (response.status >= 300 && response.status < 400) {
|
|
245
|
+
await response.body?.cancel();
|
|
246
|
+
throw new CliError("redirect_refused", "API redirects are refused. Configure the final API base URL directly.", 1, { status: response.status });
|
|
247
|
+
}
|
|
248
|
+
if ((response.status === 429 || response.status >= 500) && attempt < retries) {
|
|
249
|
+
delay = retryDelay(response.headers.get("retry-after"), attempt);
|
|
250
|
+
await response.body?.cancel();
|
|
251
|
+
} else {
|
|
252
|
+
if (!response.ok) throw await this.apiError(response);
|
|
253
|
+
if (readJson) {
|
|
254
|
+
const text = await response.text();
|
|
255
|
+
if (!text.trim()) return null;
|
|
256
|
+
try { return JSON.parse(text); }
|
|
257
|
+
catch { throw new CliError("invalid_response", "API returned an invalid JSON response.", 1, { status: response.status }); }
|
|
258
|
+
}
|
|
259
|
+
const result = this.stream(response, abort, cleanup);
|
|
260
|
+
streaming = response.body !== null;
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const failure = this.transportError(error, abort.signal);
|
|
265
|
+
if (abort.signal.aborted || failure.code !== "network_error" || failure.exitCode !== 4 || attempt >= retries) throw failure;
|
|
266
|
+
}
|
|
267
|
+
await pause(Math.min(delay, Math.max(0, deadline - Date.now())), abort.signal);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
if (!streaming) cleanup();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|