@aggc/or-info 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 +294 -0
- package/bin/or-info.mjs +240 -0
- package/lib/cache.mjs +117 -0
- package/lib/formatter.mjs +178 -0
- package/lib/lmarena.mjs +174 -0
- package/lib/openrouter.mjs +125 -0
- package/lib/paths.mjs +53 -0
- package/lib/scorer.mjs +81 -0
- package/lib/secrets.mjs +41 -0
- package/mcp/server.mjs +213 -0
- package/package.json +51 -0
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { fetchModels, findModel, pricePerMillion, contextLength, modelTags } from '../lib/openrouter.mjs';
|
|
5
|
+
import { getElo, getAllElo, loadLeaderboard } from '../lib/lmarena.mjs';
|
|
6
|
+
import { rankModels } from '../lib/scorer.mjs';
|
|
7
|
+
import { getApiKey } from '../lib/secrets.mjs';
|
|
8
|
+
|
|
9
|
+
const TOOLS = [
|
|
10
|
+
{
|
|
11
|
+
name: 'get_model_info',
|
|
12
|
+
description: 'Get pricing, context length, architecture and features for a specific OpenRouter model',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
model_id: { type: 'string', description: 'OpenRouter model ID, e.g. "anthropic/claude-sonnet-4-5"' },
|
|
17
|
+
},
|
|
18
|
+
required: ['model_id'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'list_models',
|
|
23
|
+
description: 'List OpenRouter models with pricing. Optionally filter by name/id, sort, and limit results.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
filter: { type: 'string', description: 'Case-insensitive substring to match against model ID or name' },
|
|
28
|
+
sort_by: { type: 'string', enum: ['name', 'price', 'context'], description: 'Sort order', default: 'name' },
|
|
29
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max models to return (default 50)' },
|
|
30
|
+
free_only: { type: 'boolean', description: 'Return only free models' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'get_benchmarks',
|
|
36
|
+
description: 'Get benchmark scores for a model: MMLU, HumanEval, MATH, coding, ELO, speed and latency',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
model_id: { type: 'string', description: 'OpenRouter model ID' },
|
|
41
|
+
},
|
|
42
|
+
required: ['model_id'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'compare_models',
|
|
47
|
+
description: 'Side-by-side comparison of two models: pricing, context, benchmarks and features',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
model_a: { type: 'string', description: 'First OpenRouter model ID' },
|
|
52
|
+
model_b: { type: 'string', description: 'Second OpenRouter model ID' },
|
|
53
|
+
},
|
|
54
|
+
required: ['model_a', 'model_b'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'best_for_task',
|
|
59
|
+
description: 'Rank the best models for a specific task, optionally within a price budget',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
task: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: ['coding', 'reasoning', 'general', 'vision', 'cheap'],
|
|
66
|
+
description: 'Task type to optimise for',
|
|
67
|
+
},
|
|
68
|
+
max_price_per_m_output: {
|
|
69
|
+
type: 'number',
|
|
70
|
+
description: 'Maximum price per 1M output tokens in USD (e.g. 1.0)',
|
|
71
|
+
},
|
|
72
|
+
limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Number of results (default 5)' },
|
|
73
|
+
},
|
|
74
|
+
required: ['task'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'refresh_cache',
|
|
79
|
+
description: 'Force-refresh the local cache: OpenRouter model catalog + LMArena ELO data',
|
|
80
|
+
inputSchema: { type: 'object', properties: {} },
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function safeModelSummary(model) {
|
|
85
|
+
const price = pricePerMillion(model);
|
|
86
|
+
return {
|
|
87
|
+
id: model.id,
|
|
88
|
+
name: model.name,
|
|
89
|
+
input_per_m: price.input,
|
|
90
|
+
output_per_m: price.output,
|
|
91
|
+
image_per_m: price.image,
|
|
92
|
+
cache_read_per_m: price.cacheRead,
|
|
93
|
+
context_length: contextLength(model),
|
|
94
|
+
features: modelTags(model),
|
|
95
|
+
modality: model?.architecture?.modality ?? null,
|
|
96
|
+
tokenizer: model?.architecture?.tokenizer ?? null,
|
|
97
|
+
max_output_tokens: model?.top_provider?.max_completion_tokens ?? null,
|
|
98
|
+
supported_parameters: model?.supported_parameters ?? [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function textContent(obj) {
|
|
103
|
+
return [{ type: 'text', text: JSON.stringify(obj, null, 2) }];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorContent(msg) {
|
|
107
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function handleTool(name, args) {
|
|
111
|
+
const key = await getApiKey();
|
|
112
|
+
|
|
113
|
+
if (name === 'get_model_info') {
|
|
114
|
+
const { model_id } = args;
|
|
115
|
+
if (!model_id || typeof model_id !== 'string') return errorContent('model_id is required');
|
|
116
|
+
const models = await fetchModels({ apiKey: key });
|
|
117
|
+
const model = findModel(models, model_id);
|
|
118
|
+
if (!model) return errorContent(`Model not found: ${model_id}`);
|
|
119
|
+
const elo = await getElo(model_id);
|
|
120
|
+
return { content: textContent({ ...safeModelSummary(model), lmarena_elo: elo ?? null }) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (name === 'list_models') {
|
|
124
|
+
const filter = String(args.filter ?? '').toLowerCase();
|
|
125
|
+
const sortBy = args.sort_by ?? 'name';
|
|
126
|
+
const limit = Math.min(200, Math.max(1, args.limit ?? 50));
|
|
127
|
+
const freeOnly = Boolean(args.free_only);
|
|
128
|
+
|
|
129
|
+
let models = await fetchModels({ apiKey: key });
|
|
130
|
+
if (filter) models = models.filter((m) => m.id.toLowerCase().includes(filter) || (m.name ?? '').toLowerCase().includes(filter));
|
|
131
|
+
if (freeOnly) models = models.filter((m) => { const p = pricePerMillion(m); return p.input === 0 && p.output === 0; });
|
|
132
|
+
|
|
133
|
+
if (sortBy === 'price') models.sort((a, b) => (pricePerMillion(a).output ?? Infinity) - (pricePerMillion(b).output ?? Infinity));
|
|
134
|
+
else if (sortBy === 'context') models.sort((a, b) => (contextLength(b) ?? 0) - (contextLength(a) ?? 0));
|
|
135
|
+
else models.sort((a, b) => a.id.localeCompare(b.id));
|
|
136
|
+
|
|
137
|
+
models = models.slice(0, limit);
|
|
138
|
+
return { content: textContent({ total: models.length, models: models.map(safeModelSummary) }) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (name === 'get_benchmarks') {
|
|
142
|
+
const { model_id } = args;
|
|
143
|
+
if (!model_id || typeof model_id !== 'string') return errorContent('model_id is required');
|
|
144
|
+
const elo = await getElo(model_id);
|
|
145
|
+
return { content: textContent({ model_id, lmarena_elo: elo ?? null }) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (name === 'compare_models') {
|
|
149
|
+
const { model_a, model_b } = args;
|
|
150
|
+
if (!model_a || !model_b) return errorContent('model_a and model_b are required');
|
|
151
|
+
const [models, eloA, eloB] = await Promise.all([
|
|
152
|
+
fetchModels({ apiKey: key }),
|
|
153
|
+
getElo(model_a),
|
|
154
|
+
getElo(model_b),
|
|
155
|
+
]);
|
|
156
|
+
const mA = findModel(models, model_a);
|
|
157
|
+
const mB = findModel(models, model_b);
|
|
158
|
+
if (!mA) return errorContent(`Model not found: ${model_a}`);
|
|
159
|
+
if (!mB) return errorContent(`Model not found: ${model_b}`);
|
|
160
|
+
return { content: textContent({ a: { ...safeModelSummary(mA), lmarena_elo: eloA }, b: { ...safeModelSummary(mB), lmarena_elo: eloB } }) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (name === 'best_for_task') {
|
|
164
|
+
const task = args.task ?? 'general';
|
|
165
|
+
const limit = Math.min(20, Math.max(1, args.limit ?? 5));
|
|
166
|
+
const maxPrice = args.max_price_per_m_output ?? undefined;
|
|
167
|
+
|
|
168
|
+
const [models, allElo] = await Promise.all([fetchModels({ apiKey: key }), getAllElo()]);
|
|
169
|
+
const ranked = rankModels(models, allElo, { task, maxPricePerMOutput: maxPrice, limit });
|
|
170
|
+
return { content: textContent({ task, results: ranked.map((r) => ({ ...safeModelSummary(r.model), score: r.score, lmarena_elo: r.eloEntry })) }) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (name === 'refresh_cache') {
|
|
174
|
+
const [models, elo] = await Promise.all([
|
|
175
|
+
fetchModels({ force: true, apiKey: key }),
|
|
176
|
+
loadLeaderboard({ force: true }),
|
|
177
|
+
]);
|
|
178
|
+
return { content: textContent({ refreshed: true, models_count: models.length, elo_entries: elo.length }) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return errorContent(`Unknown tool: ${name}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function startMcp() {
|
|
185
|
+
const server = new Server(
|
|
186
|
+
{ name: 'or-info', version: '0.1.0' },
|
|
187
|
+
{ capabilities: { tools: {} } }
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
191
|
+
|
|
192
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
193
|
+
const { name, arguments: args } = req.params;
|
|
194
|
+
try {
|
|
195
|
+
return await handleTool(name, args ?? {});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const safe = err.message?.replace(/sk-[a-zA-Z0-9-]+/g, '[REDACTED]') ?? 'Unexpected error';
|
|
198
|
+
return errorContent(safe);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const transport = new StdioServerTransport();
|
|
203
|
+
await server.connect(transport);
|
|
204
|
+
|
|
205
|
+
// server.connect() returns immediately after wiring up the transport.
|
|
206
|
+
// Block here until stdin closes so the process stays alive while serving.
|
|
207
|
+
if (!process.stdin.destroyed) {
|
|
208
|
+
await new Promise((resolve) => {
|
|
209
|
+
process.stdin.once('close', resolve);
|
|
210
|
+
process.stdin.once('end', resolve);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aggc/or-info",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + MCP server for OpenRouter models: prices, benchmarks, context and comparisons",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"or-info": "./bin/or-info.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"mcp"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "npm run test:local",
|
|
19
|
+
"test:local": "node --test test/local/**/*.mjs",
|
|
20
|
+
"test:online:smoke": "node --test test/online/cli.mjs test/online/mcp.mjs",
|
|
21
|
+
"test:online": "node --test --test-concurrency=1 test/online/**/*.mjs",
|
|
22
|
+
"test:watch": "node --test --watch test/local/**/*.mjs test/online/cli.mjs test/online/mcp.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
+
"chalk": "^5.6.2",
|
|
27
|
+
"commander": "^14.0.3"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"openrouter",
|
|
31
|
+
"llm",
|
|
32
|
+
"ai",
|
|
33
|
+
"mcp",
|
|
34
|
+
"benchmarks",
|
|
35
|
+
"models",
|
|
36
|
+
"pricing",
|
|
37
|
+
"cli",
|
|
38
|
+
"model-comparison",
|
|
39
|
+
"ai-tools"
|
|
40
|
+
],
|
|
41
|
+
"author": "jmtrs",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/jmtrs/or-info.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/jmtrs/or-info/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/jmtrs/or-info#readme"
|
|
51
|
+
}
|