@oddessentials/agent-guild 0.1.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 +20 -7
- package/config/providers.default.json +10 -0
- package/package.json +1 -1
- package/src/manager/config.mjs +7 -1
- package/src/manager/main.mjs +20 -7
- package/src/manager/model-stats.mjs +338 -0
- package/src/manager/providers.mjs +89 -5
- package/src/manager/self-update.mjs +205 -0
- package/src/manager/server.mjs +14 -1
- package/src/manager/session-manager.mjs +58 -7
- package/src/manager/session.mjs +4 -1
- package/src/manager/usage.mjs +106 -25
- package/web/app.js +663 -13
- package/web/art/art.css +52 -6
- package/web/index.html +49 -1
- package/web/styles.css +165 -18
- package/web/theme.js +10 -0
package/README.md
CHANGED
|
@@ -36,6 +36,12 @@ coding tool, and see at a glance which sessions and agents are working.
|
|
|
36
36
|
not survive a computer restart. **Stop manager** in the top bar stops the
|
|
37
37
|
manager and ends every session; it asks first while any session is still
|
|
38
38
|
running.
|
|
39
|
+
* **Stay current.** When a newer Agent Guild is on npm, an **Upgrade**
|
|
40
|
+
button appears in the top bar and runs `npm install -g` in a session you
|
|
41
|
+
can watch. Sessions keep running; stop the manager and run `agent-guild
|
|
42
|
+
open` to use the new version.
|
|
43
|
+
* **Light or dark.** The page follows your system theme and the top-bar
|
|
44
|
+
toggle switches it. The guild artwork is the dark theme.
|
|
39
45
|
|
|
40
46
|
## Requirements
|
|
41
47
|
|
|
@@ -95,6 +101,10 @@ one platform under a `win32` or `darwin` key. See
|
|
|
95
101
|
| `modelPattern` | Regular expression that finds the model name on the tool's screen when the tool does not report it. |
|
|
96
102
|
| `resumeArgs` | Arguments that resume the tool's own session, with `{id}` standing for the id, e.g. `["--resume", "{id}"]`. Without it the card has no **Existing** button. |
|
|
97
103
|
| `env` | Extra environment variables for the tool. |
|
|
104
|
+
| `accounts` | Further sign-ins of the tool, each in its own home folder, e.g. `[{ "id": "work", "label": "Work", "dir": "~/.claude-work" }]`. Without `dir`, the folder is `accounts/<provider>/<account>` in the data folder. The card shows one chip per account with its own meters, and a session starts under the chip picked; the tool signs in from inside the first session, and its reporting hooks are copied into the folder on first use. An entry with id `default` renames the tool's own sign-in. Needs `homeVar`. |
|
|
105
|
+
| `homeVar` | The environment variable that moves the tool's home folder, e.g. `CLAUDE_CONFIG_DIR`. Set for Claude Code, Codex CLI, Gemini CLI and Grok Build by default. |
|
|
106
|
+
| `accountEnv` | Further variables set for every account other than the default, with `{dir}` standing for the account's folder. By default Claude Code's secure-storage folder follows the account, and Gemini CLI keeps the account's sign-in in a file rather than the shared OS keychain. |
|
|
107
|
+
| `hooks` | `{ "path", "example" }`: the hooks file inside the home folder and the file in `examples/` copied there for a new account. |
|
|
98
108
|
| `color`, `monogram`, `icon` | Icon appearance. `icon` is a URL path; you can also drop `<id>.svg` into `web/icons/`. |
|
|
99
109
|
| `install`, `docs` | Help shown when the tool is not installed. |
|
|
100
110
|
| `usageUrl`, `billingUrl` | `https://` links to the vendor's usage and billing pages, shown on the card. The defaults point at the subscription pages; set your API console instead, or `null` to hide a link. Google's usage link opens AI Studio, which counts API-key usage only, not the Gemini CLI sign-in quota the card's meters show. |
|
|
@@ -102,10 +112,11 @@ one platform under a `win32` or `darwin` key. See
|
|
|
102
112
|
The page's "Working folder" field sets where new sessions start. It defaults
|
|
103
113
|
to your home folder.
|
|
104
114
|
|
|
105
|
-
Version checks
|
|
106
|
-
`npm install -g` uses) about once
|
|
107
|
-
registry. Set `AGENT_GUILD_NPM_REGISTRY`
|
|
108
|
-
`AGENT_GUILD_NO_UPDATE_CHECK=1` to skip the
|
|
115
|
+
Version checks, for the tools and for Agent Guild itself, ask the registry
|
|
116
|
+
from npm's global configuration (the one `npm install -g` uses) about once
|
|
117
|
+
an hour, and installs use the same registry. Set `AGENT_GUILD_NPM_REGISTRY`
|
|
118
|
+
to override it for both, or `AGENT_GUILD_NO_UPDATE_CHECK=1` to skip the
|
|
119
|
+
checks.
|
|
109
120
|
|
|
110
121
|
## Show agents and models
|
|
111
122
|
|
|
@@ -160,6 +171,8 @@ coding tool in `tests/fixtures`. CI runs them on Windows, macOS and Linux.
|
|
|
160
171
|
## Current limits
|
|
161
172
|
|
|
162
173
|
* Sessions end when the manager stops or the computer restarts.
|
|
163
|
-
* Gemini CLI usage meters
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
* Gemini CLI usage meters read its sign-in where Gemini CLI keeps it:
|
|
175
|
+
`oauth_creds.json`, or with `GEMINI_FORCE_ENCRYPTED_FILE_STORAGE=true`
|
|
176
|
+
the OS keychain (macOS, or Linux with `secret-tool`) or its encrypted
|
|
177
|
+
credentials file. A sign-in kept in the Windows Credential Manager
|
|
178
|
+
cannot be read.
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"package": "@anthropic-ai/claude-code",
|
|
9
9
|
"versionArgs": ["--version"],
|
|
10
10
|
"usage": "claude",
|
|
11
|
+
"homeVar": "CLAUDE_CONFIG_DIR",
|
|
12
|
+
"accountEnv": { "CLAUDE_SECURESTORAGE_CONFIG_DIR": "{dir}" },
|
|
13
|
+
"hooks": { "path": "settings.json", "example": "claude-code-settings.json" },
|
|
11
14
|
"modelPattern": "claude-(?:opus|sonnet|haiku|fable|\\d)[a-z0-9.-]*|\\b(?:opus|sonnet|haiku|fable)\\s?\\d+(?:\\.\\d+)?",
|
|
12
15
|
"args": [],
|
|
13
16
|
"resumeArgs": ["--resume", "{id}"],
|
|
@@ -41,6 +44,8 @@
|
|
|
41
44
|
"package": "@openai/codex",
|
|
42
45
|
"versionArgs": ["--version"],
|
|
43
46
|
"usage": "codex",
|
|
47
|
+
"homeVar": "CODEX_HOME",
|
|
48
|
+
"hooks": { "path": "hooks.json", "example": "codex-hooks.json" },
|
|
44
49
|
"modelPattern": "\\bgpt-\\d[a-z0-9.-]*",
|
|
45
50
|
"args": [],
|
|
46
51
|
"resumeArgs": ["resume", "{id}"],
|
|
@@ -72,6 +77,9 @@
|
|
|
72
77
|
"package": "@google/gemini-cli",
|
|
73
78
|
"versionArgs": ["--version"],
|
|
74
79
|
"usage": "gemini",
|
|
80
|
+
"homeVar": "GEMINI_CLI_HOME",
|
|
81
|
+
"accountEnv": { "GEMINI_FORCE_FILE_STORAGE": "true" },
|
|
82
|
+
"hooks": { "path": ".gemini/settings.json", "example": "gemini-settings.json" },
|
|
75
83
|
"modelPattern": "\\bgemini-\\d[a-z0-9.-]*",
|
|
76
84
|
"args": [],
|
|
77
85
|
"resumeArgs": ["--resume", "{id}"],
|
|
@@ -92,6 +100,8 @@
|
|
|
92
100
|
"command": "grok",
|
|
93
101
|
"package": "@xai-official/grok",
|
|
94
102
|
"versionArgs": ["--version"],
|
|
103
|
+
"homeVar": "GROK_HOME",
|
|
104
|
+
"hooks": { "path": "hooks/agent-guild.json", "example": "grok-hooks.json" },
|
|
95
105
|
"modelPattern": "\\bgrok-(?:build|\\d)[a-z0-9.-]*",
|
|
96
106
|
"args": [],
|
|
97
107
|
"resumeArgs": ["--resume", "{id}"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/config.mjs
CHANGED
|
@@ -8,10 +8,15 @@ import fs from 'node:fs';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_PORT = 47821;
|
|
13
14
|
export const DEFAULT_HOST = '127.0.0.1';
|
|
14
|
-
|
|
15
|
+
/** The package.json the manager runs from; an upgrade replaces it in place. */
|
|
16
|
+
export const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
17
|
+
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf8'));
|
|
18
|
+
export const VERSION = packageJson.version;
|
|
19
|
+
export const PACKAGE_NAME = packageJson.name;
|
|
15
20
|
|
|
16
21
|
/** Per-user data directory. Override with AGENT_GUILD_HOME (used by tests). */
|
|
17
22
|
export function dataDir() {
|
|
@@ -37,6 +42,7 @@ export const paths = {
|
|
|
37
42
|
get token() { return path.join(dataDir(), 'auth-token'); },
|
|
38
43
|
get runtime() { return path.join(dataDir(), 'manager.json'); },
|
|
39
44
|
get providers() { return path.join(dataDir(), 'providers.json'); },
|
|
45
|
+
get accounts() { return path.join(dataDir(), 'accounts'); },
|
|
40
46
|
get log() { return path.join(dataDir(), 'manager.log'); },
|
|
41
47
|
/** Launchers for agent-guild-report, put first on every session's PATH. */
|
|
42
48
|
get shims() { return path.join(dataDir(), 'bin'); },
|
package/src/manager/main.mjs
CHANGED
|
@@ -7,11 +7,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { ProviderRegistry } from './providers.mjs';
|
|
8
8
|
import { SessionManager } from './session-manager.mjs';
|
|
9
9
|
import { UsageMonitor } from './usage.mjs';
|
|
10
|
+
import { ModelStats } from './model-stats.mjs';
|
|
10
11
|
import { createManagerServer } from './server.mjs';
|
|
12
|
+
import { SelfUpdate } from './self-update.mjs';
|
|
11
13
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
12
14
|
import { writeReportShims } from './report-shims.mjs';
|
|
13
15
|
import {
|
|
14
16
|
DEFAULT_HOST,
|
|
17
|
+
PACKAGE_FILE,
|
|
18
|
+
PACKAGE_NAME,
|
|
15
19
|
VERSION,
|
|
16
20
|
ensureDataDir,
|
|
17
21
|
loadOrCreateToken,
|
|
@@ -26,7 +30,8 @@ const rootDir = path.resolve(here, '../..');
|
|
|
26
30
|
|
|
27
31
|
const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
/** `version` and `packageFile` stand in for the real ones in tests. */
|
|
34
|
+
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE } = {}) {
|
|
30
35
|
ensureDataDir();
|
|
31
36
|
const token = loadOrCreateToken();
|
|
32
37
|
const baseEnv = resolveBaseEnv();
|
|
@@ -50,11 +55,17 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
50
55
|
});
|
|
51
56
|
|
|
52
57
|
let api;
|
|
53
|
-
const
|
|
58
|
+
const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
|
|
59
|
+
const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
|
|
54
60
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
61
|
+
const modelStats = new ModelStats({ registry });
|
|
55
62
|
let closing = null;
|
|
56
63
|
|
|
57
|
-
const
|
|
64
|
+
const refreshVersions = () => {
|
|
65
|
+
registry.refreshVersions().catch(() => {});
|
|
66
|
+
selfUpdate.refresh().catch(() => {});
|
|
67
|
+
};
|
|
68
|
+
const versionTimer = setInterval(refreshVersions, VERSION_REFRESH_MS);
|
|
58
69
|
versionTimer.unref();
|
|
59
70
|
|
|
60
71
|
const shutdown = (reason = 'shutdown') => {
|
|
@@ -79,11 +90,13 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
79
90
|
manager,
|
|
80
91
|
registry,
|
|
81
92
|
usage,
|
|
93
|
+
modelStats,
|
|
82
94
|
token,
|
|
83
95
|
host,
|
|
84
96
|
port,
|
|
85
97
|
webDir,
|
|
86
|
-
version
|
|
98
|
+
version,
|
|
99
|
+
selfUpdate,
|
|
87
100
|
extraOrigins,
|
|
88
101
|
onShutdownRequest: () => shutdown('requested via API').then(() => process.exit(0)),
|
|
89
102
|
});
|
|
@@ -103,11 +116,11 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
103
116
|
host,
|
|
104
117
|
port: api.port,
|
|
105
118
|
url: api.url,
|
|
106
|
-
version
|
|
119
|
+
version,
|
|
107
120
|
startedAt: new Date().toISOString(),
|
|
108
121
|
});
|
|
109
|
-
console.log(`[manager] Agent Guild ${
|
|
110
|
-
|
|
122
|
+
console.log(`[manager] Agent Guild ${version} listening on ${api.url} (pid ${process.pid})`);
|
|
123
|
+
refreshVersions();
|
|
111
124
|
return { api, manager, registry, token, shutdown };
|
|
112
125
|
}
|
|
113
126
|
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { toNumber } from './usage.mjs';
|
|
2
|
+
|
|
3
|
+
const CATALOG_URL = 'https://openrouter.ai/api/v1/models';
|
|
4
|
+
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
|
|
5
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
6
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
7
|
+
const NEW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?::[a-z0-9-]+)?$/;
|
|
9
|
+
|
|
10
|
+
const AA = 'Artificial Analysis';
|
|
11
|
+
const DA_MODELS = 'Design Arena · Models';
|
|
12
|
+
const DA_AGENTS = 'Design Arena · Agents';
|
|
13
|
+
|
|
14
|
+
const STATS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'coding', label: 'Coding Index', short: 'Coding', group: AA, index: 'coding_index',
|
|
17
|
+
about: 'How well the model writes and fixes code, scored by Artificial Analysis on coding evaluations such as Terminal-Bench (real tasks in a terminal). When building: working code with fewer retries.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'intelligence', label: 'Intelligence Index', short: 'Intelligence', group: AA, index: 'intelligence_index',
|
|
21
|
+
about: 'Artificial Analysis\'s overall score across its evaluations of agentic work, coding, scientific reasoning and general knowledge. When building: understanding what you ask, planning changes, solving hard problems.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'agentic', label: 'Agentic Index', short: 'Agentic', group: AA, index: 'agentic_index',
|
|
25
|
+
about: 'How well the model completes multi-step tasks on its own, using tools until the job is done (Artificial Analysis). When building: longer agent runs that stay on track without your help.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'overall', label: 'Overall', group: DA_MODELS, arena: 'models', category: 'codecategories',
|
|
29
|
+
about: 'Design Arena\'s combined result for Website, UI Component, Game Dev, Data Visualization and 3D, judged by real users\' votes. When building: overall front-end quality.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'website', label: 'Website', group: DA_MODELS, arena: 'models', category: 'website',
|
|
33
|
+
about: 'Complete websites built as one HTML file from real users\' prompts; users vote for the better one. When building: landing pages and site layouts.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'uicomponent', label: 'UI Component', group: DA_MODELS, arena: 'models', category: 'uicomponent',
|
|
37
|
+
about: 'Focused interface components as one HTML file; users vote. When building: individual pieces of an interface.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'gamedev', label: 'Game Dev', group: DA_MODELS, arena: 'models', category: 'gamedev',
|
|
41
|
+
about: 'Playable browser games as one HTML file; users play them and vote. When building: interactive, game-like front ends.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'dataviz', label: 'Data Visualization', group: DA_MODELS, arena: 'models', category: 'dataviz',
|
|
45
|
+
about: 'Interactive visualizations of provided data as one HTML file; users vote. When building: charts and dashboards.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: '3d', label: '3D', group: DA_MODELS, arena: 'models', category: '3d',
|
|
49
|
+
about: 'Interactive 3D experiences in the browser as one HTML file; users try them and vote. When building: 3D scenes.',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'svg', label: 'SVG', group: DA_MODELS, arena: 'models', category: 'svg',
|
|
53
|
+
about: 'Scalable vector graphics as SVG markup: icons, illustrations, diagrams. When building: graphics as code.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'webapps', label: 'Web Apps', group: DA_AGENTS, arena: 'agents', category: 'webapps',
|
|
57
|
+
about: 'Multi-file React front ends built by the model as an agent over many steps; users compare results. When building: the multi-file work a coding agent does.',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'fullstack', label: 'Full Stack', group: DA_AGENTS, arena: 'agents', category: 'fullstack',
|
|
61
|
+
about: 'Complete React apps with sign-in, a database and a back end, built by the model as an agent. When building: whole applications, not just pages.',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'mobileapps', label: 'Mobile Apps', group: DA_AGENTS, arena: 'agents', category: 'mobileapps',
|
|
65
|
+
about: 'Cross-platform React Native mobile apps built by the model as an agent; users compare them. When building: mobile apps.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const INDEXES = STATS.filter((stat) => stat.index).map((stat) => stat.id);
|
|
69
|
+
|
|
70
|
+
class CatalogError extends Error {}
|
|
71
|
+
|
|
72
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
73
|
+
const newestFirst = (a, b) => (b.created ?? 0) - (a.created ?? 0) || compare(a.id, b.id);
|
|
74
|
+
const record = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
75
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
|
|
76
|
+
const isMeasured = (model) => Object.keys(model.values).length > 0;
|
|
77
|
+
|
|
78
|
+
function text(value, max) {
|
|
79
|
+
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function strings(value) {
|
|
83
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v).map((v) => v.slice(0, 40)) : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function perMillion(value) {
|
|
87
|
+
const n = toNumber(value);
|
|
88
|
+
return n === null || n < 0 ? null : Math.round(n * 1e10) / 1e4;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function displayName(name, id) {
|
|
92
|
+
const full = text(name, 120) ?? id;
|
|
93
|
+
const colon = full.indexOf(': ');
|
|
94
|
+
return colon > 0 && colon <= 40 && full.length > colon + 2 ? full.slice(colon + 2) : full;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readValues(benchmarks) {
|
|
98
|
+
const analysis = record(benchmarks.artificial_analysis);
|
|
99
|
+
const arena = Array.isArray(benchmarks.design_arena) ? benchmarks.design_arena.map(record) : [];
|
|
100
|
+
const values = {};
|
|
101
|
+
for (const stat of STATS) {
|
|
102
|
+
if (stat.index) {
|
|
103
|
+
const value = finite(analysis[stat.index]);
|
|
104
|
+
if (value !== null) values[stat.id] = { value, rank: null };
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const rows = arena.filter((row) => row.arena === stat.arena && row.category === stat.category && finite(row.elo) !== null);
|
|
108
|
+
if (rows.length === 1) values[stat.id] = { value: rows[0].elo, rank: finite(rows[0].rank) };
|
|
109
|
+
}
|
|
110
|
+
return values;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseCatalog(payload) {
|
|
114
|
+
const data = Array.isArray(payload?.data) ? payload.data : [];
|
|
115
|
+
const listings = [];
|
|
116
|
+
for (const raw of data) {
|
|
117
|
+
const entry = record(raw);
|
|
118
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
119
|
+
if (!ID_RE.test(id)) continue;
|
|
120
|
+
const architecture = record(entry.architecture);
|
|
121
|
+
const pricing = record(entry.pricing);
|
|
122
|
+
listings.push({
|
|
123
|
+
id,
|
|
124
|
+
slug: id.slice(id.indexOf('/') + 1),
|
|
125
|
+
canonical: text(entry.canonical_slug, 200) ?? '',
|
|
126
|
+
name: displayName(entry.name, id),
|
|
127
|
+
created: finite(entry.created),
|
|
128
|
+
context: finite(entry.context_length),
|
|
129
|
+
maxOutput: finite(record(entry.top_provider).max_completion_tokens),
|
|
130
|
+
input: strings(architecture.input_modalities),
|
|
131
|
+
text: strings(architecture.output_modalities).includes('text'),
|
|
132
|
+
tools: strings(entry.supported_parameters).includes('tools'),
|
|
133
|
+
reasoning: strings(record(entry.reasoning).supported_efforts),
|
|
134
|
+
price: { input: perMillion(pricing.prompt), output: perMillion(pricing.completion) },
|
|
135
|
+
expires: text(entry.expiration_date, 40),
|
|
136
|
+
values: readValues(record(entry.benchmarks)),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return listings;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function signature(listing) {
|
|
143
|
+
return JSON.stringify(STATS.map((stat) => listing.values[stat.id] ?? null));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function indexCatalog(listings) {
|
|
147
|
+
const byId = new Map();
|
|
148
|
+
for (const listing of listings) byId.set(listing.id, [...(byId.get(listing.id) ?? []), listing]);
|
|
149
|
+
const groups = new Map();
|
|
150
|
+
for (const copies of byId.values()) {
|
|
151
|
+
if (new Set(copies.map(signature)).size > 1) continue;
|
|
152
|
+
const key = copies[0].canonical || copies[0].id;
|
|
153
|
+
groups.set(key, [...(groups.get(key) ?? []), copies[0]]);
|
|
154
|
+
}
|
|
155
|
+
const models = new Map();
|
|
156
|
+
const aliasOf = new Map();
|
|
157
|
+
for (const group of groups.values()) {
|
|
158
|
+
const sets = new Set(group.map(signature)).size === 1 ? [group] : group.map((listing) => [listing]);
|
|
159
|
+
for (const set of sets) {
|
|
160
|
+
const [main] = [...set].sort((a, b) => a.id.includes(':') - b.id.includes(':') || compare(a.id, b.id));
|
|
161
|
+
models.set(main.id, main);
|
|
162
|
+
for (const listing of set) aliasOf.set(listing.id, main.id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const bySlug = new Map();
|
|
166
|
+
for (const [id, main] of aliasOf) {
|
|
167
|
+
const slug = id.slice(id.indexOf('/') + 1);
|
|
168
|
+
bySlug.set(slug, bySlug.has(slug) && bySlug.get(slug) !== main ? null : main);
|
|
169
|
+
}
|
|
170
|
+
return { models, aliasOf, bySlug };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function tierFor(level) {
|
|
174
|
+
if (level >= 90) return 'S';
|
|
175
|
+
if (level >= 75) return 'A';
|
|
176
|
+
if (level >= 50) return 'B';
|
|
177
|
+
if (level >= 25) return 'C';
|
|
178
|
+
return 'D';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function standing(value, others) {
|
|
182
|
+
if (others.length === 0) return null;
|
|
183
|
+
let above = 0;
|
|
184
|
+
let tied = 0;
|
|
185
|
+
for (const other of others) {
|
|
186
|
+
if (other > value) above++;
|
|
187
|
+
else if (other === value) tied++;
|
|
188
|
+
}
|
|
189
|
+
const of = others.length + 1;
|
|
190
|
+
const level = Math.round((100 * (of - 1 - above - tied / 2)) / (of - 1));
|
|
191
|
+
return { level, tier: tierFor(level), place: 1 + above, tied: tied > 0, of };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function patternFor(provider) {
|
|
195
|
+
if (!provider.modelPattern) return null;
|
|
196
|
+
try {
|
|
197
|
+
return new RegExp(`^(?:${provider.modelPattern})$`, 'i');
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function providerModels(index, provider) {
|
|
204
|
+
const pattern = patternFor(provider);
|
|
205
|
+
if (!pattern) return [];
|
|
206
|
+
return [...index.models.values()].filter((model) => model.text && model.tools && pattern.test(model.slug)).sort(newestFirst);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function modelNames(raw) {
|
|
210
|
+
const exact = String(raw ?? '').trim().toLowerCase()
|
|
211
|
+
.replace(/\[[^\]]*\]$/, '')
|
|
212
|
+
.trim()
|
|
213
|
+
.replace(/\s+/g, '-');
|
|
214
|
+
if (!exact) return [];
|
|
215
|
+
const name = exact.replace(/:[a-z0-9-]+$/, '');
|
|
216
|
+
const undated = name.replace(/(?:-\d{8}|-\d{4}-\d{2}-\d{2}|@\d{8})$/, '');
|
|
217
|
+
const dotted = undated.replace(/(\d+)-(\d+)(?=$|-)/g, '$1.$2');
|
|
218
|
+
return [...new Set([exact, name, undated, dotted])];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolveModel(index, related, model) {
|
|
222
|
+
for (const raw of [model?.name, model?.displayName]) {
|
|
223
|
+
const names = modelNames(raw);
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const id = name.includes('/') ? index.aliasOf.get(name) : index.bySlug.get(name);
|
|
226
|
+
if (id) return id;
|
|
227
|
+
}
|
|
228
|
+
const last = names.at(-1);
|
|
229
|
+
if (!last || !/[a-z]/.test(last)) continue;
|
|
230
|
+
const hits = related.filter((m) => m.slug.slice(m.slug.indexOf('-') + 1) === last);
|
|
231
|
+
if (hits.length === 1) return hits[0].id;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cardFor(model, pool, retrievedAt) {
|
|
237
|
+
const stats = {};
|
|
238
|
+
for (const stat of STATS) {
|
|
239
|
+
const own = model.values[stat.id];
|
|
240
|
+
if (!own) continue;
|
|
241
|
+
const others = pool[stat.id].filter((entry) => entry.id !== model.id).map((entry) => entry.value);
|
|
242
|
+
const place = standing(own.value, others) ?? { level: null, tier: null, place: null, tied: false, of: null };
|
|
243
|
+
stats[stat.id] = { ...place, value: own.value, rank: own.rank };
|
|
244
|
+
}
|
|
245
|
+
const created = model.created === null ? null : model.created * 1000;
|
|
246
|
+
return {
|
|
247
|
+
id: model.id,
|
|
248
|
+
name: model.name,
|
|
249
|
+
created: created === null ? null : new Date(created).toISOString(),
|
|
250
|
+
new: created !== null && Date.parse(retrievedAt) - created < NEW_MS,
|
|
251
|
+
expires: model.expires,
|
|
252
|
+
context: model.context,
|
|
253
|
+
maxOutput: model.maxOutput,
|
|
254
|
+
input: model.input,
|
|
255
|
+
reasoning: model.reasoning,
|
|
256
|
+
price: model.price,
|
|
257
|
+
stats,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function describeCatalog({ index, retrievedAt, stale, error }, providers, sessions = []) {
|
|
262
|
+
const stats = STATS.map((stat) => ({ id: stat.id, label: stat.label, short: stat.short ?? stat.label, group: stat.group, about: stat.about }));
|
|
263
|
+
const result = { retrievedAt, stale, error, stats, pool: null, providers: {}, models: {}, sessions: {} };
|
|
264
|
+
if (!index) return result;
|
|
265
|
+
const related = new Map(providers.map((provider) => [provider.id, providerModels(index, provider)]));
|
|
266
|
+
const listed = new Map([...related].map(([id, models]) => [id, models.filter(isMeasured)]));
|
|
267
|
+
const members = new Map();
|
|
268
|
+
for (const models of listed.values()) for (const model of models) members.set(model.id, model);
|
|
269
|
+
const pool = {};
|
|
270
|
+
for (const stat of STATS) {
|
|
271
|
+
pool[stat.id] = [...members.values()].filter((m) => m.values[stat.id]).map((m) => ({ id: m.id, value: m.values[stat.id].value }));
|
|
272
|
+
}
|
|
273
|
+
result.pool = { tools: providers.filter((p) => listed.get(p.id).length).map((p) => p.tool) };
|
|
274
|
+
const card = (id) => (result.models[id] ??= cardFor(index.models.get(id), pool, retrievedAt));
|
|
275
|
+
for (const provider of providers) {
|
|
276
|
+
const models = listed.get(provider.id);
|
|
277
|
+
if (models.length === 0) continue;
|
|
278
|
+
const featured = models.find((m) => INDEXES.every((stat) => m.values[stat])) ?? models[0];
|
|
279
|
+
result.providers[provider.id] = { featured: featured.id, models: models.map((m) => card(m.id).id) };
|
|
280
|
+
}
|
|
281
|
+
for (const session of sessions) {
|
|
282
|
+
if (!session.model) continue;
|
|
283
|
+
const id = resolveModel(index, related.get(session.provider?.id) ?? [], session.model);
|
|
284
|
+
result.sessions[session.id] = id;
|
|
285
|
+
if (id) card(id);
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function failure(err) {
|
|
291
|
+
if (err instanceof CatalogError) return err.message;
|
|
292
|
+
if (err?.name === 'TimeoutError') return `OpenRouter did not answer within ${FETCH_TIMEOUT_MS / 1000} seconds`;
|
|
293
|
+
if (err instanceof SyntaxError) return 'OpenRouter sent a model list that could not be read';
|
|
294
|
+
return `OpenRouter could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class ModelStats {
|
|
298
|
+
constructor({ registry, fetchImpl = fetch, ttlMs = CATALOG_TTL_MS, retryMs = RETRY_MS } = {}) {
|
|
299
|
+
this.registry = registry;
|
|
300
|
+
this.fetchImpl = fetchImpl;
|
|
301
|
+
this.ttlMs = ttlMs;
|
|
302
|
+
this.retryMs = retryMs;
|
|
303
|
+
this.current = null;
|
|
304
|
+
this.inflight = null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async snapshot(sessions = []) {
|
|
308
|
+
return describeCatalog(await this.catalog(), this.registry.providers, sessions);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
catalog() {
|
|
312
|
+
if (this.inflight) return this.inflight;
|
|
313
|
+
const entry = this.current;
|
|
314
|
+
if (entry && Date.now() - entry.at < (entry.catalog.error ? this.retryMs : this.ttlMs)) return Promise.resolve(entry.catalog);
|
|
315
|
+
this.inflight = this._fetch().then((catalog) => {
|
|
316
|
+
this.current = { catalog, at: Date.now() };
|
|
317
|
+
return catalog;
|
|
318
|
+
}).finally(() => { this.inflight = null; });
|
|
319
|
+
return this.inflight;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async _fetch() {
|
|
323
|
+
try {
|
|
324
|
+
const res = await this.fetchImpl(CATALOG_URL, {
|
|
325
|
+
headers: { Accept: 'application/json', 'User-Agent': 'agent-guild' },
|
|
326
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok) throw new CatalogError(`OpenRouter answered HTTP ${res.status}`);
|
|
329
|
+
const listings = parseCatalog(await res.json());
|
|
330
|
+
if (listings.length === 0) throw new CatalogError('OpenRouter sent an empty model list');
|
|
331
|
+
return { index: indexCatalog(listings), retrievedAt: new Date().toISOString(), stale: false, error: null };
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const last = this.current?.catalog;
|
|
334
|
+
if (last?.index) return { ...last, stale: true, error: failure(err) };
|
|
335
|
+
return { index: null, retrievedAt: null, stale: false, error: failure(err) };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|