pi-cliproxyapi-provider 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/src/cache.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface CacheEnvelope<T> {
5
+ fetchedAt: number;
6
+ data: T;
7
+ }
8
+
9
+ export async function readCache<T>(path: string): Promise<CacheEnvelope<T> | undefined> {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(path, "utf8"));
12
+ if (!parsed || typeof parsed !== "object" || typeof parsed.fetchedAt !== "number" || !("data" in parsed)) {
13
+ return undefined;
14
+ }
15
+ return parsed as CacheEnvelope<T>;
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ export async function writeCache<T>(path: string, data: T, fetchedAt = Date.now()): Promise<void> {
23
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
24
+ await writeFile(path, `${JSON.stringify({ fetchedAt, data }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
25
+ }
26
+
27
+ export function isFresh(cache: Pick<CacheEnvelope<unknown>, "fetchedAt"> | undefined, ttlSeconds: number, now = Date.now()): boolean {
28
+ if (!cache || ttlSeconds <= 0 || cache.fetchedAt > now) return false;
29
+ return now - cache.fetchedAt < ttlSeconds * 1000;
30
+ }
31
+
32
+ export async function getCachedOrFetch<T>(options: {
33
+ path: string;
34
+ ttlSeconds: number;
35
+ fetchFresh: () => Promise<T>;
36
+ now?: number;
37
+ force?: boolean;
38
+ }): Promise<{ data: T; source: "fresh" | "cache" | "stale"; error?: unknown }> {
39
+ const cached = await readCache<T>(options.path);
40
+ const now = options.now ?? Date.now();
41
+ if (!options.force && isFresh(cached, options.ttlSeconds, now)) {
42
+ return { data: cached!.data, source: "cache" };
43
+ }
44
+
45
+ try {
46
+ const fresh = await options.fetchFresh();
47
+ await writeCache(options.path, fresh, now);
48
+ return { data: fresh, source: "fresh" };
49
+ } catch (error) {
50
+ if (cached) {
51
+ return { data: cached.data, source: "stale", error };
52
+ }
53
+ throw error;
54
+ }
55
+ }
@@ -0,0 +1,159 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { DEFAULT_CONFIG, loadConfig, globalConfigPath, readConfigFile, writeConfigFile, type ConfigLayer } from "./config.ts";
4
+ import { cpaModelsCachePath, discoverModels, modelsDevCachePath } from "./discovery.ts";
5
+ import { buildProviderModels } from "./provider.ts";
6
+ import { getDiscoveryApiKey } from "./auth.ts";
7
+
8
+ function errorText(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ export function formatStatusFailure(config: ReturnType<typeof loadConfig>, error: unknown): string {
13
+ return [
14
+ `CLIProxyAPI status failed: ${errorText(error)}`,
15
+ `Provider: ${config.providerName}`,
16
+ `Base URL: ${config.baseUrl}`,
17
+ `Auth required: ${config.authRequired ? "yes" : "no"}`,
18
+ "",
19
+ "Run /cliproxyapi config to set the CLIProxyAPI base URL.",
20
+ `If you changed config or just ran /login ${config.providerName}, run /reload before selecting models.`,
21
+ ].join("\n");
22
+ }
23
+
24
+ function redactConfig(value: unknown): unknown {
25
+ if (Array.isArray(value)) return value.map(redactConfig);
26
+ if (!value || typeof value !== "object") return value;
27
+
28
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
29
+ const isSecretLike = /authorization|api[-_]?key|token|secret|cookie/i.test(key);
30
+ return [key, isSecretLike ? "<redacted>" : redactConfig(entry)];
31
+ }));
32
+ }
33
+
34
+ function age(path: string): string {
35
+ if (!existsSync(path)) return "missing";
36
+ const seconds = Math.round((Date.now() - statSync(path).mtimeMs) / 1000);
37
+ if (seconds < 60) return `${seconds}s`;
38
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
39
+ return `${Math.round(seconds / 3600)}h`;
40
+ }
41
+
42
+ export async function runConfig(ctx: ExtensionCommandContext): Promise<void> {
43
+ if (!ctx.hasUI) {
44
+ ctx.ui.notify("/cliproxyapi config requires an interactive UI.", "warning");
45
+ return;
46
+ }
47
+
48
+ let current = DEFAULT_CONFIG;
49
+ try {
50
+ current = loadConfig(ctx.cwd);
51
+ } catch (error) {
52
+ ctx.ui.notify(`Existing CLIProxyAPI config is invalid; using defaults for repair: ${errorText(error)}`, "warning");
53
+ }
54
+
55
+ const path = globalConfigPath();
56
+ let existing: ConfigLayer | undefined;
57
+ try {
58
+ existing = readConfigFile(path);
59
+ } catch (error) {
60
+ ctx.ui.notify(`Existing global CLIProxyAPI config is invalid and will be replaced if you save: ${errorText(error)}`, "warning");
61
+ }
62
+ const defaults = existing ?? current;
63
+
64
+ if (existing) {
65
+ ctx.ui.notify(`Editing existing global config at ${path}:\n${JSON.stringify(redactConfig(existing), null, 2)}`, "info");
66
+ }
67
+
68
+ const providerNameInput = await ctx.ui.input(`Provider name (leave blank to keep: ${defaults.providerName})`, `leave blank to keep ${defaults.providerName}`);
69
+ if (providerNameInput === undefined) return;
70
+ const baseUrlInput = await ctx.ui.input(`CLIProxyAPI base URL (leave blank to keep: ${defaults.baseUrl})`, `leave blank to keep ${defaults.baseUrl}`);
71
+ if (baseUrlInput === undefined) return;
72
+ const authRequired = await ctx.ui.confirm(
73
+ `Require /login credentials? (current: ${defaults.authRequired ? "yes" : "no"})`,
74
+ "Choose yes unless this CLIProxyAPI instance accepts unauthenticated requests."
75
+ );
76
+ const authHeader = authRequired
77
+ ? await ctx.ui.confirm(
78
+ `Send Authorization bearer header? (current: ${defaults.authHeader ? "yes" : "no"})`,
79
+ "Choose yes for CLIProxyAPI API keys."
80
+ )
81
+ : false;
82
+
83
+ const config: ConfigLayer = {
84
+ providerName: providerNameInput || defaults.providerName,
85
+ baseUrl: baseUrlInput || defaults.baseUrl,
86
+ authRequired,
87
+ authHeader,
88
+ };
89
+ writeConfigFile(path, config);
90
+ ctx.ui.notify(`Saved CLIProxyAPI config to ${path}. Reloading pi to apply it...`, "info");
91
+ await ctx.reload();
92
+ }
93
+
94
+ async function runStatus(ctx: ExtensionCommandContext, bundledModelsDevPath: string): Promise<void> {
95
+ const config = loadConfig(ctx.cwd);
96
+ try {
97
+ const discovery = await discoverModels({ config, bundledModelsDevPath, discoveryApiKey: await getDiscoveryApiKey(config.providerName) });
98
+ const built = buildProviderModels(discovery.cpaModels, discovery.modelsDevCatalog, config.modelAliases);
99
+ ctx.ui.notify([
100
+ `CLIProxyAPI provider: ${config.providerName}`,
101
+ `Base URL: ${config.baseUrl}`,
102
+ `Auth required: ${config.authRequired ? "yes" : "no"}`,
103
+ `CPA models: ${built.stats.total} (${built.stats.enriched} enriched, ${built.stats.unmatched} unmatched)`,
104
+ `CPA cache: ${discovery.sources.cpa}, age ${age(cpaModelsCachePath(config))}`,
105
+ `models.dev cache: ${discovery.sources.modelsDev}, age ${age(modelsDevCachePath())}`,
106
+ ].join("\n"), "info");
107
+ } catch (error) {
108
+ ctx.ui.notify(formatStatusFailure(config, error), "error");
109
+ }
110
+ }
111
+
112
+ async function runRefresh(ctx: ExtensionCommandContext, bundledModelsDevPath: string): Promise<void> {
113
+ const config = loadConfig(ctx.cwd);
114
+ try {
115
+ const discovery = await discoverModels({ config, bundledModelsDevPath, force: true, discoveryApiKey: await getDiscoveryApiKey(config.providerName) });
116
+ const built = buildProviderModels(discovery.cpaModels, discovery.modelsDevCatalog, config.modelAliases);
117
+ ctx.ui.notify(`Refreshed CLIProxyAPI caches. ${built.stats.total} CPA models, ${built.stats.enriched} enriched, ${built.stats.unmatched} unmatched. Run /reload to load refreshed models.`, "info");
118
+ } catch (error) {
119
+ ctx.ui.notify(`CLIProxyAPI refresh failed: ${errorText(error)}`, "error");
120
+ }
121
+ }
122
+
123
+ async function runAliases(ctx: ExtensionCommandContext, bundledModelsDevPath: string): Promise<void> {
124
+ const config = loadConfig(ctx.cwd);
125
+ try {
126
+ const discovery = await discoverModels({ config, bundledModelsDevPath, discoveryApiKey: await getDiscoveryApiKey(config.providerName) });
127
+ const built = buildProviderModels(discovery.cpaModels, discovery.modelsDevCatalog, config.modelAliases);
128
+ const sample = built.stats.unmatchedModelIds.slice(0, 30);
129
+ const body = sample.length === 0
130
+ ? "All CPA models matched models.dev metadata."
131
+ : `Unmatched CPA models (${built.stats.unmatched}):\n${sample.map((id) => ` "${id}": "<models.dev-id>"`).join("\n")}`;
132
+ ctx.ui.notify(body, built.stats.unmatched ? "warning" : "info");
133
+ } catch (error) {
134
+ ctx.ui.notify(`CLIProxyAPI aliases failed: ${errorText(error)}`, "error");
135
+ }
136
+ }
137
+
138
+ export function cliproxyapiArgumentCompletions(prefix: string): Array<{ value: string; label: string }> {
139
+ return ["config", "status", "refresh", "aliases", "help"]
140
+ .filter((item) => item.startsWith(prefix))
141
+ .map((value) => ({ value, label: value }));
142
+ }
143
+
144
+ export function registerCliproxyapiCommand(pi: ExtensionAPI, bundledModelsDevPath: string): void {
145
+ pi.registerCommand("cliproxyapi", {
146
+ description: "Configure and inspect the CLIProxyAPI provider.",
147
+ getArgumentCompletions(prefix) {
148
+ return cliproxyapiArgumentCompletions(prefix);
149
+ },
150
+ async handler(args, ctx) {
151
+ const subcommand = args.trim().split(/\s+/, 1)[0] || "help";
152
+ if (subcommand === "config") return runConfig(ctx);
153
+ if (subcommand === "status") return runStatus(ctx, bundledModelsDevPath);
154
+ if (subcommand === "refresh") return runRefresh(ctx, bundledModelsDevPath);
155
+ if (subcommand === "aliases") return runAliases(ctx, bundledModelsDevPath);
156
+ ctx.ui.notify("Usage: /cliproxyapi config|status|refresh|aliases\nAfter config or refresh, run /reload.", "info");
157
+ },
158
+ });
159
+ }
package/src/config.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { CpaProviderConfig } from "./types.ts";
5
+
6
+ export type ConfigLayer = Partial<CpaProviderConfig>;
7
+
8
+ export const DEFAULT_CONFIG: CpaProviderConfig = {
9
+ providerName: "cpa",
10
+ baseUrl: "http://localhost:8317/v1",
11
+ authRequired: true,
12
+ authHeader: true,
13
+ headers: {},
14
+ cpaCacheTtlSeconds: 60 * 60,
15
+ modelsDevCacheTtlSeconds: 24 * 60 * 60,
16
+ modelsDevEnabled: true,
17
+ modelAliases: {},
18
+ };
19
+
20
+ export function globalConfigPath(): string {
21
+ return join(homedir(), ".pi", "agent", "pi-cliproxyapi-provider", "config.json");
22
+ }
23
+
24
+ export function projectConfigPath(cwd: string): string {
25
+ return join(cwd, ".pi", "pi-cliproxyapi-provider", "config.json");
26
+ }
27
+
28
+ export function cacheDir(): string {
29
+ return join(homedir(), ".cache", "pi-cliproxyapi-provider");
30
+ }
31
+
32
+ export function providerCacheKey(config: Pick<CpaProviderConfig, "providerName" | "baseUrl">): string {
33
+ return Buffer.from(`${config.providerName}\n${config.baseUrl}`).toString("base64url");
34
+ }
35
+
36
+ export function parseBooleanEnv(value: string | undefined): boolean | undefined {
37
+ if (value === undefined) return undefined;
38
+ const normalized = value.trim().toLowerCase();
39
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
40
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
41
+ return undefined;
42
+ }
43
+
44
+ function normalizeConfig(config: CpaProviderConfig): CpaProviderConfig {
45
+ return {
46
+ ...config,
47
+ authHeader: config.authRequired ? config.authHeader : false,
48
+ };
49
+ }
50
+
51
+ function safeProjectConfig(projectConfig?: ConfigLayer): ConfigLayer | undefined {
52
+ if (!projectConfig) return undefined;
53
+ return {
54
+ ...(projectConfig.modelAliases !== undefined ? { modelAliases: projectConfig.modelAliases } : {}),
55
+ };
56
+ }
57
+
58
+ function projectConfigLayer(value: unknown, path: string): ConfigLayer {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
60
+ throw new Error(`Project config file must contain a JSON object: ${path}`);
61
+ }
62
+
63
+ const record = value as Record<string, unknown>;
64
+ if (record.modelAliases !== undefined && !isStringMap(record.modelAliases)) {
65
+ throw new Error(`modelAliases must be an object with string values in project config file: ${path}`);
66
+ }
67
+
68
+ return safeProjectConfig(record as ConfigLayer) ?? {};
69
+ }
70
+
71
+ function mergeLayer(base: CpaProviderConfig, layer?: ConfigLayer): CpaProviderConfig {
72
+ if (!layer) return base;
73
+ return {
74
+ ...base,
75
+ ...layer,
76
+ headers: { ...base.headers, ...(layer.headers ?? {}) },
77
+ modelAliases: { ...base.modelAliases, ...(layer.modelAliases ?? {}) },
78
+ };
79
+ }
80
+
81
+ function envLayer(env: NodeJS.ProcessEnv): ConfigLayer {
82
+ const authRequired = parseBooleanEnv(env.CLIPROXYAPI_AUTH_REQUIRED);
83
+ const authHeader = parseBooleanEnv(env.CLIPROXYAPI_AUTH_HEADER);
84
+ const modelsDevEnabled = parseBooleanEnv(env.CLIPROXYAPI_MODELS_DEV_ENABLED);
85
+ return {
86
+ ...(env.CLIPROXYAPI_BASE_URL ? { baseUrl: env.CLIPROXYAPI_BASE_URL } : {}),
87
+ ...(env.CLIPROXYAPI_PROVIDER_NAME ? { providerName: env.CLIPROXYAPI_PROVIDER_NAME } : {}),
88
+ ...(authRequired !== undefined ? { authRequired } : {}),
89
+ ...(authHeader !== undefined ? { authHeader } : {}),
90
+ ...(modelsDevEnabled !== undefined ? { modelsDevEnabled } : {}),
91
+ };
92
+ }
93
+
94
+ export function mergeConfigLayers(
95
+ globalConfig?: ConfigLayer,
96
+ projectConfig?: ConfigLayer,
97
+ env: NodeJS.ProcessEnv = process.env,
98
+ ): CpaProviderConfig {
99
+ const envConfig = envLayer(env);
100
+ return normalizeConfig(mergeLayer(mergeLayer(mergeLayer(DEFAULT_CONFIG, globalConfig), safeProjectConfig(projectConfig)), envConfig));
101
+ }
102
+
103
+ function isStringMap(value: unknown): value is Record<string, string> {
104
+ return !!value
105
+ && typeof value === "object"
106
+ && !Array.isArray(value)
107
+ && Object.values(value).every((entry) => typeof entry === "string");
108
+ }
109
+
110
+ function validateConfigLayer(value: unknown, path: string): ConfigLayer {
111
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
112
+ throw new Error(`Config file must contain a JSON object: ${path}`);
113
+ }
114
+
115
+ const record = value as Record<string, unknown>;
116
+ const stringFields = ["providerName", "baseUrl"];
117
+ for (const field of stringFields) {
118
+ if (record[field] !== undefined && typeof record[field] !== "string") {
119
+ throw new Error(`${field} must be a string in config file: ${path}`);
120
+ }
121
+ }
122
+
123
+ const booleanFields = ["authRequired", "authHeader", "modelsDevEnabled"];
124
+ for (const field of booleanFields) {
125
+ if (record[field] !== undefined && typeof record[field] !== "boolean") {
126
+ throw new Error(`${field} must be a boolean in config file: ${path}`);
127
+ }
128
+ }
129
+
130
+ const ttlFields = ["cpaCacheTtlSeconds", "modelsDevCacheTtlSeconds"];
131
+ for (const field of ttlFields) {
132
+ if (record[field] !== undefined && (typeof record[field] !== "number" || !Number.isFinite(record[field]) || record[field] <= 0)) {
133
+ throw new Error(`${field} must be a positive number in config file: ${path}`);
134
+ }
135
+ }
136
+
137
+ if (record.headers !== undefined && !isStringMap(record.headers)) {
138
+ throw new Error(`headers must be an object with string values in config file: ${path}`);
139
+ }
140
+ if (record.modelAliases !== undefined && !isStringMap(record.modelAliases)) {
141
+ throw new Error(`modelAliases must be an object with string values in config file: ${path}`);
142
+ }
143
+
144
+ return record as ConfigLayer;
145
+ }
146
+
147
+ export function readConfigFile(path: string): ConfigLayer | undefined {
148
+ if (!existsSync(path)) return undefined;
149
+ return validateConfigLayer(JSON.parse(readFileSync(path, "utf8")), path);
150
+ }
151
+
152
+ export function readProjectConfigFile(path: string): ConfigLayer | undefined {
153
+ if (!existsSync(path)) return undefined;
154
+ return projectConfigLayer(JSON.parse(readFileSync(path, "utf8")), path);
155
+ }
156
+
157
+ export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): CpaProviderConfig {
158
+ return mergeConfigLayers(readConfigFile(globalConfigPath()), readProjectConfigFile(projectConfigPath(cwd)), env);
159
+ }
160
+
161
+ export function writeConfigFile(path: string, config: ConfigLayer): void {
162
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
163
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
164
+ }
package/src/cpa.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { withNetworkTimeout } from "./network.ts";
2
+
3
+ export interface CpaModel {
4
+ id: string;
5
+ object?: string;
6
+ owned_by?: string;
7
+ created?: number;
8
+ }
9
+
10
+ export interface CpaModelsResponse {
11
+ object?: string;
12
+ data?: unknown[];
13
+ }
14
+
15
+ export function modelsEndpoint(baseUrl: string): string {
16
+ return `${baseUrl.replace(/\/+$/, "")}/models`;
17
+ }
18
+
19
+ export function parseCpaModelsResponse(payload: unknown): CpaModel[] {
20
+ const response = payload as CpaModelsResponse;
21
+ if (!response || typeof response !== "object" || !Array.isArray(response.data)) {
22
+ throw new Error("CPA /v1/models response must contain a data array");
23
+ }
24
+
25
+ return response.data.flatMap((entry) => {
26
+ if (!entry || typeof entry !== "object") return [];
27
+ const record = entry as Record<string, unknown>;
28
+ if (typeof record.id !== "string" || record.id.trim() === "") return [];
29
+ return [{
30
+ id: record.id,
31
+ object: typeof record.object === "string" ? record.object : undefined,
32
+ owned_by: typeof record.owned_by === "string" ? record.owned_by : undefined,
33
+ created: typeof record.created === "number" ? record.created : undefined,
34
+ }];
35
+ });
36
+ }
37
+
38
+ export async function fetchCpaModels(baseUrl: string, headers: Record<string, string> = {}, timeoutMs?: number): Promise<CpaModel[]> {
39
+ return withNetworkTimeout(async (signal) => {
40
+ const response = await fetch(modelsEndpoint(baseUrl), {
41
+ headers: { Accept: "application/json", ...headers },
42
+ signal,
43
+ });
44
+ if (!response.ok) {
45
+ throw new Error(`CPA model discovery failed: HTTP ${response.status} ${response.statusText}`);
46
+ }
47
+ return parseCpaModelsResponse(await response.json());
48
+ }, timeoutMs, "CPA model discovery");
49
+ }
@@ -0,0 +1,98 @@
1
+ import { join } from "node:path";
2
+ import { cacheDir, providerCacheKey, type CpaProviderConfig } from "./config.ts";
3
+ import { fetchCpaModels, type CpaModel } from "./cpa.ts";
4
+ import { getCachedOrFetch, isFresh, readCache } from "./cache.ts";
5
+ import { fetchModelsDevCatalog, readBundledModelsDevFallback } from "./models-dev.ts";
6
+ import type { ModelsDevCatalog } from "./types.ts";
7
+
8
+ export interface DiscoveryResult {
9
+ cpaModels: CpaModel[];
10
+ modelsDevCatalog: ModelsDevCatalog;
11
+ sources: {
12
+ cpa: "fresh" | "cache" | "stale";
13
+ modelsDev: "fresh" | "cache" | "stale" | "bundled" | "disabled";
14
+ };
15
+ errors: {
16
+ cpa?: unknown;
17
+ modelsDev?: unknown;
18
+ };
19
+ }
20
+
21
+ export function cpaModelsCachePath(config: CpaProviderConfig): string {
22
+ return join(cacheDir(), providerCacheKey(config), "cpa-models.json");
23
+ }
24
+
25
+ export function modelsDevCachePath(): string {
26
+ return join(cacheDir(), "models-dev.json");
27
+ }
28
+
29
+ export function discoveryHeaders(config: CpaProviderConfig, apiKey?: string): Record<string, string> {
30
+ return {
31
+ ...config.headers,
32
+ ...(config.authHeader && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
33
+ };
34
+ }
35
+
36
+ export async function discoverModels(options: {
37
+ config: CpaProviderConfig;
38
+ bundledModelsDevPath: string;
39
+ force?: boolean;
40
+ discoveryApiKey?: string;
41
+ }): Promise<DiscoveryResult> {
42
+ const cpa = await getCachedOrFetch({
43
+ path: cpaModelsCachePath(options.config),
44
+ ttlSeconds: options.config.cpaCacheTtlSeconds,
45
+ force: options.force,
46
+ fetchFresh: () => fetchCpaModels(options.config.baseUrl, discoveryHeaders(options.config, options.discoveryApiKey)),
47
+ });
48
+
49
+ if (!options.config.modelsDevEnabled) {
50
+ return {
51
+ cpaModels: cpa.data,
52
+ modelsDevCatalog: {},
53
+ sources: { cpa: cpa.source, modelsDev: "disabled" },
54
+ errors: { cpa: cpa.error },
55
+ };
56
+ }
57
+
58
+ if (!options.force) {
59
+ const cachedModelsDev = await readCache<ModelsDevCatalog>(modelsDevCachePath());
60
+ if (isFresh(cachedModelsDev, options.config.modelsDevCacheTtlSeconds)) {
61
+ return {
62
+ cpaModels: cpa.data,
63
+ modelsDevCatalog: cachedModelsDev.data,
64
+ sources: { cpa: cpa.source, modelsDev: "cache" },
65
+ errors: { cpa: cpa.error },
66
+ };
67
+ }
68
+
69
+ return {
70
+ cpaModels: cpa.data,
71
+ modelsDevCatalog: await readBundledModelsDevFallback(options.bundledModelsDevPath),
72
+ sources: { cpa: cpa.source, modelsDev: "bundled" },
73
+ errors: { cpa: cpa.error },
74
+ };
75
+ }
76
+
77
+ try {
78
+ const modelsDev = await getCachedOrFetch({
79
+ path: modelsDevCachePath(),
80
+ ttlSeconds: options.config.modelsDevCacheTtlSeconds,
81
+ force: true,
82
+ fetchFresh: fetchModelsDevCatalog,
83
+ });
84
+ return {
85
+ cpaModels: cpa.data,
86
+ modelsDevCatalog: modelsDev.data,
87
+ sources: { cpa: cpa.source, modelsDev: modelsDev.source },
88
+ errors: { cpa: cpa.error, modelsDev: modelsDev.error },
89
+ };
90
+ } catch (error) {
91
+ return {
92
+ cpaModels: cpa.data,
93
+ modelsDevCatalog: await readBundledModelsDevFallback(options.bundledModelsDevPath),
94
+ sources: { cpa: cpa.source, modelsDev: "bundled" },
95
+ errors: { cpa: cpa.error, modelsDev: error },
96
+ };
97
+ }
98
+ }
@@ -0,0 +1,73 @@
1
+ import type { CpaModel } from "./cpa.ts";
2
+ import type { ModelsDevCatalog, ModelsDevMetadata } from "./types.ts";
3
+
4
+ const CANONICAL_OWNER_PREFIXES: Record<string, string> = {
5
+ openai: "openai",
6
+ anthropic: "anthropic",
7
+ google: "google",
8
+ deepseek: "deepseek",
9
+ mistral: "mistral",
10
+ xai: "xai",
11
+ zhipuai: "zhipuai",
12
+ alibaba: "alibaba",
13
+ moonshotai: "moonshotai",
14
+ minimax: "minimax",
15
+ nvidia: "nvidia",
16
+ cohere: "cohere",
17
+ };
18
+
19
+ export type MetadataMatchMethod = "alias" | "exact" | "owner-prefix" | "suffix" | "normalized-suffix";
20
+
21
+ export interface MetadataMatch {
22
+ metadataId: string;
23
+ metadata: ModelsDevMetadata;
24
+ method: MetadataMatchMethod;
25
+ }
26
+
27
+ export function normalizeModelName(value: string): string {
28
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
29
+ }
30
+
31
+ function oneMatch(candidates: string[]): string | undefined {
32
+ const unique = [...new Set(candidates)];
33
+ return unique.length === 1 ? unique[0] : undefined;
34
+ }
35
+
36
+ export function findMetadataMatch(
37
+ cpaModel: Pick<CpaModel, "id" | "owned_by">,
38
+ catalog: ModelsDevCatalog,
39
+ aliases: Record<string, string>,
40
+ ): MetadataMatch | undefined {
41
+ const alias = aliases[cpaModel.id];
42
+ if (alias && catalog[alias]) {
43
+ return { metadataId: alias, metadata: catalog[alias], method: "alias" };
44
+ }
45
+
46
+ if (catalog[cpaModel.id]) {
47
+ return { metadataId: cpaModel.id, metadata: catalog[cpaModel.id], method: "exact" };
48
+ }
49
+
50
+ const owner = cpaModel.owned_by?.trim().toLowerCase();
51
+ const canonicalOwner = owner ? CANONICAL_OWNER_PREFIXES[owner] : undefined;
52
+ if (canonicalOwner) {
53
+ const ownerKey = `${canonicalOwner}/${cpaModel.id}`;
54
+ if (catalog[ownerKey]) {
55
+ return { metadataId: ownerKey, metadata: catalog[ownerKey], method: "owner-prefix" };
56
+ }
57
+ }
58
+
59
+ const suffixKey = oneMatch(Object.keys(catalog).filter((key) => key.endsWith(`/${cpaModel.id}`)));
60
+ if (suffixKey) {
61
+ return { metadataId: suffixKey, metadata: catalog[suffixKey], method: "suffix" };
62
+ }
63
+
64
+ const normalizedId = normalizeModelName(cpaModel.id);
65
+ const normalizedSuffixKey = oneMatch(
66
+ Object.keys(catalog).filter((key) => normalizeModelName(key.split("/").at(-1) ?? key) === normalizedId),
67
+ );
68
+ if (normalizedSuffixKey) {
69
+ return { metadataId: normalizedSuffixKey, metadata: catalog[normalizedSuffixKey], method: "normalized-suffix" };
70
+ }
71
+
72
+ return undefined;
73
+ }
@@ -0,0 +1,50 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ModelsDevCatalog, ModelsDevMetadata } from "./types.ts";
3
+ import { withNetworkTimeout } from "./network.ts";
4
+
5
+ export const MODELS_DEV_URL = "https://models.dev/models.json";
6
+
7
+ function isMetadata(value: unknown): value is ModelsDevMetadata {
8
+ return !!value && typeof value === "object" && typeof (value as { id?: unknown }).id === "string";
9
+ }
10
+
11
+ export function parseModelsDevCatalog(payload: unknown): ModelsDevCatalog {
12
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
13
+ throw new Error("models.dev catalog must be a JSON object");
14
+ }
15
+
16
+ const record = payload as Record<string, unknown>;
17
+ const catalog: ModelsDevCatalog = {};
18
+
19
+ for (const [key, value] of Object.entries(record)) {
20
+ const provider = value as { models?: unknown };
21
+ if (provider && typeof provider === "object" && provider.models && typeof provider.models === "object") {
22
+ for (const [modelId, metadata] of Object.entries(provider.models as Record<string, unknown>)) {
23
+ if (!isMetadata(metadata)) continue;
24
+ const canonicalId = metadata.id.includes("/") ? metadata.id : `${key}/${modelId}`;
25
+ catalog[canonicalId] = { ...metadata, id: canonicalId };
26
+ }
27
+ continue;
28
+ }
29
+
30
+ if (isMetadata(value)) {
31
+ catalog[key] = value;
32
+ }
33
+ }
34
+
35
+ return catalog;
36
+ }
37
+
38
+ export async function fetchModelsDevCatalog(timeoutMs?: number): Promise<ModelsDevCatalog> {
39
+ return withNetworkTimeout(async (signal) => {
40
+ const response = await fetch(MODELS_DEV_URL, { headers: { Accept: "application/json" }, signal });
41
+ if (!response.ok) {
42
+ throw new Error(`models.dev fetch failed: HTTP ${response.status} ${response.statusText}`);
43
+ }
44
+ return parseModelsDevCatalog(await response.json());
45
+ }, timeoutMs, "models.dev fetch");
46
+ }
47
+
48
+ export async function readBundledModelsDevFallback(path: string): Promise<ModelsDevCatalog> {
49
+ return parseModelsDevCatalog(JSON.parse(await readFile(path, "utf8")));
50
+ }
package/src/network.ts ADDED
@@ -0,0 +1,28 @@
1
+ export async function withNetworkTimeout<T>(
2
+ operation: (signal: AbortSignal) => Promise<T>,
3
+ timeoutMs = 10_000,
4
+ label = "network request",
5
+ ): Promise<T> {
6
+ const controller = new AbortController();
7
+ const timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
8
+
9
+ let timeout: ReturnType<typeof setTimeout> | undefined;
10
+ const operationPromise = operation(controller.signal);
11
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
12
+ timeout = setTimeout(() => {
13
+ controller.abort(timeoutError);
14
+ reject(timeoutError);
15
+ }, timeoutMs);
16
+ });
17
+
18
+ try {
19
+ return await Promise.race([operationPromise, timeoutPromise]);
20
+ } catch (error) {
21
+ if (controller.signal.aborted && controller.signal.reason instanceof Error) {
22
+ throw controller.signal.reason;
23
+ }
24
+ throw error;
25
+ } finally {
26
+ if (timeout) clearTimeout(timeout);
27
+ }
28
+ }