@aria-framework/ai 0.2.0 → 0.3.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/index.js +2 -0
- package/package.json +3 -3
- package/providerStore.js +200 -0
- package/usageStore.js +7 -4
package/index.js
CHANGED
|
@@ -174,6 +174,8 @@ module.exports = {
|
|
|
174
174
|
// is an OPTIONAL peer — a consumer using only createAiClient/polish/facts must not be made to
|
|
175
175
|
// install a database package to require this one.
|
|
176
176
|
get createUsageStore() { return require('./usageStore').createUsageStore; },
|
|
177
|
+
get createProviderStore() { return require('./providerStore').createProviderStore; },
|
|
178
|
+
get providerSchemaFor() { return require('./providerStore').schemaFor; },
|
|
177
179
|
get usageSchemaFor() { return require('./usageStore').schemaFor; },
|
|
178
180
|
createAiClient,
|
|
179
181
|
PROVIDERS, DEFAULTS,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ai",
|
|
3
3
|
"description": "Aria App Framework — AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"generate.js",
|
|
17
17
|
"providers/openai-compatible.js",
|
|
18
18
|
"providers/anthropic.js",
|
|
19
|
-
"browser/ai-polish.js", "usageStore.js"
|
|
19
|
+
"browser/ai-polish.js", "usageStore.js", "providerStore.js"
|
|
20
20
|
],
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@aria-framework/db-worker": ">=0.7.0"
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
"@aria-framework/db-worker": { "optional": true }
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/smoke.js && node test/usageStore.js"
|
|
28
|
+
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/providerStore.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider registry — every endpoint the app can talk to, configured once.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Support101's AI settings page is one flat form with eleven fields describing a
|
|
5
|
+
* single provider, and nine of those eleven are really per-endpoint. That shape collapses the
|
|
6
|
+
* moment a second endpoint exists, which is what a backup provider is. The fix is not a
|
|
7
|
+
* `backup_base_url` beside every field — that is the same form doubled, and it caps at two.
|
|
8
|
+
*
|
|
9
|
+
* A PROVIDER IS AN ENDPOINT, not a job. It has a connection, a model or two, capability limits and
|
|
10
|
+
* its own spend guard, and it can be health-checked. Routes reference providers by id, so one
|
|
11
|
+
* endpoint can be one route's primary and another's fallback without being configured twice.
|
|
12
|
+
*
|
|
13
|
+
* ── THE ID IS THE OPERATOR'S ────────────────────────────────────────────────────────────────────
|
|
14
|
+
* `local-fast`, `gpu-box-1`, whatever they like. NO CALL SITE EVER NAMES A PROVIDER — code names a
|
|
15
|
+
* route, the operator decides what serves it. That is the whole reason failover is possible, and
|
|
16
|
+
* it means renaming a provider must break nothing.
|
|
17
|
+
*
|
|
18
|
+
* ── ONE ENDPOINT CAN SERVE CHAT AND EMBEDDINGS ──────────────────────────────────────────────────
|
|
19
|
+
* `model` and `embedding_model` are separate columns on the SAME row because that is how the
|
|
20
|
+
* servers actually work: one LM Studio instance answers both. Splitting them into two provider
|
|
21
|
+
* rows would duplicate the connection, and then its health twice. A chat route reads `model`, an
|
|
22
|
+
* embedding route reads `embedding_model`.
|
|
23
|
+
*
|
|
24
|
+
* ── THE API KEY IS NOT IN THIS TABLE ────────────────────────────────────────────────────────────
|
|
25
|
+
* It lives in whatever secret store the app already has — Support101 keeps it in an encrypted
|
|
26
|
+
* credentials database beside its Entra secret. So `secrets` is INJECTED, the package never sees a
|
|
27
|
+
* credential at rest, and `resolve()` is the only thing that pulls one. Keys are write-only from
|
|
28
|
+
* the UI's point of view: there is no read-back, because a lost LLM key is rotated at the
|
|
29
|
+
* provider, not recovered.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
/** Columns the caller may set. `id` is separate — it is the key and is never updated in place. */
|
|
35
|
+
const FIELDS = [
|
|
36
|
+
'label', 'kind', 'base_url', 'model', 'embedding_model',
|
|
37
|
+
'context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled', 'sort_order'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const NUMERIC = new Set(['context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled', 'sort_order']);
|
|
41
|
+
|
|
42
|
+
/** An id an operator typed, constrained so it can appear in a URL and a log line unescaped. */
|
|
43
|
+
function assertId(id) {
|
|
44
|
+
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/.test(id)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`provider id ${JSON.stringify(id)} is not usable: 3-40 characters, lowercase letters, ` +
|
|
47
|
+
'digits and hyphens, not starting or ending with a hyphen.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* driver @aria-framework/db-worker driver contract (required)
|
|
56
|
+
* table default 'ai_providers'
|
|
57
|
+
* secrets { get(id) => Promise<string> } — the app's credential store. Optional; without it
|
|
58
|
+
* resolve() returns an empty apiKey, which is correct for local endpoints.
|
|
59
|
+
* defaults { [kind]: { baseUrl, model, label } } — the package's PROVIDER DEFAULTS, so a row
|
|
60
|
+
* may leave base_url or model blank and still resolve to something usable.
|
|
61
|
+
*/
|
|
62
|
+
function createProviderStore(opts = {}) {
|
|
63
|
+
const driver = opts.driver;
|
|
64
|
+
if (!driver || typeof driver.run !== 'function') {
|
|
65
|
+
throw new Error('createProviderStore({ driver }): the db-worker driver contract is required');
|
|
66
|
+
}
|
|
67
|
+
const table = opts.table || 'ai_providers';
|
|
68
|
+
const secrets = opts.secrets || null;
|
|
69
|
+
const defaults = opts.defaults || {};
|
|
70
|
+
|
|
71
|
+
const clean = (p) => {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const f of FIELDS) {
|
|
74
|
+
if (p[f] === undefined) continue;
|
|
75
|
+
out[f] = NUMERIC.has(f) ? (Number(p[f]) || 0) : (p[f] == null ? null : String(p[f]));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
table,
|
|
82
|
+
|
|
83
|
+
/** Every provider, in the operator's chosen order. */
|
|
84
|
+
async all() {
|
|
85
|
+
return driver.all(`SELECT * FROM ${table} ORDER BY sort_order, id`);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** Only the ones that may be used. A disabled provider stays configured but is never called. */
|
|
89
|
+
async enabled() {
|
|
90
|
+
return driver.all(`SELECT * FROM ${table} WHERE enabled = 1 ORDER BY sort_order, id`);
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async byId(id) {
|
|
94
|
+
return driver.get(`SELECT * FROM ${table} WHERE id = ?`, [String(id)]);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async create(p = {}) {
|
|
98
|
+
const id = assertId(p.id);
|
|
99
|
+
if (await this.byId(id)) throw new Error(`a provider called ${JSON.stringify(id)} already exists`);
|
|
100
|
+
if (!p.kind) throw new Error('a provider needs a kind (the adapter that talks to it)');
|
|
101
|
+
const fields = clean(p);
|
|
102
|
+
const cols = ['id'].concat(Object.keys(fields));
|
|
103
|
+
const vals = [id].concat(Object.values(fields));
|
|
104
|
+
await driver.run(
|
|
105
|
+
`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, vals);
|
|
106
|
+
return { id };
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Update in place. The ID IS NOT UPDATABLE here on purpose: routes reference providers by id,
|
|
111
|
+
* so renaming one would silently orphan every chain that points at it. A rename is a create
|
|
112
|
+
* plus a repoint plus a delete, which is a deliberate act rather than a typo in a text field.
|
|
113
|
+
*/
|
|
114
|
+
async update(id, p = {}) {
|
|
115
|
+
const existing = await this.byId(id);
|
|
116
|
+
if (!existing) throw new Error(`no provider called ${JSON.stringify(id)}`);
|
|
117
|
+
const fields = clean(p);
|
|
118
|
+
const keys = Object.keys(fields);
|
|
119
|
+
if (!keys.length) return { changes: 0 };
|
|
120
|
+
const r = await driver.run(
|
|
121
|
+
`UPDATE ${table} SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`,
|
|
122
|
+
Object.values(fields).concat([String(id)]));
|
|
123
|
+
return { changes: r.changes };
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async remove(id) {
|
|
127
|
+
const r = await driver.run(`DELETE FROM ${table} WHERE id = ?`, [String(id)]);
|
|
128
|
+
return { changes: r.changes };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Everything needed to make a call, in the shape createAiClient's `resolveConfig` returns.
|
|
133
|
+
*
|
|
134
|
+
* This is the seam that makes adoption a swap rather than a rewrite: an app whose getConfig()
|
|
135
|
+
* read eleven settings keys can point at this instead and change nothing else.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} id
|
|
138
|
+
* @param {{embedding?: boolean}} o resolve the EMBEDDING model rather than the chat one
|
|
139
|
+
*/
|
|
140
|
+
async resolve(id, o = {}) {
|
|
141
|
+
const row = await this.byId(id);
|
|
142
|
+
if (!row) return { provider: 'off', enabled: false, missing: String(id) };
|
|
143
|
+
if (!row.enabled) return { provider: 'off', enabled: false, disabled: String(id) };
|
|
144
|
+
|
|
145
|
+
const d = defaults[row.kind] || {};
|
|
146
|
+
let apiKey = '';
|
|
147
|
+
if (secrets && typeof secrets.get === 'function') {
|
|
148
|
+
// A credential store that is locked or unavailable must not take the whole call down with
|
|
149
|
+
// an exception — a local endpoint needs no key at all, and the adapter's own auth failure
|
|
150
|
+
// is a better error than "the keychain was busy".
|
|
151
|
+
try { apiKey = (await secrets.get(row.id)) || ''; } catch (_) { apiKey = ''; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
enabled: true,
|
|
156
|
+
id: row.id,
|
|
157
|
+
provider: row.kind,
|
|
158
|
+
label: row.label || d.label || row.id,
|
|
159
|
+
baseUrl: row.base_url || d.baseUrl || '',
|
|
160
|
+
model: (o.embedding ? row.embedding_model : row.model) || (o.embedding ? '' : d.model) || '',
|
|
161
|
+
embeddingModel: row.embedding_model || '',
|
|
162
|
+
apiKey,
|
|
163
|
+
timeoutMs: Number(row.timeout_ms) || 60000,
|
|
164
|
+
maxTokens: Number(row.max_tokens) || 1024,
|
|
165
|
+
contextTokens: Number(row.context_tokens) || 8192,
|
|
166
|
+
dailyTokenCap: Number(row.daily_token_cap) || 0
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The DDL, in the caller's dialect.
|
|
174
|
+
*
|
|
175
|
+
* `id` is TEXT and the primary key rather than an autoincrement integer, because it is the name an
|
|
176
|
+
* operator types and the thing routes reference. A surrogate key would mean routes pointed at
|
|
177
|
+
* numbers nobody recognises in a config screen.
|
|
178
|
+
*/
|
|
179
|
+
function schemaFor(dialect) {
|
|
180
|
+
const t = dialect || { now: () => "datetime('now')" };
|
|
181
|
+
return `
|
|
182
|
+
id TEXT PRIMARY KEY,
|
|
183
|
+
label TEXT,
|
|
184
|
+
kind TEXT NOT NULL,
|
|
185
|
+
base_url TEXT,
|
|
186
|
+
model TEXT,
|
|
187
|
+
embedding_model TEXT,
|
|
188
|
+
context_tokens INTEGER NOT NULL DEFAULT 0,
|
|
189
|
+
max_tokens INTEGER NOT NULL DEFAULT 0,
|
|
190
|
+
timeout_ms INTEGER NOT NULL DEFAULT 0,
|
|
191
|
+
-- Per PROVIDER, not global: a ceiling is meaningless on a GPU you already own, and the whole
|
|
192
|
+
-- point of a cheap cloud fallback is capping what it may spend while the primary is down.
|
|
193
|
+
daily_token_cap INTEGER NOT NULL DEFAULT 0,
|
|
194
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
195
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
196
|
+
created_at TEXT NOT NULL DEFAULT (${t.now()})
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { createProviderStore, schemaFor, _assertId: assertId };
|
package/usageStore.js
CHANGED
|
@@ -36,10 +36,13 @@
|
|
|
36
36
|
* working". The clock is therefore injected, and defaults to the host's local date.
|
|
37
37
|
*
|
|
38
38
|
* ── WHAT STAYS IN THE APP ───────────────────────────────────────────────────────────────────────
|
|
39
|
-
* The SCOPE column and its foreign key. Support101 scopes by `ticket_id
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
39
|
+
* The SCOPE column and its foreign key. Support101 scopes by `ticket_id REFERENCES tickets(id)
|
|
40
|
+
* ON DELETE SET NULL` — deleting a ticket KEEPS the usage row and forgets which ticket it belonged
|
|
41
|
+
* to, so the spend history survives the thing it was spent on. (An earlier draft of this comment
|
|
42
|
+
* said CASCADE, which would have been the opposite and wrong: erasing a ticket would erase the
|
|
43
|
+
* record that its budget was ever consumed.) That is real behaviour a generic column could not
|
|
44
|
+
* carry, and app 3 will scope by incident instead. So the package is told the column NAME and the
|
|
45
|
+
* app owns the column, its type and its constraints.
|
|
43
46
|
*/
|
|
44
47
|
|
|
45
48
|
'use strict';
|