katman-mcp 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.
Files changed (97) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/cli.js +71 -0
  4. package/dist/context.js +43 -0
  5. package/dist/crawlers.js +86 -0
  6. package/dist/index.js +25 -0
  7. package/dist/license.js +448 -0
  8. package/dist/meta.js +28 -0
  9. package/dist/net.js +128 -0
  10. package/dist/server.js +17 -0
  11. package/dist/state.js +81 -0
  12. package/dist/tools/account/account.js +64 -0
  13. package/dist/tools/account/cli.js +21 -0
  14. package/dist/tools/account/index.js +48 -0
  15. package/dist/tools/account/sync.js +142 -0
  16. package/dist/tools/account/upsell.js +290 -0
  17. package/dist/tools/crawl/audit/analyze.js +719 -0
  18. package/dist/tools/crawl/audit/bots.js +114 -0
  19. package/dist/tools/crawl/audit/checks.js +126 -0
  20. package/dist/tools/crawl/audit/html.js +382 -0
  21. package/dist/tools/crawl/audit/index.js +364 -0
  22. package/dist/tools/crawl/audit/model.js +1 -0
  23. package/dist/tools/crawl/audit/report.js +199 -0
  24. package/dist/tools/crawl/audit/robots.js +199 -0
  25. package/dist/tools/crawl/audit/sitemap.js +147 -0
  26. package/dist/tools/crawl/cli.js +106 -0
  27. package/dist/tools/crawl/common/advice.js +61 -0
  28. package/dist/tools/crawl/common/findings.js +57 -0
  29. package/dist/tools/crawl/common/routes.js +80 -0
  30. package/dist/tools/crawl/common/text.js +89 -0
  31. package/dist/tools/crawl/common/urls.js +64 -0
  32. package/dist/tools/crawl/index.js +226 -0
  33. package/dist/tools/crawl/indexnow.js +271 -0
  34. package/dist/tools/crawl/keywords.js +178 -0
  35. package/dist/tools/crawl/scan/framework.js +645 -0
  36. package/dist/tools/crawl/scan/index.js +175 -0
  37. package/dist/tools/crawl/scan/project.js +180 -0
  38. package/dist/tools/crawl/scan/routes.js +354 -0
  39. package/dist/tools/crawl/scan/signals.js +414 -0
  40. package/dist/tools/method/cli.js +42 -0
  41. package/dist/tools/method/core/brief.js +302 -0
  42. package/dist/tools/method/core/extract.js +435 -0
  43. package/dist/tools/method/core/generate.js +504 -0
  44. package/dist/tools/method/core/lint.js +556 -0
  45. package/dist/tools/method/core/plan.js +405 -0
  46. package/dist/tools/method/core/prompt.js +150 -0
  47. package/dist/tools/method/core/questions.js +196 -0
  48. package/dist/tools/method/core/report.js +182 -0
  49. package/dist/tools/method/core/research-run.js +148 -0
  50. package/dist/tools/method/core/research.js +448 -0
  51. package/dist/tools/method/core/snippets.js +292 -0
  52. package/dist/tools/method/core/start.js +277 -0
  53. package/dist/tools/method/data/anatomy.js +141 -0
  54. package/dist/tools/method/data/appendices.js +202 -0
  55. package/dist/tools/method/data/cloudflare.js +192 -0
  56. package/dist/tools/method/data/evidence.js +137 -0
  57. package/dist/tools/method/data/geo-jobs.js +128 -0
  58. package/dist/tools/method/data/index.js +19 -0
  59. package/dist/tools/method/data/landing.js +67 -0
  60. package/dist/tools/method/data/lessons.js +127 -0
  61. package/dist/tools/method/data/measurement.js +142 -0
  62. package/dist/tools/method/data/method.js +141 -0
  63. package/dist/tools/method/data/offsite.js +148 -0
  64. package/dist/tools/method/data/pages.js +167 -0
  65. package/dist/tools/method/data/prompts.js +633 -0
  66. package/dist/tools/method/data/publish.js +113 -0
  67. package/dist/tools/method/data/render.js +117 -0
  68. package/dist/tools/method/data/research.js +131 -0
  69. package/dist/tools/method/data/roadmap.js +105 -0
  70. package/dist/tools/method/data/traps.js +74 -0
  71. package/dist/tools/method/data/verify.js +88 -0
  72. package/dist/tools/method/data/viral-list.js +39 -0
  73. package/dist/tools/method/index.js +209 -0
  74. package/dist/tools/method/lib/calls.js +53 -0
  75. package/dist/tools/method/lib/framework.js +525 -0
  76. package/dist/tools/method/lib/l10n.js +17 -0
  77. package/dist/tools/method/lib/routes.js +118 -0
  78. package/dist/tools/method/lib/state.js +454 -0
  79. package/dist/tools/method/lib/text.js +165 -0
  80. package/dist/tools/method/mcp/prompts.js +79 -0
  81. package/dist/tools/method/mcp/resources.js +50 -0
  82. package/dist/tools/visibility/analyze.js +463 -0
  83. package/dist/tools/visibility/cli.js +86 -0
  84. package/dist/tools/visibility/estimate.js +105 -0
  85. package/dist/tools/visibility/index.js +108 -0
  86. package/dist/tools/visibility/judge.js +156 -0
  87. package/dist/tools/visibility/manual.js +275 -0
  88. package/dist/tools/visibility/models.js +277 -0
  89. package/dist/tools/visibility/monitoring.js +165 -0
  90. package/dist/tools/visibility/openrouter.js +449 -0
  91. package/dist/tools/visibility/render.js +295 -0
  92. package/dist/tools/visibility/run.js +684 -0
  93. package/dist/tools/visibility/summary.js +164 -0
  94. package/dist/types.js +3 -0
  95. package/dist/util.js +92 -0
  96. package/package.json +54 -0
  97. package/server.json +34 -0
@@ -0,0 +1,449 @@
1
+ // OpenRouter access for visibility runs: public model catalog (cached 24 h in .katman/cache),
2
+ // key check, a retrying chat client, and the request/response shapes. Docs verified 2026-09-30:
3
+ // - web search: `tools: [{type: "openrouter:web_search", parameters: {engine, max_uses, ...}}]`
4
+ // (the `web` plugin still works but is deprecated; used only as a fallback surface)
5
+ // - every response carries `usage.cost` (USD); `usage: {include: true}` is a deprecated no-op
6
+ // - searches: `usage.server_tool_use.web_search_requests` (OpenAPI: `server_tool_use_details`)
7
+ // - citations: `message.annotations[{type: "url_citation", url_citation: {url, title}}]`
8
+ // - 402 with `error.metadata.limit_source = "openrouter_in_flight_budget"` is transient (Retry-After);
9
+ // any other 402 means the balance or key limit is exhausted
10
+ // - `GET /api/v1/key` works with a normal key; `/credits` needs a management key
11
+ import { mapLimit, sleep } from "../../net.js";
12
+ import { readJson, writeJson, writeText, readText } from "../../state.js";
13
+ import { EXA_PRICE_PER_SEARCH, FALLBACK_PRICES, MAX_SEARCHES_PER_ANSWER, countryForLang, engineOf, vendorOf, } from "./models.js";
14
+ export const OPENROUTER_API = "https://openrouter.ai/api/v1";
15
+ export const ATTRIBUTION_HEADERS = {
16
+ "HTTP-Referer": "https://katman.pro",
17
+ "X-OpenRouter-Title": "Katman MCP",
18
+ "X-Title": "Katman MCP",
19
+ };
20
+ export const CATALOG_CACHE = "cache/models.json";
21
+ export const CATALOG_MAX_AGE_MS = 24 * 3600 * 1000;
22
+ export const ANSWER_TIMEOUT_MS = 90_000;
23
+ const KEY_VARS = ["OPENROUTER_API_KEY", "KATMAN_OPENROUTER_API_KEY"];
24
+ /** The user's key from the environment. Never logged, printed or written to disk. */
25
+ export function getApiKey(env = process.env) {
26
+ for (const name of KEY_VARS) {
27
+ const v = (env[name] ?? "").trim();
28
+ if (v)
29
+ return { key: v, source: name };
30
+ }
31
+ return {};
32
+ }
33
+ /** Strip anything that looks like a key or bearer token from text we show or store. */
34
+ export function redact(s) {
35
+ return s
36
+ .replace(/sk-or-[A-Za-z0-9_-]{4,}/g, "sk-or-…")
37
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer …");
38
+ }
39
+ async function getJson(fetchImpl, url, timeoutMs, headers = {}) {
40
+ const ctrl = new AbortController();
41
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
42
+ try {
43
+ const res = await fetchImpl(url, { headers: { accept: "application/json", ...ATTRIBUTION_HEADERS, ...headers }, signal: ctrl.signal });
44
+ const text = await res.text();
45
+ let json;
46
+ try {
47
+ json = JSON.parse(text);
48
+ }
49
+ catch {
50
+ json = undefined;
51
+ }
52
+ return { status: res.status, json };
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ function trimModel(m) {
59
+ if (typeof m.id !== "string")
60
+ return undefined;
61
+ const p = (m.pricing ?? {});
62
+ const str = (v) => (typeof v === "string" || typeof v === "number" ? String(v) : undefined);
63
+ const r = m.reasoning;
64
+ return {
65
+ id: m.id,
66
+ name: typeof m.name === "string" ? m.name : undefined,
67
+ created: typeof m.created === "number" ? m.created : undefined,
68
+ pricing: { prompt: str(p.prompt), completion: str(p.completion), web_search: str(p.web_search), request: str(p.request) },
69
+ supported_parameters: Array.isArray(m.supported_parameters) ? m.supported_parameters.filter((x) => typeof x === "string") : [],
70
+ reasoning: r && typeof r === "object"
71
+ ? { mandatory: r.mandatory, default_enabled: r.default_enabled, supported_efforts: r.supported_efforts, default_effort: r.default_effort }
72
+ : null,
73
+ };
74
+ }
75
+ export async function fetchModelList(fetchImpl = fetch) {
76
+ const { status, json } = await getJson(fetchImpl, `${OPENROUTER_API}/models`, 30_000);
77
+ const data = json?.data;
78
+ if (status !== 200 || !Array.isArray(data))
79
+ throw new Error(`GET /models returned ${status}`);
80
+ return data.map((m) => trimModel(m)).filter((m) => !!m);
81
+ }
82
+ const SERVICE_TIER = /\/(fast|flex|priority)$/i;
83
+ export async function fetchEndpoints(id, fetchImpl = fetch, now = new Date()) {
84
+ const base = id.split(":")[0];
85
+ const { status, json } = await getJson(fetchImpl, `${OPENROUTER_API}/models/${base}/endpoints`, 15_000);
86
+ const eps = (json?.data?.endpoints ?? []);
87
+ if (status !== 200)
88
+ throw new Error(`GET /models/${base}/endpoints returned ${status}`);
89
+ const tags = [];
90
+ const nativeSearchTags = [];
91
+ for (const e of eps) {
92
+ const tag = typeof e.tag === "string" ? e.tag : undefined;
93
+ // Service-tier endpoints (openai/fast, xai/priority, …/flex) are opt-in and priced differently: naming them in
94
+ // provider.only would opt into that tier, so they are never pinned.
95
+ if (!tag || SERVICE_TIER.test(tag))
96
+ continue;
97
+ tags.push(tag);
98
+ const nt = e.native_tools;
99
+ if (nt && nt["openrouter:web_search"])
100
+ nativeSearchTags.push(tag);
101
+ }
102
+ return { fetchedAt: now.toISOString(), tags, nativeSearchTags };
103
+ }
104
+ /**
105
+ * Public model catalog (no key needed): cached in .katman/cache/models.json for 24 h.
106
+ * `root` undefined = in-memory only (CLI `models` outside a project).
107
+ */
108
+ export async function loadCatalog(root, o = {}) {
109
+ const fetchImpl = o.fetchImpl ?? fetch;
110
+ const now = o.now ?? new Date();
111
+ const maxAge = o.maxAgeMs ?? CATALOG_MAX_AGE_MS;
112
+ const cached = root ? await readJson(root, CATALOG_CACHE) : undefined;
113
+ let file = cached && Array.isArray(cached.models) && cached.fetchedAt ? { ...cached, endpoints: cached.endpoints ?? {} } : undefined;
114
+ let source;
115
+ let warning;
116
+ let dirty = false;
117
+ const age = file ? now.getTime() - Date.parse(file.fetchedAt) : Infinity;
118
+ if (file && age >= 0 && age < maxAge)
119
+ source = "cache";
120
+ else if (o.offline)
121
+ source = file ? "stale-cache" : "none";
122
+ else {
123
+ try {
124
+ const models = await fetchModelList(fetchImpl);
125
+ file = { version: 1, fetchedAt: now.toISOString(), models, endpoints: file?.endpoints ?? {} };
126
+ source = "live";
127
+ dirty = true;
128
+ }
129
+ catch (e) {
130
+ source = file ? "stale-cache" : "none";
131
+ warning = `Could not fetch the OpenRouter model list (${redact(e instanceof Error ? e.message : String(e))}); ${file ? `using the cached list from ${file.fetchedAt.slice(0, 16).replace("T", " ")} UTC` : "using built-in prices verified 2026-09-30"}.`;
132
+ }
133
+ }
134
+ // Endpoint info needs the network: skip it when the model list could not be fetched either.
135
+ if (!o.offline && o.endpointIds?.length && (source === "live" || source === "cache")) {
136
+ const f = file ?? { version: 1, fetchedAt: now.toISOString(), models: [], endpoints: {} };
137
+ const need = [...new Set(o.endpointIds.map((id) => id.split(":")[0]))].filter((id) => {
138
+ const e = f.endpoints[id];
139
+ return !e || now.getTime() - Date.parse(e.fetchedAt) >= maxAge;
140
+ });
141
+ await mapLimit(need, 5, async (id) => {
142
+ try {
143
+ f.endpoints[id] = await fetchEndpoints(id, fetchImpl, now);
144
+ dirty = true;
145
+ }
146
+ catch {
147
+ // endpoint info is an optimisation (provider pinning); static pins apply without it
148
+ }
149
+ });
150
+ file = f;
151
+ }
152
+ if (root && file && dirty) {
153
+ await writeJson(root, CATALOG_CACHE, file);
154
+ // The cache is regenerated data: keep it out of git (the monitoring workflow commits .katman/).
155
+ if ((await readText(root, "cache/.gitignore")) === undefined)
156
+ await writeText(root, "cache/.gitignore", "*\n");
157
+ }
158
+ return {
159
+ source,
160
+ fetchedAt: file?.fetchedAt,
161
+ models: new Map((file?.models ?? []).map((m) => [m.id, m])),
162
+ endpoints: file?.endpoints ?? {},
163
+ warning,
164
+ };
165
+ }
166
+ /** true/false when the catalog is known, undefined when it could not be loaded. */
167
+ export function isInCatalog(c, slug) {
168
+ if (!c || c.source === "none" || c.models.size === 0)
169
+ return undefined;
170
+ return c.models.has(slug.split(":")[0]);
171
+ }
172
+ function num(v) {
173
+ if (v === undefined)
174
+ return undefined;
175
+ const n = Number(v);
176
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
177
+ }
178
+ export function priceOf(c, slug) {
179
+ const id = slug.split(":")[0];
180
+ const m = c?.models.get(id);
181
+ if (m) {
182
+ const prompt = num(m.pricing.prompt);
183
+ const completion = num(m.pricing.completion);
184
+ if (prompt !== undefined && completion !== undefined) {
185
+ return { prompt, completion, webSearch: num(m.pricing.web_search) ?? 0.01, request: num(m.pricing.request) ?? 0, source: "catalog" };
186
+ }
187
+ }
188
+ const f = FALLBACK_PRICES[id];
189
+ if (f)
190
+ return { ...f, request: 0, source: "builtin" };
191
+ return { prompt: 3e-6, completion: 15e-6, webSearch: 0.01, request: 0, source: "unknown" };
192
+ }
193
+ /** Price of one search for this spec: the provider's own fee for native search, Exa's for OpenRouter-run search. */
194
+ export function searchUnitPrice(spec, p) {
195
+ const engine = engineOf(spec);
196
+ if (engine === "none")
197
+ return 0;
198
+ if (engine === "exa")
199
+ return EXA_PRICE_PER_SEARCH;
200
+ return p.webSearch + p.request;
201
+ }
202
+ export async function fetchKeyInfo(key, fetchImpl = fetch) {
203
+ try {
204
+ const { status, json } = await getJson(fetchImpl, `${OPENROUTER_API}/key`, 15_000, { Authorization: `Bearer ${key}` });
205
+ const d = json?.data;
206
+ if (status !== 200 || !d) {
207
+ const msg = json?.error?.message;
208
+ return { ok: false, status, error: redact(msg ?? `HTTP ${status}`) };
209
+ }
210
+ const n = (v) => (typeof v === "number" ? v : v === null ? null : undefined);
211
+ return {
212
+ ok: true,
213
+ status,
214
+ limit: n(d.limit),
215
+ limitRemaining: n(d.limit_remaining),
216
+ usage: typeof d.usage === "number" ? d.usage : undefined,
217
+ isFreeTier: typeof d.is_free_tier === "boolean" ? d.is_free_tier : undefined,
218
+ };
219
+ }
220
+ catch (e) {
221
+ return { ok: false, status: 0, error: redact(e instanceof Error ? e.message : String(e)) };
222
+ }
223
+ }
224
+ export class OpenRouterError extends Error {
225
+ kind;
226
+ status;
227
+ extra;
228
+ constructor(kind, message, status = 0, extra = {}) {
229
+ super(redact(message));
230
+ this.kind = kind;
231
+ this.status = status;
232
+ this.extra = extra;
233
+ this.name = "OpenRouterError";
234
+ }
235
+ }
236
+ export function isRetriable(e) {
237
+ if (e.kind === "credits")
238
+ return e.extra.limitSource === "openrouter_in_flight_budget" && e.extra.retryAfterMs !== undefined;
239
+ return e.kind === "rate" || e.kind === "server" || e.kind === "timeout" || e.kind === "network";
240
+ }
241
+ function retryAfterMs(h) {
242
+ const v = h.get("retry-after");
243
+ if (!v)
244
+ return undefined;
245
+ const s = Number(v);
246
+ if (Number.isFinite(s))
247
+ return Math.max(0, s * 1000);
248
+ const t = Date.parse(v);
249
+ return Number.isFinite(t) ? Math.max(0, t - Date.now()) : undefined;
250
+ }
251
+ function kindForCode(code) {
252
+ if (code === 401)
253
+ return "auth";
254
+ if (code === 402)
255
+ return "credits";
256
+ if (code === 403)
257
+ return "forbidden";
258
+ if (code === 404)
259
+ return "not_found";
260
+ if (code === 408)
261
+ return "timeout";
262
+ if (code === 429)
263
+ return "rate";
264
+ if (code >= 500)
265
+ return "server";
266
+ if (code >= 400)
267
+ return "bad_request";
268
+ return "provider";
269
+ }
270
+ async function postOnce(key, body, o) {
271
+ const fetchImpl = o.fetchImpl ?? fetch;
272
+ const ctrl = new AbortController();
273
+ const timer = setTimeout(() => ctrl.abort(), o.timeoutMs ?? ANSWER_TIMEOUT_MS);
274
+ let res;
275
+ let text;
276
+ try {
277
+ res = await fetchImpl(`${OPENROUTER_API}/chat/completions`, {
278
+ method: "POST",
279
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...ATTRIBUTION_HEADERS },
280
+ body: JSON.stringify(body),
281
+ signal: ctrl.signal,
282
+ });
283
+ text = await res.text();
284
+ }
285
+ catch (e) {
286
+ if (ctrl.signal.aborted)
287
+ throw new OpenRouterError("timeout", `no answer within ${Math.round((o.timeoutMs ?? ANSWER_TIMEOUT_MS) / 1000)} s`);
288
+ throw new OpenRouterError("network", e instanceof Error ? e.message : String(e));
289
+ }
290
+ finally {
291
+ clearTimeout(timer);
292
+ }
293
+ let json;
294
+ try {
295
+ json = JSON.parse(text);
296
+ }
297
+ catch {
298
+ json = undefined;
299
+ }
300
+ const err = json?.error;
301
+ if (!res.ok) {
302
+ const message = typeof err?.message === "string" ? err.message : `HTTP ${res.status}`;
303
+ throw new OpenRouterError(kindForCode(res.status), message, res.status, {
304
+ limitSource: typeof err?.metadata?.limit_source === "string" ? err.metadata.limit_source : undefined,
305
+ retryAfterMs: retryAfterMs(res.headers),
306
+ });
307
+ }
308
+ if (!json)
309
+ throw new OpenRouterError("server", "OpenRouter returned a response that is not JSON", res.status);
310
+ if (err) {
311
+ // A provider failure after the request was accepted arrives as HTTP 200 with an error body.
312
+ const code = typeof err.code === "number" ? err.code : Number(err.code) || 502;
313
+ const message = typeof err.message === "string" ? err.message : "provider error";
314
+ throw new OpenRouterError(kindForCode(code), message, code);
315
+ }
316
+ return json;
317
+ }
318
+ /** POST /chat/completions with retries: 429/5xx/timeouts back off exponentially (3 attempts by default). */
319
+ export async function postChat(key, body, o = {}) {
320
+ const attempts = Math.max(1, o.attempts ?? 3);
321
+ const wait = o.sleepFn ?? sleep;
322
+ let last;
323
+ for (let i = 1; i <= attempts; i++) {
324
+ try {
325
+ return await postOnce(key, body, o);
326
+ }
327
+ catch (e) {
328
+ last = e instanceof OpenRouterError ? e : new OpenRouterError("network", e instanceof Error ? e.message : String(e));
329
+ if (!isRetriable(last) || i === attempts)
330
+ throw last;
331
+ const backoff = 1000 * 2 ** (i - 1) + Math.floor(Math.random() * 300);
332
+ await wait(Math.min(30_000, last.extra.retryAfterMs ?? backoff));
333
+ }
334
+ }
335
+ throw last ?? new OpenRouterError("network", "request failed");
336
+ }
337
+ export function tuningFor(spec, m, ep) {
338
+ const v = vendorOf(spec.id);
339
+ const efforts = m?.reasoning?.supported_efforts ?? [];
340
+ let reasoningEffort;
341
+ if (v !== "perplexity") {
342
+ if (efforts.length)
343
+ reasoningEffort = ["low", "minimal", "medium"].find((e) => efforts.includes(e)) ?? efforts[efforts.length - 1];
344
+ else if (!m && v)
345
+ reasoningEffort = "low";
346
+ }
347
+ const reasoningOn = !!reasoningEffort || !!m?.reasoning?.mandatory || !!m?.reasoning?.default_enabled;
348
+ const temperature = m ? m.supported_parameters.includes("temperature") : v !== "openai";
349
+ let providerOnly;
350
+ let providerIgnore;
351
+ if (engineOf(spec) === "native" && v && v !== "perplexity") {
352
+ if (ep && ep.tags.length) {
353
+ if (ep.nativeSearchTags.length && ep.nativeSearchTags.length < ep.tags.length) {
354
+ providerOnly = [...new Set(ep.nativeSearchTags)];
355
+ providerIgnore = [...new Set(ep.tags.filter((t) => !ep.nativeSearchTags.includes(t)))];
356
+ }
357
+ }
358
+ else if (v === "anthropic")
359
+ providerOnly = ["anthropic", "claude-on-aws"];
360
+ }
361
+ // ~1200 answer tokens; reasoning models get room to think first so the answer is not cut off.
362
+ return { maxTokens: reasoningOn ? 2200 : 1200, reasoningEffort, temperature, providerOnly, providerIgnore, maxSearches: MAX_SEARCHES_PER_ANSWER };
363
+ }
364
+ /** Request body for an assistant answer: the question is the only user message (no system prompt), like a real user. */
365
+ export function buildAnswerBody(spec, messages, tuning, o) {
366
+ const body = {
367
+ model: spec.id,
368
+ messages,
369
+ max_tokens: o.maxTokens ?? tuning.maxTokens,
370
+ usage: { include: true }, // deprecated no-op (usage is always returned); kept for older gateways
371
+ };
372
+ if (tuning.temperature && typeof o.temperature === "number")
373
+ body.temperature = o.temperature;
374
+ if (tuning.reasoningEffort)
375
+ body.reasoning = { effort: tuning.reasoningEffort, exclude: true };
376
+ const provider = { data_collection: "deny" };
377
+ if (tuning.providerOnly?.length)
378
+ provider.only = tuning.providerOnly;
379
+ if (tuning.providerIgnore?.length)
380
+ provider.ignore = tuning.providerIgnore;
381
+ body.provider = provider;
382
+ const engine = engineOf(spec);
383
+ // Perplexity models search their own index on every call and take no tools.
384
+ if (engine === "none" || vendorOf(spec.id) === "perplexity")
385
+ return body;
386
+ if (o.surface === "tool") {
387
+ const parameters = { engine: engine === "native" ? "native" : "exa", max_uses: tuning.maxSearches };
388
+ if (engine === "exa")
389
+ parameters.max_results = 5;
390
+ const country = engine === "native" ? countryForLang(o.lang) : undefined;
391
+ if (country)
392
+ parameters.user_location = { type: "approximate", country };
393
+ body.tools = [{ type: "openrouter:web_search", parameters }];
394
+ }
395
+ else {
396
+ body.plugins = [engine === "native" ? { id: "web", engine: "native" } : { id: "web", engine: "exa", max_results: 5 }];
397
+ }
398
+ return body;
399
+ }
400
+ export function parseCompletion(json) {
401
+ const choice = json.choices?.[0] ?? {};
402
+ const msg = (choice.message ?? {});
403
+ let text = "";
404
+ if (typeof msg.content === "string")
405
+ text = msg.content;
406
+ else if (Array.isArray(msg.content)) {
407
+ text = msg.content.map((p) => (typeof p === "string" ? p : typeof p?.text === "string" ? p.text : "")).join("");
408
+ }
409
+ const sources = [];
410
+ const pushSource = (url, title) => {
411
+ if (typeof url === "string" && /^https?:\/\//i.test(url))
412
+ sources.push({ url, title: typeof title === "string" && title.trim() ? title.trim() : undefined });
413
+ };
414
+ for (const a of msg.annotations ?? []) {
415
+ if (a?.type !== "url_citation")
416
+ continue;
417
+ const u = (a.url_citation ?? a);
418
+ pushSource(u.url, u.title);
419
+ }
420
+ for (const c of (json.citations ?? msg.citations) ?? []) {
421
+ if (typeof c === "string")
422
+ pushSource(c);
423
+ else if (c && typeof c === "object")
424
+ pushSource(c.url, c.title);
425
+ }
426
+ for (const r of (json.search_results ?? msg.search_results) ?? []) {
427
+ pushSource(r?.url, r?.title);
428
+ }
429
+ const u = (json.usage ?? {});
430
+ const stu = (u.server_tool_use ?? u.server_tool_use_details ?? {});
431
+ const details = (u.completion_tokens_details ?? {});
432
+ const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
433
+ return {
434
+ text,
435
+ finishReason: typeof choice.finish_reason === "string" ? choice.finish_reason : undefined,
436
+ model: typeof json.model === "string" ? json.model : undefined,
437
+ provider: typeof json.provider === "string" ? json.provider : undefined,
438
+ id: typeof json.id === "string" ? json.id : undefined,
439
+ sources,
440
+ webSearches: n(stu.web_search_requests) ?? n(stu.webSearchRequests) ?? 0,
441
+ usage: { promptTokens: n(u.prompt_tokens), completionTokens: n(u.completion_tokens), reasoningTokens: n(details.reasoning_tokens), cost: n(u.cost) },
442
+ };
443
+ }
444
+ /** Charged cost: `usage.cost` when OpenRouter reports it, else computed from tokens and searches. */
445
+ export function costOf(c, price, searchUnit) {
446
+ if (typeof c.usage.cost === "number")
447
+ return c.usage.cost;
448
+ return (c.usage.promptTokens ?? 0) * price.prompt + (c.usage.completionTokens ?? 0) * price.completion + c.webSearches * searchUnit + price.request;
449
+ }