@aistupidlevel/mcp 0.0.0-stage → 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 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +40 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +61 -0
- package/dist/data-api.d.ts +76 -0
- package/dist/data-api.js +216 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +7 -0
- package/dist/format.d.ts +20 -0
- package/dist/format.js +47 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/router-api.d.ts +33 -0
- package/dist/router-api.js +131 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +514 -0
- package/package.json +33 -4
package/dist/data-api.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the ASL Data API (https://aistupidlevel.info/api/v1).
|
|
3
|
+
*
|
|
4
|
+
* A free key allows 10 requests a day and 1 a minute, so every response is cached in memory
|
|
5
|
+
* (default 15 minutes, keyed by URL), identical requests in flight are shared, refusals that will
|
|
6
|
+
* not change (403, 404) are cached too, and once the daily quota is spent no further request is
|
|
7
|
+
* sent until it resets. When a request cannot be made, an older cached answer is served with a
|
|
8
|
+
* note saying how old it is.
|
|
9
|
+
*/
|
|
10
|
+
import { LINKS, VERSION } from './config.js';
|
|
11
|
+
import { ToolError } from './errors.js';
|
|
12
|
+
/** How long an expired answer may still be served when the API cannot be reached. */
|
|
13
|
+
const STALE_LIMIT_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
15
|
+
const nextUtcMidnight = () => {
|
|
16
|
+
const d = new Date();
|
|
17
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
18
|
+
};
|
|
19
|
+
const norm = (s) => s.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
20
|
+
export class DataApi {
|
|
21
|
+
cfg;
|
|
22
|
+
cache = new Map();
|
|
23
|
+
inflight = new Map();
|
|
24
|
+
models = new Map();
|
|
25
|
+
modelsSeenAt = 0;
|
|
26
|
+
/** The newest leaderboard (/models) answer, for showing a model's row without a request. */
|
|
27
|
+
lastBoard = null;
|
|
28
|
+
dailyBlockedUntil = 0;
|
|
29
|
+
dailyBlockedMessage = '';
|
|
30
|
+
lastQuota = null;
|
|
31
|
+
/** Network requests actually sent (for tests and diagnostics). */
|
|
32
|
+
requestsSent = 0;
|
|
33
|
+
constructor(cfg) {
|
|
34
|
+
this.cfg = cfg;
|
|
35
|
+
}
|
|
36
|
+
get hasKey() { return !!this.cfg.dataKey; }
|
|
37
|
+
/** The quota from the newest response, or null once its day is over. */
|
|
38
|
+
get quota() {
|
|
39
|
+
return this.lastQuota && (this.lastQuota.resetAt ?? Infinity) > Date.now() ? this.lastQuota : null;
|
|
40
|
+
}
|
|
41
|
+
/** The error a data tool returns when no Data API key is configured. */
|
|
42
|
+
missingKeyError() {
|
|
43
|
+
const why = this.cfg.warnings.data ? `${this.cfg.warnings.data} ` : '';
|
|
44
|
+
return new ToolError(`${why}This tool reads the AI Stupid Level Data API and needs a Data API key (asl_live_…). ` +
|
|
45
|
+
`Create one at ${LINKS.dataKeys} (a free key allows 10 requests a day and 1 a minute), then set ` +
|
|
46
|
+
`ASL_DATA_KEY in the "env" section of this MCP server's configuration and restart the client. ` +
|
|
47
|
+
`API documentation: ${LINKS.dataDocs}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* GET a Data API path. `query` values that are undefined are left out, so "let the API choose"
|
|
51
|
+
* and an explicit value are cached separately, exactly as the API would answer them.
|
|
52
|
+
*/
|
|
53
|
+
async get(path, query = {}) {
|
|
54
|
+
if (!this.cfg.dataKey)
|
|
55
|
+
throw this.missingKeyError();
|
|
56
|
+
const qs = Object.entries(query)
|
|
57
|
+
.filter(([, v]) => v !== undefined && v !== '')
|
|
58
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
59
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
60
|
+
.join('&');
|
|
61
|
+
const url = `${this.cfg.dataBaseURL}${path}${qs ? `?${qs}` : ''}`;
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const hit = this.cache.get(url);
|
|
64
|
+
if (hit && hit.expires > now) {
|
|
65
|
+
if (hit.error)
|
|
66
|
+
throw new ToolError(hit.error);
|
|
67
|
+
return { body: hit.body, fetchedAt: hit.fetchedAt, cached: true };
|
|
68
|
+
}
|
|
69
|
+
if (this.dailyBlockedUntil > now)
|
|
70
|
+
return this.staleOr(url, this.dailyBlockedMessage);
|
|
71
|
+
let pending = this.inflight.get(url);
|
|
72
|
+
if (!pending) {
|
|
73
|
+
pending = this.fetchUrl(url).finally(() => this.inflight.delete(url));
|
|
74
|
+
this.inflight.set(url, pending);
|
|
75
|
+
}
|
|
76
|
+
return pending;
|
|
77
|
+
}
|
|
78
|
+
/** Resolve a model given as a numeric id, a model name (claude-opus-5-5) or a display name. */
|
|
79
|
+
async resolveModel(input) {
|
|
80
|
+
const raw = String(input).trim();
|
|
81
|
+
if (/^\d+$/.test(raw)) {
|
|
82
|
+
const id = Number(raw);
|
|
83
|
+
for (const m of this.models.values())
|
|
84
|
+
if (m.id === id)
|
|
85
|
+
return m;
|
|
86
|
+
return { id, name: raw, displayName: null };
|
|
87
|
+
}
|
|
88
|
+
if (!this.models.size || Date.now() - this.modelsSeenAt > STALE_LIMIT_MS) {
|
|
89
|
+
// One request teaches us every id; later lookups are free.
|
|
90
|
+
await this.get('/models');
|
|
91
|
+
}
|
|
92
|
+
const want = norm(raw);
|
|
93
|
+
const exact = this.models.get(want);
|
|
94
|
+
if (exact)
|
|
95
|
+
return exact;
|
|
96
|
+
const unique = [...new Map([...this.models.values()].map(m => [m.id, m])).values()];
|
|
97
|
+
const partial = unique.filter(m => norm(m.name).includes(want) || (m.displayName && norm(m.displayName).includes(want)));
|
|
98
|
+
if (partial.length === 1)
|
|
99
|
+
return partial[0];
|
|
100
|
+
const list = (partial.length ? partial : unique).map(m => `${m.name} (id ${m.id})`).join(', ');
|
|
101
|
+
throw new ToolError(partial.length
|
|
102
|
+
? `"${raw}" matches more than one model: ${list}. Pass the exact name or the numeric id.`
|
|
103
|
+
: `No tracked model matches "${raw}". Tracked models: ${list}.`);
|
|
104
|
+
}
|
|
105
|
+
/** Every model the last /models answer listed (no request is made). */
|
|
106
|
+
knownModel(id) {
|
|
107
|
+
for (const m of this.models.values())
|
|
108
|
+
if (m.id === id)
|
|
109
|
+
return m;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
learnModels(body) {
|
|
113
|
+
if (!Array.isArray(body?.data))
|
|
114
|
+
return;
|
|
115
|
+
for (const m of body.data) {
|
|
116
|
+
const id = Number(m?.id);
|
|
117
|
+
if (!Number.isFinite(id) || typeof m?.name !== 'string')
|
|
118
|
+
continue;
|
|
119
|
+
const ref = { id, name: m.name, displayName: typeof m.displayName === 'string' ? m.displayName : null };
|
|
120
|
+
this.models.set(norm(m.name), ref);
|
|
121
|
+
if (ref.displayName)
|
|
122
|
+
this.models.set(norm(ref.displayName), ref);
|
|
123
|
+
}
|
|
124
|
+
this.modelsSeenAt = Date.now();
|
|
125
|
+
this.lastBoard = body;
|
|
126
|
+
}
|
|
127
|
+
staleOr(url, message) {
|
|
128
|
+
const old = this.cache.get(url);
|
|
129
|
+
if (old?.body && Date.now() - old.fetchedAt < STALE_LIMIT_MS) {
|
|
130
|
+
return { body: old.body, fetchedAt: old.fetchedAt, cached: true, staleReason: message };
|
|
131
|
+
}
|
|
132
|
+
throw new ToolError(message);
|
|
133
|
+
}
|
|
134
|
+
readQuota(h) {
|
|
135
|
+
const limit = Number(h.get('x-ratelimit-limit'));
|
|
136
|
+
const remaining = Number(h.get('x-ratelimit-remaining'));
|
|
137
|
+
const reset = Number(h.get('x-ratelimit-reset'));
|
|
138
|
+
// The daily quota's reset is an epoch timestamp. The per-minute limiter sends the same header
|
|
139
|
+
// names with its own values (limit 1, reset in seconds), which say nothing about the day.
|
|
140
|
+
if (h.get('x-ratelimit-limit') !== null && Number.isFinite(limit) && Number.isFinite(remaining) && reset > 1e9) {
|
|
141
|
+
this.lastQuota = { limit, remaining, resetAt: reset * 1000 };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async fetchUrl(url, attempt = 0) {
|
|
145
|
+
let res;
|
|
146
|
+
try {
|
|
147
|
+
this.requestsSent++;
|
|
148
|
+
res = await this.cfg.fetch(url, {
|
|
149
|
+
method: 'GET',
|
|
150
|
+
headers: { Authorization: `Bearer ${this.cfg.dataKey}`, Accept: 'application/json', 'User-Agent': `aistupidlevel-mcp/${VERSION}` },
|
|
151
|
+
signal: AbortSignal.timeout(30_000),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
return this.staleOr(url, `Could not reach the AI Stupid Level Data API (${err?.message ?? err}). Try again shortly.`);
|
|
156
|
+
}
|
|
157
|
+
this.readQuota(res.headers);
|
|
158
|
+
let body = null;
|
|
159
|
+
const text = await res.text().catch(() => '');
|
|
160
|
+
try {
|
|
161
|
+
body = text ? JSON.parse(text) : null;
|
|
162
|
+
}
|
|
163
|
+
catch { /* not JSON */ }
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (res.ok && body && typeof body === 'object') {
|
|
166
|
+
const entry = { body, fetchedAt: now, expires: now + this.cfg.cacheTtlMs };
|
|
167
|
+
this.cache.set(url, entry);
|
|
168
|
+
if (/\/models(\?|$)/.test(url))
|
|
169
|
+
this.learnModels(body);
|
|
170
|
+
return { body, fetchedAt: now, cached: false };
|
|
171
|
+
}
|
|
172
|
+
const apiMessage = typeof body?.message === 'string' ? body.message : '';
|
|
173
|
+
const code = typeof body?.error === 'string' ? body.error : '';
|
|
174
|
+
if (res.status === 401) {
|
|
175
|
+
throw new ToolError(`The Data API rejected the key in ASL_DATA_KEY (${code || 'HTTP 401'}${apiMessage ? `: ${apiMessage}` : ''}). ` +
|
|
176
|
+
`Check it, or create a new one at ${LINKS.dataKeys}.`);
|
|
177
|
+
}
|
|
178
|
+
if (res.status === 429) {
|
|
179
|
+
const quota = this.lastQuota;
|
|
180
|
+
if (code === 'quota_exceeded') {
|
|
181
|
+
this.dailyBlockedUntil = quota?.resetAt && quota.resetAt > now ? quota.resetAt : nextUtcMidnight();
|
|
182
|
+
const mins = Math.max(1, Math.round((this.dailyBlockedUntil - now) / 60_000));
|
|
183
|
+
this.dailyBlockedMessage =
|
|
184
|
+
`The Data API daily limit for this key is used up. ${apiMessage || 'The quota resets at 00:00 UTC.'} ` +
|
|
185
|
+
`That is in about ${mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`}; until then this server sends no more requests. ` +
|
|
186
|
+
`A free key allows 10 requests a day and 1 a minute; paid plans raise this to 10,000 a day or more: ${LINKS.pricing}`;
|
|
187
|
+
return this.staleOr(url, this.dailyBlockedMessage);
|
|
188
|
+
}
|
|
189
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
190
|
+
const wait = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 60_000;
|
|
191
|
+
if (attempt === 0 && wait <= this.cfg.maxWaitMs) {
|
|
192
|
+
await sleep(wait);
|
|
193
|
+
return this.fetchUrl(url, 1);
|
|
194
|
+
}
|
|
195
|
+
return this.staleOr(url, `The Data API per-minute limit was reached; try again in ${Math.ceil(wait / 1000)} s. ` +
|
|
196
|
+
`A free key allows 1 request a minute and 10 a day (answers are cached here for ${Math.round(this.cfg.cacheTtlMs / 60_000)} min, ` +
|
|
197
|
+
`so repeating a question costs nothing). Higher limits: ${LINKS.pricing}`);
|
|
198
|
+
}
|
|
199
|
+
if (res.status === 403 || res.status === 404 || res.status === 400) {
|
|
200
|
+
let msg;
|
|
201
|
+
if (code === 'latest_requires_plan') {
|
|
202
|
+
msg = `${apiMessage || 'The latest benchmark run is part of the paid plans.'} Plans: ${typeof body?.pricing === 'string' ? body.pricing : LINKS.pricing}`;
|
|
203
|
+
}
|
|
204
|
+
else if (code === 'model_not_found' || res.status === 404) {
|
|
205
|
+
msg = `AI Stupid Level does not track a model with that id${body?.model ? ` (${body.model})` : ''}. Use get_leaderboard to see the tracked models and their ids.`;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
msg = `The Data API refused the request (HTTP ${res.status}${code ? ` ${code}` : ''}${apiMessage ? `: ${apiMessage}` : ''}).`;
|
|
209
|
+
}
|
|
210
|
+
// The answer will not change within the cache period; asking again would only spend quota.
|
|
211
|
+
this.cache.set(url, { error: msg, fetchedAt: now, expires: now + this.cfg.cacheTtlMs });
|
|
212
|
+
throw new ToolError(msg);
|
|
213
|
+
}
|
|
214
|
+
return this.staleOr(url, `The AI Stupid Level Data API answered HTTP ${res.status}${apiMessage ? ` (${apiMessage})` : ''}. Try again shortly.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Plain-text rendering of API answers: compact, readable by people and models alike. */
|
|
2
|
+
import type { DataResult, Quota } from './data-api.js';
|
|
3
|
+
/** 2026-10-20T06:00:00.239Z → 2026-10-20 06:00 UTC */
|
|
4
|
+
export declare function when(v: unknown): string;
|
|
5
|
+
export declare function ago(ms: number): string;
|
|
6
|
+
export declare const pct: (v: unknown) => string;
|
|
7
|
+
export declare const num: (v: unknown, digits?: number) => string;
|
|
8
|
+
/** "Claude Opus 5.5 (claude-opus-5-5, id 292)" */
|
|
9
|
+
export declare function modelLabel(m: {
|
|
10
|
+
displayName?: unknown;
|
|
11
|
+
name?: unknown;
|
|
12
|
+
modelName?: unknown;
|
|
13
|
+
id?: unknown;
|
|
14
|
+
modelId?: unknown;
|
|
15
|
+
}): string;
|
|
16
|
+
/** Source line, cache age and quota left: appended to every Data API answer. */
|
|
17
|
+
export declare function footer(r: DataResult, quota: Quota | null): string;
|
|
18
|
+
/** $0.012, $0.00075, $1.25: two significant digits below a dollar. */
|
|
19
|
+
export declare const usd: (v: number) => string;
|
|
20
|
+
export declare function truncate(s: string, max: number): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** 2026-10-20T06:00:00.239Z → 2026-10-20 06:00 UTC */
|
|
2
|
+
export function when(v) {
|
|
3
|
+
if (v === null || v === undefined || v === '')
|
|
4
|
+
return 'unknown';
|
|
5
|
+
const d = new Date(typeof v === 'string' && /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(v) ? `${v.replace(' ', 'T')}Z` : v);
|
|
6
|
+
if (Number.isNaN(d.getTime()))
|
|
7
|
+
return String(v);
|
|
8
|
+
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
9
|
+
}
|
|
10
|
+
export function ago(ms) {
|
|
11
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
12
|
+
if (s < 90)
|
|
13
|
+
return `${s} s`;
|
|
14
|
+
const m = Math.round(s / 60);
|
|
15
|
+
if (m < 90)
|
|
16
|
+
return `${m} min`;
|
|
17
|
+
const h = Math.round(m / 60);
|
|
18
|
+
return h < 48 ? `${h} h` : `${Math.round(h / 24)} days`;
|
|
19
|
+
}
|
|
20
|
+
export const pct = (v) => (typeof v === 'number' && Number.isFinite(v) ? `${Math.round(v * 100)}%` : 'n/a');
|
|
21
|
+
export const num = (v, digits = 0) => (typeof v === 'number' && Number.isFinite(v) ? (digits ? v.toFixed(digits) : String(Math.round(v))) : v === undefined || v === null ? 'n/a' : String(v));
|
|
22
|
+
/** "Claude Opus 5.5 (claude-opus-5-5, id 292)" */
|
|
23
|
+
export function modelLabel(m) {
|
|
24
|
+
const name = (m.name ?? m.modelName);
|
|
25
|
+
const id = m.id ?? m.modelId;
|
|
26
|
+
const display = typeof m.displayName === 'string' && m.displayName ? m.displayName : null;
|
|
27
|
+
const inner = [display && name && display !== name ? name : null, id !== undefined && id !== null ? `id ${id}` : null].filter(Boolean).join(', ');
|
|
28
|
+
return `${display ?? name ?? 'unknown model'}${inner ? ` (${inner})` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
/** Source line, cache age and quota left: appended to every Data API answer. */
|
|
31
|
+
export function footer(r, quota) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
const gen = r.body?.generated_at ? `, generated ${when(r.body.generated_at)}` : '';
|
|
34
|
+
lines.push(`Source: AI Stupid Level Data API (aistupidlevel.info)${gen}. Attribution required: cite aistupidlevel.info as the source.`);
|
|
35
|
+
if (r.staleReason)
|
|
36
|
+
lines.push(`NOTE: this is an older cached answer, fetched ${ago(Date.now() - r.fetchedAt)} ago, because a fresh one could not be fetched: ${r.staleReason}`);
|
|
37
|
+
else if (r.cached)
|
|
38
|
+
lines.push(`Served from this server's cache (fetched ${ago(Date.now() - r.fetchedAt)} ago; no request was spent).`);
|
|
39
|
+
if (quota)
|
|
40
|
+
lines.push(`Data API requests left today for this key: ${quota.remaining} of ${quota.limit}.`);
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
/** $0.012, $0.00075, $1.25: two significant digits below a dollar. */
|
|
44
|
+
export const usd = (v) => `$${v >= 1 ? v.toFixed(2) : Number(v.toPrecision(2))}`;
|
|
45
|
+
export function truncate(s, max) {
|
|
46
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
|
47
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few Smart Router calls the MCP tools make (https://aistupidlevel.info/v1): route preview,
|
|
3
|
+
* chat completion and usage. Same conventions as @aistupidlevel/router: Bearer aism_ key,
|
|
4
|
+
* X-ASL-Sandbox for mock answers, routing read from the X-AISM-* response headers, and an
|
|
5
|
+
* Idempotency-Key on chat so a retried request is never charged twice.
|
|
6
|
+
*/
|
|
7
|
+
import { type ResolvedConfig } from './config.js';
|
|
8
|
+
import { ToolError } from './errors.js';
|
|
9
|
+
export interface Routing {
|
|
10
|
+
provider: string | null;
|
|
11
|
+
model: string | null;
|
|
12
|
+
reasoning: string | null;
|
|
13
|
+
latencyMs: number | null;
|
|
14
|
+
requestId: string | null;
|
|
15
|
+
sandbox: boolean;
|
|
16
|
+
fallback: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface RouterResult<T = any> {
|
|
19
|
+
data: T;
|
|
20
|
+
routing: Routing;
|
|
21
|
+
}
|
|
22
|
+
export declare const ROUTING_MODES: readonly ["auto", "auto-best", "auto-coding", "auto-reasoning", "auto-tooling", "auto-task", "auto-value", "auto-value-coding", "auto-value-reasoning", "auto-value-tooling", "auto-fastest-quality", "auto-consistent", "auto-fastest", "auto-cheapest"];
|
|
23
|
+
export declare class RouterApi {
|
|
24
|
+
private readonly cfg;
|
|
25
|
+
requestsSent: number;
|
|
26
|
+
constructor(cfg: ResolvedConfig);
|
|
27
|
+
get hasKey(): boolean;
|
|
28
|
+
missingKeyError(): ToolError;
|
|
29
|
+
request<T = any>(method: 'GET' | 'POST', path: string, body?: unknown, opts?: {
|
|
30
|
+
sandbox?: boolean;
|
|
31
|
+
idempotent?: boolean;
|
|
32
|
+
}): Promise<RouterResult<T>>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few Smart Router calls the MCP tools make (https://aistupidlevel.info/v1): route preview,
|
|
3
|
+
* chat completion and usage. Same conventions as @aistupidlevel/router: Bearer aism_ key,
|
|
4
|
+
* X-ASL-Sandbox for mock answers, routing read from the X-AISM-* response headers, and an
|
|
5
|
+
* Idempotency-Key on chat so a retried request is never charged twice.
|
|
6
|
+
*/
|
|
7
|
+
import { LINKS, VERSION } from './config.js';
|
|
8
|
+
import { ToolError } from './errors.js';
|
|
9
|
+
export const ROUTING_MODES = [
|
|
10
|
+
'auto', 'auto-best', 'auto-coding', 'auto-reasoning', 'auto-tooling', 'auto-task',
|
|
11
|
+
'auto-value', 'auto-value-coding', 'auto-value-reasoning', 'auto-value-tooling',
|
|
12
|
+
'auto-fastest-quality', 'auto-consistent', 'auto-fastest', 'auto-cheapest',
|
|
13
|
+
];
|
|
14
|
+
function routingFrom(h) {
|
|
15
|
+
const reasoning = h.get('x-aism-reasoning');
|
|
16
|
+
const latency = h.get('x-aism-latency');
|
|
17
|
+
return {
|
|
18
|
+
provider: h.get('x-aism-provider'),
|
|
19
|
+
model: h.get('x-aism-model'),
|
|
20
|
+
reasoning,
|
|
21
|
+
latencyMs: latency !== null && latency !== '' && Number.isFinite(Number(latency)) ? Number(latency) : null,
|
|
22
|
+
requestId: h.get('x-aism-request-id'),
|
|
23
|
+
sandbox: h.get('x-aism-sandbox') === 'true',
|
|
24
|
+
fallback: !!reasoning && /fallback activated/i.test(reasoning),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const newIdempotencyKey = () => {
|
|
28
|
+
const c = globalThis.crypto;
|
|
29
|
+
if (c?.randomUUID)
|
|
30
|
+
return `asl-mcp-${c.randomUUID()}`;
|
|
31
|
+
return `asl-mcp-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
|
32
|
+
};
|
|
33
|
+
/** A router error in words that say who has to act and where. */
|
|
34
|
+
function explain(status, body) {
|
|
35
|
+
const err = body && typeof body === 'object' && body.error && typeof body.error === 'object' ? body.error : {};
|
|
36
|
+
const code = typeof err.code === 'string' ? err.code : '';
|
|
37
|
+
const msg = typeof err.message === 'string' && err.message ? err.message : `HTTP ${status}`;
|
|
38
|
+
switch (code) {
|
|
39
|
+
case 'allowance_exhausted':
|
|
40
|
+
return `The Smart Router plan's monthly allowance is used up and there are no prepaid credits (${msg}). Upgrade or buy credits: ${LINKS.billing}`;
|
|
41
|
+
case 'insufficient_quota':
|
|
42
|
+
return `This router key has reached its hard monthly budget (${msg}). Raise it at ${LINKS.routerKeys}`;
|
|
43
|
+
case 'no_provider_keys':
|
|
44
|
+
return `The Smart Router has no provider key to call a model with (${msg}). Add an OpenAI, Anthropic, Google, DeepSeek, Kimi or GLM key at ${LINKS.providerKeys}, or set sandbox: true to test the routing without one.`;
|
|
45
|
+
case 'provider_key_rejected':
|
|
46
|
+
return `A provider refused the provider key saved in your account (${msg}). Fix it at ${LINKS.providerKeys}`;
|
|
47
|
+
case 'provider_quota_exceeded':
|
|
48
|
+
return `Your provider account is out of credit or quota (${msg}).`;
|
|
49
|
+
case 'no_routable_model':
|
|
50
|
+
return `Your routing preferences exclude every model for this request (${msg}). Review them at ${LINKS.routerDocs}`;
|
|
51
|
+
case 'model_unavailable':
|
|
52
|
+
return `Every model in the fallback chain failed on the provider side (${msg}). Try again shortly.`;
|
|
53
|
+
case 'rate_limited':
|
|
54
|
+
return `The Smart Router is rate limiting this key (${msg}). Try again shortly.`;
|
|
55
|
+
}
|
|
56
|
+
if (status === 401)
|
|
57
|
+
return `The Smart Router rejected the key in ASL_ROUTER_KEY (${msg}). Check it, or create one at ${LINKS.routerKeys}`;
|
|
58
|
+
if (status === 403)
|
|
59
|
+
return `This router key may not do that (${msg}).`;
|
|
60
|
+
if (status === 404 && /model/i.test(msg))
|
|
61
|
+
return `${msg} Use one of the routing modes (auto, auto-coding, …) or a model id from the leaderboard.`;
|
|
62
|
+
return `The Smart Router answered HTTP ${status}${code ? ` ${code}` : ''}: ${msg}`;
|
|
63
|
+
}
|
|
64
|
+
export class RouterApi {
|
|
65
|
+
cfg;
|
|
66
|
+
requestsSent = 0;
|
|
67
|
+
constructor(cfg) {
|
|
68
|
+
this.cfg = cfg;
|
|
69
|
+
}
|
|
70
|
+
get hasKey() { return !!this.cfg.routerKey; }
|
|
71
|
+
missingKeyError() {
|
|
72
|
+
const why = this.cfg.warnings.router ? `${this.cfg.warnings.router} ` : '';
|
|
73
|
+
return new ToolError(`${why}This tool uses the AI Stupid Level Smart Router and needs a router key (aism_…). ` +
|
|
74
|
+
`Create one at ${LINKS.routerKeys} and add at least one provider key (OpenAI, Anthropic, Google, ` +
|
|
75
|
+
`DeepSeek, Kimi or GLM) in your account, then set ASL_ROUTER_KEY in the "env" section of this MCP ` +
|
|
76
|
+
`server's configuration and restart the client. Documentation: ${LINKS.routerDocs}`);
|
|
77
|
+
}
|
|
78
|
+
async request(method, path, body, opts = {}) {
|
|
79
|
+
if (!this.cfg.routerKey)
|
|
80
|
+
throw this.missingKeyError();
|
|
81
|
+
const headers = {
|
|
82
|
+
Authorization: `Bearer ${this.cfg.routerKey}`,
|
|
83
|
+
Accept: 'application/json',
|
|
84
|
+
'X-ASL-Client': `asl-mcp/${VERSION}`,
|
|
85
|
+
'User-Agent': `aistupidlevel-mcp/${VERSION}`,
|
|
86
|
+
};
|
|
87
|
+
if (body !== undefined)
|
|
88
|
+
headers['Content-Type'] = 'application/json';
|
|
89
|
+
if (opts.sandbox || this.cfg.forceSandbox)
|
|
90
|
+
headers['X-ASL-Sandbox'] = 'true';
|
|
91
|
+
if (opts.idempotent)
|
|
92
|
+
headers['Idempotency-Key'] = newIdempotencyKey();
|
|
93
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
94
|
+
const url = `${this.cfg.routerBaseURL}${path}`;
|
|
95
|
+
// One retry for a dropped connection or a 5xx; the idempotency key makes that safe for chat.
|
|
96
|
+
for (let attempt = 0;; attempt++) {
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
this.requestsSent++;
|
|
100
|
+
res = await this.cfg.fetch(url, { method, headers, body: payload, signal: AbortSignal.timeout(this.cfg.routerTimeoutMs) });
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (attempt === 0) {
|
|
104
|
+
await new Promise(r => setTimeout(r, 500));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const e = err;
|
|
108
|
+
throw new ToolError(e?.name === 'TimeoutError'
|
|
109
|
+
? `The Smart Router did not answer within ${Math.round(this.cfg.routerTimeoutMs / 1000)} s.`
|
|
110
|
+
: `Could not reach the Smart Router (${e?.message ?? err}).`);
|
|
111
|
+
}
|
|
112
|
+
const text = await res.text().catch(() => '');
|
|
113
|
+
let data = null;
|
|
114
|
+
try {
|
|
115
|
+
data = text ? JSON.parse(text) : null;
|
|
116
|
+
}
|
|
117
|
+
catch { /* not JSON */ }
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
if (data === null)
|
|
120
|
+
throw new ToolError('The Smart Router returned a response that is not JSON.');
|
|
121
|
+
return { data, routing: routingFrom(res.headers) };
|
|
122
|
+
}
|
|
123
|
+
const retryable = res.status >= 500 && res.headers.get('x-should-retry') !== 'false';
|
|
124
|
+
if (attempt === 0 && retryable) {
|
|
125
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
throw new ToolError(explain(res.status, data));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server: tools over the ASL Data API (rankings, history, drift, incidents, providers,
|
|
3
|
+
* recommendations) and the ASL Smart Router (route preview, routed prompts, usage).
|
|
4
|
+
*
|
|
5
|
+
* Every Data API tool makes at most one request, and usually none: answers are cached, and model
|
|
6
|
+
* names are resolved from the cached leaderboard. Tools that need a key that is not configured
|
|
7
|
+
* answer with a tool error saying which key and where to get it.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { type ServerOptions } from './config.js';
|
|
11
|
+
export declare function createServer(options?: ServerOptions): McpServer;
|