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/docs/catalog.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Model catalog
|
|
2
|
+
|
|
3
|
+
The catalog combines model prices, billing units, and capabilities.
|
|
4
|
+
An invalid revision never replaces the active one, so applications continue receiving the last validated snapshot.
|
|
5
|
+
|
|
6
|
+
The format is defined by the [schema and validator](../src/catalog-schemas.js); initial entries are available in the
|
|
7
|
+
[example catalog](../catalog.example.yaml).
|
|
8
|
+
The server settings specify the working file, which must remain separate from source files replaced during updates.
|
|
9
|
+
|
|
10
|
+
An entry can contain a model description or a string naming another entry exactly.
|
|
11
|
+
A string reference is an alternative name for the same pricing record; YAML aliases are not allowed.
|
|
12
|
+
An unknown target or reference cycle invalidates the entire revision.
|
|
13
|
+
|
|
14
|
+
Lookup first checks the full name. If it is missing, lookup removes the prefix through the first @ and checks the rest.
|
|
15
|
+
Slashes and trailing dates are not removed automatically; such names require explicit entries.
|
|
16
|
+
Responses preserve the requested, matched, and canonical names to explain the selected price.
|
|
17
|
+
|
|
18
|
+
Prices and supported modes are described separately.
|
|
19
|
+
Matching rates do not imply mode support, and an unknown rate differs from a confirmed zero.
|
|
20
|
+
Billing units are explicit; minutes, characters, images, and video seconds cannot be treated as text usage.
|
|
21
|
+
|
|
22
|
+
The server loads the catalog and access settings before opening its port.
|
|
23
|
+
File changes apply as a fully validated snapshot; failures and recovery are logged.
|
|
24
|
+
The content version is independent of property order, and each batch response uses a single version.
|
|
25
|
+
|
|
26
|
+
Insufficient evidence is stored alongside the model description.
|
|
27
|
+
It does not prevent access to the original values or a conditional calculation, but explains the result's limitations.
|
package/docs/client.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Client and request cost
|
|
2
|
+
|
|
3
|
+
The client caches prices in process memory and reuses them for calculations.
|
|
4
|
+
Each calculation returns quantities, rates, components, and a tariff snapshot so stored usage can be explained.
|
|
5
|
+
|
|
6
|
+
Parameters and results are defined in the [client types](../src/types/client.d.ts) and [calculation types](../src/types/pricing.d.ts).
|
|
7
|
+
A connection example is available in the [main description](../README.md).
|
|
8
|
+
Calculation inputs can also be obtained separately as a [tariff snapshot](../src/pricing.js).
|
|
9
|
+
|
|
10
|
+
The model resolution endpoint is `POST /v1/models/resolve` on the configured HTTPS origin.
|
|
11
|
+
Set `baseUrl` to the origin, for example `https://catalog.example.com`, without `/v1/models/resolve`.
|
|
12
|
+
The package client appends that path automatically; passing the full endpoint as `baseUrl` repeats the path.
|
|
13
|
+
For direct HTTP requests, send `Authorization: Bearer <token>` and `Content-Type: application/json`:
|
|
14
|
+
|
|
15
|
+
```http
|
|
16
|
+
POST /v1/models/resolve HTTP/1.1
|
|
17
|
+
Host: catalog.example.com
|
|
18
|
+
Authorization: Bearer <token>
|
|
19
|
+
Content-Type: application/json
|
|
20
|
+
|
|
21
|
+
{"models":["gpt-6-sol"]}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Startup loads the initial model set; requesting a new name retrieves its entry immediately.
|
|
25
|
+
Concurrent duplicate requests are combined, and missing entries are cached until the next refresh.
|
|
26
|
+
A forced refresh fetches all accumulated names again, including previously unknown names.
|
|
27
|
+
|
|
28
|
+
Scheduled refreshes run around 1 a.m. and 1 p.m. in an explicitly configured time zone.
|
|
29
|
+
Each process independently selects a random offset within twenty minutes for each window.
|
|
30
|
+
Missed polls do not accumulate while the process sleeps; stopping the client cancels timers and requests.
|
|
31
|
+
Scheduling is implemented in [time selection](../src/schedule.js), and failure handling in the [client](../src/client.js).
|
|
32
|
+
|
|
33
|
+
A network failure preserves the previous entry with a staleness reason and the last successful retrieval time.
|
|
34
|
+
If no data has been received, the client returns an unavailable state rather than reporting the model as missing.
|
|
35
|
+
Retries after temporary failures are limited; access denial and invalid requests do not trigger automatic retries.
|
|
36
|
+
For a corporate certificate, enable the Node.js system certificate store with `node --use-system-ca` if necessary;
|
|
37
|
+
certificate verification does not need to be disabled.
|
|
38
|
+
|
|
39
|
+
For text usage, ordinary input equals total input minus cache reads and writes.
|
|
40
|
+
Reasoning tokens already included in output are not added again.
|
|
41
|
+
Inconsistent quantities remain visible with an explanation; independent known components remain in the result.
|
|
42
|
+
|
|
43
|
+
An unknown total has no numeric substitute; the known subtotal is returned separately.
|
|
44
|
+
Calculations using stale or insufficiently supported prices remain numeric and include the reasons.
|
|
45
|
+
Applications handle rounding for storage or display; catalog updates do not change historical snapshots.
|
|
46
|
+
|
|
47
|
+
Audio and other non-text usage must use the corresponding unit.
|
|
48
|
+
Quantity billing uses the rate for the specified unit; image matrices use the selected quality and size.
|
|
49
|
+
A missing rate or mismatched unit never becomes a zero price.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Development setup
|
|
2
|
+
|
|
3
|
+
Install the Node.js version specified in `package.json`. Run `npm ci`, then `npm run quality` from the repository root.
|
|
4
|
+
To describe servers, copy `servers.example.md` to `servers.local.md` and fill in local values.
|
|
5
|
+
The private file is excluded from Git and is read by the agent only for operations that require connections.
|
|
6
|
+
Service configuration and startup are described in the [operations guide](operations.md).
|
|
7
|
+
|
|
8
|
+
## Checks
|
|
9
|
+
|
|
10
|
+
| Command | Purpose |
|
|
11
|
+
| --------------------------------- | ------------------------------------------------- |
|
|
12
|
+
| `npm test` or `npm run test:code` | Unit checks without external systems. |
|
|
13
|
+
| `npm run test:integration` | Server and client checks over a local connection. |
|
|
14
|
+
| `npm run lint` | Source analysis using `.oxlintrc.json`. |
|
|
15
|
+
| `npm run lint:fix` | Automatic fixes and formatting. |
|
|
16
|
+
| `npm run format:check` | Formatting checks without changing files. |
|
|
17
|
+
| `npm run docs:check` | Relative documentation link checks. |
|
|
18
|
+
| `npm run quality` | All checks above without automatic fixes. |
|
|
19
|
+
|
|
20
|
+
Review automatic fixes and rerun `npm run quality`.
|
|
21
|
+
Formatting checks include Markdown, JSON, YAML, and type declarations.
|
|
22
|
+
|
|
23
|
+
## Project settings
|
|
24
|
+
|
|
25
|
+
Development rules are in the root `AGENTS.md`. `CLAUDE.md` imports only `AGENTS.md`.
|
|
26
|
+
Code analysis uses `.oxlintrc.json`.
|
|
27
|
+
The service runs JavaScript directly, without a build step or a TypeScript compiler.
|
|
28
|
+
Public `.d.ts` declarations remain available for applications that use type checking.
|
|
29
|
+
The service reads YAML settings and does not load `.env`.
|
|
30
|
+
Use the deployment skill's `update` command to update an existing installation.
|
|
31
|
+
Keep addresses, credentials, ports, certificate paths, and installation paths in private configuration.
|
|
32
|
+
Public examples must use placeholders or reserved example domains.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Startup and operations
|
|
2
|
+
|
|
3
|
+
The service endpoint, host, ports, and certificate settings are defined by each deployment.
|
|
4
|
+
Clients call `POST /v1/models/resolve`, with a bearer access key and a JSON body such as
|
|
5
|
+
`{"models":["gpt-6-sol"]}`. The package client takes only the HTTPS origin in `baseUrl` and adds this path.
|
|
6
|
+
The deployment setup uses a loopback application listener and an existing nginx HTTPS site.
|
|
7
|
+
Its managed snippet adds only `/v1/models/resolve` and `/model-catalog/health`; other routes remain owned by the site.
|
|
8
|
+
The service requires neither databases nor calls to model providers.
|
|
9
|
+
|
|
10
|
+
For deployment from a source checkout, use the project deployment skill with your own repository and server settings.
|
|
11
|
+
Connection and application settings follow the [configuration example](../.agents/skills/deploy/config/config.example.yaml).
|
|
12
|
+
The working catalog is `catalog.yaml` in the server project root and is excluded from Git.
|
|
13
|
+
The example is `catalog.example.yaml` in the project root. Code updates preserve the working catalog.
|
|
14
|
+
Service settings and keys are stored in `/etc/fa-model-catalog`.
|
|
15
|
+
|
|
16
|
+
For a local run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm ci
|
|
20
|
+
npm run catalog:check -- --file /path/to/catalog.yaml
|
|
21
|
+
npm start -- --config /path/to/server.yaml
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The server settings specify paths to the working catalog and access file.
|
|
25
|
+
All consumers share one key; its sole local source is the private configuration file
|
|
26
|
+
(.agents/skills/deploy/config/config.local.yaml), at access.clients[0].keys[0].token.
|
|
27
|
+
Keys are created and validated by [fa-web-access](https://github.com/Bazilio-san/fa-web-access);
|
|
28
|
+
never place a real key in an example or log.
|
|
29
|
+
|
|
30
|
+
Manage the server from the repository root:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node .agents/skills/deploy/scripts/remote.js status
|
|
34
|
+
node .agents/skills/deploy/scripts/remote.js check
|
|
35
|
+
node .agents/skills/deploy/scripts/remote.js update
|
|
36
|
+
node .agents/skills/deploy/scripts/remote.js verify
|
|
37
|
+
node .agents/skills/deploy/scripts/remote.js push-catalog
|
|
38
|
+
node .agents/skills/deploy/scripts/remote.js push-access
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Catalog and access replacement commands validate their inputs before transfer and replace the working file atomically.
|
|
42
|
+
Routine code updates preserve the catalog, keys, and settings.
|
|
43
|
+
The complete command list is in the repository's deployment skill, which is not included in the installed client.
|
|
44
|
+
|
|
45
|
+
Server code is installed only from Git. The wrapper selects the clean local Git commit and requires it to exist
|
|
46
|
+
in the configured remote branch. Commit and push reviewed code after local quality checks before deployment.
|
|
47
|
+
The wrapper refreshes the root `update.js` from that commit, then uses it for updates and forced restarts.
|
|
48
|
+
The updater resets tracked files to the selected commit, preserves ignored settings and untracked data,
|
|
49
|
+
installs production dependencies when needed, and checks settings, catalog, access, systemd, and readiness.
|
|
50
|
+
It does not compile code or run database migrations. Failed updates leave a pending marker for the next retry.
|
|
51
|
+
Use `--config <path>` to select another private deployment file. No code is uploaded from the working directory.
|
|
52
|
+
|
|
53
|
+
Readiness is checked through `/health` on loopback and `/model-catalog/health` publicly.
|
|
54
|
+
Model retrieval requires a key; request handling is described in the [server](../src/server.js).
|
|
55
|
+
After updates, check the service, readiness, authenticated price retrieval, access denial without a key, and recent errors.
|
|
56
|
+
|
|
57
|
+
Catalog data does not confirm current provider prices without source verification.
|
|
58
|
+
Entry limitations remain until the data is updated with confirmed sources.
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fa-model-catalog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared model catalog, pricing service, and client with cost calculation",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Bazilio-san/fa-model-catalog.git"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"fa-model-catalog": "src/cli/server.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/*.js",
|
|
15
|
+
"src/types",
|
|
16
|
+
"src/cli",
|
|
17
|
+
"docs",
|
|
18
|
+
"catalog.example.yaml",
|
|
19
|
+
".agents/skills/deploy/config/*.example.yaml",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./src/types/client.d.ts",
|
|
27
|
+
"import": "./src/client.js"
|
|
28
|
+
},
|
|
29
|
+
"./pricing": {
|
|
30
|
+
"types": "./src/types/pricing.d.ts",
|
|
31
|
+
"import": "./src/pricing.js"
|
|
32
|
+
},
|
|
33
|
+
"./server": {
|
|
34
|
+
"types": "./src/types/server.d.ts",
|
|
35
|
+
"import": "./src/server.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "npm run test:code",
|
|
40
|
+
"start": "node src/cli/server.js",
|
|
41
|
+
"catalog:check": "node src/cli/check.js",
|
|
42
|
+
"test:code": "node --test \"tests/unit/*.test.js\"",
|
|
43
|
+
"test:integration": "node --test \"tests/integration/*.test.js\"",
|
|
44
|
+
"lint": "oxlint src tests scripts .agents/skills update.js",
|
|
45
|
+
"lint:fix": "oxlint --fix src tests scripts .agents/skills update.js && npm run format",
|
|
46
|
+
"format": "oxfmt .",
|
|
47
|
+
"format:check": "oxfmt --check .",
|
|
48
|
+
"docs:check": "node scripts/checks-docs.js",
|
|
49
|
+
"quality": "npm run test:code && npm run test:integration && npm run lint && npm run format:check && npm run docs:check",
|
|
50
|
+
"prepublishOnly": "npm run quality"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"ajv": "8.20.0",
|
|
54
|
+
"fa-web-access": "0.1.2",
|
|
55
|
+
"yaml": "2.9.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"oxfmt": "0.61.0",
|
|
59
|
+
"oxlint": "1.76.0"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=22.12"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { parseDocument } from 'yaml';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const priceFields = [
|
|
6
|
+
'inp',
|
|
7
|
+
'inpC',
|
|
8
|
+
'inpW',
|
|
9
|
+
'out',
|
|
10
|
+
'inpF',
|
|
11
|
+
'inpCF',
|
|
12
|
+
'inpWF',
|
|
13
|
+
'outF',
|
|
14
|
+
'inpB',
|
|
15
|
+
'outB',
|
|
16
|
+
'inpFast',
|
|
17
|
+
'outFast',
|
|
18
|
+
];
|
|
19
|
+
const amount = { type: ['number', 'null'], minimum: 0 };
|
|
20
|
+
const stringArray = { type: 'array', items: { type: 'string', minLength: 1 }, uniqueItems: true };
|
|
21
|
+
const matrix = { type: 'object', minProperties: 1, additionalProperties: amount };
|
|
22
|
+
export const modelSchema = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
required: ['billing', 'modes', 'capabilities', 'evidence'],
|
|
26
|
+
properties: {
|
|
27
|
+
...Object.fromEntries(priceFields.map((k) => [k, amount])),
|
|
28
|
+
kT: amount,
|
|
29
|
+
mot: amount,
|
|
30
|
+
ui: { type: 'boolean' },
|
|
31
|
+
dimensions: { type: 'array', items: { type: 'integer', minimum: 1 }, uniqueItems: true },
|
|
32
|
+
dimensionsAllowed: { type: 'boolean' },
|
|
33
|
+
low: matrix,
|
|
34
|
+
medium: matrix,
|
|
35
|
+
high: matrix,
|
|
36
|
+
modes: { type: 'array', minItems: 1, uniqueItems: true, items: { enum: ['standard', 'flex', 'batch', 'fast'] } },
|
|
37
|
+
billing: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
required: ['kind', 'unit', 'per'],
|
|
41
|
+
properties: {
|
|
42
|
+
kind: { enum: ['tokens', 'quantity', 'matrix'] },
|
|
43
|
+
unit: { enum: ['tokens', 'characters', 'minutes', 'seconds', 'images'] },
|
|
44
|
+
per: { type: 'number', exclusiveMinimum: 0 },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
capabilities: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
required: ['promptCacheKey', 'explicitPromptCaching', 'reasoningEfforts'],
|
|
51
|
+
properties: {
|
|
52
|
+
promptCacheKey: { type: 'boolean' },
|
|
53
|
+
explicitPromptCaching: { type: 'boolean' },
|
|
54
|
+
reasoningEfforts: stringArray,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
evidence: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
required: ['status', 'reason', 'sources'],
|
|
61
|
+
properties: {
|
|
62
|
+
status: { enum: ['confirmed', 'insufficient'] },
|
|
63
|
+
reason: { type: ['string', 'null'] },
|
|
64
|
+
sources: stringArray,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const schema = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ['schemaVersion', 'currency', 'models'],
|
|
73
|
+
properties: {
|
|
74
|
+
schemaVersion: { const: 1 },
|
|
75
|
+
currency: { const: 'USD' },
|
|
76
|
+
models: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
minProperties: 1,
|
|
79
|
+
propertyNames: { minLength: 1, maxLength: 512 },
|
|
80
|
+
additionalProperties: { anyOf: [{ type: 'string', minLength: 1 }, modelSchema] },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const ajv = new Ajv({ allErrors: true, strictNumbers: true, allowUnionTypes: true });
|
|
85
|
+
const validate = ajv.compile(schema);
|
|
86
|
+
export const validateModel = ajv.compile(modelSchema);
|
|
87
|
+
|
|
88
|
+
export function freeze(value) {
|
|
89
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
90
|
+
for (const child of Object.values(value)) {
|
|
91
|
+
freeze(child);
|
|
92
|
+
}
|
|
93
|
+
Object.freeze(value);
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sorted(value) {
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
return value.map(sorted);
|
|
101
|
+
}
|
|
102
|
+
if (value && typeof value === 'object') {
|
|
103
|
+
return Object.fromEntries(
|
|
104
|
+
Object.keys(value)
|
|
105
|
+
.sort()
|
|
106
|
+
.map((k) => [k, sorted(value[k])]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseCatalog(text) {
|
|
113
|
+
const doc = parseDocument(text, { uniqueKeys: true });
|
|
114
|
+
if (doc.errors.length) {
|
|
115
|
+
throw new Error('The catalog file contains a YAML error or a duplicate key.');
|
|
116
|
+
}
|
|
117
|
+
const data = doc.toJS({ maxAliasCount: 0 });
|
|
118
|
+
if (!validate(data)) {
|
|
119
|
+
throw new Error(`Invalid catalog structure: ${ajv.errorsText(validate.errors)}.`);
|
|
120
|
+
}
|
|
121
|
+
const resolved = Object.create(null);
|
|
122
|
+
for (const key of Object.keys(data.models)) {
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
let target = key;
|
|
125
|
+
while (typeof data.models[target] === 'string') {
|
|
126
|
+
if (seen.has(target)) {
|
|
127
|
+
throw new Error(`Cyclic model reference: "${key}".`);
|
|
128
|
+
}
|
|
129
|
+
seen.add(target);
|
|
130
|
+
target = data.models[target];
|
|
131
|
+
if (!Object.hasOwn(data.models, target)) {
|
|
132
|
+
throw new Error(`Reference target not found for model "${key}".`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const model = data.models[target];
|
|
136
|
+
if (model.billing.kind === 'tokens' && model.billing.unit !== 'tokens') {
|
|
137
|
+
throw new Error('Invalid token billing unit.');
|
|
138
|
+
}
|
|
139
|
+
if (model.billing.kind === 'matrix' && model.billing.unit !== 'images') {
|
|
140
|
+
throw new Error('Invalid image matrix billing unit.');
|
|
141
|
+
}
|
|
142
|
+
if (model.evidence.status === 'insufficient' && !model.evidence.reason) {
|
|
143
|
+
throw new Error('A reason for insufficient evidence is required.');
|
|
144
|
+
}
|
|
145
|
+
for (const mode of model.modes) {
|
|
146
|
+
const fields = {
|
|
147
|
+
standard: ['inp', 'out'],
|
|
148
|
+
flex: ['inpF', 'outF'],
|
|
149
|
+
batch: ['inpB', 'outB'],
|
|
150
|
+
fast: ['inpFast', 'outFast'],
|
|
151
|
+
}[mode];
|
|
152
|
+
if (model.billing.kind !== 'matrix' && fields.some((f) => !Object.hasOwn(model, f))) {
|
|
153
|
+
throw new Error(`Missing rates for model "${target}".`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
resolved[key] = { canonicalName: target, model };
|
|
157
|
+
}
|
|
158
|
+
return freeze({
|
|
159
|
+
...data,
|
|
160
|
+
resolved,
|
|
161
|
+
catalogVersion: createHash('sha256')
|
|
162
|
+
.update(JSON.stringify(sorted(data)))
|
|
163
|
+
.digest('hex'),
|
|
164
|
+
});
|
|
165
|
+
}
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseCatalog } from './catalog-schemas.js';
|
|
3
|
+
|
|
4
|
+
export function resolveModels(snapshot, names) {
|
|
5
|
+
return names.map((requestedName) => {
|
|
6
|
+
let matchedName = requestedName;
|
|
7
|
+
if (!Object.hasOwn(snapshot.resolved, matchedName) && matchedName.includes('@')) {
|
|
8
|
+
matchedName = matchedName.slice(matchedName.indexOf('@') + 1);
|
|
9
|
+
}
|
|
10
|
+
const found = Object.hasOwn(snapshot.resolved, matchedName) ? snapshot.resolved[matchedName] : null;
|
|
11
|
+
return {
|
|
12
|
+
requestedName,
|
|
13
|
+
matchedName: found ? matchedName : null,
|
|
14
|
+
canonicalName: found?.canonicalName ?? null,
|
|
15
|
+
status: found ? 'found' : 'not_found',
|
|
16
|
+
model: found?.model ?? null,
|
|
17
|
+
catalogVersion: snapshot.catalogVersion,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createCatalogStore({ file, reloadIntervalMs = 5000, onEvent = () => {} }) {
|
|
23
|
+
let snapshot;
|
|
24
|
+
let timer;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
let pending;
|
|
27
|
+
let error = null;
|
|
28
|
+
const emit = (event) => {
|
|
29
|
+
try {
|
|
30
|
+
onEvent(event);
|
|
31
|
+
} catch {
|
|
32
|
+
/* Observer cannot change state. */
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const reload = () => {
|
|
36
|
+
if (stopped) {
|
|
37
|
+
return Promise.reject(new Error('The catalog has been stopped.'));
|
|
38
|
+
}
|
|
39
|
+
if (pending) {
|
|
40
|
+
return pending;
|
|
41
|
+
}
|
|
42
|
+
pending = (async () => {
|
|
43
|
+
try {
|
|
44
|
+
const text = await readFile(file, 'utf8');
|
|
45
|
+
if (Buffer.byteLength(text) > 8 * 1024 * 1024) {
|
|
46
|
+
throw new Error('The catalog exceeds 8 MiB.');
|
|
47
|
+
}
|
|
48
|
+
const next = parseCatalog(text);
|
|
49
|
+
if (stopped) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const changed = snapshot?.catalogVersion !== next.catalogVersion;
|
|
53
|
+
if (changed || error) {
|
|
54
|
+
emit({
|
|
55
|
+
event: 'catalog_applied',
|
|
56
|
+
catalogVersion: next.catalogVersion,
|
|
57
|
+
count: Object.keys(next.models).length,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
snapshot = next;
|
|
61
|
+
error = null;
|
|
62
|
+
return changed;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (!stopped) {
|
|
65
|
+
error = 'Could not read or validate the catalog.';
|
|
66
|
+
emit({ event: 'catalog_reload_failed', reason: error });
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
} finally {
|
|
70
|
+
pending = null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
return pending;
|
|
74
|
+
};
|
|
75
|
+
const schedule = () => {
|
|
76
|
+
if (stopped) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
timer = setTimeout(async () => {
|
|
80
|
+
try {
|
|
81
|
+
await reload();
|
|
82
|
+
} catch {
|
|
83
|
+
/* Keep last good snapshot. */
|
|
84
|
+
}
|
|
85
|
+
schedule();
|
|
86
|
+
}, reloadIntervalMs);
|
|
87
|
+
timer.unref();
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
async load() {
|
|
91
|
+
await reload();
|
|
92
|
+
if (!timer) {
|
|
93
|
+
schedule();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
reload,
|
|
97
|
+
get snapshot() {
|
|
98
|
+
return snapshot;
|
|
99
|
+
},
|
|
100
|
+
get error() {
|
|
101
|
+
return error;
|
|
102
|
+
},
|
|
103
|
+
stop() {
|
|
104
|
+
stopped = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/cli/check.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { parseCatalog } from '../catalog-schemas.js';
|
|
5
|
+
try {
|
|
6
|
+
const { values } = parseArgs({ options: { file: { type: 'string', default: 'catalog.example.yaml' } } });
|
|
7
|
+
const data = parseCatalog(await readFile(values.file, 'utf8'));
|
|
8
|
+
console.log(`Catalog validated: ${Object.keys(data.models).length} names; version ${data.catalogVersion}.`);
|
|
9
|
+
} catch (e) {
|
|
10
|
+
console.error(e.message);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
import { createCatalogServer } from '../server.js';
|
|
7
|
+
try {
|
|
8
|
+
const { values } = parseArgs({
|
|
9
|
+
options: { config: { type: 'string', default: '.agents/skills/deploy/config/config.local.yaml' } },
|
|
10
|
+
});
|
|
11
|
+
const file = resolve(values.config);
|
|
12
|
+
const doc = parseDocument(await readFile(file, 'utf8'), { uniqueKeys: true });
|
|
13
|
+
if (doc.errors.length) {
|
|
14
|
+
throw new Error();
|
|
15
|
+
}
|
|
16
|
+
const document = doc.toJS({ maxAliasCount: 0 });
|
|
17
|
+
const config = document.server ?? document;
|
|
18
|
+
const allowed = ['catalogFile', 'accessFile', 'host', 'port', 'reloadIntervalMs'];
|
|
19
|
+
if (
|
|
20
|
+
!config ||
|
|
21
|
+
Object.keys(config).some((k) => !allowed.includes(k)) ||
|
|
22
|
+
!Number.isInteger(config.port) ||
|
|
23
|
+
config.port < 1 ||
|
|
24
|
+
config.port > 65535 ||
|
|
25
|
+
typeof config.host !== 'string'
|
|
26
|
+
) {
|
|
27
|
+
throw new Error();
|
|
28
|
+
}
|
|
29
|
+
const app = createCatalogServer({
|
|
30
|
+
...config,
|
|
31
|
+
catalogFile: resolve(dirname(file), config.catalogFile),
|
|
32
|
+
accessFile: resolve(dirname(file), config.accessFile),
|
|
33
|
+
onEvent: (e) => console.log(JSON.stringify(e)),
|
|
34
|
+
});
|
|
35
|
+
await app.listen(config);
|
|
36
|
+
console.log(`Service ready; port ${config.port}.`);
|
|
37
|
+
let closing = false;
|
|
38
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
39
|
+
process.on(signal, async () => {
|
|
40
|
+
if (!closing) {
|
|
41
|
+
closing = true;
|
|
42
|
+
await app.close();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
console.error('Could not start the service: check the settings, catalog, and access file.');
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
}
|