fa-model-catalog 0.0.0-stage → 0.1.1
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/.agents/skills/deploy/config/config.example.yaml +27 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/catalog.example.yaml +2216 -0
- package/docs/catalog.md +27 -0
- package/docs/client.md +49 -0
- package/docs/development.md +32 -0
- package/docs/operations.md +58 -0
- package/package.json +62 -4
- package/src/catalog-schemas.js +165 -0
- package/src/catalog.js +108 -0
- package/src/cli/check.js +12 -0
- package/src/cli/server.js +49 -0
- package/src/client.js +317 -0
- package/src/pricing.js +104 -0
- package/src/schedule.js +59 -0
- package/src/server.js +124 -0
- package/src/types/client.d.ts +22 -0
- package/src/types/pricing.d.ts +15 -0
- package/src/types/server.d.ts +21 -0
- package/src/types/types.d.ts +64 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { validateModel } from './catalog-schemas.js';
|
|
2
|
+
import { nextRefreshTime } from './schedule.js';
|
|
3
|
+
|
|
4
|
+
export function createCatalogClient({
|
|
5
|
+
baseUrl,
|
|
6
|
+
token,
|
|
7
|
+
models = [],
|
|
8
|
+
timezone = 'Europe/Moscow',
|
|
9
|
+
timeoutMs = 10000,
|
|
10
|
+
fetch: fetcher = globalThis.fetch,
|
|
11
|
+
now = Date.now,
|
|
12
|
+
random = Math.random,
|
|
13
|
+
setTimer = setTimeout,
|
|
14
|
+
clearTimer = clearTimeout,
|
|
15
|
+
onEvent = () => {},
|
|
16
|
+
}) {
|
|
17
|
+
const url = new URL(baseUrl);
|
|
18
|
+
if (url.username || url.password || url.search || url.hash || !['http:', 'https:'].includes(url.protocol)) {
|
|
19
|
+
throw new Error('Invalid service URL.');
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
|
22
|
+
throw new Error('HTTPS is required for a remote service.');
|
|
23
|
+
}
|
|
24
|
+
if (typeof token !== 'string' || !token || /[\r\n]/.test(token) || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
25
|
+
throw new Error('Invalid client settings.');
|
|
26
|
+
}
|
|
27
|
+
new Intl.DateTimeFormat('en', { timeZone: timezone }).format();
|
|
28
|
+
const cache = new Map();
|
|
29
|
+
const wanted = new Set();
|
|
30
|
+
const pending = new Map();
|
|
31
|
+
const queued = new Set();
|
|
32
|
+
const controllers = new Set();
|
|
33
|
+
let active = false;
|
|
34
|
+
let stopped = false;
|
|
35
|
+
let flushQueued = false;
|
|
36
|
+
let serial = Promise.resolve();
|
|
37
|
+
let scheduleTimer;
|
|
38
|
+
let retryTimer;
|
|
39
|
+
let retryCount = 0;
|
|
40
|
+
let refreshPromise;
|
|
41
|
+
let lastWindow = null;
|
|
42
|
+
let nextScheduledAt = null;
|
|
43
|
+
const emit = (e) => {
|
|
44
|
+
try {
|
|
45
|
+
onEvent(e);
|
|
46
|
+
} catch {
|
|
47
|
+
/* Observer cannot change results. */
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const checkNames = (names) => {
|
|
51
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== 'string' || !n.length || n.length > 512)) {
|
|
52
|
+
throw new Error('Expected an array of nonempty names, each up to 512 characters long.');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
checkNames(models);
|
|
56
|
+
models.forEach((n) => wanted.add(n));
|
|
57
|
+
const result = (name) =>
|
|
58
|
+
structuredClone(
|
|
59
|
+
cache.get(name) ?? {
|
|
60
|
+
requestedName: name,
|
|
61
|
+
matchedName: null,
|
|
62
|
+
canonicalName: null,
|
|
63
|
+
status: 'unavailable',
|
|
64
|
+
model: null,
|
|
65
|
+
catalogVersion: null,
|
|
66
|
+
stale: false,
|
|
67
|
+
lastSuccessAt: null,
|
|
68
|
+
reason: 'Data has not been received yet.',
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
const planRetry = (waitMs = 0) => {
|
|
72
|
+
if (!active || retryTimer || retryCount >= 2) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const base = [300000, 1200000][retryCount++];
|
|
76
|
+
const delay = Math.max(waitMs, base * (0.8 + random() * 0.4));
|
|
77
|
+
retryTimer = setTimer(() => {
|
|
78
|
+
retryTimer = null;
|
|
79
|
+
return refreshInternal(false);
|
|
80
|
+
}, delay);
|
|
81
|
+
retryTimer.unref?.();
|
|
82
|
+
};
|
|
83
|
+
const request = async (names) => {
|
|
84
|
+
if (stopped) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
controllers.add(controller);
|
|
89
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
90
|
+
timeout.unref?.();
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetcher(
|
|
93
|
+
new URL('v1/models/resolve', url.href.endsWith('/') ? url : new URL(`${url.href}/`)),
|
|
94
|
+
{
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({ models: names }),
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
redirect: 'error',
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const retryAfter = response.headers.get('retry-after');
|
|
104
|
+
const retrySeconds =
|
|
105
|
+
retryAfter && /^\d+$/.test(retryAfter)
|
|
106
|
+
? Number(retryAfter) * 1000
|
|
107
|
+
: retryAfter
|
|
108
|
+
? Math.max(0, Date.parse(retryAfter) - now())
|
|
109
|
+
: 0;
|
|
110
|
+
throw Object.assign(new Error(), {
|
|
111
|
+
status: response.status,
|
|
112
|
+
retryAfter: Number.isFinite(retrySeconds) ? retrySeconds : 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const text = await readBounded(response, 8 * 1024 * 1024);
|
|
116
|
+
const data = JSON.parse(text);
|
|
117
|
+
if (
|
|
118
|
+
!Array.isArray(data) ||
|
|
119
|
+
data.length !== names.length ||
|
|
120
|
+
data.some((r, i) => !validRecord(r, names[i])) ||
|
|
121
|
+
new Set(data.map((r) => r.catalogVersion)).size > 1
|
|
122
|
+
) {
|
|
123
|
+
throw Object.assign(new Error(), { status: 400, invalidResponse: true });
|
|
124
|
+
}
|
|
125
|
+
if (stopped) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const success = new Date(now()).toISOString();
|
|
129
|
+
data.forEach((r) => cache.set(r.requestedName, { ...r, stale: false, lastSuccessAt: success, reason: null }));
|
|
130
|
+
emit({ event: 'prices_received', count: data.length, catalogVersion: data[0]?.catalogVersion ?? null });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (stopped) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const reason = e.invalidResponse
|
|
136
|
+
? 'The service returned invalid data.'
|
|
137
|
+
: e.status === 401 || e.status === 403
|
|
138
|
+
? 'The service denied access.'
|
|
139
|
+
: e.status
|
|
140
|
+
? `Could not retrieve prices; response status ${e.status}.`
|
|
141
|
+
: 'The service is unavailable or the request timed out.';
|
|
142
|
+
names.forEach((name) => {
|
|
143
|
+
const old = result(name);
|
|
144
|
+
cache.set(name, { ...old, stale: old.lastSuccessAt !== null, reason });
|
|
145
|
+
});
|
|
146
|
+
emit({ event: 'prices_unavailable', count: names.length, reason });
|
|
147
|
+
if (!e.status || e.status === 429 || e.status >= 500) {
|
|
148
|
+
planRetry(e.retryAfter);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
clearTimeout(timeout);
|
|
152
|
+
controllers.delete(controller);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const enqueue = (names) => {
|
|
156
|
+
const promises = [];
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
if (!pending.has(name)) {
|
|
159
|
+
let resolve;
|
|
160
|
+
const promise = new Promise((done) => {
|
|
161
|
+
resolve = done;
|
|
162
|
+
});
|
|
163
|
+
pending.set(name, { promise, resolve });
|
|
164
|
+
queued.add(name);
|
|
165
|
+
}
|
|
166
|
+
promises.push(pending.get(name).promise);
|
|
167
|
+
}
|
|
168
|
+
if (queued.size && !flushQueued) {
|
|
169
|
+
flushQueued = true;
|
|
170
|
+
queueMicrotask(() => {
|
|
171
|
+
flushQueued = false;
|
|
172
|
+
const batch = [...queued];
|
|
173
|
+
queued.clear();
|
|
174
|
+
serial = serial.then(async () => {
|
|
175
|
+
let group = [];
|
|
176
|
+
let bytes = 13;
|
|
177
|
+
for (const name of batch) {
|
|
178
|
+
const size = Buffer.byteLength(JSON.stringify(name)) + 1;
|
|
179
|
+
if (group.length >= 1000 || bytes + size > 250 * 1024) {
|
|
180
|
+
await request(group);
|
|
181
|
+
group = [];
|
|
182
|
+
bytes = 13;
|
|
183
|
+
}
|
|
184
|
+
group.push(name);
|
|
185
|
+
bytes += size;
|
|
186
|
+
}
|
|
187
|
+
if (group.length) {
|
|
188
|
+
await request(group);
|
|
189
|
+
}
|
|
190
|
+
for (const name of batch) {
|
|
191
|
+
pending.get(name)?.resolve();
|
|
192
|
+
pending.delete(name);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return Promise.all(promises);
|
|
198
|
+
};
|
|
199
|
+
const refreshInternal = async (reset) => {
|
|
200
|
+
if (stopped || !active) {
|
|
201
|
+
throw new Error('The client has not been started.');
|
|
202
|
+
}
|
|
203
|
+
if (refreshPromise) {
|
|
204
|
+
return refreshPromise;
|
|
205
|
+
}
|
|
206
|
+
if (reset) {
|
|
207
|
+
retryCount = 0;
|
|
208
|
+
clearTimer(retryTimer);
|
|
209
|
+
retryTimer = null;
|
|
210
|
+
}
|
|
211
|
+
refreshPromise = enqueue([...wanted]).finally(() => {
|
|
212
|
+
refreshPromise = null;
|
|
213
|
+
});
|
|
214
|
+
await refreshPromise;
|
|
215
|
+
return [...wanted].map(result);
|
|
216
|
+
};
|
|
217
|
+
const schedule = () => {
|
|
218
|
+
if (!active) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const next = nextRefreshTime({ now: now(), timezone, random, afterWindow: lastWindow });
|
|
222
|
+
nextScheduledAt = new Date(next.time).toISOString();
|
|
223
|
+
scheduleTimer = setTimer(async () => {
|
|
224
|
+
lastWindow = next.windowId;
|
|
225
|
+
try {
|
|
226
|
+
await refreshInternal(true);
|
|
227
|
+
} finally {
|
|
228
|
+
if (active) {
|
|
229
|
+
schedule();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}, next.time - now());
|
|
233
|
+
scheduleTimer.unref?.();
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
async start() {
|
|
237
|
+
if (stopped) {
|
|
238
|
+
throw new Error('A stopped client cannot be restarted.');
|
|
239
|
+
}
|
|
240
|
+
if (active) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
active = true;
|
|
244
|
+
schedule();
|
|
245
|
+
await refreshInternal(true);
|
|
246
|
+
},
|
|
247
|
+
async get(names) {
|
|
248
|
+
checkNames(names);
|
|
249
|
+
if (!active || stopped) {
|
|
250
|
+
throw new Error('The client has not been started.');
|
|
251
|
+
}
|
|
252
|
+
names.forEach((n) => wanted.add(n));
|
|
253
|
+
await enqueue([...new Set(names)].filter((n) => !cache.has(n)));
|
|
254
|
+
return names.map(result);
|
|
255
|
+
},
|
|
256
|
+
refresh() {
|
|
257
|
+
return refreshInternal(true);
|
|
258
|
+
},
|
|
259
|
+
get nextRefreshAt() {
|
|
260
|
+
return nextScheduledAt;
|
|
261
|
+
},
|
|
262
|
+
stop() {
|
|
263
|
+
active = false;
|
|
264
|
+
stopped = true;
|
|
265
|
+
clearTimer(scheduleTimer);
|
|
266
|
+
clearTimer(retryTimer);
|
|
267
|
+
nextScheduledAt = null;
|
|
268
|
+
controllers.forEach((c) => c.abort());
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function readBounded(response, limit) {
|
|
274
|
+
if (!response.body) {
|
|
275
|
+
throw new Error();
|
|
276
|
+
}
|
|
277
|
+
const reader = response.body.getReader();
|
|
278
|
+
const chunks = [];
|
|
279
|
+
let size = 0;
|
|
280
|
+
try {
|
|
281
|
+
for (;;) {
|
|
282
|
+
const { value, done } = await reader.read();
|
|
283
|
+
if (done) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
size += value.byteLength;
|
|
287
|
+
if (size > limit) {
|
|
288
|
+
throw new Error();
|
|
289
|
+
}
|
|
290
|
+
chunks.push(value);
|
|
291
|
+
}
|
|
292
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
293
|
+
} finally {
|
|
294
|
+
await reader.cancel().catch(() => {});
|
|
295
|
+
reader.releaseLock();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validRecord(r, name) {
|
|
300
|
+
if (
|
|
301
|
+
!r ||
|
|
302
|
+
r.requestedName !== name ||
|
|
303
|
+
typeof r.catalogVersion !== 'string' ||
|
|
304
|
+
!/^[a-f0-9]{64}$/.test(r.catalogVersion)
|
|
305
|
+
) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (r.status === 'not_found') {
|
|
309
|
+
return r.matchedName === null && r.canonicalName === null && r.model === null;
|
|
310
|
+
}
|
|
311
|
+
return (
|
|
312
|
+
r.status === 'found' &&
|
|
313
|
+
typeof r.matchedName === 'string' &&
|
|
314
|
+
typeof r.canonicalName === 'string' &&
|
|
315
|
+
validateModel(r.model)
|
|
316
|
+
);
|
|
317
|
+
}
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fields = {
|
|
2
|
+
standard: ['inp', 'inpC', 'inpW', 'out'],
|
|
3
|
+
flex: ['inpF', 'inpCF', 'inpWF', 'outF'],
|
|
4
|
+
batch: ['inpB', null, null, 'outB'],
|
|
5
|
+
fast: ['inpFast', null, null, 'outFast'],
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function tariffSnapshot(record, { serviceTier = 'standard' } = {}) {
|
|
9
|
+
const model = record?.model;
|
|
10
|
+
if (record?.status !== 'found' || !model || !fields[serviceTier] || !model.modes.includes(serviceTier)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const [input, cachedInput, cacheWriteInput, output] = fields[serviceTier];
|
|
14
|
+
return structuredClone({
|
|
15
|
+
requestedName: record.requestedName,
|
|
16
|
+
matchedName: record.matchedName,
|
|
17
|
+
canonicalName: record.canonicalName,
|
|
18
|
+
catalogVersion: record.catalogVersion,
|
|
19
|
+
currency: 'USD',
|
|
20
|
+
serviceTier,
|
|
21
|
+
billing: model.billing,
|
|
22
|
+
evidence: model.evidence,
|
|
23
|
+
rates: {
|
|
24
|
+
input: model[input] ?? null,
|
|
25
|
+
cachedInput: cachedInput ? (model[cachedInput] ?? null) : null,
|
|
26
|
+
cacheWriteInput: cacheWriteInput ? (model[cacheWriteInput] ?? null) : null,
|
|
27
|
+
output: model[output] ?? null,
|
|
28
|
+
},
|
|
29
|
+
matrix: { low: model.low, medium: model.medium, high: model.high },
|
|
30
|
+
stale: record.stale ?? false,
|
|
31
|
+
lastSuccessAt: record.lastSuccessAt ?? null,
|
|
32
|
+
reason: record.reason ?? null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function calculatePrice(record, usage, { serviceTier = 'standard' } = {}) {
|
|
37
|
+
const snapshot = tariffSnapshot(record, { serviceTier });
|
|
38
|
+
const issues = [];
|
|
39
|
+
const quantities = structuredClone(usage);
|
|
40
|
+
const parts = {};
|
|
41
|
+
const add = (name, quantity, rate, per) => {
|
|
42
|
+
const valid =
|
|
43
|
+
typeof quantity === 'number' &&
|
|
44
|
+
Number.isFinite(quantity) &&
|
|
45
|
+
quantity >= 0 &&
|
|
46
|
+
(snapshot.billing.kind !== 'tokens' || Number.isSafeInteger(quantity));
|
|
47
|
+
const known = typeof rate === 'number' && Number.isFinite(rate) && rate >= 0;
|
|
48
|
+
const cost = valid && (known || quantity === 0) ? (quantity === 0 ? 0 : (quantity / per) * rate) : null;
|
|
49
|
+
parts[name] = { quantity: quantity ?? null, rate, per, costUsd: cost };
|
|
50
|
+
if (!valid) {
|
|
51
|
+
issues.push(`Invalid quantity: ${name}.`);
|
|
52
|
+
} else if (!known && quantity !== 0) {
|
|
53
|
+
issues.push(`Unknown rate: ${name}.`);
|
|
54
|
+
}
|
|
55
|
+
if (cost !== null && !Number.isFinite(cost)) {
|
|
56
|
+
parts[name].costUsd = null;
|
|
57
|
+
issues.push(`Cost overflow: ${name}.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
if (!snapshot) {
|
|
61
|
+
issues.push(record?.status === 'found' ? 'The service tier is not supported.' : 'Pricing is unavailable.');
|
|
62
|
+
} else {
|
|
63
|
+
const { billing, rates } = snapshot;
|
|
64
|
+
if (snapshot.stale) {
|
|
65
|
+
issues.push(`Pricing is stale: ${snapshot.reason || 'The refresh has not been confirmed.'}`);
|
|
66
|
+
}
|
|
67
|
+
if (snapshot.evidence.status === 'insufficient') {
|
|
68
|
+
issues.push(snapshot.evidence.reason);
|
|
69
|
+
}
|
|
70
|
+
if (usage.unit !== billing.unit) {
|
|
71
|
+
issues.push('The usage unit does not match the billing unit.');
|
|
72
|
+
}
|
|
73
|
+
if (billing.kind === 'tokens') {
|
|
74
|
+
const { inputTokens, outputTokens, cachedInputTokens = 0, cacheWriteInputTokens = 0 } = usage;
|
|
75
|
+
const values = [inputTokens, cachedInputTokens, cacheWriteInputTokens];
|
|
76
|
+
const valid =
|
|
77
|
+
values.every((n) => Number.isSafeInteger(n) && n >= 0) &&
|
|
78
|
+
cachedInputTokens + cacheWriteInputTokens <= inputTokens;
|
|
79
|
+
if (!valid) {
|
|
80
|
+
issues.push('Input quantities are inconsistent or are not nonnegative integers.');
|
|
81
|
+
}
|
|
82
|
+
add('input', valid ? inputTokens - cachedInputTokens - cacheWriteInputTokens : null, rates.input, billing.per);
|
|
83
|
+
add('cachedInput', cachedInputTokens, rates.cachedInput, billing.per);
|
|
84
|
+
add('cacheWriteInput', cacheWriteInputTokens, rates.cacheWriteInput, billing.per);
|
|
85
|
+
add('output', outputTokens, rates.output, billing.per);
|
|
86
|
+
} else if (billing.kind === 'quantity') {
|
|
87
|
+
add('quantity', usage.quantity, rates.input, billing.per);
|
|
88
|
+
} else {
|
|
89
|
+
add('quantity', usage.quantity, snapshot.matrix[usage.quality]?.[usage.size] ?? null, billing.per);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const knownSubtotalUsd = Object.values(parts).reduce((sum, p) => sum + (p.costUsd ?? 0), 0);
|
|
93
|
+
const complete =
|
|
94
|
+
Boolean(snapshot) && usage.unit === snapshot.billing.unit && Object.values(parts).every((p) => p.costUsd !== null);
|
|
95
|
+
return {
|
|
96
|
+
usage: quantities,
|
|
97
|
+
snapshot,
|
|
98
|
+
parts,
|
|
99
|
+
knownSubtotalUsd,
|
|
100
|
+
priceUsd: complete ? knownSubtotalUsd : null,
|
|
101
|
+
status: !complete ? 'incomplete' : issues.length ? 'qualified' : 'complete',
|
|
102
|
+
issues,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/schedule.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function partsAt(time, timezone) {
|
|
2
|
+
return Object.fromEntries(
|
|
3
|
+
new Intl.DateTimeFormat('en-CA', {
|
|
4
|
+
timeZone: timezone,
|
|
5
|
+
year: 'numeric',
|
|
6
|
+
month: '2-digit',
|
|
7
|
+
day: '2-digit',
|
|
8
|
+
hour: '2-digit',
|
|
9
|
+
minute: '2-digit',
|
|
10
|
+
second: '2-digit',
|
|
11
|
+
hourCycle: 'h23',
|
|
12
|
+
})
|
|
13
|
+
.formatToParts(time)
|
|
14
|
+
.filter((p) => p.type !== 'literal')
|
|
15
|
+
.map((p) => [p.type, Number(p.value)]),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function localTime(year, month, day, hour, timezone) {
|
|
20
|
+
const target = Date.UTC(year, month - 1, day, hour);
|
|
21
|
+
let time = target;
|
|
22
|
+
for (let i = 0; i < 4; i++) {
|
|
23
|
+
const p = partsAt(time, timezone);
|
|
24
|
+
const shift = target - Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
|
25
|
+
if (!shift) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
time += shift;
|
|
29
|
+
}
|
|
30
|
+
return time;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function nextRefreshTime({
|
|
34
|
+
now = Date.now(),
|
|
35
|
+
timezone = 'Europe/Moscow',
|
|
36
|
+
random = Math.random,
|
|
37
|
+
afterWindow = null,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const p = partsAt(now, timezone);
|
|
40
|
+
for (let offset = 0; offset < 3; offset++) {
|
|
41
|
+
const date = new Date(Date.UTC(p.year, p.month - 1, p.day + offset));
|
|
42
|
+
for (const hour of [1, 13]) {
|
|
43
|
+
const center = localTime(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), hour, timezone);
|
|
44
|
+
const windowId = center;
|
|
45
|
+
if ((afterWindow !== null && windowId <= afterWindow) || center + 1200000 <= now) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const draw = random();
|
|
49
|
+
if (!Number.isFinite(draw) || draw < 0 || draw >= 1) {
|
|
50
|
+
throw new Error('The random number source must return a number between zero and one.');
|
|
51
|
+
}
|
|
52
|
+
const time = center - 1200000 + Math.floor(draw * 2400001);
|
|
53
|
+
if (time > now) {
|
|
54
|
+
return { time, windowId };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Could not select a refresh time.');
|
|
59
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createAccessControl, AccessError } from 'fa-web-access';
|
|
3
|
+
import { createCatalogStore, resolveModels } from './catalog.js';
|
|
4
|
+
|
|
5
|
+
export function createCatalogServer({ catalogFile, accessFile, reloadIntervalMs = 5000, onEvent = () => {} }) {
|
|
6
|
+
if (!catalogFile || !accessFile || !Number.isFinite(reloadIntervalMs) || reloadIntervalMs < 10) {
|
|
7
|
+
throw new Error('Invalid server settings.');
|
|
8
|
+
}
|
|
9
|
+
const store = createCatalogStore({ file: catalogFile, reloadIntervalMs, onEvent });
|
|
10
|
+
const access = createAccessControl({ file: accessFile, reloadIntervalMs, onEvent });
|
|
11
|
+
const send = (res, status, value, headers = {}) => {
|
|
12
|
+
res.writeHead(status, {
|
|
13
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
14
|
+
'Cache-Control': 'no-store',
|
|
15
|
+
...headers,
|
|
16
|
+
});
|
|
17
|
+
res.end(JSON.stringify(value));
|
|
18
|
+
};
|
|
19
|
+
const server = createServer(async (req, res) => {
|
|
20
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
21
|
+
send(res, store.snapshot ? 200 : 503, { status: store.error ? 'degraded' : 'ok' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
access.authenticate(req);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
send(
|
|
28
|
+
res,
|
|
29
|
+
e instanceof AccessError ? e.status : 503,
|
|
30
|
+
{ error: 'Access denied.' },
|
|
31
|
+
e instanceof AccessError ? e.headers : {},
|
|
32
|
+
);
|
|
33
|
+
req.resume();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (req.method !== 'POST' || req.url !== '/v1/models/resolve') {
|
|
37
|
+
send(res, 404, { error: 'Not found.' });
|
|
38
|
+
req.resume();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) {
|
|
43
|
+
send(res, 400, { error: 'JSON is required.' });
|
|
44
|
+
req.resume();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let size = 0;
|
|
49
|
+
for await (const chunk of req) {
|
|
50
|
+
size += chunk.length;
|
|
51
|
+
if (size > 256 * 1024) {
|
|
52
|
+
send(res, 413, { error: 'Request size limit exceeded.' });
|
|
53
|
+
req.resume();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
chunks.push(chunk);
|
|
57
|
+
}
|
|
58
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
59
|
+
if (
|
|
60
|
+
!body ||
|
|
61
|
+
typeof body !== 'object' ||
|
|
62
|
+
Array.isArray(body) ||
|
|
63
|
+
Object.keys(body).some((k) => k !== 'models') ||
|
|
64
|
+
!Array.isArray(body.models) ||
|
|
65
|
+
body.models.length > 1000 ||
|
|
66
|
+
body.models.some((n) => typeof n !== 'string' || !n.length || n.length > 512)
|
|
67
|
+
) {
|
|
68
|
+
send(res, 400, { error: 'Expected an array of at most 1000 nonempty names, each up to 512 characters long.' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
send(res, 200, resolveModels(store.snapshot, body.models));
|
|
72
|
+
} catch {
|
|
73
|
+
if (!res.headersSent) {
|
|
74
|
+
send(res, 400, { error: 'Could not read the request.' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
server.requestTimeout = 10000;
|
|
79
|
+
server.headersTimeout = 10000;
|
|
80
|
+
let closed = false;
|
|
81
|
+
return {
|
|
82
|
+
server,
|
|
83
|
+
store,
|
|
84
|
+
access,
|
|
85
|
+
async listen({ port = 3000, host = '127.0.0.1' } = {}) {
|
|
86
|
+
if (closed) {
|
|
87
|
+
throw new Error('The server has been stopped.');
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await access.load();
|
|
91
|
+
await store.load();
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const fail = (e) => {
|
|
94
|
+
server.off('listening', ready);
|
|
95
|
+
reject(e);
|
|
96
|
+
};
|
|
97
|
+
const ready = () => {
|
|
98
|
+
server.off('error', fail);
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
server.once('error', fail);
|
|
102
|
+
server.once('listening', ready);
|
|
103
|
+
server.listen(port, host);
|
|
104
|
+
});
|
|
105
|
+
return server.address();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
access.stop();
|
|
108
|
+
store.stop();
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async close() {
|
|
113
|
+
closed = true;
|
|
114
|
+
access.stop();
|
|
115
|
+
store.stop();
|
|
116
|
+
if (server.listening) {
|
|
117
|
+
await new Promise((resolve, reject) => {
|
|
118
|
+
server.close((e) => (e ? reject(e) : resolve()));
|
|
119
|
+
server.closeIdleConnections();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ModelRecord } from './types.js';
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
token: string;
|
|
5
|
+
models?: string[];
|
|
6
|
+
timezone?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
random?: () => number;
|
|
11
|
+
setTimer?: typeof setTimeout;
|
|
12
|
+
clearTimer?: typeof clearTimeout;
|
|
13
|
+
onEvent?: (event: object) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface CatalogClient {
|
|
16
|
+
start(): Promise<void>;
|
|
17
|
+
get(names: string[]): Promise<ModelRecord[]>;
|
|
18
|
+
refresh(): Promise<ModelRecord[] | undefined>;
|
|
19
|
+
stop(): void;
|
|
20
|
+
readonly nextRefreshAt: string | null;
|
|
21
|
+
}
|
|
22
|
+
export function createCatalogClient(options: ClientOptions): CatalogClient;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModelRecord, Usage, ServiceTier, TariffSnapshot } from './types.js';
|
|
2
|
+
export function tariffSnapshot(record: ModelRecord, options?: { serviceTier?: ServiceTier }): TariffSnapshot | null;
|
|
3
|
+
export function calculatePrice(
|
|
4
|
+
record: ModelRecord,
|
|
5
|
+
usage: Usage,
|
|
6
|
+
options?: { serviceTier?: ServiceTier },
|
|
7
|
+
): {
|
|
8
|
+
usage: Usage;
|
|
9
|
+
snapshot: TariffSnapshot | null;
|
|
10
|
+
parts: Record<string, { quantity: number | null; rate: number | null; per: number; costUsd: number | null }>;
|
|
11
|
+
knownSubtotalUsd: number;
|
|
12
|
+
priceUsd: number | null;
|
|
13
|
+
status: 'incomplete' | 'qualified' | 'complete';
|
|
14
|
+
issues: string[];
|
|
15
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AddressInfo } from 'node:net';
|
|
2
|
+
import type { Server as HttpServer } from 'node:http';
|
|
3
|
+
export interface ServerOptions {
|
|
4
|
+
catalogFile: string;
|
|
5
|
+
accessFile: string;
|
|
6
|
+
reloadIntervalMs?: number;
|
|
7
|
+
onEvent?: (event: object) => void;
|
|
8
|
+
}
|
|
9
|
+
export function createCatalogServer(options: ServerOptions): {
|
|
10
|
+
server: HttpServer;
|
|
11
|
+
store: {
|
|
12
|
+
load(): Promise<void>;
|
|
13
|
+
reload(): Promise<boolean>;
|
|
14
|
+
readonly error: string | null;
|
|
15
|
+
readonly snapshot: object | undefined;
|
|
16
|
+
stop(): void;
|
|
17
|
+
};
|
|
18
|
+
access: { reload(): Promise<unknown>; stop(): void };
|
|
19
|
+
listen(options?: { port?: number; host?: string }): Promise<AddressInfo | string | null>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
};
|