@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
package/dist/keys.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// API key manager for Aether.
|
|
2
|
+
//
|
|
3
|
+
// WARNING: Keys are stored PLAINTEXT in ~/.aether/keys.json on this machine.
|
|
4
|
+
// This is intentional for a local CLI tool running on your own machine --
|
|
5
|
+
// anyone with read access to that file can use your keys. Do not share the
|
|
6
|
+
// file, do not commit it, and do not run Aether on machines you do not trust.
|
|
7
|
+
// For temporary keys, prefer environment variables (they are never written
|
|
8
|
+
// to disk by this module).
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
// Provider name -> environment variable that may hold its API key.
|
|
13
|
+
export const ENV_MAP = {
|
|
14
|
+
"openrouter-free": "OPENROUTER_API_KEY",
|
|
15
|
+
groq: "GROQ_API_KEY",
|
|
16
|
+
mistral: "MISTRAL_API_KEY",
|
|
17
|
+
cohere: "COHERE_API_KEY",
|
|
18
|
+
huggingface: "HUGGINGFACE_API_KEY",
|
|
19
|
+
fireworks: "FIREWORKS_API_KEY",
|
|
20
|
+
together: "TOGETHER_API_KEY",
|
|
21
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
22
|
+
gemini: "GEMINI_API_KEY",
|
|
23
|
+
xai: "XAI_API_KEY",
|
|
24
|
+
perplexity: "PERPLEXITY_API_KEY",
|
|
25
|
+
cerebras: "CEREBRAS_API_KEY",
|
|
26
|
+
nvidia: "NVIDIA_NIM_API_KEY",
|
|
27
|
+
jina: "JINA_API_KEY",
|
|
28
|
+
parasail: "PARASAIL_API_KEY",
|
|
29
|
+
featherless: "FEATHERLESS_API_KEY",
|
|
30
|
+
voyage: "VOYAGE_API_KEY",
|
|
31
|
+
cloudflare: "CLOUDFLARE_API_KEY",
|
|
32
|
+
};
|
|
33
|
+
function keysPath() {
|
|
34
|
+
return path.join(os.homedir(), ".aether", "keys.json");
|
|
35
|
+
}
|
|
36
|
+
export class KeyManager {
|
|
37
|
+
keys = new Map();
|
|
38
|
+
loaded = false;
|
|
39
|
+
ensureLoaded() {
|
|
40
|
+
if (this.loaded)
|
|
41
|
+
return;
|
|
42
|
+
this.loaded = true;
|
|
43
|
+
this.loadFromDisk();
|
|
44
|
+
}
|
|
45
|
+
loadFromDisk() {
|
|
46
|
+
try {
|
|
47
|
+
const p = keysPath();
|
|
48
|
+
if (!fs.existsSync(p))
|
|
49
|
+
return;
|
|
50
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
51
|
+
const obj = JSON.parse(raw);
|
|
52
|
+
if (obj && typeof obj === "object") {
|
|
53
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
54
|
+
if (typeof v === "string" && v)
|
|
55
|
+
this.keys.set(k, v);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Ignore a missing or corrupt keys file; we'll just have no keys.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Store a key (or remove it when given an empty/falsy value). */
|
|
64
|
+
set(providerName, key) {
|
|
65
|
+
this.ensureLoaded();
|
|
66
|
+
if (key && key.trim())
|
|
67
|
+
this.keys.set(providerName, key.trim());
|
|
68
|
+
else
|
|
69
|
+
this.keys.delete(providerName);
|
|
70
|
+
this.save();
|
|
71
|
+
}
|
|
72
|
+
get(providerName) {
|
|
73
|
+
this.ensureLoaded();
|
|
74
|
+
return this.keys.get(providerName);
|
|
75
|
+
}
|
|
76
|
+
has(providerName) {
|
|
77
|
+
this.ensureLoaded();
|
|
78
|
+
return this.keys.has(providerName);
|
|
79
|
+
}
|
|
80
|
+
remove(providerName) {
|
|
81
|
+
this.ensureLoaded();
|
|
82
|
+
this.keys.delete(providerName);
|
|
83
|
+
this.save();
|
|
84
|
+
}
|
|
85
|
+
/** Every known provider and whether a key is currently configured. */
|
|
86
|
+
list() {
|
|
87
|
+
this.ensureLoaded();
|
|
88
|
+
return Object.keys(ENV_MAP).map((provider) => ({
|
|
89
|
+
provider,
|
|
90
|
+
hasKey: this.keys.has(provider),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
/** Scan process.env for known key env vars and load any found into the map. */
|
|
94
|
+
detectFromEnv() {
|
|
95
|
+
this.ensureLoaded();
|
|
96
|
+
for (const [provider, envVar] of Object.entries(ENV_MAP)) {
|
|
97
|
+
const v = process.env[envVar];
|
|
98
|
+
if (v && v.trim())
|
|
99
|
+
this.keys.set(provider, v.trim());
|
|
100
|
+
}
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
/** Persist the current in-memory keys to disk atomically. */
|
|
104
|
+
save() {
|
|
105
|
+
this.ensureLoaded();
|
|
106
|
+
const file = keysPath();
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
108
|
+
const obj = {};
|
|
109
|
+
for (const [k, v] of this.keys)
|
|
110
|
+
obj[k] = v;
|
|
111
|
+
const tmp = file + ".tmp";
|
|
112
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
|
|
113
|
+
fs.renameSync(tmp, file);
|
|
114
|
+
}
|
|
115
|
+
/** Singleton: load from disk AND env. */
|
|
116
|
+
static load() {
|
|
117
|
+
if (!KeyManager._instance)
|
|
118
|
+
KeyManager._instance = new KeyManager();
|
|
119
|
+
const km = KeyManager._instance;
|
|
120
|
+
km.ensureLoaded();
|
|
121
|
+
km.detectFromEnv();
|
|
122
|
+
return km;
|
|
123
|
+
}
|
|
124
|
+
static instance() {
|
|
125
|
+
return KeyManager.load();
|
|
126
|
+
}
|
|
127
|
+
static _instance = null;
|
|
128
|
+
}
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
const MEMORY_DIR = path.join(os.homedir(), ".aether", "memory");
|
|
5
|
+
export class Memory {
|
|
6
|
+
facts = [];
|
|
7
|
+
dirty = false;
|
|
8
|
+
constructor() {
|
|
9
|
+
this.load();
|
|
10
|
+
}
|
|
11
|
+
static path() {
|
|
12
|
+
return path.join(MEMORY_DIR, "MEMORY.md");
|
|
13
|
+
}
|
|
14
|
+
load() {
|
|
15
|
+
this.facts = [];
|
|
16
|
+
const file = Memory.path();
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(file)) {
|
|
19
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
20
|
+
for (const line of raw.split("\n")) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (trimmed.startsWith("- ")) {
|
|
23
|
+
const fact = trimmed.slice(2).trim();
|
|
24
|
+
if (fact)
|
|
25
|
+
this.facts.push(fact);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore read errors; start with empty memory
|
|
32
|
+
}
|
|
33
|
+
this.dirty = false;
|
|
34
|
+
}
|
|
35
|
+
save() {
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.existsSync(MEMORY_DIR)) {
|
|
38
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
const lines = this.facts.map((f) => `- ${f}`);
|
|
41
|
+
const content = lines.length ? lines.join("\n") + "\n" : "";
|
|
42
|
+
const tmp = Memory.path() + ".tmp";
|
|
43
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
44
|
+
fs.renameSync(tmp, Memory.path());
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
throw new Error(`Failed to save memory: ${message}`);
|
|
49
|
+
}
|
|
50
|
+
this.dirty = false;
|
|
51
|
+
}
|
|
52
|
+
add(fact) {
|
|
53
|
+
const trimmed = fact.trim();
|
|
54
|
+
if (!trimmed)
|
|
55
|
+
return;
|
|
56
|
+
const lower = trimmed.toLowerCase();
|
|
57
|
+
for (let i = 0; i < this.facts.length; i++) {
|
|
58
|
+
if (this.facts[i].toLowerCase() === lower) {
|
|
59
|
+
// Move to end (refresh).
|
|
60
|
+
this.facts.splice(i, 1);
|
|
61
|
+
this.facts.push(trimmed);
|
|
62
|
+
this.dirty = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.facts.push(trimmed);
|
|
67
|
+
this.dirty = true;
|
|
68
|
+
}
|
|
69
|
+
remove(fact) {
|
|
70
|
+
const trimmed = fact.trim();
|
|
71
|
+
if (!trimmed)
|
|
72
|
+
return;
|
|
73
|
+
const lower = trimmed.toLowerCase();
|
|
74
|
+
const before = this.facts.length;
|
|
75
|
+
this.facts = this.facts.filter((f) => f.toLowerCase() !== lower);
|
|
76
|
+
if (this.facts.length !== before)
|
|
77
|
+
this.dirty = true;
|
|
78
|
+
}
|
|
79
|
+
search(query) {
|
|
80
|
+
const q = query.trim().toLowerCase();
|
|
81
|
+
if (!q)
|
|
82
|
+
return [];
|
|
83
|
+
return this.facts.filter((f) => f.toLowerCase().includes(q));
|
|
84
|
+
}
|
|
85
|
+
getAll() {
|
|
86
|
+
return this.facts.slice();
|
|
87
|
+
}
|
|
88
|
+
toContext() {
|
|
89
|
+
if (this.facts.length === 0)
|
|
90
|
+
return "";
|
|
91
|
+
const lines = this.facts.map((f) => `- ${f}`);
|
|
92
|
+
return "## Long-term Memory\n" + lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
ensureSaved() {
|
|
95
|
+
if (this.dirty)
|
|
96
|
+
this.save();
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/modes.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export class ModeManager {
|
|
2
|
+
static MODES = {
|
|
3
|
+
plan: "plan",
|
|
4
|
+
yolo: "yolo",
|
|
5
|
+
normal: "normal",
|
|
6
|
+
};
|
|
7
|
+
mode = "normal";
|
|
8
|
+
constructor(mode) {
|
|
9
|
+
if (mode)
|
|
10
|
+
this.setMode(mode);
|
|
11
|
+
}
|
|
12
|
+
setMode(name) {
|
|
13
|
+
const key = name.trim().toLowerCase();
|
|
14
|
+
if (!(key in ModeManager.MODES)) {
|
|
15
|
+
throw new Error(`Unknown mode "${name}". Available: ${Object.keys(ModeManager.MODES).join(", ")}`);
|
|
16
|
+
}
|
|
17
|
+
this.mode = key;
|
|
18
|
+
return this.mode;
|
|
19
|
+
}
|
|
20
|
+
getMode() {
|
|
21
|
+
return this.mode;
|
|
22
|
+
}
|
|
23
|
+
getSystemPromptModifier() {
|
|
24
|
+
switch (this.mode) {
|
|
25
|
+
case "plan":
|
|
26
|
+
return ("You are in PLAN mode. Do NOT modify files or run commands. " +
|
|
27
|
+
"Explore only with ReadFile, Glob, Grep, ListDir. Produce a detailed plan.");
|
|
28
|
+
case "yolo":
|
|
29
|
+
return ("You are in YOLO mode. Proceed with all actions without asking for " +
|
|
30
|
+
"confirmation. Be efficient and direct.");
|
|
31
|
+
default:
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
requiresConfirmation(toolName) {
|
|
36
|
+
switch (this.mode) {
|
|
37
|
+
case "plan":
|
|
38
|
+
// Plan mode should not even expose these tools; if called, block.
|
|
39
|
+
return true;
|
|
40
|
+
case "yolo":
|
|
41
|
+
return false;
|
|
42
|
+
default:
|
|
43
|
+
// Normal mode: confirm destructive actions.
|
|
44
|
+
return (toolName === "Bash" ||
|
|
45
|
+
toolName === "WriteFile" ||
|
|
46
|
+
toolName === "EditFile");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
allowedTools(mode) {
|
|
50
|
+
const m = (mode ?? this.mode);
|
|
51
|
+
switch (m) {
|
|
52
|
+
case "plan":
|
|
53
|
+
return ["ReadFile", "Glob", "Grep", "ListDir"];
|
|
54
|
+
case "yolo":
|
|
55
|
+
case "normal":
|
|
56
|
+
default:
|
|
57
|
+
return [
|
|
58
|
+
"ReadFile",
|
|
59
|
+
"WriteFile",
|
|
60
|
+
"EditFile",
|
|
61
|
+
"ListDir",
|
|
62
|
+
"Bash",
|
|
63
|
+
"Glob",
|
|
64
|
+
"Grep",
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export async function fetchWithTimeout(url, opts = {}, timeoutMs) {
|
|
2
|
+
const controller = new AbortController();
|
|
3
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4
|
+
const signal = opts.signal
|
|
5
|
+
? AbortSignal.any([controller.signal, opts.signal])
|
|
6
|
+
: controller.signal;
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(url, { ...opts, signal });
|
|
9
|
+
return res;
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function createProvider(config) {
|
|
16
|
+
switch (config.type) {
|
|
17
|
+
case "ollama": {
|
|
18
|
+
const { OllamaProvider } = await import("./ollama.js");
|
|
19
|
+
return new OllamaProvider(config);
|
|
20
|
+
}
|
|
21
|
+
case "openrouter": {
|
|
22
|
+
const { OpenRouterProvider } = await import("./openrouter.js");
|
|
23
|
+
return new OpenRouterProvider(config);
|
|
24
|
+
}
|
|
25
|
+
case "openai-compatible": {
|
|
26
|
+
const { OpenAICompatProvider } = await import("./openai-compat.js");
|
|
27
|
+
return new OpenAICompatProvider(config);
|
|
28
|
+
}
|
|
29
|
+
default:
|
|
30
|
+
throw new Error(`Unknown provider type: ${config.type}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "./index.js";
|
|
2
|
+
function estimateTokens(text) {
|
|
3
|
+
return Math.ceil(text.split(/\s+/).filter(Boolean).length * 1.3);
|
|
4
|
+
}
|
|
5
|
+
export class OllamaProvider {
|
|
6
|
+
name;
|
|
7
|
+
config;
|
|
8
|
+
resolvedModel;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.name = config.name;
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
get base() {
|
|
14
|
+
return this.config.baseURL.replace(/\/$/, "");
|
|
15
|
+
}
|
|
16
|
+
async listModels() {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetchWithTimeout(`${this.base}/api/tags`, {}, this.config.timeoutMs);
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
return [];
|
|
21
|
+
const data = (await res.json());
|
|
22
|
+
return (data?.models ?? []).map((m) => m.name).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Query /api/tags and return the installed models together with their
|
|
30
|
+
* Ollama-reported capabilities (e.g. ["completion","tools","vision"]).
|
|
31
|
+
*/
|
|
32
|
+
async listModelsWithCapabilities() {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchWithTimeout(`${this.base}/api/tags`, {}, this.config.timeoutMs);
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
return out;
|
|
38
|
+
const data = (await res.json());
|
|
39
|
+
for (const m of data?.models ?? []) {
|
|
40
|
+
const name = m?.name;
|
|
41
|
+
if (!name)
|
|
42
|
+
continue;
|
|
43
|
+
const caps = Array.isArray(m?.capabilities) ? m.capabilities.map(String) : [];
|
|
44
|
+
out.set(name, caps);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the actual model to use for a request.
|
|
54
|
+
*
|
|
55
|
+
* 1. If the configured model (config.models[0]) is installed locally, use it.
|
|
56
|
+
* 2. Otherwise fall back to the first installed model that supports tools
|
|
57
|
+
* (its capabilities include "tools") � this is what lets the agent call
|
|
58
|
+
* tools without failing over to cloud providers.
|
|
59
|
+
* 3. If no installed model supports tools, fall back to the first installed
|
|
60
|
+
* model, else a sane default.
|
|
61
|
+
*
|
|
62
|
+
* The result is cached so /api/tags is only hit once per provider instance.
|
|
63
|
+
*/
|
|
64
|
+
async resolveModel() {
|
|
65
|
+
if (this.resolvedModel)
|
|
66
|
+
return this.resolvedModel;
|
|
67
|
+
const configured = this.config.models[0];
|
|
68
|
+
const withCaps = await this.listModelsWithCapabilities();
|
|
69
|
+
const installed = Array.from(withCaps.keys());
|
|
70
|
+
let chosen;
|
|
71
|
+
if (configured && installed.includes(configured)) {
|
|
72
|
+
chosen = configured;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Prefer a tool-capable installed model so the agent can call tools.
|
|
76
|
+
const toolCapable = installed.find((n) => (withCaps.get(n) ?? []).includes("tools"));
|
|
77
|
+
chosen = toolCapable ?? installed[0] ?? configured ?? "aether";
|
|
78
|
+
}
|
|
79
|
+
this.resolvedModel = chosen;
|
|
80
|
+
return chosen;
|
|
81
|
+
}
|
|
82
|
+
async health() {
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchWithTimeout(`${this.base}/api/version`, {}, 5000);
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: `HTTP ${res.status}`, circuitOpen: false, cooldownUntil: 0 };
|
|
88
|
+
}
|
|
89
|
+
return { healthy: true, failures: 0, lastCheck: start, circuitOpen: false, cooldownUntil: 0 };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { healthy: false, failures: 0, lastCheck: start, lastError: err.message, circuitOpen: false, cooldownUntil: 0 };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async countTokens(text) {
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetchWithTimeout(`${this.base}/api/count`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ text }),
|
|
101
|
+
}, 5000);
|
|
102
|
+
if (res.ok) {
|
|
103
|
+
const data = (await res.json());
|
|
104
|
+
if (typeof data?.count === "number")
|
|
105
|
+
return data.count;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// fall back to estimation
|
|
110
|
+
}
|
|
111
|
+
return estimateTokens(text);
|
|
112
|
+
}
|
|
113
|
+
async *chat(messages, tools, opts) {
|
|
114
|
+
// Auto-detect the real installed model at runtime; fall back gracefully.
|
|
115
|
+
const model = await this.resolveModel();
|
|
116
|
+
const body = {
|
|
117
|
+
model,
|
|
118
|
+
messages: messages.map((m) => ({
|
|
119
|
+
role: m.role,
|
|
120
|
+
content: m.content,
|
|
121
|
+
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
122
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
123
|
+
...(m.name ? { name: m.name } : {}),
|
|
124
|
+
// Ollama vision: top-level `images` array of base64 strings on a message.
|
|
125
|
+
...(Array.isArray(m.images) && m.images.length > 0
|
|
126
|
+
? { images: m.images }
|
|
127
|
+
: {}),
|
|
128
|
+
})),
|
|
129
|
+
stream: true,
|
|
130
|
+
options: {
|
|
131
|
+
temperature: opts?.temperature ?? 0.7,
|
|
132
|
+
num_ctx: 4096,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
if (tools && tools.length > 0) {
|
|
136
|
+
body.tools = tools.map((t) => ({
|
|
137
|
+
type: "function",
|
|
138
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
if (opts?.maxTokens) {
|
|
142
|
+
body.options.num_predict = opts.maxTokens;
|
|
143
|
+
}
|
|
144
|
+
const res = await fetchWithTimeout(`${this.base}/api/chat`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
signal: opts?.signal,
|
|
149
|
+
}, this.config.timeoutMs);
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
const text = await res.text().catch(() => "");
|
|
152
|
+
throw new Error(`Ollama chat failed: HTTP ${res.status} ${text}`);
|
|
153
|
+
}
|
|
154
|
+
const reader = res.body?.getReader();
|
|
155
|
+
if (!reader) {
|
|
156
|
+
throw new Error("Ollama chat: no response body");
|
|
157
|
+
}
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
let buffer = "";
|
|
160
|
+
let inputTokens = 0;
|
|
161
|
+
let outputTokens = 0;
|
|
162
|
+
while (true) {
|
|
163
|
+
const { done, value } = await reader.read();
|
|
164
|
+
if (done)
|
|
165
|
+
break;
|
|
166
|
+
buffer += decoder.decode(value, { stream: true });
|
|
167
|
+
let idx = buffer.indexOf("\n");
|
|
168
|
+
while (idx !== -1) {
|
|
169
|
+
const line = buffer.slice(0, idx).trim();
|
|
170
|
+
buffer = buffer.slice(idx + 1);
|
|
171
|
+
if (line) {
|
|
172
|
+
const obj = JSON.parse(line);
|
|
173
|
+
if (obj?.message?.content) {
|
|
174
|
+
yield { type: "text", text: obj.message.content };
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(obj?.message?.tool_calls)) {
|
|
177
|
+
for (const tc of obj.message.tool_calls) {
|
|
178
|
+
yield {
|
|
179
|
+
type: "tool_call",
|
|
180
|
+
tool_call: {
|
|
181
|
+
id: tc.id || `call_${Date.now()}`,
|
|
182
|
+
type: "function",
|
|
183
|
+
function: {
|
|
184
|
+
name: tc.function.name,
|
|
185
|
+
arguments: tc.function.arguments ?? "",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (typeof obj?.prompt_eval_count === "number")
|
|
192
|
+
inputTokens = obj.prompt_eval_count;
|
|
193
|
+
if (typeof obj?.eval_count === "number")
|
|
194
|
+
outputTokens = obj.eval_count;
|
|
195
|
+
if (obj?.done) {
|
|
196
|
+
yield {
|
|
197
|
+
type: "done",
|
|
198
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
idx = buffer.indexOf("\n");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|