@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.
package/src/config.ts ADDED
@@ -0,0 +1,296 @@
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 {
5
+ CpaProviderConfig,
6
+ ProviderModelOverride,
7
+ ProviderModelOverrideLayer,
8
+ ProviderModelOverrideLayers,
9
+ ProviderModelOverrides,
10
+ } from "./types.ts";
11
+
12
+ export type ConfigLayer = Partial<CpaProviderConfig>;
13
+
14
+ export const CONTEXT_WINDOW_PRESETS = [128000, 272000, 512000, 1000000] as const;
15
+ export const MAX_TOKEN_PRESETS = [4096, 8192, 16384, 32768, 65536, 128000] as const;
16
+
17
+ export const DEFAULT_CONFIG: CpaProviderConfig = {
18
+ providerName: "cpa",
19
+ baseUrl: "http://localhost:8317/v1",
20
+ authRequired: true,
21
+ authHeader: true,
22
+ headers: {},
23
+ modelsDevEnabled: true,
24
+ metadataFallbackProvider: "openrouter",
25
+ modelAliases: {},
26
+ modelOverrides: {},
27
+ };
28
+
29
+ export function globalConfigPath(): string {
30
+ return join(homedir(), ".pi", "agent", "pi-cliproxyapi-provider", "config.json");
31
+ }
32
+
33
+ export function projectConfigPath(cwd: string): string {
34
+ return join(cwd, ".pi", "pi-cliproxyapi-provider", "config.json");
35
+ }
36
+
37
+ export function cacheDir(): string {
38
+ return join(homedir(), ".cache", "pi-cliproxyapi-provider");
39
+ }
40
+
41
+ export function providerCacheKey(config: Pick<CpaProviderConfig, "providerName" | "baseUrl">): string {
42
+ return Buffer.from(`${config.providerName}\n${config.baseUrl}`).toString("base64url");
43
+ }
44
+
45
+ export function parseBooleanEnv(value: string | undefined): boolean | undefined {
46
+ if (value === undefined) return undefined;
47
+ const normalized = value.trim().toLowerCase();
48
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
49
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
50
+ return undefined;
51
+ }
52
+
53
+ function normalizeMetadataFallbackProvider(value: string | null): string | null {
54
+ return value?.trim().toLowerCase() === "none" ? null : value;
55
+ }
56
+
57
+ function normalizeConfig(config: CpaProviderConfig): CpaProviderConfig {
58
+ return {
59
+ ...config,
60
+ authHeader: config.authRequired ? config.authHeader : false,
61
+ metadataFallbackProvider: normalizeMetadataFallbackProvider(config.metadataFallbackProvider),
62
+ };
63
+ }
64
+
65
+ function safeProjectConfig(projectConfig?: ConfigLayer): ConfigLayer | undefined {
66
+ if (!projectConfig) return undefined;
67
+ return {
68
+ ...(projectConfig.metadataFallbackProvider !== undefined
69
+ ? { metadataFallbackProvider: projectConfig.metadataFallbackProvider }
70
+ : {}),
71
+ ...(projectConfig.modelAliases !== undefined ? { modelAliases: projectConfig.modelAliases } : {}),
72
+ ...(projectConfig.modelOverrides !== undefined ? { modelOverrides: projectConfig.modelOverrides } : {}),
73
+ };
74
+ }
75
+
76
+ function mergeModelOverrides(
77
+ base: ProviderModelOverrides,
78
+ layer: ProviderModelOverrideLayers | undefined,
79
+ ): ProviderModelOverrides {
80
+ if (!layer) return base;
81
+ const merged = { ...base };
82
+ for (const [modelId, override] of Object.entries(layer)) {
83
+ const next = { ...merged[modelId] } as ProviderModelOverride;
84
+ for (const field of ["reasoning", "contextWindow", "maxTokens"] as const) {
85
+ const value = override[field];
86
+ if (value === null) delete next[field];
87
+ else if (value !== undefined) (next as Record<string, boolean | number>)[field] = value;
88
+ }
89
+ if (Object.keys(next).length === 0) delete merged[modelId];
90
+ else merged[modelId] = next;
91
+ }
92
+ return merged;
93
+ }
94
+
95
+ function projectConfigLayer(value: unknown, path: string): ConfigLayer {
96
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
97
+ throw new Error(`Project config file must contain a JSON object: ${path}`);
98
+ }
99
+
100
+ const record = value as Record<string, unknown>;
101
+ if (
102
+ record.metadataFallbackProvider !== undefined &&
103
+ record.metadataFallbackProvider !== null &&
104
+ (typeof record.metadataFallbackProvider !== "string" || !record.metadataFallbackProvider.trim())
105
+ ) {
106
+ throw new Error(`metadataFallbackProvider must be a non-empty string or null in project config file: ${path}`);
107
+ }
108
+ if (record.modelAliases !== undefined && !isStringMap(record.modelAliases)) {
109
+ throw new Error(`modelAliases must be an object with string values in project config file: ${path}`);
110
+ }
111
+ const modelOverrides = record.modelOverrides === undefined
112
+ ? undefined
113
+ : parseModelOverrides(record.modelOverrides, "project");
114
+
115
+ return safeProjectConfig({ ...record, ...(modelOverrides ? { modelOverrides } : {}) } as ConfigLayer) ?? {};
116
+ }
117
+
118
+ function mergeLayer(base: CpaProviderConfig, layer?: ConfigLayer): CpaProviderConfig {
119
+ if (!layer) return base;
120
+ return {
121
+ ...base,
122
+ ...layer,
123
+ headers: { ...base.headers, ...(layer.headers ?? {}) },
124
+ modelAliases: { ...base.modelAliases, ...(layer.modelAliases ?? {}) },
125
+ modelOverrides: mergeModelOverrides(base.modelOverrides, layer.modelOverrides),
126
+ };
127
+ }
128
+
129
+ function envLayer(env: NodeJS.ProcessEnv): ConfigLayer {
130
+ const authRequired = parseBooleanEnv(env.CLIPROXYAPI_AUTH_REQUIRED);
131
+ const authHeader = parseBooleanEnv(env.CLIPROXYAPI_AUTH_HEADER);
132
+ const modelsDevEnabled = parseBooleanEnv(env.CLIPROXYAPI_MODELS_DEV_ENABLED);
133
+ const metadataFallbackProvider = env.CLIPROXYAPI_METADATA_FALLBACK_PROVIDER?.trim();
134
+ return {
135
+ ...(env.CLIPROXYAPI_BASE_URL ? { baseUrl: env.CLIPROXYAPI_BASE_URL } : {}),
136
+ ...(env.CLIPROXYAPI_PROVIDER_NAME ? { providerName: env.CLIPROXYAPI_PROVIDER_NAME } : {}),
137
+ ...(authRequired !== undefined ? { authRequired } : {}),
138
+ ...(authHeader !== undefined ? { authHeader } : {}),
139
+ ...(modelsDevEnabled !== undefined ? { modelsDevEnabled } : {}),
140
+ ...(metadataFallbackProvider ? { metadataFallbackProvider } : {}),
141
+ };
142
+ }
143
+
144
+ export function mergeConfigLayers(
145
+ globalConfig?: ConfigLayer,
146
+ projectConfig?: ConfigLayer,
147
+ env: NodeJS.ProcessEnv = process.env,
148
+ ): CpaProviderConfig {
149
+ const envConfig = envLayer(env);
150
+ return normalizeConfig(mergeLayer(mergeLayer(mergeLayer(DEFAULT_CONFIG, globalConfig), safeProjectConfig(projectConfig)), envConfig));
151
+ }
152
+
153
+ function isStringMap(value: unknown): value is Record<string, string> {
154
+ return !!value
155
+ && typeof value === "object"
156
+ && !Array.isArray(value)
157
+ && Object.values(value).every((entry) => typeof entry === "string");
158
+ }
159
+
160
+ function isAllowedPreset(value: unknown, presets: readonly number[]): value is number {
161
+ return typeof value === "number" && presets.includes(value);
162
+ }
163
+
164
+ function parseModelOverrides(value: unknown, scope: string): ProviderModelOverrideLayers {
165
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
166
+ throw new Error(`modelOverrides must be an object in ${scope} config file`);
167
+ }
168
+
169
+ const parsed: ProviderModelOverrideLayers = {};
170
+ for (const [modelId, rawOverride] of Object.entries(value)) {
171
+ if (!modelId.trim()) throw new Error(`modelOverrides keys must be non-empty model IDs in ${scope} config file`);
172
+ if (!rawOverride || typeof rawOverride !== "object" || Array.isArray(rawOverride)) {
173
+ throw new Error(`modelOverrides.${modelId} must be an object in ${scope} config file`);
174
+ }
175
+
176
+ const record = rawOverride as Record<string, unknown>;
177
+ const unknown = Object.keys(record).filter(
178
+ (key) => key !== "reasoning" && key !== "contextWindow" && key !== "maxTokens",
179
+ );
180
+ if (unknown.length > 0) {
181
+ throw new Error(`modelOverrides.${modelId} contains unsupported fields: ${unknown.join(", ")}`);
182
+ }
183
+ const nullable = scope === "project";
184
+ if (record.reasoning !== undefined && typeof record.reasoning !== "boolean" && !(nullable && record.reasoning === null)) {
185
+ throw new Error(`modelOverrides.${modelId}.reasoning must be a boolean${nullable ? " or null" : ""} in ${scope} config file`);
186
+ }
187
+ const presets = { contextWindow: CONTEXT_WINDOW_PRESETS, maxTokens: MAX_TOKEN_PRESETS };
188
+ for (const field of ["contextWindow", "maxTokens"] as const) {
189
+ const value = record[field];
190
+ if (value !== undefined && !isAllowedPreset(value, presets[field]) && !(nullable && value === null)) {
191
+ throw new Error(`modelOverrides.${modelId}.${field} must be one of ${presets[field].join(", ")}${nullable ? " or null" : ""} in ${scope} config file`);
192
+ }
193
+ }
194
+
195
+ parsed[modelId] = {
196
+ ...(record.reasoning !== undefined ? { reasoning: record.reasoning as boolean | null } : {}),
197
+ ...(record.contextWindow !== undefined ? { contextWindow: record.contextWindow as number | null } : {}),
198
+ ...(record.maxTokens !== undefined ? { maxTokens: record.maxTokens as number | null } : {}),
199
+ } satisfies ProviderModelOverrideLayer;
200
+ }
201
+ return parsed;
202
+ }
203
+
204
+ function validateConfigLayer(value: unknown, path: string): ConfigLayer {
205
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
206
+ throw new Error(`Config file must contain a JSON object: ${path}`);
207
+ }
208
+
209
+ const record = value as Record<string, unknown>;
210
+ const stringFields = ["providerName", "baseUrl"];
211
+ for (const field of stringFields) {
212
+ if (record[field] !== undefined && typeof record[field] !== "string") {
213
+ throw new Error(`${field} must be a string in config file: ${path}`);
214
+ }
215
+ }
216
+ if (
217
+ record.metadataFallbackProvider !== undefined &&
218
+ record.metadataFallbackProvider !== null &&
219
+ (typeof record.metadataFallbackProvider !== "string" || !record.metadataFallbackProvider.trim())
220
+ ) {
221
+ throw new Error(`metadataFallbackProvider must be a non-empty string or null in config file: ${path}`);
222
+ }
223
+
224
+ const booleanFields = ["authRequired", "authHeader", "modelsDevEnabled"];
225
+ for (const field of booleanFields) {
226
+ if (record[field] !== undefined && typeof record[field] !== "boolean") {
227
+ throw new Error(`${field} must be a boolean in config file: ${path}`);
228
+ }
229
+ }
230
+
231
+ if (record.headers !== undefined && !isStringMap(record.headers)) {
232
+ throw new Error(`headers must be an object with string values in config file: ${path}`);
233
+ }
234
+ if (record.modelAliases !== undefined && !isStringMap(record.modelAliases)) {
235
+ throw new Error(`modelAliases must be an object with string values in config file: ${path}`);
236
+ }
237
+ const modelOverrides = record.modelOverrides === undefined
238
+ ? undefined
239
+ : parseModelOverrides(record.modelOverrides, "global");
240
+
241
+ return { ...record, ...(modelOverrides ? { modelOverrides } : {}) } as ConfigLayer;
242
+ }
243
+
244
+ export function readConfigFile(path: string): ConfigLayer | undefined {
245
+ if (!existsSync(path)) return undefined;
246
+ return validateConfigLayer(JSON.parse(readFileSync(path, "utf8")), path);
247
+ }
248
+
249
+ export function readProjectConfigFile(path: string): ConfigLayer | undefined {
250
+ if (!existsSync(path)) return undefined;
251
+ return projectConfigLayer(JSON.parse(readFileSync(path, "utf8")), path);
252
+ }
253
+
254
+ export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): CpaProviderConfig {
255
+ return mergeConfigLayers(readConfigFile(globalConfigPath()), readProjectConfigFile(projectConfigPath(cwd)), env);
256
+ }
257
+
258
+ export function writeConfigFile(path: string, config: ConfigLayer): void {
259
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
260
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
261
+ }
262
+
263
+ export function loadModelOverrideLayers(cwd: string): {
264
+ global: ProviderModelOverrideLayers;
265
+ project: ProviderModelOverrideLayers;
266
+ } {
267
+ return {
268
+ global: (readConfigFile(globalConfigPath())?.modelOverrides ?? {}) as ProviderModelOverrideLayers,
269
+ project: (readProjectConfigFile(projectConfigPath(cwd))?.modelOverrides ?? {}) as ProviderModelOverrideLayers,
270
+ };
271
+ }
272
+
273
+ export function saveModelOverride(
274
+ cwd: string,
275
+ modelId: string,
276
+ override: ProviderModelOverrideLayer,
277
+ ): { path: string; overrides: ProviderModelOverrideLayers } {
278
+ const path = projectConfigPath(cwd);
279
+ let raw: Record<string, unknown> = {};
280
+ if (existsSync(path)) {
281
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
282
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
283
+ throw new Error(`Config file must contain a JSON object: ${path}`);
284
+ }
285
+ raw = parsed as Record<string, unknown>;
286
+ }
287
+
288
+ const existing = raw.modelOverrides === undefined
289
+ ? {}
290
+ : parseModelOverrides(raw.modelOverrides, "project");
291
+ const next = { ...existing };
292
+ if (Object.keys(override).length === 0) delete next[modelId];
293
+ else next[modelId] = override;
294
+ writeConfigFile(path, { ...raw, modelOverrides: next } as ConfigLayer);
295
+ return { path, overrides: next };
296
+ }
package/src/cpa.ts ADDED
@@ -0,0 +1,68 @@
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
+ function parseCpaModelEntries(entries: unknown[]): CpaModel[] {
20
+ const models = entries.flatMap((entry) => {
21
+ if (!entry || typeof entry !== "object") return [];
22
+ const record = entry as Record<string, unknown>;
23
+ if (typeof record.id !== "string" || record.id.trim() === "") return [];
24
+ return [{
25
+ id: record.id,
26
+ object: typeof record.object === "string" ? record.object : undefined,
27
+ owned_by: typeof record.owned_by === "string" ? record.owned_by : undefined,
28
+ created: typeof record.created === "number" ? record.created : undefined,
29
+ }];
30
+ });
31
+
32
+ const unique = new Map<string, CpaModel>();
33
+ for (const model of models) unique.set(model.id, model);
34
+ return [...unique.values()].sort((left, right) => left.id.localeCompare(right.id));
35
+ }
36
+
37
+ export function parseCpaModelsCache(payload: unknown): CpaModel[] {
38
+ if (!Array.isArray(payload)) throw new Error("CPA model snapshot must be an array");
39
+ const models = parseCpaModelEntries(payload);
40
+ if (models.length !== payload.length) throw new Error("CPA model snapshot contains invalid entries");
41
+ return models;
42
+ }
43
+
44
+ export function parseCpaModelsResponse(payload: unknown): CpaModel[] {
45
+ const response = payload as CpaModelsResponse;
46
+ if (!response || typeof response !== "object" || !Array.isArray(response.data)) {
47
+ throw new Error("CPA /v1/models response must contain a data array");
48
+ }
49
+ return parseCpaModelEntries(response.data);
50
+ }
51
+
52
+ export async function fetchCpaModels(
53
+ baseUrl: string,
54
+ headers: Record<string, string> = {},
55
+ timeoutMs?: number,
56
+ signal?: AbortSignal,
57
+ ): Promise<CpaModel[]> {
58
+ return withNetworkTimeout(async (reqSignal) => {
59
+ const response = await fetch(modelsEndpoint(baseUrl), {
60
+ headers: { Accept: "application/json", ...headers },
61
+ signal: reqSignal,
62
+ });
63
+ if (!response.ok) {
64
+ throw new Error(`CPA model discovery failed: HTTP ${response.status} ${response.statusText}`);
65
+ }
66
+ return parseCpaModelsResponse(await response.json());
67
+ }, timeoutMs, "CPA model discovery", signal);
68
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import { cacheDir, providerCacheKey } from "./config.ts";
3
+ import type { CpaProviderConfig } from "./types.ts";
4
+
5
+ export function cpaModelsCachePath(config: CpaProviderConfig): string {
6
+ return join(cacheDir(), providerCacheKey(config), "cpa-models.json");
7
+ }
8
+
9
+ export function modelsDevCachePath(): string {
10
+ // Keep provider-catalog metadata separate from the former lab-level
11
+ // models.json cache, which omitted provider pricing.
12
+ return join(cacheDir(), "models-dev-api.json");
13
+ }
14
+
15
+ export function discoveryHeaders(config: CpaProviderConfig, apiKey?: string): Record<string, string> {
16
+ return {
17
+ ...config.headers,
18
+ ...(config.authHeader && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
19
+ };
20
+ }
@@ -0,0 +1,150 @@
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" | "owner-hint" | "suffix" | "normalized-suffix" | "provider-fallback";
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 metadataModelName(metadataId: string, metadata: ModelsDevMetadata): string {
32
+ return metadata.id.split("/").at(-1) ?? metadataId.split("/").at(-1) ?? metadataId;
33
+ }
34
+
35
+ function oneMatch(candidates: string[]): string | undefined {
36
+ const unique = [...new Set(candidates)];
37
+ return unique.length === 1 ? unique[0] : undefined;
38
+ }
39
+
40
+ function identifierTokens(value: string): string[] {
41
+ return value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
42
+ }
43
+
44
+ function containsContiguousTokens(container: string[], sequence: string[]): boolean {
45
+ if (sequence.length === 0 || sequence.length > container.length) return false;
46
+ return container.some((_, start) =>
47
+ start + sequence.length <= container.length &&
48
+ sequence.every((token, offset) => container[start + offset] === token)
49
+ );
50
+ }
51
+
52
+ function sourceProvider(metadataId: string, metadata: ModelsDevMetadata): string {
53
+ // sourceProvider is retained by current catalog snapshots. The prefix fallback
54
+ // keeps older bundled/cache snapshots useful until they are refreshed.
55
+ return metadata.sourceProvider ?? metadataId.split("/")[0] ?? metadataId;
56
+ }
57
+
58
+ function ownerHintMatch(
59
+ owner: string | undefined,
60
+ candidates: string[],
61
+ catalog: ModelsDevCatalog,
62
+ ): string | undefined {
63
+ if (!owner) return undefined;
64
+ const ownerTokens = identifierTokens(owner);
65
+ const matches = candidates.flatMap((metadataId) => {
66
+ const metadata = catalog[metadataId];
67
+ if (!metadata) return [];
68
+ const provider = sourceProvider(metadataId, metadata);
69
+ const providerTokens = identifierTokens(provider);
70
+ if (!containsContiguousTokens(ownerTokens, providerTokens)) return [];
71
+ return [{ metadataId, tokenCount: providerTokens.length, characterCount: provider.length }];
72
+ });
73
+ if (matches.length === 0) return undefined;
74
+
75
+ const bestTokenCount = Math.max(...matches.map((match) => match.tokenCount));
76
+ const mostTokens = matches.filter((match) => match.tokenCount === bestTokenCount);
77
+ const bestCharacterCount = Math.max(...mostTokens.map((match) => match.characterCount));
78
+ return oneMatch(
79
+ mostTokens
80
+ .filter((match) => match.characterCount === bestCharacterCount)
81
+ .map((match) => match.metadataId),
82
+ );
83
+ }
84
+
85
+ export function findMetadataMatch(
86
+ cpaModel: Pick<CpaModel, "id" | "owned_by">,
87
+ catalog: ModelsDevCatalog,
88
+ aliases: Record<string, string>,
89
+ fallbackProvider?: string | null,
90
+ ): MetadataMatch | undefined {
91
+ const alias = aliases[cpaModel.id];
92
+ if (alias && catalog[alias]) {
93
+ return { metadataId: alias, metadata: catalog[alias], method: "alias" };
94
+ }
95
+
96
+ if (catalog[cpaModel.id]) {
97
+ return { metadataId: cpaModel.id, metadata: catalog[cpaModel.id], method: "exact" };
98
+ }
99
+
100
+ const catalogKeys = Object.keys(catalog);
101
+ const exactMetadataCandidates = catalogKeys.filter((key) => catalog[key]?.id === cpaModel.id);
102
+ const exactMetadataKey = oneMatch(exactMetadataCandidates);
103
+ if (exactMetadataKey) {
104
+ return { metadataId: exactMetadataKey, metadata: catalog[exactMetadataKey], method: "exact" };
105
+ }
106
+
107
+ const suffixCandidates = catalogKeys.filter((key) =>
108
+ metadataModelName(key, catalog[key]) === cpaModel.id
109
+ );
110
+ const normalizedId = normalizeModelName(cpaModel.id);
111
+ const normalizedSuffixCandidates = catalogKeys.filter(
112
+ (key) => normalizeModelName(metadataModelName(key, catalog[key])) === normalizedId,
113
+ );
114
+ const owner = cpaModel.owned_by?.trim().toLowerCase();
115
+ const canonicalOwner = owner ? CANONICAL_OWNER_PREFIXES[owner] : undefined;
116
+ if (canonicalOwner) {
117
+ const ownerKey = `${canonicalOwner}/${cpaModel.id}`;
118
+ if (catalog[ownerKey]) {
119
+ return { metadataId: ownerKey, metadata: catalog[ownerKey], method: "owner-prefix" };
120
+ }
121
+ }
122
+
123
+ const hintedKey = ownerHintMatch(owner, normalizedSuffixCandidates, catalog);
124
+ if (hintedKey) {
125
+ return { metadataId: hintedKey, metadata: catalog[hintedKey], method: "owner-hint" };
126
+ }
127
+
128
+ const suffixKey = oneMatch(suffixCandidates);
129
+ if (suffixKey) {
130
+ return { metadataId: suffixKey, metadata: catalog[suffixKey], method: "suffix" };
131
+ }
132
+
133
+ const normalizedSuffixKey = oneMatch(normalizedSuffixCandidates);
134
+ if (normalizedSuffixKey) {
135
+ return { metadataId: normalizedSuffixKey, metadata: catalog[normalizedSuffixKey], method: "normalized-suffix" };
136
+ }
137
+
138
+ if (fallbackProvider) {
139
+ const normalizedFallbackProvider = fallbackProvider.trim().toLowerCase();
140
+ const fallbackKey = oneMatch(normalizedSuffixCandidates.filter((metadataId) => {
141
+ const metadata = catalog[metadataId];
142
+ return metadata && sourceProvider(metadataId, metadata).toLowerCase() === normalizedFallbackProvider;
143
+ }));
144
+ if (fallbackKey) {
145
+ return { metadataId: fallbackKey, metadata: catalog[fallbackKey], method: "provider-fallback" };
146
+ }
147
+ }
148
+
149
+ return undefined;
150
+ }
@@ -0,0 +1,26 @@
1
+ import type { ProviderModelConfigLike } from "./types.ts";
2
+
3
+ export interface ModelApiContext {
4
+ availableModelId: string;
5
+ metadataModelId?: string;
6
+ }
7
+
8
+ const GPT_5_6_MODEL = /^gpt-5\.6(?:-|$)/;
9
+
10
+ function modelName(id: string): string {
11
+ return id.slice(id.lastIndexOf("/") + 1);
12
+ }
13
+
14
+ export function isGpt56Model(context: ModelApiContext): boolean {
15
+ const ids = [context.availableModelId, context.metadataModelId].filter((id): id is string => id !== undefined);
16
+ return ids.some((id) => GPT_5_6_MODEL.test(modelName(id)));
17
+ }
18
+
19
+ /**
20
+ * Select a model-level API when a mixed CLIProxyAPI catalog cannot share the
21
+ * provider default. GPT-5.6, including Codex variants, uses the Responses API
22
+ * so Pi receives the Responses usage shape needed for token-cost accounting.
23
+ */
24
+ export function getModelApiOverride(context: ModelApiContext): ProviderModelConfigLike["api"] | undefined {
25
+ return isGpt56Model(context) ? "openai-responses" : undefined;
26
+ }
@@ -0,0 +1,65 @@
1
+ import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
2
+
3
+ export interface ModelCapabilityContext {
4
+ availableModelId: string;
5
+ metadataModelId?: string;
6
+ }
7
+
8
+ export interface ModelCapabilityOverrides {
9
+ reasoning?: boolean;
10
+ thinkingLevelMap?: ThinkingLevelMap;
11
+ }
12
+
13
+ interface ModelCapabilityRule {
14
+ matches: (context: ModelCapabilityContext) => boolean;
15
+ overrides: ModelCapabilityOverrides;
16
+ }
17
+
18
+ /**
19
+ * Fallback level map for the GPT-5.6 family, used only when models.dev publishes
20
+ * no effort list for the model.
21
+ *
22
+ * `minimal` is explicitly unsupported. Measured against a live CLIProxyAPI
23
+ * instance, gpt-5.6-sol, -terra, and -luna all answer
24
+ * `400 level "minimal" not supported, valid levels: low, medium, high, xhigh, max`,
25
+ * so offering it produces a failing request. models.dev omits `minimal` for the
26
+ * same models, which is why this rule is the fallback rather than the source of
27
+ * truth; it stays correct by hiding what the model does not accept.
28
+ */
29
+ const GPT_5_6_THINKING_LEVEL_MAP: ThinkingLevelMap = {
30
+ off: "none",
31
+ minimal: null,
32
+ low: "low",
33
+ medium: "medium",
34
+ high: "high",
35
+ xhigh: "xhigh",
36
+ max: "max",
37
+ };
38
+
39
+ function includesModelFamily(context: ModelCapabilityContext, family: string): boolean {
40
+ return [context.availableModelId, context.metadataModelId]
41
+ .filter((id): id is string => id !== undefined)
42
+ .some((id) => id.includes(family));
43
+ }
44
+
45
+ const MODEL_CAPABILITY_RULES: readonly ModelCapabilityRule[] = [
46
+ {
47
+ matches: (context) => includesModelFamily(context, "gpt-5.6"),
48
+ overrides: {
49
+ reasoning: true,
50
+ thinkingLevelMap: GPT_5_6_THINKING_LEVEL_MAP,
51
+ },
52
+ },
53
+ ];
54
+
55
+ export function getModelCapabilityOverrides(context: ModelCapabilityContext): ModelCapabilityOverrides {
56
+ const resolved: ModelCapabilityOverrides = {};
57
+
58
+ for (const rule of MODEL_CAPABILITY_RULES) {
59
+ if (!rule.matches(context)) continue;
60
+ if (rule.overrides.reasoning !== undefined) resolved.reasoning = rule.overrides.reasoning;
61
+ if (rule.overrides.thinkingLevelMap) resolved.thinkingLevelMap = { ...rule.overrides.thinkingLevelMap };
62
+ }
63
+
64
+ return resolved;
65
+ }