char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somfilter openai/anthropic from openrouter0331a75

main
5.2 KiB118 linesraw
1import type { Api, Model } from "@earendil-works/pi-ai";
2import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
3import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
5const CATALOG_URL = "https://openrouter.ai/api/v1/models";
6const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
7const CACHE_MARKER = "openrouter-direct";
8
9type OpenRouterModel = {
10  id: string;
11  name: string;
12  context_length?: number;
13  architecture?: { input_modalities?: string[]; output_modalities?: string[]; modality?: string };
14  pricing?: { prompt?: string; completion?: string; input_cache_read?: string; input_cache_write?: string };
15  top_provider?: { context_length?: number; max_completion_tokens?: number };
16  supported_parameters?: string[];
17  reasoning?: { mandatory?: boolean; supported_efforts?: string[] };
18};
19
20function catalogModel(entry: OpenRouterModel): Model<Api> | undefined {
21  if (!entry.id || !entry.name) return;
22  if (entry.architecture?.output_modalities && !entry.architecture.output_modalities.includes("text")) return;
23  if (entry.architecture?.input_modalities && !entry.architecture.input_modalities.includes("text")) return;
24
25  const rate = (value: string | undefined): number => {
26    const cost = Number(value ?? 0) * 1_000_000;
27    return Number.isFinite(cost) && cost >= 0 ? Number(cost.toFixed(6)) : 0;
28  };
29  const efforts = entry.reasoning?.supported_efforts;
30  const thinkingLevelMap = efforts?.length
31    ? {
32        off: entry.reasoning?.mandatory ? null : "none",
33        ...Object.fromEntries(
34          (["minimal", "low", "medium", "high", "xhigh", "max"] as const).map((level) => [
35            level,
36            efforts.includes(level) ? level : null,
37          ]),
38        ),
39      }
40    : entry.reasoning?.mandatory ? { off: null } : undefined;
41  const anthropic = entry.id.startsWith("anthropic/") && !entry.id.endsWith(":batch");
42  return {
43    id: entry.id,
44    name: entry.name,
45    provider: "openrouter",
46    api: anthropic ? "anthropic-messages" : "openai-completions",
47    baseUrl: anthropic ? "https://openrouter.ai/api" : "https://openrouter.ai/api/v1",
48    reasoning: entry.supported_parameters?.includes("reasoning") ?? false,
49    ...(thinkingLevelMap && { thinkingLevelMap }),
50    input: entry.architecture?.input_modalities?.includes("image") || entry.architecture?.modality?.includes("image")
51      ? ["text", "image"]
52      : ["text"],
53    cost: {
54      input: rate(entry.pricing?.prompt),
55      output: rate(entry.pricing?.completion),
56      cacheRead: rate(entry.pricing?.input_cache_read),
57      cacheWrite: rate(entry.pricing?.input_cache_write),
58    },
59    contextWindow: entry.top_provider?.context_length || entry.context_length || 4096,
60    maxTokens: entry.top_provider?.max_completion_tokens || 4096,
61  };
62}
63
64export default function (pi: ExtensionAPI) {
65  const builtIn = openrouterProvider();
66  let liveModels: readonly Model<Api>[] = [];
67
68  pi.registerProvider({
69    ...builtIn,
70    getModels: () => {
71      // Keep Pi's hand-tuned settings for known models; add models missing from its catalog.
72      const models = new Map<string, Model<Api>>(liveModels.map((model) => [model.id, model]));
73      for (const model of builtIn.getModels()) models.set(model.id, model);
74      return [...models.values()].filter((model) => !/^(openai|anthropic)\//.test(model.id));
75    },
76    async refreshModels(context) {
77      const stored = context.stored;
78      if (stored?.models.length) {
79        await context.publish({
80          update: () => {
81            liveModels = stored.models.filter((model) => model.provider === "openrouter");
82          },
83        });
84      }
85      if (!context.allowNetwork || context.signal.aborted) return;
86      if (
87        !context.force && stored?.etag === CACHE_MARKER && stored.checkedAt &&
88        Date.now() - stored.checkedAt < REFRESH_INTERVAL_MS
89      ) return;
90
91      const response = await fetch(CATALOG_URL, {
92        headers: { accept: "application/json" },
93        signal: AbortSignal.any([context.signal, AbortSignal.timeout(10_000)]),
94      });
95      if (!response.ok) throw new Error(`OpenRouter catalog: HTTP ${response.status}`);
96      const data: unknown = await response.json();
97      if (typeof data !== "object" || data === null || !("data" in data) || !Array.isArray(data.data)) {
98        throw new Error("OpenRouter catalog: invalid response");
99      }
100      const models = data.data.flatMap((entry: unknown) => {
101        if (typeof entry !== "object" || entry === null || typeof (entry as OpenRouterModel).id !== "string" || typeof (entry as OpenRouterModel).name !== "string") return [];
102        const model = catalogModel(entry as OpenRouterModel);
103        return model ? [model] : [];
104      });
105      if (!models.length) throw new Error("OpenRouter catalog: no text models returned");
106      await context.publish({
107        persist: { models, checkedAt: Date.now(), etag: CACHE_MARKER },
108        update: () => { liveModels = models; },
109      });
110    },
111  });
112
113  pi.on("session_start", async (_event, ctx) => {
114    const result = await ctx.modelRegistry.refresh({ providers: ["openrouter"] });
115    const error = result.errors.get("openrouter");
116    if (error) ctx.ui.notify(`OpenRouter catalog refresh failed: ${error.message}`, "warning");
117  });
118}