@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,222 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "./index.js";
|
|
2
|
+
import type { ChatChunk, HealthStatus, Message, ProviderConfig, ToolDef } from "../types.js";
|
|
3
|
+
import { Provider } from "./index.js";
|
|
4
|
+
|
|
5
|
+
function estimateTokens(text: string): number {
|
|
6
|
+
return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.3);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class OllamaProvider implements Provider {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly config: ProviderConfig;
|
|
12
|
+
private resolvedModel?: string;
|
|
13
|
+
|
|
14
|
+
constructor(config: ProviderConfig) {
|
|
15
|
+
this.name = config.name;
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private get base(): string {
|
|
20
|
+
return this.config.baseURL.replace(/\/$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async listModels(): Promise<string[]> {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchWithTimeout(`${this.base}/api/tags`, {}, this.config.timeoutMs);
|
|
26
|
+
if (!res.ok) return [];
|
|
27
|
+
const data = (await res.json()) as any;
|
|
28
|
+
return (data?.models ?? []).map((m: any) => m.name).filter(Boolean);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Query /api/tags and return the installed models together with their
|
|
36
|
+
* Ollama-reported capabilities (e.g. ["completion","tools","vision"]).
|
|
37
|
+
*/
|
|
38
|
+
async listModelsWithCapabilities(): Promise<Map<string, string[]>> {
|
|
39
|
+
const out = new Map<string, string[]>();
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetchWithTimeout(`${this.base}/api/tags`, {}, this.config.timeoutMs);
|
|
42
|
+
if (!res.ok) return out;
|
|
43
|
+
const data = (await res.json()) as any;
|
|
44
|
+
for (const m of data?.models ?? []) {
|
|
45
|
+
const name = m?.name;
|
|
46
|
+
if (!name) continue;
|
|
47
|
+
const caps = Array.isArray(m?.capabilities) ? m.capabilities.map(String) : [];
|
|
48
|
+
out.set(name, caps);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the actual model to use for a request.
|
|
58
|
+
*
|
|
59
|
+
* 1. If the configured model (config.models[0]) is installed locally, use it.
|
|
60
|
+
* 2. Otherwise fall back to the first installed model that supports tools
|
|
61
|
+
* (its capabilities include "tools") � this is what lets the agent call
|
|
62
|
+
* tools without failing over to cloud providers.
|
|
63
|
+
* 3. If no installed model supports tools, fall back to the first installed
|
|
64
|
+
* model, else a sane default.
|
|
65
|
+
*
|
|
66
|
+
* The result is cached so /api/tags is only hit once per provider instance.
|
|
67
|
+
*/
|
|
68
|
+
async resolveModel(): Promise<string> {
|
|
69
|
+
if (this.resolvedModel) return this.resolvedModel;
|
|
70
|
+
const configured = this.config.models[0];
|
|
71
|
+
const withCaps = await this.listModelsWithCapabilities();
|
|
72
|
+
const installed = Array.from(withCaps.keys());
|
|
73
|
+
|
|
74
|
+
let chosen: string;
|
|
75
|
+
if (configured && installed.includes(configured)) {
|
|
76
|
+
chosen = configured;
|
|
77
|
+
} else {
|
|
78
|
+
// Prefer a tool-capable installed model so the agent can call tools.
|
|
79
|
+
const toolCapable = installed.find((n) => (withCaps.get(n) ?? []).includes("tools"));
|
|
80
|
+
chosen = toolCapable ?? installed[0] ?? configured ?? "aether";
|
|
81
|
+
}
|
|
82
|
+
this.resolvedModel = chosen;
|
|
83
|
+
return chosen;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async health(): Promise<Omit<HealthStatus, "provider">> {
|
|
87
|
+
const start = Date.now();
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetchWithTimeout(`${this.base}/api/version`, {}, 5000);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: `HTTP ${res.status}`, circuitOpen: false, cooldownUntil: 0 };
|
|
92
|
+
}
|
|
93
|
+
return { healthy: true, failures: 0, lastCheck: start, circuitOpen: false, cooldownUntil: 0 };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: (err as Error).message, circuitOpen: false, cooldownUntil: 0 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async countTokens(text: string): Promise<number> {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetchWithTimeout(
|
|
102
|
+
`${this.base}/api/count`,
|
|
103
|
+
{
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
body: JSON.stringify({ text }),
|
|
107
|
+
},
|
|
108
|
+
5000
|
|
109
|
+
);
|
|
110
|
+
if (res.ok) {
|
|
111
|
+
const data = (await res.json()) as any;
|
|
112
|
+
if (typeof data?.count === "number") return data.count;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// fall back to estimation
|
|
116
|
+
}
|
|
117
|
+
return estimateTokens(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async *chat(
|
|
121
|
+
messages: Message[],
|
|
122
|
+
tools: ToolDef[],
|
|
123
|
+
opts?: { signal?: AbortSignal; temperature?: number; maxTokens?: number }
|
|
124
|
+
): AsyncIterable<ChatChunk> {
|
|
125
|
+
// Auto-detect the real installed model at runtime; fall back gracefully.
|
|
126
|
+
const model = await this.resolveModel();
|
|
127
|
+
const body: any = {
|
|
128
|
+
model,
|
|
129
|
+
messages: messages.map((m) => ({
|
|
130
|
+
role: m.role,
|
|
131
|
+
content: m.content,
|
|
132
|
+
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
133
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
134
|
+
...(m.name ? { name: m.name } : {}),
|
|
135
|
+
// Ollama vision: top-level `images` array of base64 strings on a message.
|
|
136
|
+
...(Array.isArray((m as any).images) && (m as any).images.length > 0
|
|
137
|
+
? { images: (m as any).images }
|
|
138
|
+
: {}),
|
|
139
|
+
})),
|
|
140
|
+
stream: true,
|
|
141
|
+
options: {
|
|
142
|
+
temperature: opts?.temperature ?? 0.7,
|
|
143
|
+
num_ctx: 4096,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
if (tools && tools.length > 0) {
|
|
147
|
+
body.tools = tools.map((t) => ({
|
|
148
|
+
type: "function",
|
|
149
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
if (opts?.maxTokens) {
|
|
153
|
+
body.options.num_predict = opts.maxTokens;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const res = await fetchWithTimeout(
|
|
157
|
+
`${this.base}/api/chat`,
|
|
158
|
+
{
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
signal: opts?.signal,
|
|
163
|
+
},
|
|
164
|
+
this.config.timeoutMs
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
const text = await res.text().catch(() => "");
|
|
169
|
+
throw new Error(`Ollama chat failed: HTTP ${res.status} ${text}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const reader = res.body?.getReader();
|
|
173
|
+
if (!reader) {
|
|
174
|
+
throw new Error("Ollama chat: no response body");
|
|
175
|
+
}
|
|
176
|
+
const decoder = new TextDecoder();
|
|
177
|
+
let buffer = "";
|
|
178
|
+
let inputTokens = 0;
|
|
179
|
+
let outputTokens = 0;
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
let idx = buffer.indexOf("\n");
|
|
186
|
+
while (idx !== -1) {
|
|
187
|
+
const line = buffer.slice(0, idx).trim();
|
|
188
|
+
buffer = buffer.slice(idx + 1);
|
|
189
|
+
if (line) {
|
|
190
|
+
const obj = JSON.parse(line);
|
|
191
|
+
if (obj?.message?.content) {
|
|
192
|
+
yield { type: "text", text: obj.message.content };
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(obj?.message?.tool_calls)) {
|
|
195
|
+
for (const tc of obj.message.tool_calls) {
|
|
196
|
+
yield {
|
|
197
|
+
type: "tool_call",
|
|
198
|
+
tool_call: {
|
|
199
|
+
id: tc.id || `call_${Date.now()}`,
|
|
200
|
+
type: "function",
|
|
201
|
+
function: {
|
|
202
|
+
name: tc.function.name,
|
|
203
|
+
arguments: tc.function.arguments ?? "",
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (typeof obj?.prompt_eval_count === "number") inputTokens = obj.prompt_eval_count;
|
|
210
|
+
if (typeof obj?.eval_count === "number") outputTokens = obj.eval_count;
|
|
211
|
+
if (obj?.done) {
|
|
212
|
+
yield {
|
|
213
|
+
type: "done",
|
|
214
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
idx = buffer.indexOf("\n");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "./index.js";
|
|
2
|
+
import type { ChatChunk, HealthStatus, Message, ProviderConfig, ToolCall, ToolDef } from "../types.js";
|
|
3
|
+
import { Provider } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export class OpenAICompatProvider implements Provider {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly config: ProviderConfig;
|
|
8
|
+
|
|
9
|
+
constructor(config: ProviderConfig) {
|
|
10
|
+
this.name = config.name;
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private get base(): string {
|
|
15
|
+
return this.config.baseURL.replace(/\/$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private headers(): Record<string, string> {
|
|
19
|
+
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
20
|
+
if (this.config.apiKey) {
|
|
21
|
+
h.Authorization = `Bearer ${this.config.apiKey}`;
|
|
22
|
+
}
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async listModels(): Promise<string[]> {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetchWithTimeout(`${this.base}/models`, { headers: this.headers() }, this.config.timeoutMs);
|
|
29
|
+
if (!res.ok) return [];
|
|
30
|
+
const data = (await res.json()) as any;
|
|
31
|
+
return (data?.data ?? []).map((m: any) => m.id).filter(Boolean);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async health(): Promise<Omit<HealthStatus, "provider">> {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetchWithTimeout(`${this.base}/models`, { headers: this.headers() }, 5000);
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: `HTTP ${res.status}`, circuitOpen: false, cooldownUntil: 0 };
|
|
43
|
+
}
|
|
44
|
+
return { healthy: true, failures: 0, lastCheck: start, circuitOpen: false, cooldownUntil: 0 };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: (err as Error).message, circuitOpen: false, cooldownUntil: 0 };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async *chat(
|
|
51
|
+
messages: Message[],
|
|
52
|
+
tools: ToolDef[],
|
|
53
|
+
opts?: { signal?: AbortSignal; temperature?: number; maxTokens?: number }
|
|
54
|
+
): AsyncIterable<ChatChunk> {
|
|
55
|
+
const body: any = {
|
|
56
|
+
model: this.config.models[0] || "gpt-4o-mini",
|
|
57
|
+
messages: messages.map((m) => ({
|
|
58
|
+
role: m.role,
|
|
59
|
+
content: m.content,
|
|
60
|
+
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
61
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
62
|
+
...(m.name ? { name: m.name } : {}),
|
|
63
|
+
})),
|
|
64
|
+
stream: true,
|
|
65
|
+
temperature: opts?.temperature ?? 0.7,
|
|
66
|
+
};
|
|
67
|
+
if (tools && tools.length > 0) {
|
|
68
|
+
body.tools = tools.map((t) => ({
|
|
69
|
+
type: "function",
|
|
70
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
if (opts?.maxTokens) {
|
|
74
|
+
body.max_tokens = opts.maxTokens;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const res = await fetchWithTimeout(
|
|
78
|
+
`${this.base}/chat/completions`,
|
|
79
|
+
{
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: this.headers(),
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
signal: opts?.signal,
|
|
84
|
+
},
|
|
85
|
+
this.config.timeoutMs
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const text = await res.text().catch(() => "");
|
|
90
|
+
throw new Error(`OpenAI-compatible chat failed: HTTP ${res.status} ${text}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const reader = res.body?.getReader();
|
|
94
|
+
if (!reader) {
|
|
95
|
+
throw new Error("OpenAI-compatible chat: no response body");
|
|
96
|
+
}
|
|
97
|
+
const decoder = new TextDecoder();
|
|
98
|
+
let buffer = "";
|
|
99
|
+
const toolParts: Record<number, { id?: string; name: string; arguments: string }> = {};
|
|
100
|
+
let inputTokens = 0;
|
|
101
|
+
let outputTokens = 0;
|
|
102
|
+
let sawDone = false;
|
|
103
|
+
|
|
104
|
+
const emitToolCalls = (): ChatChunk | null => {
|
|
105
|
+
const calls: ToolCall[] = [];
|
|
106
|
+
for (const idx of Object.keys(toolParts).map(Number).sort((a, b) => a - b)) {
|
|
107
|
+
const p = toolParts[idx];
|
|
108
|
+
if (!p) continue;
|
|
109
|
+
calls.push({
|
|
110
|
+
id: p.id || `call_${idx}`,
|
|
111
|
+
type: "function",
|
|
112
|
+
function: { name: p.name, arguments: p.arguments },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
Object.keys(toolParts).forEach((k) => delete toolParts[Number(k)]);
|
|
116
|
+
return calls.length ? { type: "tool_call", tool_call: calls[0] } : null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
while (true) {
|
|
120
|
+
const { done, value } = await reader.read();
|
|
121
|
+
if (done) break;
|
|
122
|
+
buffer += decoder.decode(value, { stream: true });
|
|
123
|
+
let idx = buffer.indexOf("\n");
|
|
124
|
+
while (idx !== -1) {
|
|
125
|
+
const rawLine = buffer.slice(0, idx);
|
|
126
|
+
buffer = buffer.slice(idx + 1);
|
|
127
|
+
const line = rawLine.trim();
|
|
128
|
+
if (!line) continue;
|
|
129
|
+
if (line === "data: [DONE]") {
|
|
130
|
+
sawDone = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!line.startsWith("data:")) continue;
|
|
134
|
+
const payload = line.slice(5).trim();
|
|
135
|
+
if (!payload) continue;
|
|
136
|
+
let obj: any;
|
|
137
|
+
try {
|
|
138
|
+
obj = JSON.parse(payload);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (obj?.error) {
|
|
143
|
+
throw new Error(`OpenAI-compatible error: ${JSON.stringify(obj.error)}`);
|
|
144
|
+
}
|
|
145
|
+
const choice = obj?.choices?.[0];
|
|
146
|
+
if (!choice) {
|
|
147
|
+
if (obj?.usage) {
|
|
148
|
+
inputTokens = obj.usage.prompt_tokens ?? inputTokens;
|
|
149
|
+
outputTokens = obj.usage.completion_tokens ?? outputTokens;
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const delta = choice.delta ?? {};
|
|
154
|
+
if (delta.content) {
|
|
155
|
+
yield { type: "text", text: delta.content };
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
158
|
+
for (const tc of delta.tool_calls) {
|
|
159
|
+
const i = tc.index ?? 0;
|
|
160
|
+
if (!toolParts[i]) toolParts[i] = { name: "", arguments: "" };
|
|
161
|
+
if (tc.id) toolParts[i].id = tc.id;
|
|
162
|
+
if (tc.function?.name) toolParts[i].name = tc.function.name;
|
|
163
|
+
if (tc.function?.arguments) toolParts[i].arguments += tc.function.arguments;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (obj?.usage) {
|
|
167
|
+
inputTokens = obj.usage.prompt_tokens ?? inputTokens;
|
|
168
|
+
outputTokens = obj.usage.completion_tokens ?? outputTokens;
|
|
169
|
+
}
|
|
170
|
+
if (choice.finish_reason) {
|
|
171
|
+
const tc = emitToolCalls();
|
|
172
|
+
if (tc) yield tc;
|
|
173
|
+
yield {
|
|
174
|
+
type: "done",
|
|
175
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
176
|
+
};
|
|
177
|
+
sawDone = true;
|
|
178
|
+
}
|
|
179
|
+
idx = buffer.indexOf("\n");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!sawDone) {
|
|
183
|
+
const tc = emitToolCalls();
|
|
184
|
+
if (tc) yield tc;
|
|
185
|
+
yield { type: "done", usage: { input_tokens: inputTokens, output_tokens: outputTokens } };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "./index.js";
|
|
2
|
+
import type { ChatChunk, HealthStatus, Message, ProviderConfig, ToolCall, ToolDef } from "../types.js";
|
|
3
|
+
import { Provider } from "./index.js";
|
|
4
|
+
|
|
5
|
+
const FREE_MARKERS = ["/free", "free-", "-free", "zero", "lite", "nemo"];
|
|
6
|
+
|
|
7
|
+
function isFree(id: string): boolean {
|
|
8
|
+
const lower = id.toLowerCase();
|
|
9
|
+
return FREE_MARKERS.some((m) => lower.includes(m));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class OpenRouterProvider implements Provider {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly config: ProviderConfig;
|
|
15
|
+
|
|
16
|
+
constructor(config: ProviderConfig) {
|
|
17
|
+
this.name = config.name;
|
|
18
|
+
this.config = config;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private get base(): string {
|
|
22
|
+
return this.config.baseURL.replace(/\/$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private headers(): Record<string, string> {
|
|
26
|
+
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
27
|
+
if (this.config.apiKey) {
|
|
28
|
+
h.Authorization = `Bearer ${this.config.apiKey}`;
|
|
29
|
+
}
|
|
30
|
+
h["HTTP-Referer"] = "https://github.com/kilocode/aether";
|
|
31
|
+
h["X-Title"] = "aether";
|
|
32
|
+
return h;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async listModels(): Promise<string[]> {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchWithTimeout(`${this.base}/models`, { headers: this.headers() }, this.config.timeoutMs);
|
|
38
|
+
if (!res.ok) return [];
|
|
39
|
+
const data = (await res.json()) as any;
|
|
40
|
+
const all: string[] = (data?.data ?? []).map((m: any) => m.id).filter(Boolean);
|
|
41
|
+
return all.filter(isFree);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async health(): Promise<Omit<HealthStatus, "provider">> {
|
|
48
|
+
const start = Date.now();
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetchWithTimeout(`${this.base}/models`, { headers: this.headers() }, 5000);
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: `HTTP ${res.status}`, circuitOpen: false, cooldownUntil: 0 };
|
|
53
|
+
}
|
|
54
|
+
return { healthy: true, failures: 0, lastCheck: start, circuitOpen: false, cooldownUntil: 0 };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: (err as Error).message, circuitOpen: false, cooldownUntil: 0 };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async *chat(
|
|
61
|
+
messages: Message[],
|
|
62
|
+
tools: ToolDef[],
|
|
63
|
+
opts?: { signal?: AbortSignal; temperature?: number; maxTokens?: number }
|
|
64
|
+
): AsyncIterable<ChatChunk> {
|
|
65
|
+
const body: any = {
|
|
66
|
+
model: this.config.models[0] || "openrouter/auto",
|
|
67
|
+
messages: messages.map((m) => ({
|
|
68
|
+
role: m.role,
|
|
69
|
+
content: m.content,
|
|
70
|
+
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
71
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
72
|
+
...(m.name ? { name: m.name } : {}),
|
|
73
|
+
})),
|
|
74
|
+
stream: true,
|
|
75
|
+
temperature: opts?.temperature ?? 0.7,
|
|
76
|
+
};
|
|
77
|
+
if (tools && tools.length > 0) {
|
|
78
|
+
body.tools = tools.map((t) => ({
|
|
79
|
+
type: "function",
|
|
80
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
if (opts?.maxTokens) {
|
|
84
|
+
body.max_tokens = opts.maxTokens;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const res = await fetchWithTimeout(
|
|
88
|
+
`${this.base}/chat/completions`,
|
|
89
|
+
{
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: this.headers(),
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
signal: opts?.signal,
|
|
94
|
+
},
|
|
95
|
+
this.config.timeoutMs
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const text = await res.text().catch(() => "");
|
|
100
|
+
throw new Error(`OpenRouter chat failed: HTTP ${res.status} ${text}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reader = res.body?.getReader();
|
|
104
|
+
if (!reader) {
|
|
105
|
+
throw new Error("OpenRouter chat: no response body");
|
|
106
|
+
}
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
let buffer = "";
|
|
109
|
+
const toolParts: Record<number, { id?: string; name: string; arguments: string }> = {};
|
|
110
|
+
let inputTokens = 0;
|
|
111
|
+
let outputTokens = 0;
|
|
112
|
+
let sawDone = false;
|
|
113
|
+
|
|
114
|
+
const emitToolCalls = (): ChatChunk | null => {
|
|
115
|
+
const calls: ToolCall[] = [];
|
|
116
|
+
for (const idx of Object.keys(toolParts).map(Number).sort((a, b) => a - b)) {
|
|
117
|
+
const p = toolParts[idx];
|
|
118
|
+
if (!p) continue;
|
|
119
|
+
calls.push({
|
|
120
|
+
id: p.id || `call_${idx}`,
|
|
121
|
+
type: "function",
|
|
122
|
+
function: { name: p.name, arguments: p.arguments },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
Object.keys(toolParts).forEach((k) => delete toolParts[Number(k)]);
|
|
126
|
+
return calls.length ? { type: "tool_call", tool_call: calls[0] } : null;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
while (true) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
buffer += decoder.decode(value, { stream: true });
|
|
133
|
+
let idx = buffer.indexOf("\n");
|
|
134
|
+
while (idx !== -1) {
|
|
135
|
+
const rawLine = buffer.slice(0, idx);
|
|
136
|
+
buffer = buffer.slice(idx + 1);
|
|
137
|
+
const line = rawLine.trim();
|
|
138
|
+
if (!line) continue;
|
|
139
|
+
if (line === "data: [DONE]") {
|
|
140
|
+
sawDone = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!line.startsWith("data:")) continue;
|
|
144
|
+
const payload = line.slice(5).trim();
|
|
145
|
+
if (!payload) continue;
|
|
146
|
+
let obj: any;
|
|
147
|
+
try {
|
|
148
|
+
obj = JSON.parse(payload);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (obj?.error) {
|
|
153
|
+
throw new Error(`OpenRouter error: ${JSON.stringify(obj.error)}`);
|
|
154
|
+
}
|
|
155
|
+
const choice = obj?.choices?.[0];
|
|
156
|
+
if (!choice) {
|
|
157
|
+
if (obj?.usage) {
|
|
158
|
+
inputTokens = obj.usage.prompt_tokens ?? inputTokens;
|
|
159
|
+
outputTokens = obj.usage.completion_tokens ?? outputTokens;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const delta = choice.delta ?? {};
|
|
164
|
+
if (delta.content) {
|
|
165
|
+
yield { type: "text", text: delta.content };
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
168
|
+
for (const tc of delta.tool_calls) {
|
|
169
|
+
const i = tc.index ?? 0;
|
|
170
|
+
if (!toolParts[i]) toolParts[i] = { name: "", arguments: "" };
|
|
171
|
+
if (tc.id) toolParts[i].id = tc.id;
|
|
172
|
+
if (tc.function?.name) toolParts[i].name = tc.function.name;
|
|
173
|
+
if (tc.function?.arguments) toolParts[i].arguments += tc.function.arguments;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (obj?.usage) {
|
|
177
|
+
inputTokens = obj.usage.prompt_tokens ?? inputTokens;
|
|
178
|
+
outputTokens = obj.usage.completion_tokens ?? outputTokens;
|
|
179
|
+
}
|
|
180
|
+
if (choice.finish_reason) {
|
|
181
|
+
const tc = emitToolCalls();
|
|
182
|
+
if (tc) yield tc;
|
|
183
|
+
yield {
|
|
184
|
+
type: "done",
|
|
185
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
186
|
+
};
|
|
187
|
+
sawDone = true;
|
|
188
|
+
}
|
|
189
|
+
idx = buffer.indexOf("\n");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!sawDone) {
|
|
193
|
+
const tc = emitToolCalls();
|
|
194
|
+
if (tc) yield tc;
|
|
195
|
+
yield { type: "done", usage: { input_tokens: inputTokens, output_tokens: outputTokens } };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|