@samrito/pi-cliproxyapi-provider 0.16.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.
@@ -0,0 +1,373 @@
1
+ import { DynamicBorder, getSettingsListTheme, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Key, matchesKey, type SelectItem, SelectList, type SettingItem, SettingsList, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ CONTEXT_WINDOW_PRESETS,
5
+ loadModelOverrideLayers,
6
+ MAX_TOKEN_PRESETS,
7
+ saveModelOverride,
8
+ } from "./config.ts";
9
+ import type { ProviderCatalog } from "./catalog.ts";
10
+ import { normalizeProviderModels } from "./registration.ts";
11
+ import { saveProviderSettings, type ProviderSettings } from "./settings.ts";
12
+ import type {
13
+ CpaProviderConfig,
14
+ ProviderModelConfigLike,
15
+ ProviderModelOverride,
16
+ ProviderModelOverrideLayer,
17
+ } from "./types.ts";
18
+
19
+ function modelOverride(model: ProviderModelConfigLike, overrides: Record<string, ProviderModelOverride>): ProviderModelOverride {
20
+ return overrides[model.id] ?? {};
21
+ }
22
+
23
+ function valuesFor(current: number, presets: number[]): string[] {
24
+ return ["auto", ...new Set([current, ...presets]).values()].map(String);
25
+ }
26
+
27
+ function effectiveReasoning(model: ProviderModelConfigLike, override: ProviderModelOverrideLayer): boolean {
28
+ return typeof override.reasoning === "boolean" ? override.reasoning : model.reasoning;
29
+ }
30
+
31
+ function effectiveNumber(
32
+ model: ProviderModelConfigLike,
33
+ override: ProviderModelOverrideLayer,
34
+ field: "contextWindow" | "maxTokens",
35
+ ): number {
36
+ return typeof override[field] === "number" ? override[field] : model[field];
37
+ }
38
+
39
+ function strictModeLabel(model: ProviderModelConfigLike): string {
40
+ const strict = (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode;
41
+ return strict === false ? "disabled" : strict === true ? "enabled" : "auto";
42
+ }
43
+
44
+ function formattedOtherCompat(model: ProviderModelConfigLike): string {
45
+ const entries = Object.entries(model.compat ?? {}).filter(([key]) => key !== "supportsStrictMode");
46
+ return entries.length === 0 ? "none" : entries.map(([key, value]) => `${key}=${String(value)}`).join(", ");
47
+ }
48
+
49
+ function detailItems(model: ProviderModelConfigLike, override: ProviderModelOverrideLayer): SettingItem[] {
50
+ const reasoning = effectiveReasoning(model, override);
51
+ const contextWindow = effectiveNumber(model, override, "contextWindow");
52
+ const maxTokens = effectiveNumber(model, override, "maxTokens");
53
+ return [
54
+ {
55
+ id: "reasoning",
56
+ label: "Reasoning",
57
+ description: `Effective: ${reasoning ? "on" : "off"}. ${override.reasoning === undefined ? "Metadata/capability default." : "User override."}`,
58
+ currentValue: typeof override.reasoning !== "boolean" ? "auto" : override.reasoning ? "on" : "off",
59
+ values: ["auto", "on", "off"],
60
+ },
61
+ {
62
+ id: "contextWindow",
63
+ label: "Context window",
64
+ description: `Effective: ${contextWindow} tokens. ${override.contextWindow === undefined ? "Derived default." : "User override."}`,
65
+ currentValue: typeof override.contextWindow !== "number" ? "auto" : String(override.contextWindow),
66
+ values: valuesFor(contextWindow, [...CONTEXT_WINDOW_PRESETS]),
67
+ },
68
+ {
69
+ id: "maxTokens",
70
+ label: "Max output tokens",
71
+ description: `Effective: ${maxTokens} tokens. ${override.maxTokens === undefined ? "Derived default." : "User override."}`,
72
+ currentValue: typeof override.maxTokens !== "number" ? "auto" : String(override.maxTokens),
73
+ values: valuesFor(maxTokens, [...MAX_TOKEN_PRESETS]),
74
+ },
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * Render the thinking levels pi will actually offer.
80
+ *
81
+ * Listing `Object.keys(thinkingLevelMap)` would be misleading: a key is present
82
+ * with value `null` precisely to mean "unsupported, hidden", so the keys alone
83
+ * include levels the user can never select. Show the mapped values instead and
84
+ * state the count of explicitly unsupported levels.
85
+ */
86
+ function thinkingLevelSummary(map: ProviderModelConfigLike["thinkingLevelMap"]): string {
87
+ if (!map) return "none";
88
+
89
+ const offered = Object.entries(map)
90
+ .filter(([, value]) => typeof value === "string")
91
+ .map(([level]) => level);
92
+ const hidden = Object.values(map).filter((value) => value === null).length;
93
+ if (offered.length === 0) return "none";
94
+
95
+ return hidden > 0 ? `${offered.join(", ")} (${hidden} unsupported)` : offered.join(", ");
96
+ }
97
+
98
+ function details(model: ProviderModelConfigLike): string[] {
99
+ return [
100
+ `Name: ${model.name}`,
101
+ `API: ${model.api ?? "openai-completions (provider default)"}`,
102
+ `Input: ${model.input.join(", ")}`,
103
+ `Cost: in ${model.cost.input}, out ${model.cost.output}, cache read ${model.cost.cacheRead}, cache write ${model.cost.cacheWrite}`,
104
+ `Thinking map: ${thinkingLevelSummary(model.thinkingLevelMap)}`,
105
+ `Other compat: ${formattedOtherCompat(model)}`,
106
+ ];
107
+ }
108
+
109
+ function modelSelectorItems(
110
+ models: ProviderModelConfigLike[],
111
+ overrides: Record<string, ProviderModelOverride>,
112
+ showStrictMode: boolean,
113
+ ): SelectItem[] {
114
+ const sorted = [...models].sort((left, right) => left.id.localeCompare(right.id));
115
+ const idWidth = Math.max(...sorted.map((model) => visibleWidth(model.id)));
116
+ const apiWidth = Math.max(...sorted.map((model) => visibleWidth(model.api ?? "completions")));
117
+ const modeWidth = Math.max(...sorted.map((model) => visibleWidth(model.reasoning ? "reasoning" : "standard")));
118
+ const strictWidth = showStrictMode
119
+ ? Math.max(...sorted.map((model) => visibleWidth(strictModeLabel(model))))
120
+ : 0;
121
+ const contextWidth = Math.max(...sorted.map((model) => String(model.contextWindow).length));
122
+ const pad = (value: string, width: number) => `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`;
123
+
124
+ return sorted.map((model) => {
125
+ const override = modelOverride(model, overrides);
126
+ const overrideLabel = Object.keys(override).length > 0 ? " override" : "";
127
+ const api = model.api ?? "completions";
128
+ const mode = model.reasoning ? "reasoning" : "standard";
129
+ const strict = strictModeLabel(model);
130
+ const context = String(model.contextWindow).padStart(contextWidth);
131
+ return {
132
+ value: model.id,
133
+ label: pad(model.id, idWidth),
134
+ description: [
135
+ pad(api, apiWidth),
136
+ pad(mode, modeWidth),
137
+ ...(showStrictMode ? [`${pad(strict, strictWidth)} strict`] : []),
138
+ `${context} ctx${overrideLabel}`,
139
+ ].join(" "),
140
+ };
141
+ });
142
+ }
143
+
144
+ type ConfigTab = "Connection" | "Models" | "Display";
145
+
146
+ const CONFIG_TABS: ConfigTab[] = ["Connection", "Models", "Display"];
147
+
148
+ function providerSettingsItems(
149
+ tab: ConfigTab,
150
+ settings: ProviderSettings,
151
+ connection: CpaProviderConfig,
152
+ ): SettingItem[] {
153
+ if (tab === "Connection") {
154
+ return [{
155
+ id: "connection",
156
+ label: "Endpoint and authentication",
157
+ description: `${connection.providerName} ${connection.baseUrl} ${connection.authRequired ? "credentials required" : "no credentials"}`,
158
+ currentValue: "open",
159
+ values: ["open", "edit"],
160
+ }];
161
+ }
162
+ if (tab === "Models") return [
163
+ {
164
+ id: "gpt56ContextWindow",
165
+ label: "GPT-5.6 context window",
166
+ description: "canonical advertises 272000 tokens; full uses the models.dev limit.",
167
+ currentValue: settings.gpt56ContextWindow,
168
+ values: ["canonical", "full"],
169
+ },
170
+ ];
171
+ return [
172
+ {
173
+ id: "showStrictMode",
174
+ label: "Show strict tool schema",
175
+ description: "Show the published strict-schema compatibility value in the model selector.",
176
+ currentValue: settings.showStrictMode ? "enabled" : "disabled",
177
+ values: ["disabled", "enabled"],
178
+ },
179
+ ];
180
+ }
181
+
182
+ function nextTab(tab: ConfigTab, direction: 1 | -1): ConfigTab {
183
+ const index = CONFIG_TABS.indexOf(tab);
184
+ return CONFIG_TABS[(index + direction + CONFIG_TABS.length) % CONFIG_TABS.length] ?? "Connection";
185
+ }
186
+
187
+ export async function openProviderConfig(
188
+ ctx: ExtensionCommandContext,
189
+ settings: ProviderSettings,
190
+ connection: CpaProviderConfig,
191
+ ): Promise<"connection" | undefined> {
192
+ if (ctx.mode !== "tui") {
193
+ ctx.ui.notify("/cliproxyapi config requires interactive TUI mode.", "warning");
194
+ return;
195
+ }
196
+
197
+ const edited: ProviderSettings = { ...settings };
198
+ const action = await ctx.ui.custom<"connection" | undefined>((tui, theme, _keybindings, done) => {
199
+ let activeTab: ConfigTab = "Connection";
200
+ let list: SettingsList;
201
+ const makeList = () => new SettingsList(
202
+ providerSettingsItems(activeTab, edited, connection),
203
+ 8,
204
+ getSettingsListTheme(),
205
+ (id, value) => {
206
+ if (id === "connection" && value === "edit") {
207
+ done("connection");
208
+ return;
209
+ }
210
+ if (id === "gpt56ContextWindow" && (value === "canonical" || value === "full")) {
211
+ edited.gpt56ContextWindow = value;
212
+ }
213
+ if (id === "showStrictMode" && (value === "enabled" || value === "disabled")) {
214
+ edited.showStrictMode = value === "enabled";
215
+ }
216
+ list.updateValue(id, value);
217
+ tui.requestRender();
218
+ },
219
+ () => done(undefined),
220
+ { enableSearch: true },
221
+ );
222
+ list = makeList();
223
+ return {
224
+ render: (width) => {
225
+ const border = theme.fg("border", "─".repeat(Math.max(0, width)));
226
+ const tabs = CONFIG_TABS.map((tab) => tab === activeTab
227
+ ? theme.fg("accent", theme.bold(tab))
228
+ : theme.fg("muted", tab));
229
+ return [
230
+ border,
231
+ ` ${tabs.join(theme.fg("muted", " / "))}`,
232
+ border,
233
+ ...list.render(width),
234
+ theme.fg("dim", " Enter/Space changes Tab switches sections Esc saves and reloads Pi"),
235
+ border,
236
+ ].map((line) => truncateToWidth(line, width, ""));
237
+ },
238
+ invalidate: () => list.invalidate(),
239
+ handleInput: (data) => {
240
+ if (matchesKey(data, Key.tab)) {
241
+ activeTab = nextTab(activeTab, 1);
242
+ list = makeList();
243
+ tui.requestRender();
244
+ return;
245
+ }
246
+ if (matchesKey(data, Key.shift("tab"))) {
247
+ activeTab = nextTab(activeTab, -1);
248
+ list = makeList();
249
+ tui.requestRender();
250
+ return;
251
+ }
252
+ list.handleInput(data);
253
+ },
254
+ };
255
+ });
256
+
257
+ if (action === "connection") return action;
258
+ if (JSON.stringify(settings) === JSON.stringify(edited)) return;
259
+ try {
260
+ const path = saveProviderSettings(ctx.cwd, edited);
261
+ ctx.ui.notify(`Saved CLIProxyAPI configuration to ${path}. Reloading Pi...`, "info");
262
+ await ctx.reload();
263
+ } catch (error) {
264
+ const message = error instanceof Error ? error.message : String(error);
265
+ ctx.ui.notify(`Could not save CLIProxyAPI configuration: ${message}`, "error");
266
+ }
267
+ }
268
+
269
+ export async function openModelInspector(
270
+ ctx: ExtensionCommandContext,
271
+ catalog: ProviderCatalog,
272
+ overrides: Record<string, ProviderModelOverride>,
273
+ showStrictMode: boolean,
274
+ ): Promise<void> {
275
+ if (ctx.mode !== "tui") {
276
+ ctx.ui.notify("/cliproxyapi models requires interactive TUI mode.", "warning");
277
+ return;
278
+ }
279
+
280
+ const snapshot = catalog.current() ?? await catalog.load();
281
+ const models = normalizeProviderModels(snapshot.built.models);
282
+ if (models.length === 0) {
283
+ ctx.ui.notify("No available models in the current CLIProxyAPI snapshot. Run /cliproxyapi refresh models first.", "warning");
284
+ return;
285
+ }
286
+
287
+ const selectedId = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
288
+ const container = new Container();
289
+ container.addChild(new DynamicBorder((text) => theme.fg("border", text)));
290
+ container.addChild(new Text(theme.fg("accent", theme.bold("CLIProxyAPI models")), 1, 0));
291
+ container.addChild(new Text(theme.fg("muted", "Select a model to inspect or override bounded limits."), 1, 0));
292
+ const list = new SelectList(modelSelectorItems(models, overrides, showStrictMode), Math.min(models.length, 12), {
293
+ selectedPrefix: (text) => theme.fg("accent", text),
294
+ selectedText: (text) => theme.fg("accent", text),
295
+ description: (text) => theme.fg("muted", text),
296
+ scrollInfo: (text) => theme.fg("dim", text),
297
+ noMatch: (text) => theme.fg("warning", text),
298
+ });
299
+ list.onSelect = (item) => done(item.value);
300
+ list.onCancel = () => done(undefined);
301
+ container.addChild(list);
302
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate Enter inspect Esc close"), 1, 0));
303
+ container.addChild(new DynamicBorder((text) => theme.fg("border", text)));
304
+ return {
305
+ render: (width) => container.render(width),
306
+ invalidate: () => container.invalidate(),
307
+ handleInput: (data) => { list.handleInput(data); tui.requestRender(); },
308
+ };
309
+ });
310
+ if (!selectedId) return;
311
+
312
+ const model = models.find((candidate) => candidate.id === selectedId);
313
+ if (!model) return;
314
+ const layers = loadModelOverrideLayers(ctx.cwd);
315
+ const globalOverride = layers.global[model.id] ?? {};
316
+ const original: ProviderModelOverrideLayer = layers.project[model.id] ?? {};
317
+ const edited: ProviderModelOverrideLayer = { ...original };
318
+ const selectAuto = (field: keyof ProviderModelOverrideLayer) => {
319
+ if (globalOverride[field] === undefined) delete edited[field];
320
+ else edited[field] = null;
321
+ };
322
+
323
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
324
+ const container = new Container();
325
+ container.addChild(new DynamicBorder((text) => theme.fg("border", text)));
326
+ container.addChild(new Text(theme.fg("accent", theme.bold(model.id)), 1, 0));
327
+ container.addChild(new Text(theme.fg("accent", `Strict tool schema: ${strictModeLabel(model)}`), 1, 0));
328
+ for (const line of details(model)) container.addChild(new Text(theme.fg("muted", line), 1, 0));
329
+
330
+ const onChange = (id: string, value: string) => {
331
+ if (id === "reasoning") {
332
+ if (value === "auto") selectAuto("reasoning");
333
+ else edited.reasoning = value === "on";
334
+ }
335
+ if (id === "contextWindow") {
336
+ if (value === "auto") selectAuto("contextWindow");
337
+ else edited.contextWindow = Number(value);
338
+ }
339
+ if (id === "maxTokens") {
340
+ if (value === "auto") selectAuto("maxTokens");
341
+ else edited.maxTokens = Number(value);
342
+ }
343
+ list.updateValue(id, value);
344
+ tui.requestRender();
345
+ };
346
+ const list = new SettingsList(
347
+ detailItems(model, edited),
348
+ 8,
349
+ getSettingsListTheme(),
350
+ onChange,
351
+ () => done(undefined),
352
+ { enableSearch: true },
353
+ );
354
+ container.addChild(list);
355
+ container.addChild(new Text(theme.fg("dim", "Enter/Space changes Esc saves and reloads Pi"), 1, 0));
356
+ container.addChild(new DynamicBorder((text) => theme.fg("border", text)));
357
+ return {
358
+ render: (width) => container.render(width),
359
+ invalidate: () => { container.invalidate(); list.invalidate(); },
360
+ handleInput: (data) => list.handleInput(data),
361
+ };
362
+ });
363
+
364
+ if (JSON.stringify(original) === JSON.stringify(edited)) return;
365
+ try {
366
+ const saved = saveModelOverride(ctx.cwd, model.id, edited);
367
+ ctx.ui.notify(`Saved overrides for ${model.id} to ${saved.path}. Reloading Pi...`, "info");
368
+ await ctx.reload();
369
+ } catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error);
371
+ ctx.ui.notify(`Could not save model overrides: ${message}`, "error");
372
+ }
373
+ }
@@ -0,0 +1,58 @@
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/api.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
+ const catalogKey = canonicalId.startsWith(`${key}/`) ? canonicalId : `${key}/${canonicalId}`;
26
+ catalog[catalogKey] = { ...metadata, id: canonicalId, sourceProvider: key };
27
+ }
28
+ continue;
29
+ }
30
+
31
+ if (isMetadata(value)) {
32
+ catalog[key] = value;
33
+ }
34
+ }
35
+
36
+ if (Object.keys(catalog).length === 0 && Object.keys(record).length > 0) {
37
+ throw new Error("models.dev catalog contained no valid models");
38
+ }
39
+ return catalog;
40
+ }
41
+
42
+ export async function fetchModelsDevCatalog(timeoutMs?: number, signal?: AbortSignal): Promise<ModelsDevCatalog> {
43
+ return withNetworkTimeout(async (reqSignal) => {
44
+ const response = await fetch(MODELS_DEV_URL, { headers: { Accept: "application/json" }, signal: reqSignal });
45
+ if (!response.ok) {
46
+ throw new Error(`models.dev fetch failed: HTTP ${response.status} ${response.statusText}`);
47
+ }
48
+ return parseModelsDevCatalog(await response.json());
49
+ }, timeoutMs, "models.dev fetch", signal);
50
+ }
51
+
52
+ export function hasSourceProviderMetadata(catalog: ModelsDevCatalog): boolean {
53
+ return Object.values(catalog).every((metadata) => typeof metadata.sourceProvider === "string");
54
+ }
55
+
56
+ export async function readBundledModelsDevFallback(path: string): Promise<ModelsDevCatalog> {
57
+ return parseModelsDevCatalog(JSON.parse(await readFile(path, "utf8")));
58
+ }
package/src/network.ts ADDED
@@ -0,0 +1,47 @@
1
+ export async function withNetworkTimeout<T>(
2
+ operation: (signal: AbortSignal) => Promise<T>,
3
+ timeoutMs = 10_000,
4
+ label = "network request",
5
+ parentSignal?: AbortSignal,
6
+ ): Promise<T> {
7
+ const controller = new AbortController();
8
+ const timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
9
+
10
+ const onParentAbort = () => {
11
+ controller.abort(parentSignal?.reason ?? new Error(`${label} aborted`));
12
+ };
13
+
14
+ if (parentSignal) {
15
+ if (parentSignal.aborted) {
16
+ controller.abort(parentSignal.reason ?? new Error(`${label} aborted`));
17
+ } else {
18
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
19
+ }
20
+ }
21
+
22
+ let timeout: ReturnType<typeof setTimeout> | undefined;
23
+ const operationPromise = operation(controller.signal);
24
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
25
+ timeout = setTimeout(() => {
26
+ controller.abort(timeoutError);
27
+ reject(timeoutError);
28
+ }, timeoutMs);
29
+ });
30
+
31
+ try {
32
+ return await Promise.race([operationPromise, timeoutPromise]);
33
+ } catch (error) {
34
+ if (parentSignal?.aborted) {
35
+ throw parentSignal.reason ?? error;
36
+ }
37
+ if (controller.signal.aborted && controller.signal.reason instanceof Error) {
38
+ throw controller.signal.reason;
39
+ }
40
+ throw error;
41
+ } finally {
42
+ if (timeout) clearTimeout(timeout);
43
+ if (parentSignal) {
44
+ parentSignal.removeEventListener("abort", onParentAbort);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,200 @@
1
+ import type { CpaModel } from "./cpa.ts";
2
+ import { findMetadataMatch, type MetadataMatchMethod } from "./matching.ts";
3
+ import { getModelApiOverride, isGpt56Model, type ModelApiContext } from "./model-api.ts";
4
+ import { getModelCapabilityOverrides } from "./model-capabilities.ts";
5
+ import { thinkingLevelMapFromReasoningOptions } from "./reasoning-levels.ts";
6
+ import type { Gpt56ContextWindowMode } from "./settings.ts";
7
+ import type {
8
+ InputModality,
9
+ ModelsDevCatalog,
10
+ ModelsDevMetadata,
11
+ ProviderModelConfigLike,
12
+ ProviderModelOverrides,
13
+ } from "./types.ts";
14
+
15
+ export const GPT_5_6_CANONICAL_CONTEXT_WINDOW = 272000;
16
+
17
+ export const PI_MODEL_DEFAULTS = {
18
+ reasoning: false,
19
+ input: ["text"] as InputModality[],
20
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
21
+ contextWindow: 128000,
22
+ maxTokens: 16384,
23
+ };
24
+
25
+ export interface BuildProviderModelsStats {
26
+ total: number;
27
+ enriched: number;
28
+ unmatched: number;
29
+ matchMethods: Record<MetadataMatchMethod, number>;
30
+ unmatchedModelIds: string[];
31
+ }
32
+
33
+ export interface BuildProviderModelsResult {
34
+ models: ProviderModelConfigLike[];
35
+ stats: BuildProviderModelsStats;
36
+ }
37
+
38
+ function inputFromMetadata(metadata: ModelsDevMetadata): InputModality[] {
39
+ const input = metadata.modalities?.input ?? [];
40
+ return input.includes("image") ? ["text", "image"] : ["text"];
41
+ }
42
+
43
+ function costFromMetadata(metadata: ModelsDevMetadata): ProviderModelConfigLike["cost"] {
44
+ const tiers = metadata.cost?.tiers?.flatMap((tier) => {
45
+ const threshold = tier.tier?.size;
46
+ if (tier.tier?.type !== "context" || typeof threshold !== "number") return [];
47
+ return [{
48
+ inputTokensAbove: threshold,
49
+ input: tier.input ?? 0,
50
+ output: tier.output ?? 0,
51
+ cacheRead: tier.cache_read ?? 0,
52
+ cacheWrite: tier.cache_write ?? 0,
53
+ }];
54
+ });
55
+
56
+ return {
57
+ input: metadata.cost?.input ?? 0,
58
+ output: metadata.cost?.output ?? 0,
59
+ cacheRead: metadata.cost?.cache_read ?? 0,
60
+ cacheWrite: metadata.cost?.cache_write ?? 0,
61
+ ...(tiers && tiers.length > 0 ? { tiers } : {}),
62
+ };
63
+ }
64
+
65
+ function contextWindowForModel(
66
+ context: ModelApiContext,
67
+ metadataContextWindow: number | undefined,
68
+ mode: Gpt56ContextWindowMode,
69
+ ): number {
70
+ if (!isGpt56Model(context)) return metadataContextWindow ?? PI_MODEL_DEFAULTS.contextWindow;
71
+ if (mode === "full") return metadataContextWindow ?? GPT_5_6_CANONICAL_CONTEXT_WINDOW;
72
+ return GPT_5_6_CANONICAL_CONTEXT_WINDOW;
73
+ }
74
+
75
+ function modelFromMetadata(
76
+ cpaModel: CpaModel,
77
+ metadata: ModelsDevMetadata,
78
+ gpt56ContextWindow: Gpt56ContextWindowMode,
79
+ ): ProviderModelConfigLike {
80
+ const capabilityContext = {
81
+ availableModelId: cpaModel.id,
82
+ metadataModelId: metadata.id,
83
+ };
84
+ const capabilityOverrides = getModelCapabilityOverrides(capabilityContext);
85
+ const api = getModelApiOverride(capabilityContext);
86
+
87
+ // Precedence: models.dev wins when it publishes a level list, because that
88
+ // list tracks the model's current capability while the rules were written
89
+ // against an older catalog. Measured against a live CLIProxyAPI instance, the
90
+ // gpt-5.6 rule is now stale: it maps `minimal`, which the proxy rejects with
91
+ // `400 level "minimal" not supported`, whereas the models.dev list for the
92
+ // same model omits `minimal` and matches the proxy exactly. The rules still
93
+ // fill the gap when metadata is absent or carries no effort list.
94
+ const thinkingLevelMap =
95
+ thinkingLevelMapFromReasoningOptions(metadata.reasoning_options) ?? capabilityOverrides.thinkingLevelMap;
96
+
97
+ return {
98
+ id: cpaModel.id,
99
+ name: metadata.name ?? cpaModel.id,
100
+ reasoning: capabilityOverrides.reasoning ?? metadata.reasoning ?? PI_MODEL_DEFAULTS.reasoning,
101
+ ...(api ? { api } : {}),
102
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
103
+ input: inputFromMetadata(metadata),
104
+ cost: costFromMetadata(metadata),
105
+ contextWindow: contextWindowForModel(capabilityContext, metadata.limit?.context, gpt56ContextWindow),
106
+ maxTokens: metadata.limit?.output ?? PI_MODEL_DEFAULTS.maxTokens,
107
+ };
108
+ }
109
+
110
+ function cloneModelDefaults(): typeof PI_MODEL_DEFAULTS {
111
+ return {
112
+ ...PI_MODEL_DEFAULTS,
113
+ input: [...PI_MODEL_DEFAULTS.input],
114
+ cost: { ...PI_MODEL_DEFAULTS.cost },
115
+ };
116
+ }
117
+
118
+ function defaultModel(cpaModel: CpaModel, gpt56ContextWindow: Gpt56ContextWindowMode): ProviderModelConfigLike {
119
+ const modelContext = { availableModelId: cpaModel.id };
120
+ const capabilityOverrides = getModelCapabilityOverrides(modelContext);
121
+ const api = getModelApiOverride(modelContext);
122
+
123
+ return {
124
+ id: cpaModel.id,
125
+ name: cpaModel.id,
126
+ ...cloneModelDefaults(),
127
+ ...capabilityOverrides,
128
+ ...(api ? { api } : {}),
129
+ contextWindow: contextWindowForModel(modelContext, undefined, gpt56ContextWindow),
130
+ };
131
+ }
132
+
133
+ function emptyMatchMethods(): Record<MetadataMatchMethod, number> {
134
+ return {
135
+ alias: 0,
136
+ exact: 0,
137
+ "owner-prefix": 0,
138
+ "owner-hint": 0,
139
+ suffix: 0,
140
+ "normalized-suffix": 0,
141
+ "provider-fallback": 0,
142
+ };
143
+ }
144
+
145
+ function applyModelOverride(
146
+ model: ProviderModelConfigLike,
147
+ overrides: ProviderModelOverrides,
148
+ ): ProviderModelConfigLike {
149
+ const override = overrides[model.id];
150
+ if (!override) return model;
151
+ return {
152
+ ...model,
153
+ ...(override.reasoning !== undefined ? { reasoning: override.reasoning } : {}),
154
+ ...(override.contextWindow !== undefined ? { contextWindow: override.contextWindow } : {}),
155
+ ...(override.maxTokens !== undefined ? { maxTokens: override.maxTokens } : {}),
156
+ };
157
+ }
158
+
159
+ export function buildUnavailableProviderModels(id = "login-required"): ProviderModelConfigLike[] {
160
+ return [{ id, name: id, ...cloneModelDefaults() }];
161
+ }
162
+
163
+ export function buildProviderModels(
164
+ cpaModels: CpaModel[],
165
+ catalog: ModelsDevCatalog,
166
+ aliases: Record<string, string>,
167
+ gpt56ContextWindow: Gpt56ContextWindowMode = "canonical",
168
+ overrides: ProviderModelOverrides = {},
169
+ metadataFallbackProvider: string | null = "openrouter",
170
+ ): BuildProviderModelsResult {
171
+ const matchMethods = emptyMatchMethods();
172
+ const unmatchedModelIds: string[] = [];
173
+ let enriched = 0;
174
+
175
+ const models = cpaModels.map((cpaModel) => {
176
+ const match = findMetadataMatch(cpaModel, catalog, aliases, metadataFallbackProvider);
177
+ if (!match) {
178
+ unmatchedModelIds.push(cpaModel.id);
179
+ return applyModelOverride(defaultModel(cpaModel, gpt56ContextWindow), overrides);
180
+ }
181
+
182
+ enriched += 1;
183
+ matchMethods[match.method] += 1;
184
+ return applyModelOverride(
185
+ modelFromMetadata(cpaModel, match.metadata, gpt56ContextWindow),
186
+ overrides,
187
+ );
188
+ });
189
+
190
+ return {
191
+ models,
192
+ stats: {
193
+ total: cpaModels.length,
194
+ enriched,
195
+ unmatched: unmatchedModelIds.length,
196
+ matchMethods,
197
+ unmatchedModelIds,
198
+ },
199
+ };
200
+ }