@hemansubedi/aether-ai 1.0.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/.gitattributes +3 -0
- package/.github/workflows/live-stats.yml +42 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/update-preview.yml +41 -0
- package/INSTALL.md +59 -0
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/assets/aether-arena.svg +72 -0
- package/assets/aether-banner.svg +62 -0
- package/assets/aether-router.svg +129 -0
- package/dist/agent.js +125 -0
- package/dist/arena.js +486 -0
- package/dist/checkpoint.js +105 -0
- package/dist/client.js +95 -0
- package/dist/combos.js +176 -0
- package/dist/commands.js +483 -0
- package/dist/config.js +104 -0
- package/dist/cost.js +176 -0
- package/dist/git.js +52 -0
- package/dist/health.js +81 -0
- package/dist/index.js +272 -0
- package/dist/keys.js +128 -0
- package/dist/memory.js +98 -0
- package/dist/modes.js +68 -0
- package/dist/providers/index.js +32 -0
- package/dist/providers/ollama.js +206 -0
- package/dist/providers/openai-compat.js +181 -0
- package/dist/providers/openrouter.js +189 -0
- package/dist/providers/registry.js +211 -0
- package/dist/router-engine.js +200 -0
- package/dist/router.js +171 -0
- package/dist/server.js +210 -0
- package/dist/session.js +97 -0
- package/dist/settings.js +97 -0
- package/dist/skills.js +100 -0
- package/dist/tokensaver.js +50 -0
- package/dist/tools/filesystem.js +243 -0
- package/dist/tools/git.js +53 -0
- package/dist/tools/glob.js +175 -0
- package/dist/tools/grep.js +193 -0
- package/dist/tools/registry.js +39 -0
- package/dist/tools/vision.js +140 -0
- package/dist/tools/websearch.js +118 -0
- package/dist/tui.js +562 -0
- package/dist/types.js +8 -0
- package/docs/preview.txt +51 -0
- package/docs/screenshots.md +110 -0
- package/docs/stats.md +5 -0
- package/install.ps1 +170 -0
- package/install.sh +196 -0
- package/package.json +34 -0
- package/scripts/generate-stats-card.ts +62 -0
- package/scripts/patch_index.ps1 +17 -0
- package/scripts/release.sh +7 -0
- package/src/agent.ts +146 -0
- package/src/arena.ts +584 -0
- package/src/checkpoint.ts +111 -0
- package/src/client.ts +172 -0
- package/src/combos.ts +199 -0
- package/src/commands.ts +973 -0
- package/src/config.ts +122 -0
- package/src/cost.ts +206 -0
- package/src/git.ts +68 -0
- package/src/health.ts +90 -0
- package/src/index.ts +281 -0
- package/src/keys.ts +135 -0
- package/src/memory.ts +101 -0
- package/src/modes.ts +84 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/ollama.ts +222 -0
- package/src/providers/openai-compat.ts +188 -0
- package/src/providers/openrouter.ts +198 -0
- package/src/providers/registry.ts +223 -0
- package/src/router-engine.ts +214 -0
- package/src/router.ts +195 -0
- package/src/server.ts +242 -0
- package/src/session.ts +111 -0
- package/src/settings.ts +125 -0
- package/src/skills.ts +106 -0
- package/src/tokensaver.ts +57 -0
- package/src/tools/filesystem.ts +258 -0
- package/src/tools/git.ts +53 -0
- package/src/tools/glob.ts +180 -0
- package/src/tools/grep.ts +192 -0
- package/src/tools/registry.ts +54 -0
- package/src/tools/vision.ts +152 -0
- package/src/tools/websearch.ts +130 -0
- package/src/tui.ts +664 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { HealthTracker } from "./health.js";
|
|
2
|
+
import { createProvider } from "./providers/index.js";
|
|
3
|
+
import { PROVIDER_REGISTRY } from "./providers/registry.js";
|
|
4
|
+
import { KeyManager, ENV_MAP } from "./keys.js";
|
|
5
|
+
export class RouterEngine {
|
|
6
|
+
health;
|
|
7
|
+
cache = new Map();
|
|
8
|
+
configs;
|
|
9
|
+
keyManager;
|
|
10
|
+
activeModel;
|
|
11
|
+
constructor(providers, health, keyManager) {
|
|
12
|
+
this.health = health ?? new HealthTracker();
|
|
13
|
+
this.keyManager = keyManager ?? KeyManager.instance();
|
|
14
|
+
this.configs = (providers ?? PROVIDER_REGISTRY).map((p) => ({
|
|
15
|
+
name: p.name,
|
|
16
|
+
type: p.type,
|
|
17
|
+
baseURL: p.baseURL,
|
|
18
|
+
apiKey: this.keyManager.get(p.name) ?? process.env[ENV_MAP[p.name] ?? ""],
|
|
19
|
+
models: p.models,
|
|
20
|
+
priority: p.priority,
|
|
21
|
+
enabled: p.enabled,
|
|
22
|
+
maxRetries: p.maxRetries,
|
|
23
|
+
timeoutMs: p.timeoutMs,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
get configs_() { return this.configs; }
|
|
27
|
+
get healthTracker() { return this.health; }
|
|
28
|
+
get keys() { return this.keyManager; }
|
|
29
|
+
/** Update the key for a provider and invalidate the cached provider so the
|
|
30
|
+
* new credential takes effect on the next request. */
|
|
31
|
+
setKey(name, key) {
|
|
32
|
+
this.keyManager.set(name, key);
|
|
33
|
+
const cfg = this.configs.find((c) => c.name === name);
|
|
34
|
+
if (cfg)
|
|
35
|
+
cfg.apiKey = key.trim() || undefined;
|
|
36
|
+
this.cache.delete(name);
|
|
37
|
+
}
|
|
38
|
+
/** Activate a combo: rebuild the router's active provider list from the
|
|
39
|
+
* combo's providers (in order), set the active model to the combo's first
|
|
40
|
+
* model, and invalidate the provider cache so the new providers load. */
|
|
41
|
+
applyCombo(combo) {
|
|
42
|
+
this.configs = (combo.providers ?? [])
|
|
43
|
+
.map((name) => {
|
|
44
|
+
const p = PROVIDER_REGISTRY.find((x) => x.name === name);
|
|
45
|
+
if (!p)
|
|
46
|
+
return undefined;
|
|
47
|
+
return {
|
|
48
|
+
name: p.name,
|
|
49
|
+
type: p.type,
|
|
50
|
+
baseURL: p.baseURL,
|
|
51
|
+
apiKey: this.keyManager.get(p.name) ?? process.env[ENV_MAP[p.name] ?? ""],
|
|
52
|
+
models: p.models,
|
|
53
|
+
priority: p.priority,
|
|
54
|
+
enabled: p.enabled,
|
|
55
|
+
maxRetries: p.maxRetries,
|
|
56
|
+
timeoutMs: p.timeoutMs,
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
.filter((c) => Boolean(c));
|
|
60
|
+
this.cache.clear();
|
|
61
|
+
this.activeModel = combo.models?.[0];
|
|
62
|
+
const provs = this.configs.map((c) => c.name).join(", ") || "(none)";
|
|
63
|
+
return `Combo "${combo.name}" active: ${this.configs.length} provider(s) [${provs}], model: ${this.activeModel ?? "(none)"}`;
|
|
64
|
+
}
|
|
65
|
+
async getProvider(cfg) {
|
|
66
|
+
const cached = this.cache.get(cfg.name);
|
|
67
|
+
if (cached)
|
|
68
|
+
return cached;
|
|
69
|
+
const p = await createProvider(cfg);
|
|
70
|
+
this.cache.set(cfg.name, p);
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
sorted() {
|
|
74
|
+
return [...this.configs].filter((p) => p.enabled).sort((a, b) => a.priority - b.priority);
|
|
75
|
+
}
|
|
76
|
+
async *chatStream(messages, tools, opts) {
|
|
77
|
+
const order = this.sorted().filter((p) => !opts?.excludeProviders?.includes(p.name));
|
|
78
|
+
if (order.length === 0) {
|
|
79
|
+
yield { type: "error", error: "No providers configured" };
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let lastError = "";
|
|
83
|
+
for (const cfg of order) {
|
|
84
|
+
if (!this.health.isAvailable(cfg.name))
|
|
85
|
+
continue;
|
|
86
|
+
const provider = await this.getProvider(cfg);
|
|
87
|
+
const model = cfg.models[0] ?? "";
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
try {
|
|
90
|
+
let usage;
|
|
91
|
+
for await (const chunk of provider.chat(messages, tools, {
|
|
92
|
+
signal: opts?.signal,
|
|
93
|
+
temperature: opts?.temperature,
|
|
94
|
+
maxTokens: opts?.maxTokens,
|
|
95
|
+
})) {
|
|
96
|
+
if (chunk.type === "done")
|
|
97
|
+
usage = chunk.usage;
|
|
98
|
+
if (chunk.type === "error" && chunk.error)
|
|
99
|
+
throw new Error(chunk.error);
|
|
100
|
+
yield chunk;
|
|
101
|
+
}
|
|
102
|
+
this.health.recordSuccess(cfg.name);
|
|
103
|
+
if (!usage)
|
|
104
|
+
yield { type: "done", usage };
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = err.message;
|
|
109
|
+
lastError = msg;
|
|
110
|
+
this.health.recordFailure(cfg.name, msg);
|
|
111
|
+
if (opts?.signal?.aborted) {
|
|
112
|
+
yield { type: "error", error: `Aborted: ${msg}` };
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
yield {
|
|
116
|
+
type: "error",
|
|
117
|
+
error: `Provider ${cfg.name} failed (${msg}); failing over...`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
yield {
|
|
122
|
+
type: "error",
|
|
123
|
+
error: `All ${order.length} providers failed. Last: ${lastError || "unknown"}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async chat(messages, tools, opts) {
|
|
127
|
+
const order = this.sorted().filter((p) => !opts?.excludeProviders?.includes(p.name));
|
|
128
|
+
const attempts = [];
|
|
129
|
+
let lastError = "";
|
|
130
|
+
for (const cfg of order) {
|
|
131
|
+
if (!this.health.isAvailable(cfg.name))
|
|
132
|
+
continue;
|
|
133
|
+
const provider = await this.getProvider(cfg);
|
|
134
|
+
const model = cfg.models[0] ?? "";
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
try {
|
|
137
|
+
let text = "", toolCalls = [], usage;
|
|
138
|
+
for await (const chunk of provider.chat(messages, tools, { signal: opts?.signal, temperature: opts?.temperature, maxTokens: opts?.maxTokens })) {
|
|
139
|
+
if (chunk.type === "text" && chunk.text)
|
|
140
|
+
text += chunk.text;
|
|
141
|
+
if (chunk.type === "tool_call" && chunk.tool_call)
|
|
142
|
+
toolCalls.push(chunk.tool_call);
|
|
143
|
+
if (chunk.type === "done")
|
|
144
|
+
usage = chunk.usage;
|
|
145
|
+
if (chunk.type === "error" && chunk.error)
|
|
146
|
+
throw new Error(chunk.error);
|
|
147
|
+
}
|
|
148
|
+
this.health.recordSuccess(cfg.name);
|
|
149
|
+
attempts.push({ provider: cfg.name, model, ok: true, latencyMs: Date.now() - start });
|
|
150
|
+
return { text, toolCalls, usage, attempts, provider: cfg.name, model };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const msg = err.message;
|
|
154
|
+
lastError = msg;
|
|
155
|
+
this.health.recordFailure(cfg.name, msg);
|
|
156
|
+
attempts.push({ provider: cfg.name, model, ok: false, error: msg, latencyMs: Date.now() - start });
|
|
157
|
+
if (opts?.signal?.aborted)
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`All ${order.length} providers failed. Last: ${lastError}. Attempts: ${JSON.stringify(attempts)}`);
|
|
162
|
+
}
|
|
163
|
+
async listFreeModels() {
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const cfg of this.sorted()) {
|
|
166
|
+
try {
|
|
167
|
+
const p = await this.getProvider(cfg);
|
|
168
|
+
out[cfg.name] = await p.listModels();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
out[cfg.name] = cfg.models;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
async healthAll() {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const cfg of this.configs) {
|
|
179
|
+
if (!cfg.enabled)
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
const provider = await this.getProvider(cfg);
|
|
183
|
+
const status = await provider.health();
|
|
184
|
+
out.push({ provider: cfg.name, ...status });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
out.push({
|
|
188
|
+
provider: cfg.name,
|
|
189
|
+
healthy: false,
|
|
190
|
+
failures: 0,
|
|
191
|
+
lastCheck: 0,
|
|
192
|
+
circuitOpen: false,
|
|
193
|
+
cooldownUntil: 0,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
resetHealth() { this.health.resetAll(); }
|
|
200
|
+
}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createProvider } from "./providers/index.js";
|
|
2
|
+
export class Router {
|
|
3
|
+
configs;
|
|
4
|
+
health;
|
|
5
|
+
cache = new Map();
|
|
6
|
+
modelCache = new Map();
|
|
7
|
+
activeProvider;
|
|
8
|
+
activeModel;
|
|
9
|
+
constructor(providers, health) {
|
|
10
|
+
this.configs = providers;
|
|
11
|
+
this.health = health;
|
|
12
|
+
}
|
|
13
|
+
async getProvider(config) {
|
|
14
|
+
const cached = this.cache.get(config.name);
|
|
15
|
+
if (cached)
|
|
16
|
+
return cached;
|
|
17
|
+
const p = await createProvider(config);
|
|
18
|
+
this.cache.set(config.name, p);
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
sortedConfigs() {
|
|
22
|
+
return [...this.configs]
|
|
23
|
+
.filter((p) => p.enabled)
|
|
24
|
+
.sort((a, b) => a.priority - b.priority);
|
|
25
|
+
}
|
|
26
|
+
findConfig(name) {
|
|
27
|
+
if (!name)
|
|
28
|
+
return undefined;
|
|
29
|
+
return this.configs.find((c) => c.name === name);
|
|
30
|
+
}
|
|
31
|
+
resolveModel(modelId) {
|
|
32
|
+
const exact = this.configs.find((c) => c.models.includes(modelId));
|
|
33
|
+
if (exact)
|
|
34
|
+
return { provider: exact, model: modelId };
|
|
35
|
+
const first = this.sortedConfigs()[0];
|
|
36
|
+
return first ? { provider: first, model: modelId } : null;
|
|
37
|
+
}
|
|
38
|
+
async getModelProvider(modelId) {
|
|
39
|
+
const cached = this.modelCache.get(modelId);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const cfg = this.configs.find((c) => c.models.includes(modelId)) || this.sortedConfigs()[0];
|
|
43
|
+
if (!cfg)
|
|
44
|
+
throw new Error(`No provider configured to serve model "${modelId}"`);
|
|
45
|
+
const override = { ...cfg, models: [modelId] };
|
|
46
|
+
const p = await createProvider(override);
|
|
47
|
+
this.modelCache.set(modelId, p);
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
select() {
|
|
51
|
+
const cfg = this.findConfig(this.activeProvider) || this.sortedConfigs()[0];
|
|
52
|
+
if (cfg && this.health.isAvailable(cfg.name)) {
|
|
53
|
+
return {
|
|
54
|
+
provider: cfg.name,
|
|
55
|
+
model: this.activeModel || cfg.models[0] || "",
|
|
56
|
+
reason: this.activeProvider ? `selected (active: ${this.activeProvider})` : `selected by priority ${cfg.priority}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
for (const c of this.sortedConfigs()) {
|
|
60
|
+
if (this.health.isAvailable(c.name)) {
|
|
61
|
+
return { provider: c.name, model: this.activeModel || c.models[0] || "", reason: "fallback" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
setActiveProvider(name) {
|
|
67
|
+
this.activeProvider = name;
|
|
68
|
+
}
|
|
69
|
+
setActiveModel(model) {
|
|
70
|
+
this.activeModel = model;
|
|
71
|
+
const cfg = this.findConfig(this.activeProvider) || this.sortedConfigs()[0];
|
|
72
|
+
if (cfg) {
|
|
73
|
+
cfg.models = [model];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
getActiveProvider() {
|
|
77
|
+
return this.activeProvider;
|
|
78
|
+
}
|
|
79
|
+
getActiveModel() {
|
|
80
|
+
return this.activeModel;
|
|
81
|
+
}
|
|
82
|
+
getProviderNames() {
|
|
83
|
+
return this.configs.filter((c) => c.enabled).map((c) => c.name);
|
|
84
|
+
}
|
|
85
|
+
getModelsFor(name) {
|
|
86
|
+
const cfg = name
|
|
87
|
+
? this.configs.find((c) => c.name === name)
|
|
88
|
+
: this.activeProvider
|
|
89
|
+
? this.configs.find((c) => c.name === this.activeProvider)
|
|
90
|
+
: this.sortedConfigs()[0];
|
|
91
|
+
return cfg?.models ?? [];
|
|
92
|
+
}
|
|
93
|
+
async *chat(messages, tools, opts) {
|
|
94
|
+
const order = this.sortedConfigs();
|
|
95
|
+
if (order.length === 0) {
|
|
96
|
+
yield { type: "error", error: "No providers configured" };
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let lastError = null;
|
|
100
|
+
for (const cfg of order) {
|
|
101
|
+
if (!this.health.isAvailable(cfg.name))
|
|
102
|
+
continue;
|
|
103
|
+
const provider = await this.getProvider(cfg);
|
|
104
|
+
try {
|
|
105
|
+
for await (const chunk of provider.chat(messages, tools, {
|
|
106
|
+
signal: opts?.signal,
|
|
107
|
+
temperature: opts?.temperature,
|
|
108
|
+
maxTokens: opts?.maxTokens,
|
|
109
|
+
})) {
|
|
110
|
+
if (chunk.type === "error" && chunk.error) {
|
|
111
|
+
throw new Error(chunk.error);
|
|
112
|
+
}
|
|
113
|
+
yield chunk;
|
|
114
|
+
}
|
|
115
|
+
this.health.recordSuccess(cfg.name);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
lastError = err;
|
|
120
|
+
this.health.recordFailure(cfg.name, lastError?.message);
|
|
121
|
+
if (opts?.signal?.aborted) {
|
|
122
|
+
yield { type: "error", error: `Aborted: ${lastError.message}` };
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
yield {
|
|
126
|
+
type: "error",
|
|
127
|
+
error: `Provider ${cfg.name} failed (${lastError.message}); failing over...`,
|
|
128
|
+
};
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
yield {
|
|
133
|
+
type: "error",
|
|
134
|
+
error: `All providers failed. Last error: ${lastError?.message ?? "unknown"}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async listAllModels() {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const cfg of this.sortedConfigs()) {
|
|
140
|
+
try {
|
|
141
|
+
const provider = await this.getProvider(cfg);
|
|
142
|
+
out[cfg.name] = await provider.listModels();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
out[cfg.name] = [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
async healthAll() {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const cfg of this.configs) {
|
|
153
|
+
try {
|
|
154
|
+
const provider = await this.getProvider(cfg);
|
|
155
|
+
const status = await provider.health();
|
|
156
|
+
out.push({ provider: cfg.name, ...status });
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
out.push({
|
|
160
|
+
provider: cfg.name,
|
|
161
|
+
healthy: false,
|
|
162
|
+
failures: 0,
|
|
163
|
+
lastCheck: 0,
|
|
164
|
+
circuitOpen: false,
|
|
165
|
+
cooldownUntil: 0,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { RouterEngine } from "./router-engine.js";
|
|
5
|
+
function parseJsonBody(req) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
req.on("data", (c) => chunks.push(c));
|
|
9
|
+
req.on("end", () => {
|
|
10
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
11
|
+
if (!raw)
|
|
12
|
+
return resolve({});
|
|
13
|
+
try {
|
|
14
|
+
resolve(JSON.parse(raw));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
reject(new Error("Invalid JSON body"));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
req.on("error", reject);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function sendJson(res, status, body) {
|
|
24
|
+
const payload = JSON.stringify(body);
|
|
25
|
+
res.writeHead(status, {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"Content-Length": Buffer.byteLength(payload).toString(),
|
|
28
|
+
});
|
|
29
|
+
res.end(payload);
|
|
30
|
+
}
|
|
31
|
+
function sendSSE(res, chunk) {
|
|
32
|
+
const data = JSON.stringify(chunk);
|
|
33
|
+
res.write(`data: ${data}\n\n`);
|
|
34
|
+
}
|
|
35
|
+
async function writeSSEStream(res, gen) {
|
|
36
|
+
try {
|
|
37
|
+
for await (const chunk of gen) {
|
|
38
|
+
if (res.writableEnded)
|
|
39
|
+
break;
|
|
40
|
+
sendSSE(res, chunk);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (!res.writableEnded) {
|
|
45
|
+
res.write("data: [DONE]\n\n");
|
|
46
|
+
res.end();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function modelsToList(models) {
|
|
51
|
+
const data = [];
|
|
52
|
+
for (const [provider, list] of Object.entries(models)) {
|
|
53
|
+
for (const id of list) {
|
|
54
|
+
data.push({ id, object: "model", owned_by: provider });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { object: "list", data };
|
|
58
|
+
}
|
|
59
|
+
export function createServer(opts = {}) {
|
|
60
|
+
const engine = opts.engine ?? new RouterEngine();
|
|
61
|
+
const server = http.createServer(async (req, res) => {
|
|
62
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
63
|
+
const path = url.pathname;
|
|
64
|
+
const method = req.method ?? "GET";
|
|
65
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
66
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
67
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
68
|
+
if (method === "OPTIONS") {
|
|
69
|
+
res.writeHead(204);
|
|
70
|
+
res.end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
if (method === "GET" && path === "/health") {
|
|
75
|
+
const [all, healthies] = await Promise.all([
|
|
76
|
+
engine.listFreeModels(),
|
|
77
|
+
engine.healthAll(),
|
|
78
|
+
]);
|
|
79
|
+
const providerCount = Object.keys(all).length;
|
|
80
|
+
const healthyCount = healthies.filter((h) => h.healthy).length;
|
|
81
|
+
return sendJson(res, 200, {
|
|
82
|
+
status: "ok",
|
|
83
|
+
providers: providerCount,
|
|
84
|
+
healthy: healthyCount,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (method === "GET" && (path === "/v1/models" || path === "/models")) {
|
|
88
|
+
const models = await engine.listFreeModels();
|
|
89
|
+
return sendJson(res, 200, modelsToList(models));
|
|
90
|
+
}
|
|
91
|
+
if (method === "GET" && path === "/providers") {
|
|
92
|
+
const statuses = await engine.healthAll();
|
|
93
|
+
return sendJson(res, 200, statuses);
|
|
94
|
+
}
|
|
95
|
+
if (method === "POST" && path === "/reset-health") {
|
|
96
|
+
engine.resetHealth();
|
|
97
|
+
return sendJson(res, 200, { status: "ok", message: "Health state reset" });
|
|
98
|
+
}
|
|
99
|
+
if (method === "POST" && path === "/v1/chat/completions") {
|
|
100
|
+
const body = await parseJsonBody(req);
|
|
101
|
+
const { model, messages, tools, stream, temperature, max_tokens } = body ?? {};
|
|
102
|
+
if (!Array.isArray(messages)) {
|
|
103
|
+
return sendJson(res, 400, { error: { message: "messages array is required", type: "invalid_request_error" } });
|
|
104
|
+
}
|
|
105
|
+
const opts = {
|
|
106
|
+
temperature: typeof temperature === "number" ? temperature : undefined,
|
|
107
|
+
maxTokens: typeof max_tokens === "number" ? max_tokens : undefined,
|
|
108
|
+
};
|
|
109
|
+
if (stream) {
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
"Content-Type": "text/event-stream",
|
|
112
|
+
"Cache-Control": "no-cache",
|
|
113
|
+
Connection: "keep-alive",
|
|
114
|
+
});
|
|
115
|
+
const gen = engine.chatStream(messages, tools ?? [], opts);
|
|
116
|
+
await writeSSEStream(res, gen);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const result = await engine.chat(messages, tools ?? [], opts);
|
|
120
|
+
const chosenModel = model || result.model;
|
|
121
|
+
return sendJson(res, 200, {
|
|
122
|
+
id: `chatcmpl-${Date.now()}`,
|
|
123
|
+
object: "chat.completion",
|
|
124
|
+
created: Math.floor(Date.now() / 1000),
|
|
125
|
+
model: chosenModel,
|
|
126
|
+
choices: [
|
|
127
|
+
{
|
|
128
|
+
index: 0,
|
|
129
|
+
message: {
|
|
130
|
+
role: "assistant",
|
|
131
|
+
content: result.text,
|
|
132
|
+
...(result.toolCalls.length
|
|
133
|
+
? { tool_calls: result.toolCalls.map((tc) => ({
|
|
134
|
+
id: tc.id || `call_${Date.now()}`,
|
|
135
|
+
type: "function",
|
|
136
|
+
function: { name: tc.function.name, arguments: tc.function.arguments ?? "" },
|
|
137
|
+
})) }
|
|
138
|
+
: {}),
|
|
139
|
+
},
|
|
140
|
+
finish_reason: result.toolCalls.length ? "tool_calls" : "stop",
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
usage: result.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
144
|
+
_aether: {
|
|
145
|
+
attempts: result.attempts,
|
|
146
|
+
provider: result.provider,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (method === "POST" && path === "/v1/chat") {
|
|
151
|
+
const body = await parseJsonBody(req);
|
|
152
|
+
const { model, messages, tools, temperature, max_tokens } = body ?? {};
|
|
153
|
+
if (!Array.isArray(messages)) {
|
|
154
|
+
return sendJson(res, 400, { error: "messages array is required" });
|
|
155
|
+
}
|
|
156
|
+
const result = await engine.chat(messages, tools ?? [], {
|
|
157
|
+
temperature: typeof temperature === "number" ? temperature : undefined,
|
|
158
|
+
maxTokens: typeof max_tokens === "number" ? max_tokens : undefined,
|
|
159
|
+
});
|
|
160
|
+
return sendJson(res, 200, {
|
|
161
|
+
text: result.text,
|
|
162
|
+
toolCalls: result.toolCalls,
|
|
163
|
+
usage: result.usage,
|
|
164
|
+
attempts: result.attempts,
|
|
165
|
+
provider: result.provider,
|
|
166
|
+
model: model || result.model,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
sendJson(res, 404, { error: { message: `Not found: ${method} ${path}`, type: "not_found_error" } });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const msg = err.message;
|
|
173
|
+
sendJson(res, 500, { error: { message: msg, type: "internal_error" } });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return server;
|
|
177
|
+
}
|
|
178
|
+
export function startServer(opts = {}) {
|
|
179
|
+
const envPort = Number(process.env.AETHER_PORT);
|
|
180
|
+
const port = opts.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : 31415);
|
|
181
|
+
const host = opts.host ?? "0.0.0.0";
|
|
182
|
+
const engine = opts.engine ?? new RouterEngine();
|
|
183
|
+
const server = createServer({ ...opts, engine });
|
|
184
|
+
server.listen(port, host, () => {
|
|
185
|
+
const providerCount = engine.configs_.filter((c) => c.enabled).length;
|
|
186
|
+
console.log(`Aether free-model server running at http://localhost:${port}`);
|
|
187
|
+
console.log(`Endpoints:`);
|
|
188
|
+
console.log(` GET /health`);
|
|
189
|
+
console.log(` GET /v1/models`);
|
|
190
|
+
console.log(` POST /v1/chat/completions`);
|
|
191
|
+
console.log(` POST /v1/chat`);
|
|
192
|
+
console.log(` GET /providers`);
|
|
193
|
+
console.log(` POST /reset-health`);
|
|
194
|
+
console.log(`Providers: ${providerCount} configured`);
|
|
195
|
+
});
|
|
196
|
+
return server;
|
|
197
|
+
}
|
|
198
|
+
if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
|
|
199
|
+
try {
|
|
200
|
+
const server = startServer();
|
|
201
|
+
server.on("error", (err) => {
|
|
202
|
+
console.error("Failed to start server:", err);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.error("Failed to start server:", err);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { compressHistory } from "./tokensaver.js";
|
|
5
|
+
const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".aether", "sessions");
|
|
6
|
+
export class Session {
|
|
7
|
+
messages = [];
|
|
8
|
+
createdAt = Date.now();
|
|
9
|
+
updatedAt = Date.now();
|
|
10
|
+
add(m) {
|
|
11
|
+
this.messages.push(m);
|
|
12
|
+
this.updatedAt = Date.now();
|
|
13
|
+
}
|
|
14
|
+
clear() {
|
|
15
|
+
this.messages = [];
|
|
16
|
+
this.updatedAt = Date.now();
|
|
17
|
+
}
|
|
18
|
+
get size() {
|
|
19
|
+
return this.messages.length;
|
|
20
|
+
}
|
|
21
|
+
toJSON() {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
createdAt: this.createdAt,
|
|
25
|
+
updatedAt: this.updatedAt,
|
|
26
|
+
messages: this.messages,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
fromJSON(obj) {
|
|
30
|
+
this.messages = obj.messages ?? [];
|
|
31
|
+
this.createdAt = obj.createdAt ?? Date.now();
|
|
32
|
+
this.updatedAt = obj.updatedAt ?? Date.now();
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
summarizeOld(router, keepRecent = 6) {
|
|
36
|
+
// Keep the most recent messages and compress older ones via tokensaver.
|
|
37
|
+
// If a router is available, a weak model could be asked to summarize the
|
|
38
|
+
// dropped tail; we keep it simple and fall back to compressHistory.
|
|
39
|
+
if (this.messages.length <= keepRecent) {
|
|
40
|
+
return this.messages.slice();
|
|
41
|
+
}
|
|
42
|
+
const recent = this.messages.slice(-keepRecent);
|
|
43
|
+
const older = this.messages.slice(0, -keepRecent);
|
|
44
|
+
const compressed = compressHistory(older, 4096);
|
|
45
|
+
return [...compressed, ...recent];
|
|
46
|
+
}
|
|
47
|
+
static load(filePath) {
|
|
48
|
+
const session = new Session();
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.existsSync(filePath))
|
|
51
|
+
return session;
|
|
52
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
53
|
+
const obj = JSON.parse(raw);
|
|
54
|
+
session.fromJSON(obj);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore malformed sessions
|
|
58
|
+
}
|
|
59
|
+
return session;
|
|
60
|
+
}
|
|
61
|
+
static save(filePath, session) {
|
|
62
|
+
const dir = path.dirname(filePath);
|
|
63
|
+
if (!fs.existsSync(dir)) {
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
const tmp = filePath + ".tmp";
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(session.toJSON(), null, 2), "utf8");
|
|
68
|
+
fs.renameSync(tmp, filePath);
|
|
69
|
+
}
|
|
70
|
+
static list(dir = DEFAULT_SESSION_DIR) {
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(dir))
|
|
73
|
+
return [];
|
|
74
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (!e.isFile())
|
|
78
|
+
continue;
|
|
79
|
+
if (!e.name.endsWith(".json"))
|
|
80
|
+
continue;
|
|
81
|
+
const full = path.join(dir, e.name);
|
|
82
|
+
try {
|
|
83
|
+
const st = fs.statSync(full);
|
|
84
|
+
out.push({ file: full, mtime: st.mtimeMs, size: st.size });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// skip
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|