@aisa-one/cli 0.2.2 → 0.2.3
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/CHANGELOG.md +134 -1
- package/README.md +249 -103
- package/dist/api.d.ts +46 -1
- package/dist/api.js +77 -8
- package/dist/cache.d.ts +18 -0
- package/dist/cache.js +65 -0
- package/dist/catalog.d.ts +90 -0
- package/dist/catalog.js +101 -0
- package/dist/commands/account.js +3 -1
- package/dist/commands/api.d.ts +18 -4
- package/dist/commands/api.js +357 -24
- package/dist/commands/cacheCmd.d.ts +2 -0
- package/dist/commands/cacheCmd.js +10 -0
- package/dist/commands/completionCmd.d.ts +7 -0
- package/dist/commands/completionCmd.js +37 -0
- package/dist/commands/configCmd.js +19 -1
- package/dist/commands/finance.d.ts +4 -0
- package/dist/commands/finance.js +212 -29
- package/dist/commands/models.js +6 -0
- package/dist/commands/run.d.ts +21 -0
- package/dist/commands/run.js +113 -6
- package/dist/commands/search.js +8 -3
- package/dist/commands/skills.d.ts +6 -2
- package/dist/commands/skills.js +189 -134
- package/dist/commands/twitter.js +2 -3
- package/dist/commands/video.d.ts +40 -0
- package/dist/commands/video.js +272 -55
- package/dist/completion.d.ts +26 -0
- package/dist/completion.js +293 -0
- package/dist/constants.d.ts +55 -5
- package/dist/constants.js +136 -11
- package/dist/index.js +84 -22
- package/dist/skills-registry.d.ts +64 -0
- package/dist/skills-registry.js +219 -0
- package/dist/types.d.ts +24 -0
- package/dist/utils/file.d.ts +5 -1
- package/dist/utils/file.js +5 -1
- package/dist/utils/pool.d.ts +8 -0
- package/dist/utils/pool.js +27 -0
- package/package.json +9 -3
- package/tests/account.test.ts +0 -89
- package/tests/streaming.test.ts +0 -73
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readCache, writeCache, touchCache } from "./cache.js";
|
|
2
|
+
import { pool } from "./utils/pool.js";
|
|
3
|
+
/**
|
|
4
|
+
* GitHub-backed skill registry.
|
|
5
|
+
*
|
|
6
|
+
* The repo nests skills two levels deep (`<category>/<name>/SKILL.md`), so the
|
|
7
|
+
* canonical slug is the repo-relative directory. Everything here is driven off
|
|
8
|
+
* a single recursive tree fetch: api.github.com allows only 60 unauthenticated
|
|
9
|
+
* requests per hour per IP, and the previous per-command fetching burned
|
|
10
|
+
* several of those on every invocation.
|
|
11
|
+
*/
|
|
12
|
+
export const SKILLS_REPO = "AIsa-team/agent-skills";
|
|
13
|
+
const GH_API = `https://api.github.com/repos/${SKILLS_REPO}`;
|
|
14
|
+
export const GH_RAW = `https://raw.githubusercontent.com/${SKILLS_REPO}/main`;
|
|
15
|
+
const TREE_CACHE_KEY = "skills/tree.json";
|
|
16
|
+
const META_CACHE_KEY = "skills/meta.json";
|
|
17
|
+
const TREE_TTL_MS = 60 * 60 * 1000;
|
|
18
|
+
const META_TTL_MS = 30 * 24 * 60 * 60 * 1000; // keyed by blob sha, so effectively permanent
|
|
19
|
+
const FETCH_CONCURRENCY = 8;
|
|
20
|
+
function ghHeaders(etag) {
|
|
21
|
+
const headers = {
|
|
22
|
+
Accept: "application/vnd.github+json",
|
|
23
|
+
"User-Agent": "aisa-cli",
|
|
24
|
+
};
|
|
25
|
+
// Raises the rate limit from 60/hr to 5000/hr when the user has one set.
|
|
26
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
27
|
+
if (token)
|
|
28
|
+
headers.Authorization = `Bearer ${token}`;
|
|
29
|
+
if (etag)
|
|
30
|
+
headers["If-None-Match"] = etag;
|
|
31
|
+
return headers;
|
|
32
|
+
}
|
|
33
|
+
function buildIndex(tree) {
|
|
34
|
+
// A skill is any directory containing a SKILL.md, at whatever depth. Deriving
|
|
35
|
+
// slugs from the SKILL.md locations rather than guessing at directory levels
|
|
36
|
+
// is what keeps this working if the repo layout changes again.
|
|
37
|
+
const slugs = tree
|
|
38
|
+
.filter((e) => e.type === "blob" && e.path.endsWith("/SKILL.md"))
|
|
39
|
+
.map((e) => e.path.slice(0, -"/SKILL.md".length))
|
|
40
|
+
.sort();
|
|
41
|
+
const blobs = {};
|
|
42
|
+
for (const slug of slugs)
|
|
43
|
+
blobs[slug] = [];
|
|
44
|
+
for (const entry of tree) {
|
|
45
|
+
if (entry.type !== "blob")
|
|
46
|
+
continue;
|
|
47
|
+
// Longest matching slug wins, so a nested skill doesn't steal its parent's files.
|
|
48
|
+
const owner = slugs
|
|
49
|
+
.filter((s) => entry.path.startsWith(`${s}/`))
|
|
50
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
51
|
+
if (owner)
|
|
52
|
+
blobs[owner].push({ path: entry.path, sha: entry.sha });
|
|
53
|
+
}
|
|
54
|
+
return { slugs, blobs };
|
|
55
|
+
}
|
|
56
|
+
let indexPromise;
|
|
57
|
+
/**
|
|
58
|
+
* Fetch (or reuse) the skill index. Memoized per process on top of a 1h disk
|
|
59
|
+
* cache with ETag revalidation, so repeated commands cost at most one
|
|
60
|
+
* conditional request.
|
|
61
|
+
*/
|
|
62
|
+
export function getSkillIndex(options = {}) {
|
|
63
|
+
if (options.refresh)
|
|
64
|
+
indexPromise = undefined;
|
|
65
|
+
if (!indexPromise) {
|
|
66
|
+
indexPromise = loadSkillIndex(options.refresh).catch((err) => {
|
|
67
|
+
indexPromise = undefined; // don't cache failures for the process lifetime
|
|
68
|
+
throw err;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return indexPromise;
|
|
72
|
+
}
|
|
73
|
+
async function loadSkillIndex(refresh) {
|
|
74
|
+
const cached = readCache(TREE_CACHE_KEY);
|
|
75
|
+
if (cached?.fresh && !refresh)
|
|
76
|
+
return cached.data;
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await fetch(`${GH_API}/git/trees/main?recursive=1`, {
|
|
80
|
+
headers: ghHeaders(refresh ? undefined : cached?.etag),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (cached)
|
|
85
|
+
return cached.data; // offline: stale beats nothing
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
if (res.status === 304 && cached) {
|
|
89
|
+
touchCache(TREE_CACHE_KEY, TREE_TTL_MS);
|
|
90
|
+
return cached.data;
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
if (cached)
|
|
94
|
+
return cached.data;
|
|
95
|
+
const detail = res.status === 403 ? " (rate limited — set GITHUB_TOKEN to raise the limit)" : "";
|
|
96
|
+
throw new Error(`GitHub API error: ${res.status}${detail}`);
|
|
97
|
+
}
|
|
98
|
+
const data = (await res.json());
|
|
99
|
+
const index = buildIndex(data.tree || []);
|
|
100
|
+
writeCache(TREE_CACHE_KEY, index, TREE_TTL_MS, res.headers.get("etag") || undefined);
|
|
101
|
+
return index;
|
|
102
|
+
}
|
|
103
|
+
/** Last path segment — what the skill is called once installed. */
|
|
104
|
+
export function leafName(slug) {
|
|
105
|
+
return slug.split("/").pop() || slug;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Accept either a canonical `<category>/<name>` slug or a bare name. A bare
|
|
109
|
+
* name that matches several skills is an error rather than a guess: installing
|
|
110
|
+
* the wrong skill silently is worse than asking.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveSlug(input, index) {
|
|
113
|
+
const clean = input.replace(/^\/+|\/+$/g, "");
|
|
114
|
+
if (index.slugs.includes(clean))
|
|
115
|
+
return clean;
|
|
116
|
+
const matches = index.slugs.filter((s) => leafName(s) === clean);
|
|
117
|
+
if (matches.length === 1)
|
|
118
|
+
return matches[0];
|
|
119
|
+
if (matches.length > 1) {
|
|
120
|
+
throw new Error(`Ambiguous skill "${input}". Matches: ${matches.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`No skill "${input}" in ${SKILLS_REPO}`);
|
|
123
|
+
}
|
|
124
|
+
/** Parse YAML frontmatter from SKILL.md content. */
|
|
125
|
+
export function parseSkillFrontmatter(slug, content) {
|
|
126
|
+
const info = { slug, name: leafName(slug), description: "", emoji: "" };
|
|
127
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
128
|
+
if (!match)
|
|
129
|
+
return info;
|
|
130
|
+
const frontmatter = match[1];
|
|
131
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
132
|
+
if (nameMatch)
|
|
133
|
+
info.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
134
|
+
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
135
|
+
if (descMatch)
|
|
136
|
+
info.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
137
|
+
const metaMatch = frontmatter.match(/^metadata:\s*(.+)$/m);
|
|
138
|
+
if (metaMatch) {
|
|
139
|
+
try {
|
|
140
|
+
const meta = JSON.parse(metaMatch[1]);
|
|
141
|
+
// agent-skills repo uses metadata.aisa; legacy skills used metadata.openclaw.
|
|
142
|
+
info.emoji = meta?.aisa?.emoji || meta?.openclaw?.emoji || "";
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// ignore malformed metadata
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return info;
|
|
149
|
+
}
|
|
150
|
+
function skillMdBlob(slug, index) {
|
|
151
|
+
return index.blobs[slug]?.find((b) => b.path === `${slug}/SKILL.md`);
|
|
152
|
+
}
|
|
153
|
+
export async function fetchSkillMarkdown(slug) {
|
|
154
|
+
const res = await fetch(`${GH_RAW}/${slug}/SKILL.md`);
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
throw new Error(`Cannot read ${slug}/SKILL.md: ${res.status}`);
|
|
157
|
+
return res.text();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Metadata for every skill. Cached by blob sha, so a warm cache needs zero
|
|
161
|
+
* content requests and only re-fetches the skills that actually changed.
|
|
162
|
+
*/
|
|
163
|
+
export async function listSkills(index) {
|
|
164
|
+
const cached = readCache(META_CACHE_KEY)?.data || {};
|
|
165
|
+
const bySha = { ...cached };
|
|
166
|
+
const missing = index.slugs.filter((slug) => {
|
|
167
|
+
const blob = skillMdBlob(slug, index);
|
|
168
|
+
return blob ? !bySha[blob.sha] : false;
|
|
169
|
+
});
|
|
170
|
+
if (missing.length > 0) {
|
|
171
|
+
const fetched = await pool(missing.map((slug) => async () => ({
|
|
172
|
+
slug,
|
|
173
|
+
content: await fetchSkillMarkdown(slug),
|
|
174
|
+
})), FETCH_CONCURRENCY);
|
|
175
|
+
for (const item of fetched) {
|
|
176
|
+
if (!item)
|
|
177
|
+
continue;
|
|
178
|
+
const blob = skillMdBlob(item.slug, index);
|
|
179
|
+
if (blob)
|
|
180
|
+
bySha[blob.sha] = parseSkillFrontmatter(item.slug, item.content);
|
|
181
|
+
}
|
|
182
|
+
writeCache(META_CACHE_KEY, bySha, META_TTL_MS);
|
|
183
|
+
}
|
|
184
|
+
return index.slugs
|
|
185
|
+
.map((slug) => {
|
|
186
|
+
const blob = skillMdBlob(slug, index);
|
|
187
|
+
const meta = blob ? bySha[blob.sha] : undefined;
|
|
188
|
+
// Cached entries carry the slug they were first seen under; the current
|
|
189
|
+
// one is authoritative if a skill moved between categories.
|
|
190
|
+
return meta ? { ...meta, slug } : { slug, name: leafName(slug), description: "", emoji: "" };
|
|
191
|
+
})
|
|
192
|
+
.filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Download every file in a skill directory. Content is kept as bytes: skills
|
|
196
|
+
* may ship images or fonts, and decoding those as UTF-8 corrupts them.
|
|
197
|
+
*
|
|
198
|
+
* A partial download is an error, not a smaller skill. Installing replaces the
|
|
199
|
+
* target directory, so silently dropping failed files would delete a working
|
|
200
|
+
* skill and leave a broken one — possibly without its SKILL.md.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchSkillFiles(slug, index) {
|
|
203
|
+
const blobs = index.blobs[slug] || [];
|
|
204
|
+
const prefix = `${slug}/`;
|
|
205
|
+
const results = await pool(blobs.map((blob) => async () => {
|
|
206
|
+
const res = await fetch(`${GH_RAW}/${blob.path}`);
|
|
207
|
+
if (!res.ok)
|
|
208
|
+
throw new Error(`${blob.path}: ${res.status}`);
|
|
209
|
+
return {
|
|
210
|
+
path: blob.path.slice(prefix.length),
|
|
211
|
+
content: Buffer.from(await res.arrayBuffer()),
|
|
212
|
+
};
|
|
213
|
+
}), FETCH_CONCURRENCY);
|
|
214
|
+
const missing = blobs.filter((_, i) => results[i] === undefined).map((b) => b.path);
|
|
215
|
+
if (missing.length > 0) {
|
|
216
|
+
throw new Error(`Could not download ${missing.length} of ${blobs.length} files for ${slug}: ${missing.join(", ")}`);
|
|
217
|
+
}
|
|
218
|
+
return results;
|
|
219
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -79,8 +79,32 @@ export interface VideoTask {
|
|
|
79
79
|
resultUrl?: string;
|
|
80
80
|
createdAt: string;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Billing facts the gateway reports in response headers.
|
|
84
|
+
*
|
|
85
|
+
* Metered providers (DataForSEO, Jina) bill per consumed credit, so the
|
|
86
|
+
* catalog's flat `per_request` price does not describe what a call actually
|
|
87
|
+
* cost — these headers are the only accurate source. Plain integration
|
|
88
|
+
* responses carry just a price key, and the LLM gateway sends nothing at all,
|
|
89
|
+
* so every field is optional.
|
|
90
|
+
*/
|
|
91
|
+
export interface CostInfo {
|
|
92
|
+
/** Dollars charged, e.g. "0.012000". Metered responses only. */
|
|
93
|
+
priceUsd?: string;
|
|
94
|
+
pricingStrategy?: string;
|
|
95
|
+
pricingVersion?: string;
|
|
96
|
+
creditModel?: string;
|
|
97
|
+
estimatedCredits?: string;
|
|
98
|
+
accountedCredits?: string;
|
|
99
|
+
requestMultiplier?: string;
|
|
100
|
+
/** Opaque pricing identifier; present on integration responses. */
|
|
101
|
+
priceKey?: string;
|
|
102
|
+
requestId?: string;
|
|
103
|
+
}
|
|
82
104
|
export interface ApiResponse<T = unknown> {
|
|
83
105
|
success: boolean;
|
|
84
106
|
data?: T;
|
|
85
107
|
error?: string;
|
|
108
|
+
/** Present when the response carried any billing header. */
|
|
109
|
+
cost?: CostInfo;
|
|
86
110
|
}
|
package/dist/utils/file.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export declare function expandHome(p: string): string;
|
|
2
2
|
export declare function ensureDir(dirPath: string): void;
|
|
3
|
+
/**
|
|
4
|
+
* Content is written as raw bytes: skills may ship images or fonts alongside
|
|
5
|
+
* SKILL.md, and round-tripping those through a UTF-8 string corrupts them.
|
|
6
|
+
*/
|
|
3
7
|
export declare function writeSkillFiles(targetDir: string, files: Array<{
|
|
4
8
|
path: string;
|
|
5
|
-
content: string;
|
|
9
|
+
content: string | Uint8Array;
|
|
6
10
|
}>): void;
|
|
7
11
|
export declare function removeDir(dirPath: string): void;
|
|
8
12
|
export declare function detectAgents(agentDirs: Record<string, string>): string[];
|
package/dist/utils/file.js
CHANGED
|
@@ -12,12 +12,16 @@ export function ensureDir(dirPath) {
|
|
|
12
12
|
mkdirSync(dirPath, { recursive: true });
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Content is written as raw bytes: skills may ship images or fonts alongside
|
|
17
|
+
* SKILL.md, and round-tripping those through a UTF-8 string corrupts them.
|
|
18
|
+
*/
|
|
15
19
|
export function writeSkillFiles(targetDir, files) {
|
|
16
20
|
for (const file of files) {
|
|
17
21
|
const safePath = sanitizePath(file.path);
|
|
18
22
|
const fullPath = join(targetDir, safePath);
|
|
19
23
|
ensureDir(join(fullPath, ".."));
|
|
20
|
-
writeFileSync(fullPath, file.content
|
|
24
|
+
writeFileSync(fullPath, file.content);
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
export function removeDir(dirPath) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run tasks with a bounded number in flight.
|
|
3
|
+
*
|
|
4
|
+
* Cold-starting the catalog means ~29 requests and the skills index ~42, both
|
|
5
|
+
* against third parties that rate-limit. Results keep the input order; a task
|
|
6
|
+
* that rejects yields `undefined` rather than failing the whole batch.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pool<T>(tasks: Array<() => Promise<T>>, concurrency: number, onProgress?: (done: number, total: number) => void): Promise<Array<T | undefined>>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run tasks with a bounded number in flight.
|
|
3
|
+
*
|
|
4
|
+
* Cold-starting the catalog means ~29 requests and the skills index ~42, both
|
|
5
|
+
* against third parties that rate-limit. Results keep the input order; a task
|
|
6
|
+
* that rejects yields `undefined` rather than failing the whole batch.
|
|
7
|
+
*/
|
|
8
|
+
export async function pool(tasks, concurrency, onProgress) {
|
|
9
|
+
const results = new Array(tasks.length);
|
|
10
|
+
let next = 0;
|
|
11
|
+
let done = 0;
|
|
12
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
|
13
|
+
while (next < tasks.length) {
|
|
14
|
+
const index = next++;
|
|
15
|
+
try {
|
|
16
|
+
results[index] = await tasks[index]();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
results[index] = undefined;
|
|
20
|
+
}
|
|
21
|
+
done++;
|
|
22
|
+
onProgress?.(done, tasks.length);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
await Promise.all(workers);
|
|
26
|
+
return results;
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aisa-one/cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "CLI for the AISA unified AI infrastructure platform -
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "CLI for the AISA unified AI infrastructure platform - one API key for 80+ LLMs and 900+ endpoints across finance, search, social, and video APIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
"CHANGELOG.md"
|
|
12
|
+
],
|
|
8
13
|
"bin": {
|
|
9
14
|
"aisa": "dist/index.js"
|
|
10
15
|
},
|
|
11
16
|
"scripts": {
|
|
12
|
-
"
|
|
17
|
+
"clean": "rm -rf dist",
|
|
18
|
+
"build": "npm run clean && tsc",
|
|
13
19
|
"dev": "tsc -w",
|
|
14
20
|
"start": "node dist/index.js",
|
|
15
21
|
"test": "vitest run",
|
package/tests/account.test.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { balanceAction, formatMicrosUSD } from "../src/commands/account.js";
|
|
3
|
-
|
|
4
|
-
const balance = {
|
|
5
|
-
currency: "USD",
|
|
6
|
-
account_balance_micros_usd: 2_500_000,
|
|
7
|
-
available_balance_micros_usd: 1_000_000,
|
|
8
|
-
api_key: {
|
|
9
|
-
unlimited: false,
|
|
10
|
-
remaining_micros_usd: 1_000_000,
|
|
11
|
-
used_micros_usd: 300_000,
|
|
12
|
-
},
|
|
13
|
-
as_of: "2026-08-03T12:00:00Z",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
describe("balanceAction", () => {
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
process.env.AISA_API_KEY = "test-balance-key";
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
delete process.env.AISA_API_KEY;
|
|
23
|
-
vi.unstubAllGlobals();
|
|
24
|
-
vi.restoreAllMocks();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("formats microdollars without floating-point drift", () => {
|
|
28
|
-
expect(formatMicrosUSD(2_500_000)).toBe("$2.50");
|
|
29
|
-
expect(formatMicrosUSD(2_505_001)).toBe("$2.51");
|
|
30
|
-
expect(formatMicrosUSD(1_999_999)).toBe("$2.00");
|
|
31
|
-
expect(formatMicrosUSD(-1_500_000)).toBe("-$1.50");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("requests the balance endpoint with the API key and renders the summary", async () => {
|
|
35
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify(balance), { status: 200 }));
|
|
36
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
37
|
-
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
38
|
-
|
|
39
|
-
await balanceAction();
|
|
40
|
-
|
|
41
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
42
|
-
const [url, init] = fetchMock.mock.calls[0];
|
|
43
|
-
expect(url).toBe("https://api.aisa.one/v1/credits/balance");
|
|
44
|
-
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer test-balance-key");
|
|
45
|
-
expect(log.mock.calls.map(([line]) => line).join("\n")).toContain("Balance:");
|
|
46
|
-
expect(log.mock.calls.map(([line]) => line).join("\n")).toContain("$2.50 USD");
|
|
47
|
-
expect(log.mock.calls.map(([line]) => line).join("\n")).toContain("$1.00 USD");
|
|
48
|
-
expect(log.mock.calls.map(([line]) => line).join("\n")).not.toContain("test-balance-key");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("renders unlimited keys", async () => {
|
|
52
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
53
|
-
...balance,
|
|
54
|
-
api_key: { ...balance.api_key, unlimited: true },
|
|
55
|
-
}), { status: 200 }));
|
|
56
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
57
|
-
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
58
|
-
|
|
59
|
-
await balanceAction();
|
|
60
|
-
|
|
61
|
-
expect(log.mock.calls.map(([line]) => line).join("\n")).toContain("API key limit: Unlimited");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("supports raw JSON output", async () => {
|
|
65
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify(balance), { status: 200 }));
|
|
66
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
67
|
-
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
68
|
-
|
|
69
|
-
await balanceAction({ json: true });
|
|
70
|
-
|
|
71
|
-
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual(balance);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it.each([401, 403, 404, 500])("raises API errors for HTTP %s", async (status) => {
|
|
75
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: "balance unavailable" }), { status }));
|
|
76
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
77
|
-
|
|
78
|
-
await expect(balanceAction()).rejects.toThrow(`${status}: balance unavailable`);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("raises network errors", async () => {
|
|
82
|
-
const fetchMock = vi.fn(async () => {
|
|
83
|
-
throw new Error("connection refused");
|
|
84
|
-
});
|
|
85
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
86
|
-
|
|
87
|
-
await expect(balanceAction()).rejects.toThrow("connection refused");
|
|
88
|
-
});
|
|
89
|
-
});
|
package/tests/streaming.test.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { handleSSEStream } from "../src/utils/streaming.js";
|
|
3
|
-
|
|
4
|
-
function sseResponse(chunks: string[]): Response {
|
|
5
|
-
const encoder = new TextEncoder();
|
|
6
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
7
|
-
start(controller) {
|
|
8
|
-
for (const chunk of chunks) {
|
|
9
|
-
controller.enqueue(encoder.encode(chunk));
|
|
10
|
-
}
|
|
11
|
-
controller.close();
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
return new Response(stream);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe("handleSSEStream", () => {
|
|
18
|
-
it("emits tokens from a native fetch Response body", async () => {
|
|
19
|
-
const res = sseResponse([
|
|
20
|
-
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
|
|
21
|
-
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
|
22
|
-
"data: [DONE]\n\n",
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
const tokens: string[] = [];
|
|
26
|
-
let done = false;
|
|
27
|
-
await handleSSEStream(
|
|
28
|
-
res,
|
|
29
|
-
(t) => tokens.push(t),
|
|
30
|
-
() => {
|
|
31
|
-
done = true;
|
|
32
|
-
}
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
expect(tokens.join("")).toBe("Hello world");
|
|
36
|
-
expect(done).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("handles multi-byte characters split across chunks", async () => {
|
|
40
|
-
const encoder = new TextEncoder();
|
|
41
|
-
const line = encoder.encode(
|
|
42
|
-
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\ndata: [DONE]\n\n'
|
|
43
|
-
);
|
|
44
|
-
// split in the middle of a multi-byte character
|
|
45
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
46
|
-
start(controller) {
|
|
47
|
-
controller.enqueue(line.slice(0, 40));
|
|
48
|
-
controller.enqueue(line.slice(40));
|
|
49
|
-
controller.close();
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
const res = new Response(stream);
|
|
53
|
-
|
|
54
|
-
const tokens: string[] = [];
|
|
55
|
-
await handleSSEStream(res, (t) => tokens.push(t));
|
|
56
|
-
|
|
57
|
-
expect(tokens.join("")).toBe("你好");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("reports usage to onDone", async () => {
|
|
61
|
-
const res = sseResponse([
|
|
62
|
-
'data: {"choices":[{"delta":{"content":"hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n',
|
|
63
|
-
"data: [DONE]\n\n",
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
let usage: { prompt_tokens: number; completion_tokens: number } | undefined;
|
|
67
|
-
await handleSSEStream(res, () => {}, (u) => {
|
|
68
|
-
usage = u;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
expect(usage).toEqual({ prompt_tokens: 3, completion_tokens: 1 });
|
|
72
|
-
});
|
|
73
|
-
});
|