@aisa-one/cli 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +250 -103
  3. package/dist/api.d.ts +46 -1
  4. package/dist/api.js +77 -8
  5. package/dist/cache.d.ts +18 -0
  6. package/dist/cache.js +65 -0
  7. package/dist/catalog.d.ts +90 -0
  8. package/dist/catalog.js +101 -0
  9. package/dist/commands/account.js +4 -2
  10. package/dist/commands/api.d.ts +18 -4
  11. package/dist/commands/api.js +357 -24
  12. package/dist/commands/auth.js +1 -1
  13. package/dist/commands/cacheCmd.d.ts +2 -0
  14. package/dist/commands/cacheCmd.js +10 -0
  15. package/dist/commands/completionCmd.d.ts +7 -0
  16. package/dist/commands/completionCmd.js +37 -0
  17. package/dist/commands/configCmd.js +19 -1
  18. package/dist/commands/finance.d.ts +4 -0
  19. package/dist/commands/finance.js +212 -29
  20. package/dist/commands/models.js +6 -0
  21. package/dist/commands/run.d.ts +21 -0
  22. package/dist/commands/run.js +113 -6
  23. package/dist/commands/search.js +8 -3
  24. package/dist/commands/skills.d.ts +6 -2
  25. package/dist/commands/skills.js +199 -144
  26. package/dist/commands/twitter.js +2 -3
  27. package/dist/commands/video.d.ts +40 -0
  28. package/dist/commands/video.js +272 -55
  29. package/dist/completion.d.ts +26 -0
  30. package/dist/completion.js +293 -0
  31. package/dist/constants.d.ts +55 -5
  32. package/dist/constants.js +136 -11
  33. package/dist/index.js +88 -26
  34. package/dist/skills-registry.d.ts +64 -0
  35. package/dist/skills-registry.js +219 -0
  36. package/dist/types.d.ts +24 -0
  37. package/dist/utils/file.d.ts +5 -1
  38. package/dist/utils/file.js +5 -1
  39. package/dist/utils/pool.d.ts +8 -0
  40. package/dist/utils/pool.js +27 -0
  41. package/package.json +10 -4
  42. package/tests/account.test.ts +0 -89
  43. 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
  }
@@ -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[];
@@ -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, "utf-8");
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.2",
4
- "description": "CLI for the AISA unified AI infrastructure platform - access 70+ LLMs, web search, finance, Twitter, and video generation APIs",
3
+ "version": "0.2.4",
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
- "build": "tsc",
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",
@@ -54,5 +60,5 @@
54
60
  "url": "https://github.com/AIsa-team/cli/issues"
55
61
  },
56
62
  "homepage": "https://aisa.one",
57
- "author": "AISA Team <support@aisa.one> (https://aisa.one)"
63
+ "author": "AIsa Team <support@aisa.one> (https://aisa.one)"
58
64
  }
@@ -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
- });
@@ -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
- });