@aisa-one/cli 0.2.0 → 0.2.2

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 ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@aisa-one/cli` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] — 2026-08-03
11
+
12
+ ### Added
13
+
14
+ - **`aisa balance` now queries the live account wallet through the API-key
15
+ balance endpoint.** It reports both the account balance and the amount
16
+ available to the current API key, with `--json` support for automation.
17
+
18
+ ### Fixed
19
+
20
+ - **Skills registry now points at the canonical `agent-skills` repo.** The
21
+ `SKILLS_REPO` constant in `src/commands/skills.ts` was pinned to
22
+ `AIsa-team/OpenClaw-Skills`, which has been renamed to
23
+ `AIsa-team/agent-skills`. Every `aisa skills` subcommand kept working
24
+ only because GitHub serves a 301 redirect on the old slug; the constant
25
+ now references the canonical name so the registry no longer depends on
26
+ that redirect. ([#3](https://github.com/AIsa-team/cli/pull/3))
27
+ - **`aisa skills list` and `aisa skills show` now render emojis for live
28
+ skills.** `parseSkillFrontmatter` only read `metadata.openclaw.emoji`,
29
+ but skills in `agent-skills` publish under `metadata.aisa.emoji`. The
30
+ parser now reads the new key first and falls back to the legacy
31
+ `openclaw` key for any older skills still in the wild.
32
+ ([#4](https://github.com/AIsa-team/cli/pull/4))
33
+
34
+ ### Changed
35
+
36
+ - **`aisa skills init` templates aligned with `agent-skills` conventions.**
37
+ All six built-in scaffolds (`default`, `llm`, `search`, `finance`,
38
+ `twitter`, `video`) now emit:
39
+ - `homepage: https://aisa.one` (was `https://openclaw.ai`)
40
+ - `metadata.aisa.*` instead of `metadata.openclaw.*`
41
+ - `compatibility: ["openclaw", "claude-code", "hermes"]` to match the
42
+ shape used by `marketpulse`, `multi-source-search`,
43
+ `prediction-market-arbitrage`, and the rest of the live skills.
44
+
45
+ Skills scaffolded with `aisa skills init` will now render correctly in
46
+ the registry and pass review when contributed back upstream.
47
+ ([#5](https://github.com/AIsa-team/cli/pull/5))
48
+
49
+ ### Documentation
50
+
51
+ - README Skills section now references
52
+ [`AIsa-team/agent-skills`](https://github.com/AIsa-team/agent-skills)
53
+ directly instead of the renamed `OpenClaw-Skills`.
54
+ ([#2](https://github.com/AIsa-team/cli/pull/2))
55
+
56
+ ## [0.2.0] — Initial public release
57
+
58
+ - LLM gateway commands (`aisa chat`, `aisa models`).
59
+ - API discovery and execution (`aisa run`, `aisa api …` WIP).
60
+ - Web search (`aisa web-search`, `aisa scholar`).
61
+ - Finance (`aisa stock`, `aisa crypto`, `aisa screener`).
62
+ - Twitter/X (`aisa twitter …`, `aisa tweet`).
63
+ - Video generation (`aisa video create|status`).
64
+ - Skills registry (`aisa skills list|search|show|install|remove|init`).
65
+ - MCP server auto-config (`aisa mcp setup|status`).
66
+ - Config commands (`aisa config get|set|list|reset`) and auth
67
+ (`aisa login|logout|whoami`).
68
+
69
+ [Unreleased]: https://github.com/AIsa-team/cli/compare/v0.2.2...HEAD
70
+ [0.2.2]: https://github.com/AIsa-team/cli/compare/v0.2.1...v0.2.2
71
+ [0.2.1]: https://github.com/AIsa-team/cli/releases/tag/v0.2.1
72
+ [0.2.0]: https://github.com/AIsa-team/cli/releases/tag/v0.2.0
package/README.md CHANGED
@@ -47,14 +47,14 @@ aisa whoami # Show auth status
47
47
  Chat with 70+ models (GPT, Claude, Gemini, Qwen, Deepseek, Grok) through a single OpenAI-compatible endpoint.
48
48
 
49
49
  ```bash
50
- aisa chat "your message" --model gpt-4.1
50
+ aisa chat "your message" --model gpt-4.1-mini
51
51
  aisa chat "explain this" --model claude-opus-4-6 --stream
52
52
  aisa chat "respond in JSON" --model gemini-2.5-pro --json
53
53
  echo "summarize this" | aisa chat # pipe support
54
54
 
55
55
  aisa models # list all models
56
56
  aisa models --provider anthropic # filter by provider
57
- aisa models show gpt-4.1 # model details
57
+ aisa models show gpt-4.1-mini # model details
58
58
  ```
59
59
 
60
60
  ### API Discovery & Execution
@@ -115,22 +115,23 @@ aisa video status <task-id> # check status
115
115
  ### Account
116
116
 
117
117
  ```bash
118
- aisa balance # 🚧 WIP — check https://aisa.one/dashboard
118
+ aisa balance # Show wallet and API key credit balance
119
+ aisa balance --json # Output the balance response as JSON
119
120
  aisa usage --limit 20 # 🚧 WIP
120
121
  ```
121
122
 
122
123
  ## Skills
123
124
 
124
- Skills are markdown files that teach AI coding agents (Claude Code, Cursor, Copilot, etc.) how to use AISA APIs. Skills are sourced from the [OpenClaw-Skills](https://github.com/AIsa-team/OpenClaw-Skills) repository.
125
+ Skills are markdown files that teach AI coding agents (Claude Code, Cursor, Copilot, etc.) how to use AISA APIs. Skills are sourced from the [agent-skills](https://github.com/AIsa-team/agent-skills) repository.
125
126
 
126
127
  ### Browse & Install
127
128
 
128
129
  ```bash
129
130
  aisa skills list # list all available skills
130
131
  aisa skills search "financial analysis" # search by keyword
131
- aisa skills show market # show skill details
132
- aisa skills install market # install to agent directories
133
- aisa skills remove market # uninstall
132
+ aisa skills show marketpulse # show skill details
133
+ aisa skills install marketpulse # install to agent directories
134
+ aisa skills remove marketpulse # uninstall
134
135
  ```
135
136
 
136
137
  Skills install to agent directories automatically:
@@ -155,7 +156,7 @@ aisa skills init my-skill --template llm # LLM template
155
156
 
156
157
  Available templates: `default`, `llm`, `search`, `finance`, `twitter`, `video`
157
158
 
158
- To publish a skill, submit a pull request to [AIsa-team/OpenClaw-Skills](https://github.com/AIsa-team/OpenClaw-Skills).
159
+ To publish a skill, submit a pull request to [AIsa-team/agent-skills](https://github.com/AIsa-team/agent-skills).
159
160
 
160
161
  ## MCP Server
161
162
 
@@ -178,7 +179,7 @@ aisa config reset # reset to defaults
178
179
  ```
179
180
 
180
181
  Settings:
181
- - `defaultModel` — default model for `aisa chat` (default: `gpt-4.1`)
182
+ - `defaultModel` — default model for `aisa chat` (default: `gpt-4.1-mini`)
182
183
  - `baseUrl` — API base URL (default: `https://api.aisa.one`)
183
184
  - `outputFormat` — output format: `text` or `json`
184
185
 
package/dist/api.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { type Response } from "node-fetch";
2
1
  import type { ApiResponse } from "./types.js";
3
2
  export interface RequestOptions {
4
3
  method?: "GET" | "POST" | "PUT" | "DELETE";
package/dist/api.js CHANGED
@@ -1,4 +1,3 @@
1
- import fetch from "node-fetch";
2
1
  import { CLI_BASE_URL, APIS_BASE_URL } from "./constants.js";
3
2
  import { getConfig } from "./config.js";
4
3
  function getBaseUrl() {
@@ -1,4 +1,7 @@
1
- export declare function balanceAction(): Promise<void>;
1
+ export declare function formatMicrosUSD(micros: number | string | bigint): string;
2
+ export declare function balanceAction(options?: {
3
+ json?: boolean;
4
+ }): Promise<void>;
2
5
  export declare function usageAction(_options: {
3
6
  limit?: string;
4
7
  days?: string;
@@ -1,10 +1,38 @@
1
1
  import chalk from "chalk";
2
2
  import { requireApiKey } from "../config.js";
3
- import { hint } from "../utils/display.js";
4
- export async function balanceAction() {
5
- requireApiKey();
6
- console.log(chalk.yellow(" Balance API is not yet available."));
7
- hint("Check your balance at https://aisa.one/dashboard");
3
+ import { apiRequest } from "../api.js";
4
+ import { formatJson, hint } from "../utils/display.js";
5
+ export function formatMicrosUSD(micros) {
6
+ const value = typeof micros === "bigint" ? micros : BigInt(micros);
7
+ const negative = value < 0n;
8
+ let absolute = negative ? -value : value;
9
+ let cents = (absolute % 1000000n + 5000n) / 10000n;
10
+ let dollars = absolute / 1000000n;
11
+ if (cents === 100n) {
12
+ dollars += 1n;
13
+ cents = 0n;
14
+ }
15
+ return `${negative ? "-" : ""}$${dollars.toString()}.${cents.toString().padStart(2, "0")}`;
16
+ }
17
+ export async function balanceAction(options = {}) {
18
+ const key = requireApiKey();
19
+ const res = await apiRequest(key, "credits/balance");
20
+ if (!res.success || !res.data) {
21
+ throw new Error(res.error || "Failed to fetch balance");
22
+ }
23
+ if (options.json) {
24
+ console.log(formatJson(res.data));
25
+ return;
26
+ }
27
+ const balance = res.data;
28
+ console.log(` Balance: ${formatMicrosUSD(balance.account_balance_micros_usd)} ${balance.currency}`);
29
+ console.log(` Available with key: ${formatMicrosUSD(balance.available_balance_micros_usd)} ${balance.currency}`);
30
+ if (balance.api_key.unlimited) {
31
+ console.log(" API key limit: Unlimited");
32
+ }
33
+ else {
34
+ console.log(` API key limit: ${formatMicrosUSD(balance.api_key.remaining_micros_usd)} remaining`);
35
+ }
8
36
  }
9
37
  export async function usageAction(_options) {
10
38
  requireApiKey();
@@ -23,7 +23,7 @@ export async function chatAction(message, options) {
23
23
  error("Empty message");
24
24
  process.exit(1);
25
25
  }
26
- const model = options.model || getConfig("defaultModel") || "gpt-4.1";
26
+ const model = options.model || getConfig("defaultModel") || "gpt-4.1-mini";
27
27
  const stream = options.stream !== false; // default true
28
28
  const messages = [];
29
29
  if (options.system) {
@@ -31,7 +31,7 @@ export async function runAction(slug, path, options) {
31
31
  }
32
32
  // Detect method
33
33
  const method = (options.method || (body ? "POST" : "GET")).toUpperCase();
34
- const endpoint = `${slug}${path}`;
34
+ const endpoint = path.startsWith("/") ? `${slug}${path}` : `${slug}/${path}`;
35
35
  // Auto-detect domain API vs LLM API based on slug
36
36
  const domainSlugs = ["financial", "search", "youtube", "scholar", "tavily", "querit", "twitter", "services", "crypto"];
37
37
  const isDomain = domainSlugs.some((s) => slug.startsWith(s));
@@ -2,12 +2,11 @@ import ora from "ora";
2
2
  import chalk from "chalk";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
- import fetch from "node-fetch";
6
5
  import { error, success, hint, truncate } from "../utils/display.js";
7
6
  import { expandHome, ensureDir, writeSkillFiles, removeDir, detectAgents } from "../utils/file.js";
8
7
  import { AGENT_DIRS } from "../constants.js";
9
8
  // --- GitHub-backed skill registry ---
10
- const SKILLS_REPO = "AIsa-team/OpenClaw-Skills";
9
+ const SKILLS_REPO = "AIsa-team/agent-skills";
11
10
  const GH_API = `https://api.github.com/repos/${SKILLS_REPO}`;
12
11
  const GH_RAW = `https://raw.githubusercontent.com/${SKILLS_REPO}/main`;
13
12
  /** Fetch the repo tree and extract top-level skill folder names. */
@@ -48,7 +47,8 @@ function parseSkillFrontmatter(slug, content) {
48
47
  if (metaMatch) {
49
48
  try {
50
49
  const meta = JSON.parse(metaMatch[1]);
51
- info.emoji = meta?.openclaw?.emoji || "";
50
+ // agent-skills repo uses metadata.aisa; legacy skills used metadata.openclaw.
51
+ info.emoji = meta?.aisa?.emoji || meta?.openclaw?.emoji || "";
52
52
  }
53
53
  catch {
54
54
  // ignore malformed metadata
@@ -80,8 +80,8 @@ const TEMPLATES = {
80
80
  default: `---
81
81
  name: my-skill
82
82
  description: "Describe what this skill does."
83
- homepage: https://openclaw.ai
84
- metadata: {"openclaw":{"emoji":"","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
83
+ homepage: https://aisa.one
84
+ metadata: {"aisa":{"emoji":"","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
85
85
  ---
86
86
 
87
87
  # My Skill
@@ -103,8 +103,8 @@ aisa run <slug> <path> -q "param=value"
103
103
  llm: `---
104
104
  name: llm-assistant
105
105
  description: "Use AISA's unified LLM gateway to chat with 70+ AI models."
106
- homepage: https://openclaw.ai
107
- metadata: {"openclaw":{"emoji":"🤖","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
106
+ homepage: https://aisa.one
107
+ metadata: {"aisa":{"emoji":"🤖","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
108
108
  ---
109
109
 
110
110
  # LLM Assistant Skill
@@ -120,7 +120,7 @@ export AISA_API_KEY=sk-your-key
120
120
  ## Chat Completion
121
121
 
122
122
  \`\`\`bash
123
- aisa chat "Your question here" --model gpt-4.1
123
+ aisa chat "Your question here" --model gpt-4.1-mini
124
124
  \`\`\`
125
125
 
126
126
  ## Streaming
@@ -139,8 +139,8 @@ aisa models --provider anthropic
139
139
  search: `---
140
140
  name: web-search
141
141
  description: "Search the web, YouTube, and academic papers via AISA APIs."
142
- homepage: https://openclaw.ai
143
- metadata: {"openclaw":{"emoji":"🔍","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
142
+ homepage: https://aisa.one
143
+ metadata: {"aisa":{"emoji":"🔍","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
144
144
  ---
145
145
 
146
146
  # Web Search Skill
@@ -174,8 +174,8 @@ aisa scholar "transformer architecture"
174
174
  finance: `---
175
175
  name: finance-analyst
176
176
  description: "Access stock prices, earnings, SEC filings, and financial data via AISA."
177
- homepage: https://openclaw.ai
178
- metadata: {"openclaw":{"emoji":"📊","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
177
+ homepage: https://aisa.one
178
+ metadata: {"aisa":{"emoji":"📊","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
179
179
  ---
180
180
 
181
181
  # Finance Analyst Skill
@@ -206,8 +206,8 @@ aisa crypto ETH --period 30d
206
206
  twitter: `---
207
207
  name: twitter-manager
208
208
  description: "Search Twitter, get user profiles and trends via AISA."
209
- homepage: https://openclaw.ai
210
- metadata: {"openclaw":{"emoji":"🐦","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
209
+ homepage: https://aisa.one
210
+ metadata: {"aisa":{"emoji":"🐦","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
211
211
  ---
212
212
 
213
213
  # Twitter Manager Skill
@@ -241,8 +241,8 @@ aisa twitter trends
241
241
  video: `---
242
242
  name: video-generator
243
243
  description: "Generate videos from text prompts using AISA's video synthesis API."
244
- homepage: https://openclaw.ai
245
- metadata: {"openclaw":{"emoji":"🎬","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY"}}
244
+ homepage: https://aisa.one
245
+ metadata: {"aisa":{"emoji":"🎬","requires":{"bins":["curl"],"env":["AISA_API_KEY"]},"primaryEnv":"AISA_API_KEY","compatibility":["openclaw","claude-code","hermes"]}}
246
246
  ---
247
247
 
248
248
  # Video Generator Skill
@@ -1,6 +1,5 @@
1
1
  import ora from "ora";
2
2
  import chalk from "chalk";
3
- import fetch from "node-fetch";
4
3
  import { requireApiKey, getConfig, setConfig } from "../config.js";
5
4
  import { apiRequest } from "../api.js";
6
5
  import { APIS_BASE_URL } from "../constants.js";
@@ -333,9 +332,10 @@ export async function twitterUploadMediaAction(filePath, options) {
333
332
  const spinner = ora("Uploading media...").start();
334
333
  let formData;
335
334
  try {
336
- const { FormData: NFFormData, fileFromSync } = await import("node-fetch");
337
- formData = new NFFormData();
338
- formData.append("file", fileFromSync(filePath));
335
+ const { readFileSync } = await import("node:fs");
336
+ const { basename } = await import("node:path");
337
+ formData = new FormData();
338
+ formData.append("file", new Blob([readFileSync(filePath)]), basename(filePath));
339
339
  }
340
340
  catch {
341
341
  spinner.fail("Failed to read file");
package/dist/config.js CHANGED
@@ -4,7 +4,7 @@ const config = new Conf({
4
4
  projectName: "aisa-cli",
5
5
  schema: {
6
6
  apiKey: { type: "string", default: "" },
7
- defaultModel: { type: "string", default: "gpt-4.1" },
7
+ defaultModel: { type: "string", default: "gpt-4.1-mini" },
8
8
  baseUrl: { type: "string", default: "https://api.aisa.one/v1" },
9
9
  outputFormat: { type: "string", default: "text" },
10
10
  twitterCookies: { type: "string", default: "" },
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.2.0";
1
+ export declare const VERSION = "0.2.2";
2
2
  export declare const BASE_URL = "https://api.aisa.one";
3
3
  export declare const CLI_BASE_URL = "https://api.aisa.one/v1";
4
4
  export declare const APIS_BASE_URL = "https://api.aisa.one/apis/v1";
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.2.0";
1
+ export const VERSION = "0.2.2";
2
2
  export const BASE_URL = "https://api.aisa.one";
3
3
  export const CLI_BASE_URL = `${BASE_URL}/v1`;
4
4
  export const APIS_BASE_URL = `${BASE_URL}/apis/v1`;
package/dist/index.js CHANGED
@@ -58,7 +58,8 @@ program
58
58
  // ── Account ──
59
59
  program
60
60
  .command("balance")
61
- .description("Show credit balance (WIP)")
61
+ .description("Show credit balance")
62
+ .option("--json", "Output raw JSON")
62
63
  .action(wrap(balanceAction));
63
64
  program
64
65
  .command("usage")
@@ -107,7 +108,7 @@ program
107
108
  program
108
109
  .command("chat [message]")
109
110
  .description("Chat with AI models via the AISA gateway")
110
- .option("--model <model>", "Model ID (default: gpt-4.1)")
111
+ .option("--model <model>", "Model ID (default: gpt-4.1-mini)")
111
112
  .option("--system <prompt>", "System prompt")
112
113
  .option("--no-stream", "Disable streaming")
113
114
  .option("--json", "Output raw JSON response")
package/dist/types.d.ts CHANGED
@@ -26,8 +26,15 @@ export interface Model {
26
26
  supported_endpoint_types?: string[] | null;
27
27
  }
28
28
  export interface BalanceResponse {
29
- balance: number;
30
29
  currency: string;
30
+ account_balance_micros_usd: number;
31
+ available_balance_micros_usd: number;
32
+ api_key: {
33
+ unlimited: boolean;
34
+ remaining_micros_usd: number;
35
+ used_micros_usd: number;
36
+ };
37
+ as_of: string;
31
38
  }
32
39
  export interface UsageRecord {
33
40
  timestamp: string;
@@ -1,4 +1,3 @@
1
- import type { Response } from "node-fetch";
2
1
  export declare function handleSSEStream(res: Response, onToken: (token: string) => void, onDone?: (usage?: {
3
2
  prompt_tokens: number;
4
3
  completion_tokens: number;
@@ -2,10 +2,11 @@ export async function handleSSEStream(res, onToken, onDone) {
2
2
  const body = res.body;
3
3
  if (!body)
4
4
  throw new Error("No response body");
5
+ const decoder = new TextDecoder();
5
6
  let buffer = "";
6
7
  let usage;
7
8
  for await (const chunk of body) {
8
- buffer += chunk.toString();
9
+ buffer += decoder.decode(chunk, { stream: true });
9
10
  const lines = buffer.split("\n");
10
11
  buffer = lines.pop() || "";
11
12
  for (const line of lines) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aisa-one/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CLI for the AISA unified AI infrastructure platform - access 70+ LLMs, web search, finance, Twitter, and video generation APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,6 @@
26
26
  "chalk": "^5.3.0",
27
27
  "commander": "^12.0.0",
28
28
  "conf": "^12.0.0",
29
- "node-fetch": "^3.3.2",
30
29
  "ora": "^8.0.1"
31
30
  },
32
31
  "devDependencies": {
@@ -0,0 +1,89 @@
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
+ });
@@ -0,0 +1,73 @@
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
+ });