@azmxailabs/agent-sdk 0.1.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/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/approval/gate.d.ts +77 -0
- package/dist/approval/gate.js +92 -0
- package/dist/approval/index.d.ts +2 -0
- package/dist/approval/index.js +2 -0
- package/dist/approval/policies.d.ts +24 -0
- package/dist/approval/policies.js +95 -0
- package/dist/audit/index.d.ts +1 -0
- package/dist/audit/index.js +1 -0
- package/dist/audit/log.d.ts +70 -0
- package/dist/audit/log.js +160 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/providers/anthropic.d.ts +34 -0
- package/dist/providers/anthropic.js +159 -0
- package/dist/providers/index.d.ts +4 -0
- package/dist/providers/index.js +4 -0
- package/dist/providers/ollama.d.ts +27 -0
- package/dist/providers/ollama.js +125 -0
- package/dist/providers/router.d.ts +32 -0
- package/dist/providers/router.js +59 -0
- package/dist/providers/types.d.ts +63 -0
- package/dist/providers/types.js +6 -0
- package/dist/security/deny-list.d.ts +41 -0
- package/dist/security/deny-list.js +0 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashChainedAuditLog — append-only log where each entry's hash includes
|
|
3
|
+
* the previous entry's hash. Tampering with any past entry breaks the
|
|
4
|
+
* chain at that point and every entry after it.
|
|
5
|
+
*
|
|
6
|
+
* Format: JSON Lines (one entry per line). Each line is:
|
|
7
|
+
* { "seq": N, "ts": "...", "prevHash": "...", "hash": "...", "data": {...} }
|
|
8
|
+
*
|
|
9
|
+
* Genesis entry has prevHash = "0".repeat(64).
|
|
10
|
+
*
|
|
11
|
+
* Storage adapters: file (Node only, default) or in-memory (for tests).
|
|
12
|
+
* Verify with verify() — walks from genesis, recomputes every hash.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
export class InMemoryStorage {
|
|
16
|
+
buf = "";
|
|
17
|
+
async read() {
|
|
18
|
+
return this.buf;
|
|
19
|
+
}
|
|
20
|
+
async append(line) {
|
|
21
|
+
this.buf += line + "\n";
|
|
22
|
+
}
|
|
23
|
+
/** Test helper — corrupts an existing entry to verify detection works. */
|
|
24
|
+
tamper(replacer) {
|
|
25
|
+
this.buf = replacer(this.buf);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class FileStorage {
|
|
29
|
+
path;
|
|
30
|
+
constructor(path) {
|
|
31
|
+
this.path = path;
|
|
32
|
+
}
|
|
33
|
+
async read() {
|
|
34
|
+
const { readFile } = await import("node:fs/promises");
|
|
35
|
+
try {
|
|
36
|
+
return await readFile(this.path, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err.code === "ENOENT")
|
|
40
|
+
return "";
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async append(line) {
|
|
45
|
+
const { appendFile, mkdir } = await import("node:fs/promises");
|
|
46
|
+
const { dirname } = await import("node:path");
|
|
47
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
48
|
+
await appendFile(this.path, line + "\n", { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const GENESIS = "0".repeat(64);
|
|
52
|
+
export class HashChainedAuditLog {
|
|
53
|
+
storage;
|
|
54
|
+
nextSeq = 0;
|
|
55
|
+
lastHash = GENESIS;
|
|
56
|
+
bootstrapped = false;
|
|
57
|
+
constructor(opts = {}) {
|
|
58
|
+
if (opts.storage) {
|
|
59
|
+
this.storage = opts.storage;
|
|
60
|
+
}
|
|
61
|
+
else if (opts.path) {
|
|
62
|
+
this.storage = new FileStorage(opts.path);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
this.storage = new InMemoryStorage();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Append a new entry, returning the resulting entry. Thread-safe per instance. */
|
|
69
|
+
async append(data) {
|
|
70
|
+
await this.bootstrap();
|
|
71
|
+
const seq = this.nextSeq;
|
|
72
|
+
const ts = new Date().toISOString();
|
|
73
|
+
const prevHash = this.lastHash;
|
|
74
|
+
const hash = hashEntry({ seq, ts, prevHash, data });
|
|
75
|
+
const entry = { seq, ts, prevHash, hash, data };
|
|
76
|
+
await this.storage.append(JSON.stringify(entry));
|
|
77
|
+
this.nextSeq = seq + 1;
|
|
78
|
+
this.lastHash = hash;
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Walk every entry from genesis and recompute hashes. Returns:
|
|
83
|
+
* { ok: true, count } on success
|
|
84
|
+
* { ok: false, brokenAtSeq, expected, found } on first breakage
|
|
85
|
+
*/
|
|
86
|
+
async verify() {
|
|
87
|
+
const text = await this.storage.read();
|
|
88
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
89
|
+
let prevHash = GENESIS;
|
|
90
|
+
let expectedSeq = 0;
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
let entry;
|
|
93
|
+
try {
|
|
94
|
+
entry = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { ok: false, brokenAtSeq: expectedSeq, reason: "invalid JSON" };
|
|
98
|
+
}
|
|
99
|
+
if (entry.seq !== expectedSeq) {
|
|
100
|
+
return { ok: false, brokenAtSeq: expectedSeq, reason: `seq mismatch (got ${entry.seq})` };
|
|
101
|
+
}
|
|
102
|
+
if (entry.prevHash !== prevHash) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
brokenAtSeq: entry.seq,
|
|
106
|
+
reason: "prevHash mismatch",
|
|
107
|
+
expected: prevHash,
|
|
108
|
+
found: entry.prevHash,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const recomputed = hashEntry({
|
|
112
|
+
seq: entry.seq,
|
|
113
|
+
ts: entry.ts,
|
|
114
|
+
prevHash: entry.prevHash,
|
|
115
|
+
data: entry.data,
|
|
116
|
+
});
|
|
117
|
+
if (recomputed !== entry.hash) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
brokenAtSeq: entry.seq,
|
|
121
|
+
reason: "hash mismatch (entry was modified)",
|
|
122
|
+
expected: recomputed,
|
|
123
|
+
found: entry.hash,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
prevHash = entry.hash;
|
|
127
|
+
expectedSeq += 1;
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, count: expectedSeq };
|
|
130
|
+
}
|
|
131
|
+
/** Read everything as parsed entries. Convenience for callers that need to display the log. */
|
|
132
|
+
async entries() {
|
|
133
|
+
const text = await this.storage.read();
|
|
134
|
+
return text
|
|
135
|
+
.split("\n")
|
|
136
|
+
.filter((l) => l.trim().length > 0)
|
|
137
|
+
.map((l) => JSON.parse(l));
|
|
138
|
+
}
|
|
139
|
+
async bootstrap() {
|
|
140
|
+
if (this.bootstrapped)
|
|
141
|
+
return;
|
|
142
|
+
const text = await this.storage.read();
|
|
143
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
144
|
+
if (lines.length > 0) {
|
|
145
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
146
|
+
this.nextSeq = last.seq + 1;
|
|
147
|
+
this.lastHash = last.hash;
|
|
148
|
+
}
|
|
149
|
+
this.bootstrapped = true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function hashEntry(input) {
|
|
153
|
+
const canonical = JSON.stringify({
|
|
154
|
+
seq: input.seq,
|
|
155
|
+
ts: input.ts,
|
|
156
|
+
prevHash: input.prevHash,
|
|
157
|
+
data: input.data,
|
|
158
|
+
});
|
|
159
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
160
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @azmxailabs/agent-sdk — build approval-gated AI agents with the same
|
|
3
|
+
* primitives that power AZMX AI.
|
|
4
|
+
*
|
|
5
|
+
* Modular barrel: pull from sub-paths to keep your bundle tight.
|
|
6
|
+
* import { ApprovalGate, standardPolicy } from "@azmxailabs/agent-sdk/approval";
|
|
7
|
+
* import { DenyList, denyListPolicy } from "@azmxailabs/agent-sdk/security";
|
|
8
|
+
* import { HashChainedAuditLog } from "@azmxailabs/agent-sdk/audit";
|
|
9
|
+
* import { ProviderRouter, AnthropicProvider, OllamaProvider } from "@azmxailabs/agent-sdk/providers";
|
|
10
|
+
*
|
|
11
|
+
* Or import the everything-bundle from the root:
|
|
12
|
+
* import { ApprovalGate, DenyList, ProviderRouter, ... } from "@azmxailabs/agent-sdk";
|
|
13
|
+
*/
|
|
14
|
+
export * from "./approval/index.js";
|
|
15
|
+
export * from "./security/index.js";
|
|
16
|
+
export * from "./audit/index.js";
|
|
17
|
+
export * from "./providers/index.js";
|
|
18
|
+
export declare const SDK_VERSION = "0.1.0";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @azmxailabs/agent-sdk — build approval-gated AI agents with the same
|
|
3
|
+
* primitives that power AZMX AI.
|
|
4
|
+
*
|
|
5
|
+
* Modular barrel: pull from sub-paths to keep your bundle tight.
|
|
6
|
+
* import { ApprovalGate, standardPolicy } from "@azmxailabs/agent-sdk/approval";
|
|
7
|
+
* import { DenyList, denyListPolicy } from "@azmxailabs/agent-sdk/security";
|
|
8
|
+
* import { HashChainedAuditLog } from "@azmxailabs/agent-sdk/audit";
|
|
9
|
+
* import { ProviderRouter, AnthropicProvider, OllamaProvider } from "@azmxailabs/agent-sdk/providers";
|
|
10
|
+
*
|
|
11
|
+
* Or import the everything-bundle from the root:
|
|
12
|
+
* import { ApprovalGate, DenyList, ProviderRouter, ... } from "@azmxailabs/agent-sdk";
|
|
13
|
+
*/
|
|
14
|
+
export * from "./approval/index.js";
|
|
15
|
+
export * from "./security/index.js";
|
|
16
|
+
export * from "./audit/index.js";
|
|
17
|
+
export * from "./providers/index.js";
|
|
18
|
+
export const SDK_VERSION = "0.1.0";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ChatRequest, ChatResponse, Provider, StreamChunk } from "./types.js";
|
|
2
|
+
export interface AnthropicProviderOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
/** Provider model id, e.g. "claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5". */
|
|
5
|
+
model: string;
|
|
6
|
+
/** Override the API host. Useful for proxies / on-prem gateways. */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
/** Anthropic API version header. */
|
|
9
|
+
version?: string;
|
|
10
|
+
/** Default max_tokens — Anthropic requires this; caller can still override per-request. */
|
|
11
|
+
defaultMaxTokens?: number;
|
|
12
|
+
/** Custom fetch (for testing). */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Anthropic Messages API adapter — POST /v1/messages.
|
|
17
|
+
*
|
|
18
|
+
* BYOK direct: requests go from the caller's machine straight to
|
|
19
|
+
* api.anthropic.com (or a custom baseUrl). No proxy via AZMX servers.
|
|
20
|
+
*/
|
|
21
|
+
export declare class AnthropicProvider implements Provider {
|
|
22
|
+
readonly name = "anthropic";
|
|
23
|
+
private apiKey;
|
|
24
|
+
private model;
|
|
25
|
+
private baseUrl;
|
|
26
|
+
private version;
|
|
27
|
+
private defaultMaxTokens;
|
|
28
|
+
private fetch;
|
|
29
|
+
constructor(opts: AnthropicProviderOptions);
|
|
30
|
+
complete(req: ChatRequest): Promise<ChatResponse>;
|
|
31
|
+
stream(req: ChatRequest): AsyncIterable<StreamChunk>;
|
|
32
|
+
private buildBody;
|
|
33
|
+
private headers;
|
|
34
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API adapter — POST /v1/messages.
|
|
3
|
+
*
|
|
4
|
+
* BYOK direct: requests go from the caller's machine straight to
|
|
5
|
+
* api.anthropic.com (or a custom baseUrl). No proxy via AZMX servers.
|
|
6
|
+
*/
|
|
7
|
+
export class AnthropicProvider {
|
|
8
|
+
name = "anthropic";
|
|
9
|
+
apiKey;
|
|
10
|
+
model;
|
|
11
|
+
baseUrl;
|
|
12
|
+
version;
|
|
13
|
+
defaultMaxTokens;
|
|
14
|
+
fetch;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
if (!opts.apiKey)
|
|
17
|
+
throw new Error("AnthropicProvider: apiKey is required");
|
|
18
|
+
if (!opts.model)
|
|
19
|
+
throw new Error("AnthropicProvider: model is required");
|
|
20
|
+
this.apiKey = opts.apiKey;
|
|
21
|
+
this.model = opts.model;
|
|
22
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
|
|
23
|
+
this.version = opts.version ?? "2023-06-01";
|
|
24
|
+
this.defaultMaxTokens = opts.defaultMaxTokens ?? 4096;
|
|
25
|
+
this.fetch = opts.fetch ?? fetch;
|
|
26
|
+
}
|
|
27
|
+
async complete(req) {
|
|
28
|
+
const body = this.buildBody(req, false);
|
|
29
|
+
const res = await this.fetch(`${this.baseUrl}/v1/messages`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: this.headers(),
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
signal: req.signal,
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw await httpError(res);
|
|
37
|
+
const data = (await res.json());
|
|
38
|
+
const text = (data.content ?? [])
|
|
39
|
+
.filter((b) => b.type === "text")
|
|
40
|
+
.map((b) => b.text ?? "")
|
|
41
|
+
.join("");
|
|
42
|
+
return {
|
|
43
|
+
text,
|
|
44
|
+
finishReason: data.stop_reason ?? "stop",
|
|
45
|
+
usage: data.usage
|
|
46
|
+
? {
|
|
47
|
+
inputTokens: data.usage.input_tokens ?? 0,
|
|
48
|
+
outputTokens: data.usage.output_tokens ?? 0,
|
|
49
|
+
cacheReadTokens: data.usage.cache_read_input_tokens,
|
|
50
|
+
cacheCreationTokens: data.usage.cache_creation_input_tokens,
|
|
51
|
+
}
|
|
52
|
+
: undefined,
|
|
53
|
+
raw: data,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async *stream(req) {
|
|
57
|
+
const body = this.buildBody(req, true);
|
|
58
|
+
const res = await this.fetch(`${this.baseUrl}/v1/messages`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: this.headers(),
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
signal: req.signal,
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok)
|
|
65
|
+
throw await httpError(res);
|
|
66
|
+
if (!res.body)
|
|
67
|
+
throw new Error("AnthropicProvider: streaming response had no body");
|
|
68
|
+
let usage;
|
|
69
|
+
let finishReason;
|
|
70
|
+
for await (const event of sseLines(res.body)) {
|
|
71
|
+
if (!event.data)
|
|
72
|
+
continue;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(event.data);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (parsed.type === "content_block_delta" && parsed.delta?.type === "text_delta") {
|
|
81
|
+
yield { delta: parsed.delta.text ?? "" };
|
|
82
|
+
}
|
|
83
|
+
else if (parsed.type === "message_delta") {
|
|
84
|
+
if (parsed.delta?.stop_reason)
|
|
85
|
+
finishReason = parsed.delta.stop_reason;
|
|
86
|
+
if (parsed.usage) {
|
|
87
|
+
usage = {
|
|
88
|
+
inputTokens: parsed.usage.input_tokens ?? 0,
|
|
89
|
+
outputTokens: parsed.usage.output_tokens ?? 0,
|
|
90
|
+
cacheReadTokens: parsed.usage.cache_read_input_tokens,
|
|
91
|
+
cacheCreationTokens: parsed.usage.cache_creation_input_tokens,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (parsed.type === "message_stop") {
|
|
96
|
+
yield { delta: "", done: true, finishReason: finishReason ?? "stop", usage };
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
yield { delta: "", done: true, finishReason: finishReason ?? "stop", usage };
|
|
101
|
+
}
|
|
102
|
+
buildBody(req, stream) {
|
|
103
|
+
const system = req.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
|
|
104
|
+
const messages = req.messages
|
|
105
|
+
.filter((m) => m.role !== "system")
|
|
106
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
107
|
+
return {
|
|
108
|
+
model: this.model,
|
|
109
|
+
max_tokens: req.maxTokens ?? this.defaultMaxTokens,
|
|
110
|
+
temperature: req.temperature,
|
|
111
|
+
stop_sequences: req.stop,
|
|
112
|
+
system: system || undefined,
|
|
113
|
+
messages,
|
|
114
|
+
stream,
|
|
115
|
+
...(req.providerOptions ?? {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
headers() {
|
|
119
|
+
return {
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"x-api-key": this.apiKey,
|
|
122
|
+
"anthropic-version": this.version,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function httpError(res) {
|
|
127
|
+
let body = "";
|
|
128
|
+
try {
|
|
129
|
+
body = await res.text();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
return new Error(`Anthropic HTTP ${res.status}: ${body.slice(0, 500)}`);
|
|
135
|
+
}
|
|
136
|
+
async function* sseLines(body) {
|
|
137
|
+
const reader = body.getReader();
|
|
138
|
+
const decoder = new TextDecoder();
|
|
139
|
+
let buf = "";
|
|
140
|
+
while (true) {
|
|
141
|
+
const { value, done } = await reader.read();
|
|
142
|
+
if (done)
|
|
143
|
+
break;
|
|
144
|
+
buf += decoder.decode(value, { stream: true });
|
|
145
|
+
let nl;
|
|
146
|
+
while ((nl = buf.indexOf("\n\n")) !== -1) {
|
|
147
|
+
const chunk = buf.slice(0, nl);
|
|
148
|
+
buf = buf.slice(nl + 2);
|
|
149
|
+
const ev = { data: "" };
|
|
150
|
+
for (const line of chunk.split("\n")) {
|
|
151
|
+
if (line.startsWith("event:"))
|
|
152
|
+
ev.event = line.slice(6).trim();
|
|
153
|
+
else if (line.startsWith("data:"))
|
|
154
|
+
ev.data += line.slice(5).trim();
|
|
155
|
+
}
|
|
156
|
+
yield ev;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ChatRequest, ChatResponse, Provider, StreamChunk } from "./types.js";
|
|
2
|
+
export interface OllamaProviderOptions {
|
|
3
|
+
/** Provider model id, e.g. "llama3.2", "qwen2.5-coder:14b", "deepseek-r1". */
|
|
4
|
+
model: string;
|
|
5
|
+
/** Ollama server base URL. Default http://localhost:11434. */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** Default options forwarded to Ollama's "options" field per request. */
|
|
8
|
+
defaultOptions?: Record<string, unknown>;
|
|
9
|
+
/** Custom fetch (for testing). */
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Ollama adapter — POST /api/chat.
|
|
14
|
+
*
|
|
15
|
+
* Fully local: no API key, no telemetry, no network leaves the box.
|
|
16
|
+
*/
|
|
17
|
+
export declare class OllamaProvider implements Provider {
|
|
18
|
+
readonly name = "ollama";
|
|
19
|
+
private model;
|
|
20
|
+
private baseUrl;
|
|
21
|
+
private defaultOptions;
|
|
22
|
+
private fetch;
|
|
23
|
+
constructor(opts: OllamaProviderOptions);
|
|
24
|
+
complete(req: ChatRequest): Promise<ChatResponse>;
|
|
25
|
+
stream(req: ChatRequest): AsyncIterable<StreamChunk>;
|
|
26
|
+
private buildBody;
|
|
27
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama adapter — POST /api/chat.
|
|
3
|
+
*
|
|
4
|
+
* Fully local: no API key, no telemetry, no network leaves the box.
|
|
5
|
+
*/
|
|
6
|
+
export class OllamaProvider {
|
|
7
|
+
name = "ollama";
|
|
8
|
+
model;
|
|
9
|
+
baseUrl;
|
|
10
|
+
defaultOptions;
|
|
11
|
+
fetch;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
if (!opts.model)
|
|
14
|
+
throw new Error("OllamaProvider: model is required");
|
|
15
|
+
this.model = opts.model;
|
|
16
|
+
this.baseUrl = (opts.baseUrl ?? "http://localhost:11434").replace(/\/+$/, "");
|
|
17
|
+
this.defaultOptions = opts.defaultOptions ?? {};
|
|
18
|
+
this.fetch = opts.fetch ?? fetch;
|
|
19
|
+
}
|
|
20
|
+
async complete(req) {
|
|
21
|
+
const body = this.buildBody(req, false);
|
|
22
|
+
const res = await this.fetch(`${this.baseUrl}/api/chat`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: req.signal,
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw await httpError(res);
|
|
30
|
+
const data = (await res.json());
|
|
31
|
+
return {
|
|
32
|
+
text: data.message?.content ?? "",
|
|
33
|
+
finishReason: data.done_reason ?? (data.done ? "stop" : "length"),
|
|
34
|
+
usage: data.prompt_eval_count != null || data.eval_count != null
|
|
35
|
+
? {
|
|
36
|
+
inputTokens: data.prompt_eval_count ?? 0,
|
|
37
|
+
outputTokens: data.eval_count ?? 0,
|
|
38
|
+
}
|
|
39
|
+
: undefined,
|
|
40
|
+
raw: data,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async *stream(req) {
|
|
44
|
+
const body = this.buildBody(req, true);
|
|
45
|
+
const res = await this.fetch(`${this.baseUrl}/api/chat`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
signal: req.signal,
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw await httpError(res);
|
|
53
|
+
if (!res.body)
|
|
54
|
+
throw new Error("OllamaProvider: streaming response had no body");
|
|
55
|
+
for await (const line of ndjsonLines(res.body)) {
|
|
56
|
+
let evt;
|
|
57
|
+
try {
|
|
58
|
+
evt = JSON.parse(line);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const delta = evt.message?.content ?? "";
|
|
64
|
+
if (evt.done) {
|
|
65
|
+
yield {
|
|
66
|
+
delta,
|
|
67
|
+
done: true,
|
|
68
|
+
finishReason: evt.done_reason ?? "stop",
|
|
69
|
+
usage: evt.prompt_eval_count != null || evt.eval_count != null
|
|
70
|
+
? { inputTokens: evt.prompt_eval_count ?? 0, outputTokens: evt.eval_count ?? 0 }
|
|
71
|
+
: undefined,
|
|
72
|
+
};
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
else if (delta) {
|
|
76
|
+
yield { delta };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
buildBody(req, stream) {
|
|
81
|
+
return {
|
|
82
|
+
model: this.model,
|
|
83
|
+
messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
84
|
+
stream,
|
|
85
|
+
options: {
|
|
86
|
+
...(this.defaultOptions ?? {}),
|
|
87
|
+
...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
|
|
88
|
+
...(req.maxTokens !== undefined ? { num_predict: req.maxTokens } : {}),
|
|
89
|
+
...(req.stop ? { stop: req.stop } : {}),
|
|
90
|
+
...(req.providerOptions?.options ?? {}),
|
|
91
|
+
},
|
|
92
|
+
...(req.providerOptions ?? {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function httpError(res) {
|
|
97
|
+
let body = "";
|
|
98
|
+
try {
|
|
99
|
+
body = await res.text();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
return new Error(`Ollama HTTP ${res.status}: ${body.slice(0, 500)}`);
|
|
105
|
+
}
|
|
106
|
+
async function* ndjsonLines(body) {
|
|
107
|
+
const reader = body.getReader();
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let buf = "";
|
|
110
|
+
while (true) {
|
|
111
|
+
const { value, done } = await reader.read();
|
|
112
|
+
if (done)
|
|
113
|
+
break;
|
|
114
|
+
buf += decoder.decode(value, { stream: true });
|
|
115
|
+
let nl;
|
|
116
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
117
|
+
const line = buf.slice(0, nl).trim();
|
|
118
|
+
buf = buf.slice(nl + 1);
|
|
119
|
+
if (line)
|
|
120
|
+
yield line;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (buf.trim())
|
|
124
|
+
yield buf.trim();
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChatRequest, ChatResponse, Provider, StreamChunk } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* ProviderRouter — register providers under stable alias names; route
|
|
4
|
+
* each ChatRequest to the right one. Keeps the rest of your code
|
|
5
|
+
* independent of which model you're using.
|
|
6
|
+
*
|
|
7
|
+
* Example:
|
|
8
|
+
* const router = new ProviderRouter();
|
|
9
|
+
* router.register("claude-fast", new AnthropicProvider({ apiKey: ..., model: "claude-haiku-4-5" }));
|
|
10
|
+
* router.register("claude-smart", new AnthropicProvider({ apiKey: ..., model: "claude-opus-4-7" }));
|
|
11
|
+
* router.register("local", new OllamaProvider({ model: "qwen2.5-coder:14b" }));
|
|
12
|
+
*
|
|
13
|
+
* const r = await router.complete({ model: "claude-fast", messages: [...] });
|
|
14
|
+
* for await (const c of router.stream({ model: "local", messages: [...] })) { ... }
|
|
15
|
+
*/
|
|
16
|
+
export declare class ProviderRouter {
|
|
17
|
+
private providers;
|
|
18
|
+
private default?;
|
|
19
|
+
/** Register a provider under an alias used by ChatRequest.model. */
|
|
20
|
+
register(alias: string, provider: Provider, opts?: {
|
|
21
|
+
default?: boolean;
|
|
22
|
+
}): this;
|
|
23
|
+
/** Make `alias` the default. Used when ChatRequest.model is omitted. */
|
|
24
|
+
setDefault(alias: string): this;
|
|
25
|
+
/** All registered aliases. */
|
|
26
|
+
list(): string[];
|
|
27
|
+
/** Direct access to a registered provider. */
|
|
28
|
+
get(alias: string): Provider | undefined;
|
|
29
|
+
complete(req: ChatRequest): Promise<ChatResponse>;
|
|
30
|
+
stream(req: ChatRequest): AsyncIterable<StreamChunk>;
|
|
31
|
+
private resolve;
|
|
32
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProviderRouter — register providers under stable alias names; route
|
|
3
|
+
* each ChatRequest to the right one. Keeps the rest of your code
|
|
4
|
+
* independent of which model you're using.
|
|
5
|
+
*
|
|
6
|
+
* Example:
|
|
7
|
+
* const router = new ProviderRouter();
|
|
8
|
+
* router.register("claude-fast", new AnthropicProvider({ apiKey: ..., model: "claude-haiku-4-5" }));
|
|
9
|
+
* router.register("claude-smart", new AnthropicProvider({ apiKey: ..., model: "claude-opus-4-7" }));
|
|
10
|
+
* router.register("local", new OllamaProvider({ model: "qwen2.5-coder:14b" }));
|
|
11
|
+
*
|
|
12
|
+
* const r = await router.complete({ model: "claude-fast", messages: [...] });
|
|
13
|
+
* for await (const c of router.stream({ model: "local", messages: [...] })) { ... }
|
|
14
|
+
*/
|
|
15
|
+
export class ProviderRouter {
|
|
16
|
+
providers = new Map();
|
|
17
|
+
default;
|
|
18
|
+
/** Register a provider under an alias used by ChatRequest.model. */
|
|
19
|
+
register(alias, provider, opts = {}) {
|
|
20
|
+
this.providers.set(alias, provider);
|
|
21
|
+
if (opts.default || this.providers.size === 1) {
|
|
22
|
+
this.default = alias;
|
|
23
|
+
}
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
/** Make `alias` the default. Used when ChatRequest.model is omitted. */
|
|
27
|
+
setDefault(alias) {
|
|
28
|
+
if (!this.providers.has(alias)) {
|
|
29
|
+
throw new Error(`ProviderRouter: cannot set default to "${alias}" — not registered`);
|
|
30
|
+
}
|
|
31
|
+
this.default = alias;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
/** All registered aliases. */
|
|
35
|
+
list() {
|
|
36
|
+
return [...this.providers.keys()];
|
|
37
|
+
}
|
|
38
|
+
/** Direct access to a registered provider. */
|
|
39
|
+
get(alias) {
|
|
40
|
+
return this.providers.get(alias);
|
|
41
|
+
}
|
|
42
|
+
async complete(req) {
|
|
43
|
+
return this.resolve(req).complete(req);
|
|
44
|
+
}
|
|
45
|
+
async *stream(req) {
|
|
46
|
+
yield* this.resolve(req).stream(req);
|
|
47
|
+
}
|
|
48
|
+
resolve(req) {
|
|
49
|
+
const alias = req.model || this.default;
|
|
50
|
+
if (!alias) {
|
|
51
|
+
throw new Error("ProviderRouter: no model specified and no default registered");
|
|
52
|
+
}
|
|
53
|
+
const p = this.providers.get(alias);
|
|
54
|
+
if (!p) {
|
|
55
|
+
throw new Error(`ProviderRouter: no provider registered for "${alias}". Registered: ${this.list().join(", ") || "(none)"}`);
|
|
56
|
+
}
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
}
|