aether-code 0.12.0 → 0.14.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/src/api.js CHANGED
@@ -1,234 +1,287 @@
1
- // API client.
2
-
3
- import { getConfig } from "./config.js";
4
-
5
- /**
6
- * Free balance + plan check via /api/v1/me. Doesn't charge credits.
7
- */
8
- export async function fetchBalance() {
9
- const { apiKey, baseUrl } = getConfig();
10
- if (!apiKey) {
11
- throw new AetherError(
12
- "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
13
- "NO_API_KEY",
14
- 0,
15
- );
16
- }
17
- let res;
18
- try {
19
- res = await fetch(`${baseUrl}/api/v1/me`, {
20
- method: "GET",
21
- headers: {
22
- Accept: "application/json",
23
- Authorization: `Bearer ${apiKey}`,
24
- "User-Agent": "aether-code/0.2.0",
25
- },
26
- });
27
- } catch (e) {
28
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
29
- }
30
- let data = null;
31
- try { data = await res.json(); } catch { /* non-JSON */ }
32
- if (!res.ok) {
33
- const code = data?.code || `HTTP_${res.status}`;
34
- const msg = data?.error || `${res.status} ${res.statusText}`;
35
- throw new AetherError(msg, code, res.status, data);
36
- }
37
- return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
38
- }
39
-
40
- export class AetherError extends Error {
41
- constructor(message, code, status, data) {
42
- super(message);
43
- this.name = "AetherError";
44
- this.code = code;
45
- this.status = status;
46
- this.data = data;
47
- }
48
- }
49
-
50
- /**
51
- * Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
52
- * callbacks as events arrive. Returns the assembled assistant message + final
53
- * usage/credit info once the stream ends.
54
- *
55
- * Event handlers (all optional):
56
- * onDelta(text) — text fragment from the model
57
- * onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
58
- * onFinish(reason) — per-choice finish reason ("stop" | "tool_calls" | "length")
59
- *
60
- * Returns:
61
- * { message: { role:"assistant", content, tool_calls }, finish_reason,
62
- * creditsCharged, balanceAfter, usage }
63
- */
64
- export async function agentTurnStream({
65
- messages,
66
- tools,
67
- maxTokens,
68
- temperature,
69
- onDelta,
70
- onToolCallDelta,
71
- onFinish,
72
- }) {
73
- const { apiKey, baseUrl } = getConfig();
74
- if (!apiKey) {
75
- throw new AetherError(
76
- "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
77
- "NO_API_KEY",
78
- 0,
79
- );
80
- }
81
-
82
- let res;
83
- try {
84
- res = await fetch(`${baseUrl}/api/v1/agent/stream`, {
85
- method: "POST",
86
- headers: {
87
- "Content-Type": "application/json",
88
- Accept: "text/event-stream",
89
- Authorization: `Bearer ${apiKey}`,
90
- "User-Agent": "aether-code/0.1.0",
91
- },
92
- body: JSON.stringify({
93
- messages,
94
- tools,
95
- max_tokens: maxTokens,
96
- temperature,
97
- }),
98
- });
99
- } catch (e) {
100
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
101
- }
102
-
103
- // Non-streaming JSON error response (auth, credit, validation failures)
104
- if (!res.ok) {
105
- let data = null;
106
- try { data = await res.json(); } catch { /* non-JSON */ }
107
- const code = data?.code || `HTTP_${res.status}`;
108
- const msg = data?.error || `${res.status} ${res.statusText}`;
109
- throw new AetherError(msg, code, res.status, data);
110
- }
111
- if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
112
-
113
- // Accumulate streaming state
114
- let textBuf = "";
115
- const toolBuf = new Map(); // index -> { id, name, args }
116
- let finishReason = "stop";
117
- let creditsCharged = 0;
118
- let balanceAfter = null;
119
- let usage = { prompt_tokens: 0, completion_tokens: 0 };
120
- let streamError = null;
121
-
122
- const reader = res.body.getReader();
123
- const decoder = new TextDecoder();
124
- let buffer = "";
125
- while (true) {
126
- const { value, done } = await reader.read();
127
- if (done) break;
128
- buffer += decoder.decode(value, { stream: true });
129
- const lines = buffer.split("\n");
130
- buffer = lines.pop() ?? "";
131
- for (const line of lines) {
132
- if (!line.startsWith("data:")) continue;
133
- const json = line.slice(5).trim();
134
- if (!json) continue;
135
- let evt;
136
- try { evt = JSON.parse(json); } catch { continue; }
137
- if (evt.kind === "delta") {
138
- textBuf += evt.content;
139
- if (onDelta) onDelta(evt.content);
140
- } else if (evt.kind === "tool_call_delta") {
141
- const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
142
- if (evt.id) slot.id = evt.id;
143
- if (evt.name) slot.name = evt.name;
144
- if (evt.args_delta) slot.args += evt.args_delta;
145
- toolBuf.set(evt.index, slot);
146
- if (onToolCallDelta) onToolCallDelta(evt);
147
- } else if (evt.kind === "finish") {
148
- finishReason = evt.reason;
149
- if (onFinish) onFinish(evt.reason);
150
- } else if (evt.kind === "done") {
151
- creditsCharged = evt.creditsCharged ?? 0;
152
- balanceAfter = evt.balanceAfter ?? null;
153
- usage = evt.usage ?? usage;
154
- } else if (evt.kind === "error") {
155
- streamError = evt.error;
156
- }
157
- }
158
- }
159
-
160
- if (streamError) {
161
- throw new AetherError(streamError, "STREAM_ERROR", 0);
162
- }
163
-
164
- // Assemble final tool_calls in stable index order
165
- const tool_calls = [...toolBuf.entries()]
166
- .sort((a, b) => a[0] - b[0])
167
- .map(([, slot]) => ({
168
- id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
169
- type: "function",
170
- function: { name: slot.name || "", arguments: slot.args || "" },
171
- }));
172
-
173
- return {
174
- message: {
175
- role: "assistant",
176
- content: textBuf || null,
177
- tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
178
- },
179
- finish_reason: finishReason,
180
- creditsCharged,
181
- balanceAfter,
182
- usage,
183
- };
184
- }
185
-
186
- /**
187
- * Non-streaming variant — kept as a fallback for environments where SSE is
188
- * problematic (corporate proxies, weird client setups). The CLI defaults to
189
- * streaming.
190
- */
191
- export async function agentTurn({ messages, tools, maxTokens, temperature }) {
192
- const { apiKey, baseUrl } = getConfig();
193
- if (!apiKey) {
194
- throw new AetherError(
195
- "No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
196
- "NO_API_KEY",
197
- 0,
198
- );
199
- }
200
-
201
- let res;
202
- try {
203
- res = await fetch(`${baseUrl}/api/v1/agent`, {
204
- method: "POST",
205
- headers: {
206
- "Content-Type": "application/json",
207
- Authorization: `Bearer ${apiKey}`,
208
- "User-Agent": "aether-code/0.1.0",
209
- },
210
- body: JSON.stringify({
211
- messages,
212
- tools,
213
- max_tokens: maxTokens,
214
- temperature,
215
- }),
216
- });
217
- } catch (e) {
218
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
219
- }
220
-
221
- let data = null;
222
- try {
223
- data = await res.json();
224
- } catch {
225
- /* non-JSON */
226
- }
227
-
228
- if (!res.ok) {
229
- const code = (data && data.code) || `HTTP_${res.status}`;
230
- const msg = (data && data.error) || `${res.status} ${res.statusText}`;
231
- throw new AetherError(msg, code, res.status, data);
232
- }
233
- return data;
234
- }
1
+ // API client.
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getConfig } from "./config.js";
7
+
8
+ // Resolve our own version once for the User-Agent header — read from
9
+ // package.json so it can't drift (the file previously sent three different
10
+ // hardcoded versions across three calls).
11
+ function readVersion() {
12
+ try {
13
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
14
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
15
+ } catch {
16
+ return "0";
17
+ }
18
+ }
19
+ const USER_AGENT = `aether-code/${readVersion()}`;
20
+
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+
23
+ /**
24
+ * fetch() with bounded exponential-backoff retries. Retries on thrown network
25
+ * errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
26
+ * (auth/credit/validation — a retry won't help). Body is always a small JSON
27
+ * string, so resending is safe. For the streaming endpoint only the initial
28
+ * connection is retried.
29
+ */
30
+ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
31
+ let attempt = 0;
32
+ while (true) {
33
+ try {
34
+ const res = await fetch(url, options);
35
+ if (res.status >= 500 && attempt < retries) {
36
+ attempt++;
37
+ if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
38
+ await sleep(baseDelay * 2 ** (attempt - 1));
39
+ continue;
40
+ }
41
+ return res;
42
+ } catch (e) {
43
+ if (attempt < retries) {
44
+ attempt++;
45
+ if (onRetry) onRetry(attempt, e.message);
46
+ await sleep(baseDelay * 2 ** (attempt - 1));
47
+ continue;
48
+ }
49
+ throw e;
50
+ }
51
+ }
52
+ }
53
+
54
+ function defaultOnRetry(attempt, why) {
55
+ process.stderr.write(` ⟳ connection issue (${why}) — retry ${attempt}…\n`);
56
+ }
57
+
58
+ /**
59
+ * Free balance + plan check via /api/v1/me. Doesn't charge credits.
60
+ */
61
+ export async function fetchBalance() {
62
+ const { apiKey, baseUrl } = getConfig();
63
+ if (!apiKey) {
64
+ throw new AetherError(
65
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
66
+ "NO_API_KEY",
67
+ 0,
68
+ );
69
+ }
70
+ let res;
71
+ try {
72
+ res = await fetchWithRetry(`${baseUrl}/api/v1/me`, {
73
+ method: "GET",
74
+ headers: {
75
+ Accept: "application/json",
76
+ Authorization: `Bearer ${apiKey}`,
77
+ "User-Agent": USER_AGENT,
78
+ },
79
+ }, { onRetry: defaultOnRetry });
80
+ } catch (e) {
81
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
82
+ }
83
+ let data = null;
84
+ try { data = await res.json(); } catch { /* non-JSON */ }
85
+ if (!res.ok) {
86
+ const code = data?.code || `HTTP_${res.status}`;
87
+ const msg = data?.error || `${res.status} ${res.statusText}`;
88
+ throw new AetherError(msg, code, res.status, data);
89
+ }
90
+ return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
91
+ }
92
+
93
+ export class AetherError extends Error {
94
+ constructor(message, code, status, data) {
95
+ super(message);
96
+ this.name = "AetherError";
97
+ this.code = code;
98
+ this.status = status;
99
+ this.data = data;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
105
+ * callbacks as events arrive. Returns the assembled assistant message + final
106
+ * usage/credit info once the stream ends.
107
+ *
108
+ * Event handlers (all optional):
109
+ * onDelta(text) — text fragment from the model
110
+ * onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
111
+ * onFinish(reason) per-choice finish reason ("stop" | "tool_calls" | "length")
112
+ *
113
+ * Returns:
114
+ * { message: { role:"assistant", content, tool_calls }, finish_reason,
115
+ * creditsCharged, balanceAfter, usage }
116
+ */
117
+ export async function agentTurnStream({
118
+ messages,
119
+ tools,
120
+ maxTokens,
121
+ temperature,
122
+ onDelta,
123
+ onToolCallDelta,
124
+ onFinish,
125
+ }) {
126
+ const { apiKey, baseUrl } = getConfig();
127
+ if (!apiKey) {
128
+ throw new AetherError(
129
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
130
+ "NO_API_KEY",
131
+ 0,
132
+ );
133
+ }
134
+
135
+ let res;
136
+ try {
137
+ res = await fetchWithRetry(`${baseUrl}/api/v1/agent/stream`, {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ Accept: "text/event-stream",
142
+ Authorization: `Bearer ${apiKey}`,
143
+ "User-Agent": USER_AGENT,
144
+ },
145
+ body: JSON.stringify({
146
+ messages,
147
+ tools,
148
+ max_tokens: maxTokens,
149
+ temperature,
150
+ }),
151
+ }, { onRetry: defaultOnRetry });
152
+ } catch (e) {
153
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
154
+ }
155
+
156
+ // Non-streaming JSON error response (auth, credit, validation failures)
157
+ if (!res.ok) {
158
+ let data = null;
159
+ try { data = await res.json(); } catch { /* non-JSON */ }
160
+ const code = data?.code || `HTTP_${res.status}`;
161
+ const msg = data?.error || `${res.status} ${res.statusText}`;
162
+ throw new AetherError(msg, code, res.status, data);
163
+ }
164
+ if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
165
+
166
+ // Accumulate streaming state
167
+ let textBuf = "";
168
+ const toolBuf = new Map(); // index -> { id, name, args }
169
+ let finishReason = "stop";
170
+ let creditsCharged = 0;
171
+ let balanceAfter = null;
172
+ let usage = { prompt_tokens: 0, completion_tokens: 0 };
173
+ let streamError = null;
174
+
175
+ const reader = res.body.getReader();
176
+ const decoder = new TextDecoder();
177
+ let buffer = "";
178
+ while (true) {
179
+ const { value, done } = await reader.read();
180
+ if (done) break;
181
+ buffer += decoder.decode(value, { stream: true });
182
+ const lines = buffer.split("\n");
183
+ buffer = lines.pop() ?? "";
184
+ for (const line of lines) {
185
+ if (!line.startsWith("data:")) continue;
186
+ const json = line.slice(5).trim();
187
+ if (!json) continue;
188
+ let evt;
189
+ try { evt = JSON.parse(json); } catch { continue; }
190
+ if (evt.kind === "delta") {
191
+ textBuf += evt.content;
192
+ if (onDelta) onDelta(evt.content);
193
+ } else if (evt.kind === "tool_call_delta") {
194
+ const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
195
+ if (evt.id) slot.id = evt.id;
196
+ if (evt.name) slot.name = evt.name;
197
+ if (evt.args_delta) slot.args += evt.args_delta;
198
+ toolBuf.set(evt.index, slot);
199
+ if (onToolCallDelta) onToolCallDelta(evt);
200
+ } else if (evt.kind === "finish") {
201
+ finishReason = evt.reason;
202
+ if (onFinish) onFinish(evt.reason);
203
+ } else if (evt.kind === "done") {
204
+ creditsCharged = evt.creditsCharged ?? 0;
205
+ balanceAfter = evt.balanceAfter ?? null;
206
+ usage = evt.usage ?? usage;
207
+ } else if (evt.kind === "error") {
208
+ streamError = evt.error;
209
+ }
210
+ }
211
+ }
212
+
213
+ if (streamError) {
214
+ throw new AetherError(streamError, "STREAM_ERROR", 0);
215
+ }
216
+
217
+ // Assemble final tool_calls in stable index order
218
+ const tool_calls = [...toolBuf.entries()]
219
+ .sort((a, b) => a[0] - b[0])
220
+ .map(([, slot]) => ({
221
+ id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
222
+ type: "function",
223
+ function: { name: slot.name || "", arguments: slot.args || "" },
224
+ }));
225
+
226
+ return {
227
+ message: {
228
+ role: "assistant",
229
+ content: textBuf || null,
230
+ tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
231
+ },
232
+ finish_reason: finishReason,
233
+ creditsCharged,
234
+ balanceAfter,
235
+ usage,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Non-streaming variant — kept as a fallback for environments where SSE is
241
+ * problematic (corporate proxies, weird client setups). The CLI defaults to
242
+ * streaming.
243
+ */
244
+ export async function agentTurn({ messages, tools, maxTokens, temperature }) {
245
+ const { apiKey, baseUrl } = getConfig();
246
+ if (!apiKey) {
247
+ throw new AetherError(
248
+ "No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
249
+ "NO_API_KEY",
250
+ 0,
251
+ );
252
+ }
253
+
254
+ let res;
255
+ try {
256
+ res = await fetchWithRetry(`${baseUrl}/api/v1/agent`, {
257
+ method: "POST",
258
+ headers: {
259
+ "Content-Type": "application/json",
260
+ Authorization: `Bearer ${apiKey}`,
261
+ "User-Agent": USER_AGENT,
262
+ },
263
+ body: JSON.stringify({
264
+ messages,
265
+ tools,
266
+ max_tokens: maxTokens,
267
+ temperature,
268
+ }),
269
+ }, { onRetry: defaultOnRetry });
270
+ } catch (e) {
271
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
272
+ }
273
+
274
+ let data = null;
275
+ try {
276
+ data = await res.json();
277
+ } catch {
278
+ /* non-JSON */
279
+ }
280
+
281
+ if (!res.ok) {
282
+ const code = (data && data.code) || `HTTP_${res.status}`;
283
+ const msg = (data && data.error) || `${res.status} ${res.statusText}`;
284
+ throw new AetherError(msg, code, res.status, data);
285
+ }
286
+ return data;
287
+ }
package/src/config.js CHANGED
@@ -1,38 +1,38 @@
1
- // Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
2
- // Same precedence: env wins over file.
3
-
4
- import fs from "node:fs";
5
- import path from "node:path";
6
- import os from "node:os";
7
-
8
- const FILE = path.join(os.homedir(), ".aetherrc");
9
- const DEFAULT_BASE = "https://trynoguard.com";
10
-
11
- export function readConfigFile() {
12
- try {
13
- const raw = fs.readFileSync(FILE, "utf8");
14
- return JSON.parse(raw);
15
- } catch {
16
- return {};
17
- }
18
- }
19
-
20
- export function writeConfigFile(patch) {
21
- const current = readConfigFile();
22
- const next = { ...current, ...patch };
23
- // 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
24
- // on credential files. Treat the API key like an SSH key.
25
- fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
26
- try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
27
- }
28
-
29
- export function getConfig() {
30
- const file = readConfigFile();
31
- return {
32
- apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
33
- baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
34
- configPath: FILE,
35
- };
36
- }
37
-
38
- export const CONFIG_PATH = FILE;
1
+ // Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
2
+ // Same precedence: env wins over file.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ const FILE = path.join(os.homedir(), ".aetherrc");
9
+ const DEFAULT_BASE = "https://trynoguard.com";
10
+
11
+ export function readConfigFile() {
12
+ try {
13
+ const raw = fs.readFileSync(FILE, "utf8");
14
+ return JSON.parse(raw);
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function writeConfigFile(patch) {
21
+ const current = readConfigFile();
22
+ const next = { ...current, ...patch };
23
+ // 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
24
+ // on credential files. Treat the API key like an SSH key.
25
+ fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
26
+ try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
27
+ }
28
+
29
+ export function getConfig() {
30
+ const file = readConfigFile();
31
+ return {
32
+ apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
33
+ baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
34
+ configPath: FILE,
35
+ };
36
+ }
37
+
38
+ export const CONFIG_PATH = FILE;