@tianmucreations/jeeves 0.2.1 → 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/README.md +79 -18
- package/bin/jeeves +8 -1
- package/dist/agent/auto-ids.js +66 -0
- package/dist/agent/auto.js +178 -0
- package/dist/agent/context.js +55 -13
- package/dist/agent/errors.js +83 -22
- package/dist/agent/expert-chat.js +33 -0
- package/dist/agent/housekeeping.js +55 -0
- package/dist/agent/loop.js +168 -12
- package/dist/agent/permissions.js +167 -0
- package/dist/agent/research-gate.js +267 -0
- package/dist/agent/review.js +135 -0
- package/dist/agent/spending.js +73 -0
- package/dist/agent/systemPrompt.js +112 -0
- package/dist/app.js +25 -10
- package/dist/checkpoints/index.js +103 -0
- package/dist/checkpoints/store.js +239 -0
- package/dist/commands/address.js +5 -0
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +7 -4
- package/dist/commands/keys.js +1 -1
- package/dist/commands/verbose.js +1 -1
- package/dist/components/AddressPrompt.js +31 -0
- package/dist/components/Footer.js +74 -102
- package/dist/components/Input.js +76 -25
- package/dist/components/KeysManager.js +65 -20
- package/dist/components/ModelPicker.js +348 -75
- package/dist/components/ProjectPicker.js +4 -1
- package/dist/components/Transcript.js +29 -14
- package/dist/components/input-layout.js +34 -0
- package/dist/components/transcript-layout.js +13 -17
- package/dist/index.js +25 -7
- package/dist/ink/AlternateScreen.js +33 -16
- package/dist/ink/cursor.js +18 -0
- package/dist/ink/mouse.js +48 -0
- package/dist/keys/store.js +2 -1
- package/dist/models/registry.js +18 -2
- package/dist/platform/config.js +63 -7
- package/dist/providers/catalogue.js +293 -0
- package/dist/providers/direct-services.js +65 -0
- package/dist/providers/direct.js +145 -0
- package/dist/providers/index.js +123 -13
- package/dist/providers/models-snapshot.js +1037 -0
- package/dist/providers/ollama.js +21 -4
- package/dist/providers/openrouter.js +39 -4
- package/dist/providers/step-control.js +28 -0
- package/dist/providers/zai.js +31 -11
- package/dist/state/session.js +90 -36
- package/dist/state/today-spend.js +26 -0
- package/dist/tools/index.js +118 -10
- package/dist/tools/runBash.js +58 -11
- package/dist/tools/web/htmlToText.js +32 -0
- package/dist/tools/web/openrouterChat.js +31 -0
- package/dist/tools/web/research.js +191 -0
- package/package.json +32 -6
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { DIRECT_SERVICES, directService } from './direct-services.js';
|
|
2
|
+
import { getDirectCatalogue, setDirectCatalogue } from '../platform/config.js';
|
|
3
|
+
import { MODELS_SNAPSHOT } from './models-snapshot.js';
|
|
4
|
+
// Model lists and prices for the direct connections.
|
|
5
|
+
// - What each model can do and costs comes from models.dev (MIT licence), the open
|
|
6
|
+
// catalogue OpenCode uses; refreshed daily, saved between launches, and a copy is
|
|
7
|
+
// built into Jeeves so a first launch without internet still has prices.
|
|
8
|
+
// - Which models a key can actually use comes live from the company itself, so a
|
|
9
|
+
// retired model or one the key has no access to is never offered.
|
|
10
|
+
export const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
// Jeeves's rulebook and tools alone take several thousand tokens; smaller models can't work.
|
|
13
|
+
const MIN_CONTEXT = 32_000;
|
|
14
|
+
function num(value) {
|
|
15
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
// Keeps only what Jeeves needs from the full models.dev file (about 5 MB, 200+ services).
|
|
18
|
+
export function trimCatalogue(body) {
|
|
19
|
+
const out = {};
|
|
20
|
+
if (typeof body !== 'object' || body === null)
|
|
21
|
+
return out;
|
|
22
|
+
for (const service of DIRECT_SERVICES) {
|
|
23
|
+
const entry = body[service.id];
|
|
24
|
+
if (!entry || typeof entry.models !== 'object' || entry.models === null)
|
|
25
|
+
continue;
|
|
26
|
+
const models = [];
|
|
27
|
+
for (const raw of Object.values(entry.models)) {
|
|
28
|
+
if (typeof raw !== 'object' || raw === null)
|
|
29
|
+
continue;
|
|
30
|
+
const m = raw;
|
|
31
|
+
const output = m.modalities?.output;
|
|
32
|
+
const input = m.modalities?.input;
|
|
33
|
+
const context = num(m.limit?.context) ?? 0;
|
|
34
|
+
if (typeof m.id !== 'string' || m.tool_call !== true || m.status === 'deprecated')
|
|
35
|
+
continue;
|
|
36
|
+
if (!Array.isArray(output) || output.length !== 1 || output[0] !== 'text')
|
|
37
|
+
continue;
|
|
38
|
+
if (!Array.isArray(input) || !input.includes('text'))
|
|
39
|
+
continue;
|
|
40
|
+
if (context < MIN_CONTEXT)
|
|
41
|
+
continue;
|
|
42
|
+
const inputPrice = num(m.cost?.input);
|
|
43
|
+
const outputPrice = num(m.cost?.output);
|
|
44
|
+
models.push({
|
|
45
|
+
id: m.id,
|
|
46
|
+
name: typeof m.name === 'string' && m.name ? m.name : m.id,
|
|
47
|
+
released: typeof m.release_date === 'string' ? m.release_date : '',
|
|
48
|
+
context,
|
|
49
|
+
...(inputPrice !== undefined && outputPrice !== undefined
|
|
50
|
+
? {
|
|
51
|
+
cost: {
|
|
52
|
+
input: inputPrice,
|
|
53
|
+
output: outputPrice,
|
|
54
|
+
...(num(m.cost?.cache_read) !== undefined ? { cacheRead: num(m.cost.cache_read) } : {}),
|
|
55
|
+
...(num(m.cost?.cache_write) !== undefined ? { cacheWrite: num(m.cost.cache_write) } : {}),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
out[service.id] = models;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
let loaded = null;
|
|
66
|
+
// The catalogue: today's saved copy, else a fresh download, else the last saved copy,
|
|
67
|
+
// else the copy built into Jeeves. Never fails.
|
|
68
|
+
export async function loadCatalogue(now = Date.now()) {
|
|
69
|
+
if (loaded)
|
|
70
|
+
return loaded;
|
|
71
|
+
const saved = getDirectCatalogue();
|
|
72
|
+
if (saved && now - saved.fetchedAt < DAY_MS) {
|
|
73
|
+
loaded = saved.catalogue;
|
|
74
|
+
return loaded;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(MODELS_DEV_URL, { signal: AbortSignal.timeout(20_000) });
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
throw new Error(`models.dev returned ${response.status}`);
|
|
80
|
+
const trimmed = trimCatalogue(await response.json());
|
|
81
|
+
if (Object.keys(trimmed).length === 0)
|
|
82
|
+
throw new Error('models.dev returned no models');
|
|
83
|
+
setDirectCatalogue(trimmed, now);
|
|
84
|
+
loaded = trimmed;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
loaded = saved?.catalogue ?? MODELS_SNAPSHOT;
|
|
88
|
+
}
|
|
89
|
+
return loaded;
|
|
90
|
+
}
|
|
91
|
+
// For tests.
|
|
92
|
+
export function resetCatalogue() {
|
|
93
|
+
loaded = null;
|
|
94
|
+
}
|
|
95
|
+
// The price list of one model, once the catalogue has loaded (for cost estimates).
|
|
96
|
+
export function priceOf(serviceId, modelId) {
|
|
97
|
+
const service = directService(serviceId);
|
|
98
|
+
if (!service)
|
|
99
|
+
return undefined;
|
|
100
|
+
const models = (loaded ?? MODELS_SNAPSHOT)[service.id] ?? [];
|
|
101
|
+
return models.find((model) => model.id === modelId)?.cost;
|
|
102
|
+
}
|
|
103
|
+
// What a step cost by the company's price list, in dollars. An estimate: a model with
|
|
104
|
+
// dearer rates above some length (models.dev "tiers") is charged at its base rate.
|
|
105
|
+
export function estimateCost(price, usage) {
|
|
106
|
+
if (!price)
|
|
107
|
+
return 0;
|
|
108
|
+
const input = usage.inputTokens ?? 0;
|
|
109
|
+
const cacheRead = usage.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
110
|
+
const cacheWrite = usage.inputTokenDetails?.cacheWriteTokens ?? 0;
|
|
111
|
+
const fresh = usage.inputTokenDetails?.noCacheTokens ?? Math.max(0, input - cacheRead - cacheWrite);
|
|
112
|
+
const dollars = fresh * price.input +
|
|
113
|
+
cacheRead * (price.cacheRead ?? price.input) +
|
|
114
|
+
cacheWrite * (price.cacheWrite ?? price.input) +
|
|
115
|
+
(usage.outputTokens ?? 0) * price.output;
|
|
116
|
+
return dollars / 1_000_000;
|
|
117
|
+
}
|
|
118
|
+
export function toModelInfo(serviceId, model) {
|
|
119
|
+
return {
|
|
120
|
+
id: model.id,
|
|
121
|
+
name: model.name,
|
|
122
|
+
contextLength: model.context,
|
|
123
|
+
// Per token, the unit the picker's price column uses for OpenRouter.
|
|
124
|
+
promptPrice: model.cost ? model.cost.input / 1_000_000 : 0,
|
|
125
|
+
completionPrice: model.cost ? model.cost.output / 1_000_000 : 0,
|
|
126
|
+
supportedParameters: ['tools'],
|
|
127
|
+
provider: serviceId,
|
|
128
|
+
...(model.cost ? {} : { priceLabel: 'price not listed' }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Each company's own model list, which also proves the key works. Free to call.
|
|
132
|
+
export function modelListRequest(serviceId, key) {
|
|
133
|
+
const bearer = { Authorization: `Bearer ${key}` };
|
|
134
|
+
switch (serviceId) {
|
|
135
|
+
case 'anthropic':
|
|
136
|
+
return { url: 'https://api.anthropic.com/v1/models?limit=1000', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
|
|
137
|
+
case 'openai':
|
|
138
|
+
return { url: 'https://api.openai.com/v1/models', headers: bearer };
|
|
139
|
+
case 'google':
|
|
140
|
+
return { url: 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000', headers: { 'x-goog-api-key': key } };
|
|
141
|
+
case 'xai':
|
|
142
|
+
return { url: 'https://api.x.ai/v1/models', headers: bearer };
|
|
143
|
+
case 'mistral':
|
|
144
|
+
return { url: 'https://api.mistral.ai/v1/models', headers: bearer };
|
|
145
|
+
case 'groq':
|
|
146
|
+
return { url: 'https://api.groq.com/openai/v1/models', headers: bearer };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Model ids from a list reply: {data:[{id}]} for most, {models:[{name:"models/x"}]} for Google.
|
|
150
|
+
export function idsFromList(body) {
|
|
151
|
+
const ids = new Set();
|
|
152
|
+
if (typeof body !== 'object' || body === null)
|
|
153
|
+
return ids;
|
|
154
|
+
const record = body;
|
|
155
|
+
for (const item of Array.isArray(record.data) ? record.data : []) {
|
|
156
|
+
const id = item?.id;
|
|
157
|
+
if (typeof id === 'string')
|
|
158
|
+
ids.add(id);
|
|
159
|
+
}
|
|
160
|
+
for (const item of Array.isArray(record.models) ? record.models : []) {
|
|
161
|
+
const name = item?.name;
|
|
162
|
+
if (typeof name === 'string')
|
|
163
|
+
ids.add(name.replace(/^models\//, ''));
|
|
164
|
+
}
|
|
165
|
+
return ids;
|
|
166
|
+
}
|
|
167
|
+
// A refused key: 401/403 from most; Google says 400 "API key not valid" (measured 18 Sept).
|
|
168
|
+
export function isRejection(status, text) {
|
|
169
|
+
return status === 401 || status === 403 || (status === 400 && /api key/i.test(text));
|
|
170
|
+
}
|
|
171
|
+
export async function checkKey(serviceId, key) {
|
|
172
|
+
const request = modelListRequest(serviceId, key);
|
|
173
|
+
try {
|
|
174
|
+
const response = await fetch(request.url, { headers: request.headers, signal: AbortSignal.timeout(15_000) });
|
|
175
|
+
const text = await response.text();
|
|
176
|
+
if (isRejection(response.status, text))
|
|
177
|
+
return { ok: false, reason: 'rejected' };
|
|
178
|
+
if (!response.ok)
|
|
179
|
+
return { ok: false, reason: 'unreachable' };
|
|
180
|
+
return { ok: true, ids: idsFromList(JSON.parse(text)) };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { ok: false, reason: 'unreachable' };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// The picker's list for one company: newest first, its everyday model at the top.
|
|
187
|
+
// Limited to what the key can use when the company answered; the whole catalogue
|
|
188
|
+
// list otherwise (a model the key can't use then fails with a plain message).
|
|
189
|
+
export function directModelList(serviceId, catalogue, available) {
|
|
190
|
+
const service = directService(serviceId);
|
|
191
|
+
const all = catalogue[serviceId] ?? [];
|
|
192
|
+
const usable = available && available.size > 0 ? all.filter((model) => available.has(model.id)) : all;
|
|
193
|
+
const sorted = [...usable].sort((a, b) => b.released.localeCompare(a.released));
|
|
194
|
+
const everyday = service?.defaults.find((id) => sorted.some((model) => model.id === id));
|
|
195
|
+
const ordered = everyday ? [sorted.find((model) => model.id === everyday), ...sorted.filter((model) => model.id !== everyday)] : sorted;
|
|
196
|
+
return ordered.map((model) => toModelInfo(serviceId, model));
|
|
197
|
+
}
|
|
198
|
+
const liveLists = new Map();
|
|
199
|
+
// Loads one company's models for the picker, asking the company which the key can use
|
|
200
|
+
// (remembered for this session).
|
|
201
|
+
export async function loadDirectModels(serviceId, key) {
|
|
202
|
+
const catalogue = await loadCatalogue();
|
|
203
|
+
let available = liveLists.get(serviceId) ?? null;
|
|
204
|
+
if (!available) {
|
|
205
|
+
const check = await checkKey(serviceId, key);
|
|
206
|
+
if (check.ok) {
|
|
207
|
+
available = check.ids;
|
|
208
|
+
liveLists.set(serviceId, available);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const models = directModelList(serviceId, catalogue, available);
|
|
212
|
+
rememberModels(models);
|
|
213
|
+
return models;
|
|
214
|
+
}
|
|
215
|
+
// Forget a company's live list when its key changes.
|
|
216
|
+
export function forgetLiveList(serviceId) {
|
|
217
|
+
liveLists.delete(serviceId);
|
|
218
|
+
}
|
|
219
|
+
// The company's everyday model, for when a key is first added.
|
|
220
|
+
export async function everydayModel(serviceId, key) {
|
|
221
|
+
const models = await loadDirectModels(serviceId, key);
|
|
222
|
+
return models[0]?.id ?? directService(serviceId)?.defaults[0] ?? null;
|
|
223
|
+
}
|
|
224
|
+
// Models seen this session from direct and compatible services, so the rest of Jeeves
|
|
225
|
+
// can look up a model's memory size without knowing where it came from.
|
|
226
|
+
const seen = new Map();
|
|
227
|
+
export function rememberModels(models) {
|
|
228
|
+
for (const model of models)
|
|
229
|
+
seen.set(`${model.provider}:${model.id}`, model);
|
|
230
|
+
}
|
|
231
|
+
export function seenModels(serviceId) {
|
|
232
|
+
return [...seen.values()].filter((model) => model.provider === serviceId);
|
|
233
|
+
}
|
|
234
|
+
export function findSeenModel(serviceId, modelId) {
|
|
235
|
+
return seen.get(`${serviceId}:${modelId}`);
|
|
236
|
+
}
|
|
237
|
+
// The "any compatible service": the address as pasted, tidied - https:// added when
|
|
238
|
+
// missing, and a pasted request address (…/chat/completions) cut back to its base.
|
|
239
|
+
export function tidyAddress(raw) {
|
|
240
|
+
let address = raw.trim().replace(/\/+$/, '');
|
|
241
|
+
if (!/^https?:\/\//i.test(address))
|
|
242
|
+
address = `https://${address}`;
|
|
243
|
+
return address.replace(/\/chat\/completions$/i, '').replace(/\/models$/i, '');
|
|
244
|
+
}
|
|
245
|
+
// Asks a compatible service for its models. Tries the address as given, then with
|
|
246
|
+
// /v1 added (most services live there). Returns the working address and its models,
|
|
247
|
+
// and whether that proved the key: some services (OpenRouter, measured 18 Sept) show
|
|
248
|
+
// their model list to anyone, so a wrong key would pass unnoticed.
|
|
249
|
+
export async function checkCustomService(raw, key) {
|
|
250
|
+
const base = tidyAddress(raw);
|
|
251
|
+
const candidates = /\/v\d+$/.test(base) ? [base] : [base, `${base}/v1`];
|
|
252
|
+
let rejected = false;
|
|
253
|
+
for (const baseURL of candidates) {
|
|
254
|
+
try {
|
|
255
|
+
const response = await fetch(`${baseURL}/models`, {
|
|
256
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
257
|
+
signal: AbortSignal.timeout(15_000),
|
|
258
|
+
});
|
|
259
|
+
const text = await response.text();
|
|
260
|
+
if (isRejection(response.status, text)) {
|
|
261
|
+
rejected = true;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!response.ok)
|
|
265
|
+
continue;
|
|
266
|
+
const ids = [...idsFromList(JSON.parse(text))].sort();
|
|
267
|
+
if (ids.length === 0)
|
|
268
|
+
continue;
|
|
269
|
+
const models = ids.map((id) => customModelInfo(id));
|
|
270
|
+
rememberModels(models);
|
|
271
|
+
const open = await fetch(`${baseURL}/models`, { signal: AbortSignal.timeout(15_000) }).then((reply) => reply.ok, () => false);
|
|
272
|
+
return { ok: true, baseURL, models, keyChecked: !open };
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Try the next form of the address.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { ok: false, reason: rejected ? 'rejected' : 'unreachable' };
|
|
279
|
+
}
|
|
280
|
+
// A compatible service says nothing about prices or tools. Assumption: it can use
|
|
281
|
+
// tools (a model that can't will say so when asked to do a task).
|
|
282
|
+
export function customModelInfo(id) {
|
|
283
|
+
return {
|
|
284
|
+
id,
|
|
285
|
+
name: id,
|
|
286
|
+
contextLength: 0,
|
|
287
|
+
promptPrice: 0,
|
|
288
|
+
completionPrice: 0,
|
|
289
|
+
supportedParameters: ['tools'],
|
|
290
|
+
provider: 'custom',
|
|
291
|
+
priceLabel: 'price not listed',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The model makers Jeeves connects to directly, each with the user's own key.
|
|
2
|
+
// A module with no imports, so the catalogue, key screens and picker can all use it.
|
|
3
|
+
//
|
|
4
|
+
// Approach copied from OpenCode (anomalyco/opencode, packages/opencode/src/provider):
|
|
5
|
+
// the official AI SDK package for each company, and prices and abilities from the
|
|
6
|
+
// open models.dev catalogue (MIT licence), which OpenCode also uses.
|
|
7
|
+
export const DIRECT_SERVICES = [
|
|
8
|
+
{
|
|
9
|
+
id: 'anthropic',
|
|
10
|
+
label: 'Anthropic',
|
|
11
|
+
keyPage: 'console.anthropic.com/settings/keys',
|
|
12
|
+
defaults: ['claude-sonnet-5', 'claude-sonnet-4-6', 'claude-sonnet-4-5'],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'openai',
|
|
16
|
+
label: 'OpenAI',
|
|
17
|
+
keyPage: 'platform.openai.com/api-keys',
|
|
18
|
+
defaults: ['gpt-5.6-terra', 'gpt-5.5', 'gpt-5.4'],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'google',
|
|
22
|
+
label: 'Google',
|
|
23
|
+
keyPage: 'aistudio.google.com/apikey',
|
|
24
|
+
defaults: ['gemini-3.8-flash', 'gemini-flash-latest', 'gemini-3.5-flash'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'xai',
|
|
28
|
+
label: 'xAI (Grok)',
|
|
29
|
+
keyPage: 'console.x.ai',
|
|
30
|
+
defaults: ['grok-4.6', 'grok-4.5', 'grok-4.3'],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'mistral',
|
|
34
|
+
label: 'Mistral',
|
|
35
|
+
keyPage: 'console.mistral.ai/api-keys',
|
|
36
|
+
defaults: ['mistral-medium-latest', 'mistral-large-latest', 'mistral-small-latest'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'groq',
|
|
40
|
+
label: 'Groq',
|
|
41
|
+
keyPage: 'console.groq.com/keys',
|
|
42
|
+
defaults: ['openai/gpt-oss-120b', 'qwen/qwen3.8-27b', 'llama-3.3-70b-versatile'],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
// "Any compatible service": an address and a key the person pastes in.
|
|
46
|
+
export const CUSTOM_SERVICE_ID = 'custom';
|
|
47
|
+
export function directService(id) {
|
|
48
|
+
return DIRECT_SERVICES.find((service) => service.id === id);
|
|
49
|
+
}
|
|
50
|
+
export function isDirectService(id) {
|
|
51
|
+
return directService(id) !== undefined;
|
|
52
|
+
}
|
|
53
|
+
// Services whose cost Jeeves works out from a price list (not reported by the service).
|
|
54
|
+
export function isEstimatedCostService(id) {
|
|
55
|
+
return isDirectService(id) || id === CUSTOM_SERVICE_ID;
|
|
56
|
+
}
|
|
57
|
+
// The name a compatible service is known by: its web address without "api." or "www.".
|
|
58
|
+
export function serviceNameFor(baseURL) {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(baseURL).hostname.replace(/^(api|www)\./, '');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return 'your service';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { streamText, stepCountIs } from 'ai';
|
|
2
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
3
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
4
|
+
import { createGoogle } from '@ai-sdk/google';
|
|
5
|
+
import { createXai } from '@ai-sdk/xai';
|
|
6
|
+
import { createMistral } from '@ai-sdk/mistral';
|
|
7
|
+
import { createGroq } from '@ai-sdk/groq';
|
|
8
|
+
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
9
|
+
import { prepareStepFor } from './step-control.js';
|
|
10
|
+
import { directService, serviceNameFor, CUSTOM_SERVICE_ID } from './direct-services.js';
|
|
11
|
+
import { estimateCost, priceOf } from './catalogue.js';
|
|
12
|
+
const MAX_TOOL_STEPS = 25;
|
|
13
|
+
// Each company's official AI SDK package, at its default model type (OpenCode does the same).
|
|
14
|
+
export function modelFactory(serviceId, apiKey) {
|
|
15
|
+
switch (serviceId) {
|
|
16
|
+
case 'anthropic': {
|
|
17
|
+
const client = createAnthropic({ apiKey });
|
|
18
|
+
return (id) => client.languageModel(id);
|
|
19
|
+
}
|
|
20
|
+
case 'openai': {
|
|
21
|
+
const client = createOpenAI({ apiKey });
|
|
22
|
+
return (id) => client.languageModel(id);
|
|
23
|
+
}
|
|
24
|
+
case 'google': {
|
|
25
|
+
const client = createGoogle({ apiKey });
|
|
26
|
+
return (id) => client.languageModel(id);
|
|
27
|
+
}
|
|
28
|
+
case 'xai': {
|
|
29
|
+
const client = createXai({ apiKey });
|
|
30
|
+
return (id) => client.languageModel(id);
|
|
31
|
+
}
|
|
32
|
+
case 'mistral': {
|
|
33
|
+
const client = createMistral({ apiKey });
|
|
34
|
+
return (id) => client.languageModel(id);
|
|
35
|
+
}
|
|
36
|
+
case 'groq': {
|
|
37
|
+
const client = createGroq({ apiKey });
|
|
38
|
+
return (id) => client.languageModel(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const ANTHROPIC_CACHE = { anthropic: { cacheControl: { type: 'ephemeral' } } };
|
|
43
|
+
// Anthropic only reuses the unchanged start of a conversation (at a tenth of the
|
|
44
|
+
// price) when told where it ends. As OpenCode does: mark the rulebook and the last
|
|
45
|
+
// two messages. Anthropic allows four marks, so marks on older messages are removed,
|
|
46
|
+
// and the stored conversation is never changed (copies are marked).
|
|
47
|
+
export function markForCaching(messages) {
|
|
48
|
+
const firstMarked = messages.length - 2;
|
|
49
|
+
return messages.map((message, index) => {
|
|
50
|
+
const options = message.providerOptions;
|
|
51
|
+
if (index >= firstMarked) {
|
|
52
|
+
return { ...message, providerOptions: { ...options, anthropic: { ...options?.anthropic, ...ANTHROPIC_CACHE.anthropic } } };
|
|
53
|
+
}
|
|
54
|
+
if (!options?.anthropic?.cacheControl)
|
|
55
|
+
return message;
|
|
56
|
+
const { cacheControl, ...keep } = options.anthropic;
|
|
57
|
+
void cacheControl;
|
|
58
|
+
const { anthropic, ...others } = options;
|
|
59
|
+
void anthropic;
|
|
60
|
+
return { ...message, providerOptions: Object.keys(keep).length > 0 ? { ...others, anthropic: keep } : others };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function streamWith(id, name, modelFor, costOf, caching) {
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
name,
|
|
67
|
+
async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
|
|
68
|
+
const stepCostOf = (step) => costOf(step.model?.modelId ?? modelId, step.usage ?? {});
|
|
69
|
+
const prepare = prepareStepFor(beforeStep, modelFor, stepCostOf);
|
|
70
|
+
const system = caching && instructions ? { role: 'system', content: instructions, providerOptions: ANTHROPIC_CACHE } : instructions;
|
|
71
|
+
const result = streamText({
|
|
72
|
+
instructions: system,
|
|
73
|
+
// The same limits on silence as the other services (see openrouter.ts).
|
|
74
|
+
timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
|
|
75
|
+
model: modelFor(modelId),
|
|
76
|
+
messages: caching ? markForCaching(messages) : messages,
|
|
77
|
+
tools,
|
|
78
|
+
stopWhen: stepCountIs(MAX_TOOL_STEPS),
|
|
79
|
+
// The marks move to the newest messages at every step of a job.
|
|
80
|
+
prepareStep: prepare || caching
|
|
81
|
+
? async (step) => {
|
|
82
|
+
const control = prepare ? await prepare(step) : {};
|
|
83
|
+
if (!caching)
|
|
84
|
+
return control;
|
|
85
|
+
return { ...control, messages: markForCaching(control.messages ?? step.messages) };
|
|
86
|
+
}
|
|
87
|
+
: undefined,
|
|
88
|
+
abortSignal,
|
|
89
|
+
// The library prints every failure to the screen by default, over Jeeves's
|
|
90
|
+
// window; the failure still arrives below and is explained in plain English.
|
|
91
|
+
onError: () => { },
|
|
92
|
+
});
|
|
93
|
+
let streamedError = null;
|
|
94
|
+
for await (const part of result.stream) {
|
|
95
|
+
if (part.type === 'text-delta') {
|
|
96
|
+
onToken(part.text);
|
|
97
|
+
}
|
|
98
|
+
else if (part.type === 'reasoning-delta') {
|
|
99
|
+
onReasoning(part.text);
|
|
100
|
+
}
|
|
101
|
+
else if (part.type === 'tool-call') {
|
|
102
|
+
onToolCall({ id: part.toolCallId, name: part.toolName });
|
|
103
|
+
}
|
|
104
|
+
else if (part.type === 'error') {
|
|
105
|
+
streamedError = part.error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// The real stream error (a rejected key, a missing model) must win over the
|
|
109
|
+
// SDK's generic no-output error, which would otherwise mask the cause.
|
|
110
|
+
if (streamedError !== null) {
|
|
111
|
+
throw streamedError instanceof Error ? streamedError : new Error(String(streamedError));
|
|
112
|
+
}
|
|
113
|
+
const text = await result.text;
|
|
114
|
+
const finalStep = await result.finalStep;
|
|
115
|
+
const responseMessages = await result.responseMessages;
|
|
116
|
+
const usage = await result.usage;
|
|
117
|
+
const steps = await result.steps;
|
|
118
|
+
return {
|
|
119
|
+
text,
|
|
120
|
+
reasoning: finalStep.reasoningText ?? '',
|
|
121
|
+
messages: responseMessages,
|
|
122
|
+
usage: {
|
|
123
|
+
input: usage.inputTokens ?? 0,
|
|
124
|
+
output: usage.outputTokens ?? 0,
|
|
125
|
+
total: usage.totalTokens ?? 0,
|
|
126
|
+
cached: usage.inputTokenDetails?.cacheReadTokens ?? 0,
|
|
127
|
+
},
|
|
128
|
+
cost: 0,
|
|
129
|
+
rateLimit: null,
|
|
130
|
+
// Worked out from the price list: these services don't report a cost.
|
|
131
|
+
stepCosts: steps.map(stepCostOf),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function createDirectProvider(serviceId, apiKey) {
|
|
137
|
+
const service = directService(serviceId);
|
|
138
|
+
return streamWith(serviceId, service.label, modelFactory(serviceId, apiKey), (modelId, usage) => estimateCost(priceOf(serviceId, modelId), usage), serviceId === 'anthropic');
|
|
139
|
+
}
|
|
140
|
+
// The "any compatible service": most AI services accept the OpenAI request format at
|
|
141
|
+
// an address ending in /v1. Its prices are unknown, so its cost can't be estimated.
|
|
142
|
+
export function createCustomProvider(baseURL, apiKey) {
|
|
143
|
+
const client = createOpenAICompatible({ name: 'custom', baseURL, apiKey, includeUsage: true });
|
|
144
|
+
return streamWith(CUSTOM_SERVICE_ID, serviceNameFor(baseURL), (id) => client.chatModel(id), () => 0, false);
|
|
145
|
+
}
|