pi-free 2.2.7 → 2.2.8
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/CHANGELOG.md +12 -0
- package/config.ts +815 -821
- package/constants.ts +113 -124
- package/lib/built-in-toggle.ts +426 -426
- package/lib/model-detection.ts +1 -1
- package/package.json +5 -5
- package/provider-helper.ts +29 -7
- package/providers/anyapi/anyapi.ts +270 -270
- package/providers/cline/cline-auth.ts +473 -473
- package/providers/cline/cline-xml-bridge.ts +1506 -1506
- package/providers/cline/cline.ts +205 -205
- package/providers/dynamic-built-in/index.ts +718 -718
- package/providers/kilo/kilo-auth.ts +152 -155
- package/providers/kilo/kilo.ts +13 -7
- package/providers/opencode-session.ts +514 -514
- package/providers/qoder/auth.ts +548 -548
- package/providers/qoder/qoder.ts +119 -119
- package/providers/qoder/stream.ts +585 -585
- package/providers/qoder/thinking-parser.ts +251 -251
- package/providers/qoder/transform.ts +192 -192
- package/providers/tokenrouter/tokenrouter.ts +636 -636
- package/providers/qwen/qwen-auth.ts +0 -416
- package/providers/qwen/qwen-models.ts +0 -101
- package/providers/qwen/qwen.ts +0 -200
package/lib/model-detection.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-free",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "AI model providers for Pi with free model filtering and dynamic model fetching",
|
|
6
6
|
"keywords": [
|
|
@@ -56,14 +56,14 @@
|
|
|
56
56
|
"smoke:openmodel": "tsx scripts/smoke-openmodel-wire-format.ts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@earendil-works/pi-ai": ">=0.80.
|
|
60
|
-
"@earendil-works/pi-coding-agent": ">=0.80.
|
|
61
|
-
"@earendil-works/pi-tui": ">=0.80.
|
|
59
|
+
"@earendil-works/pi-ai": ">=0.80.8",
|
|
60
|
+
"@earendil-works/pi-coding-agent": ">=0.80.8",
|
|
61
|
+
"@earendil-works/pi-tui": ">=0.80.8"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@vitest/ui": "^4.1.10",
|
|
65
65
|
"tsx": "^4.23.0",
|
|
66
|
-
"typescript": "^
|
|
66
|
+
"typescript": "^7.0.2",
|
|
67
67
|
"vitest": "^4.1.10"
|
|
68
68
|
},
|
|
69
69
|
"pi": {
|
package/provider-helper.ts
CHANGED
|
@@ -24,6 +24,33 @@ import { enhanceModelNameWithCodingIndex } from "./provider-failover/benchmark-l
|
|
|
24
24
|
|
|
25
25
|
const _logger = createLogger("provider-helper");
|
|
26
26
|
|
|
27
|
+
interface ContextWithOAuthStatus {
|
|
28
|
+
model?: unknown;
|
|
29
|
+
modelRegistry?: {
|
|
30
|
+
isUsingOAuth?: (model: unknown) => boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check OAuth status through Pi's ModelRegistry compatibility facade.
|
|
36
|
+
* Newer Pi versions no longer expose authStorage on the context.
|
|
37
|
+
*/
|
|
38
|
+
export function isCurrentModelOAuth(ctx: unknown): boolean {
|
|
39
|
+
if (!ctx || typeof ctx !== "object") return false;
|
|
40
|
+
const context = ctx as ContextWithOAuthStatus;
|
|
41
|
+
return Boolean(
|
|
42
|
+
context.model && context.modelRegistry?.isUsingOAuth?.(context.model),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isOAuthCredential(
|
|
47
|
+
credential: unknown,
|
|
48
|
+
): credential is { type: "oauth"; access: string } {
|
|
49
|
+
if (!credential || typeof credential !== "object") return false;
|
|
50
|
+
const candidate = credential as { type?: unknown; access?: unknown };
|
|
51
|
+
return candidate.type === "oauth" && typeof candidate.access === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
// =============================================================================
|
|
28
55
|
// Types
|
|
29
56
|
// =============================================================================
|
|
@@ -124,11 +151,7 @@ export async function loadCachedOrFetchModels(
|
|
|
124
151
|
const ttlMs = options?.ttlMs ?? DEFAULT_PROVIDER_CACHE_TTL_MS;
|
|
125
152
|
const cached = loadProviderCache(providerId);
|
|
126
153
|
|
|
127
|
-
if (
|
|
128
|
-
cached &&
|
|
129
|
-
cached.length > 0 &&
|
|
130
|
-
isProviderCacheFresh(providerId, ttlMs)
|
|
131
|
-
) {
|
|
154
|
+
if (cached && cached.length > 0 && isProviderCacheFresh(providerId, ttlMs)) {
|
|
132
155
|
return cached;
|
|
133
156
|
}
|
|
134
157
|
|
|
@@ -336,8 +359,7 @@ export function setupProvider(
|
|
|
336
359
|
if (tosShown || ctx.model?.provider !== providerId) return;
|
|
337
360
|
tosShown = true;
|
|
338
361
|
if (config.hasKey) return;
|
|
339
|
-
|
|
340
|
-
if (cred?.type === "oauth") return;
|
|
362
|
+
if (isCurrentModelOAuth(ctx)) return;
|
|
341
363
|
ctx.ui.notify(
|
|
342
364
|
`Using ${providerId} free models. Set API key for paid access. Terms: ${tosUrl}`,
|
|
343
365
|
"info",
|
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AnyAPI provider extension.
|
|
3
|
-
*
|
|
4
|
-
* AnyAPI is an OpenAI-compatible gateway with a free plan and a catalog of
|
|
5
|
-
* explicitly free models. It exposes the catalog at /v1/models and routes
|
|
6
|
-
* chat requests through /v1/chat/completions.
|
|
7
|
-
*
|
|
8
|
-
* Setup:
|
|
9
|
-
* ANYAPI_API_KEY=...
|
|
10
|
-
* # or add anyapi_api_key to ~/.pi/free.json
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type {
|
|
14
|
-
ExtensionAPI,
|
|
15
|
-
ProviderModelConfig,
|
|
16
|
-
} from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import {
|
|
18
|
-
applyHidden,
|
|
19
|
-
getAnyapiApiKey,
|
|
20
|
-
getAnyapiShowPaid,
|
|
21
|
-
} from "../../config.ts";
|
|
22
|
-
import {
|
|
23
|
-
BASE_URL_ANYAPI,
|
|
24
|
-
DEFAULT_FETCH_TIMEOUT_MS,
|
|
25
|
-
PROVIDER_ANYAPI,
|
|
26
|
-
} from "../../constants.ts";
|
|
27
|
-
import { createLogger } from "../../lib/logger.ts";
|
|
28
|
-
import { loadProviderCache } from "../../lib/provider-cache.ts";
|
|
29
|
-
import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
30
|
-
import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
|
|
31
|
-
import { fetchWithRetry, mapOpenRouterModel } from "../../lib/util.ts";
|
|
32
|
-
import {
|
|
33
|
-
createReRegister,
|
|
34
|
-
loadCachedOrFetchModels,
|
|
35
|
-
setupProvider,
|
|
36
|
-
} from "../../provider-helper.ts";
|
|
37
|
-
|
|
38
|
-
const _logger = createLogger("anyapi");
|
|
39
|
-
|
|
40
|
-
interface AnyApiModel {
|
|
41
|
-
id: string;
|
|
42
|
-
name?: string;
|
|
43
|
-
context_length?: number;
|
|
44
|
-
max_completion_tokens?: number | null;
|
|
45
|
-
top_provider?: {
|
|
46
|
-
context_length?: number | null;
|
|
47
|
-
max_completion_tokens?: number | null;
|
|
48
|
-
};
|
|
49
|
-
pricing?: {
|
|
50
|
-
prompt?: string | number | null;
|
|
51
|
-
completion?: string | number | null;
|
|
52
|
-
input_cache_read?: string | number | null;
|
|
53
|
-
input_cache_write?: string | number | null;
|
|
54
|
-
};
|
|
55
|
-
architecture?: {
|
|
56
|
-
input_modalities?: string[] | null;
|
|
57
|
-
output_modalities?: string[] | null;
|
|
58
|
-
};
|
|
59
|
-
supported_parameters?: string[] | null;
|
|
60
|
-
tags?: string[];
|
|
61
|
-
isFree?: boolean;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const ANYAPI_METADATA_VERSION = 1;
|
|
65
|
-
|
|
66
|
-
type AnyApiProviderModel = ProviderModelConfig & {
|
|
67
|
-
_pricingKnown?: boolean;
|
|
68
|
-
_freeKnown?: boolean;
|
|
69
|
-
_isFree?: boolean;
|
|
70
|
-
_anyapiMetadataVersion?: number;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
function hasPricing(model: AnyApiModel): boolean {
|
|
74
|
-
return (
|
|
75
|
-
(model.pricing?.prompt !== null && model.pricing?.prompt !== undefined) ||
|
|
76
|
-
(model.pricing?.completion !== null &&
|
|
77
|
-
model.pricing?.completion !== undefined) ||
|
|
78
|
-
(model.pricing?.input_cache_read !== null &&
|
|
79
|
-
model.pricing?.input_cache_read !== undefined) ||
|
|
80
|
-
(model.pricing?.input_cache_write !== null &&
|
|
81
|
-
model.pricing?.input_cache_write !== undefined)
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizePrice(
|
|
86
|
-
value: string | number | null | undefined,
|
|
87
|
-
): string | null {
|
|
88
|
-
return value === null || value === undefined ? null : String(value);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Detect AnyAPI's explicitly free model labels without treating every model
|
|
93
|
-
* with omitted pricing as free. The API may also expose an authoritative flag
|
|
94
|
-
* or zero pricing, both of which are handled here.
|
|
95
|
-
*/
|
|
96
|
-
export function isAnyApiFreeModel(model: AnyApiModel): boolean {
|
|
97
|
-
if (typeof model.isFree === "boolean") return model.isFree;
|
|
98
|
-
|
|
99
|
-
const label = `${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
100
|
-
if (/\bfree\b/.test(label)) return true;
|
|
101
|
-
|
|
102
|
-
if (!hasPricing(model)) return false;
|
|
103
|
-
const input = Number(model.pricing?.prompt);
|
|
104
|
-
const output = Number(model.pricing?.completion);
|
|
105
|
-
return input === 0 && output === 0;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function mapAnyApiModel(model: AnyApiModel): AnyApiProviderModel {
|
|
109
|
-
const name = model.name ?? model.id;
|
|
110
|
-
const pricingKnown = hasPricing(model);
|
|
111
|
-
const freeKnown =
|
|
112
|
-
typeof model.isFree === "boolean" ||
|
|
113
|
-
/\bfree\b/i.test(`${model.id} ${name}`) ||
|
|
114
|
-
(pricingKnown && isAnyApiFreeModel(model));
|
|
115
|
-
|
|
116
|
-
const tags = model.tags ?? [];
|
|
117
|
-
const supportsReasoning =
|
|
118
|
-
tags.includes("reasoning") || tags.includes("chat_completions:reasoning");
|
|
119
|
-
const supportsVision =
|
|
120
|
-
tags.includes("vision") || tags.includes("chat_completions:vision");
|
|
121
|
-
|
|
122
|
-
const mapped = mapOpenRouterModel({
|
|
123
|
-
...model,
|
|
124
|
-
name,
|
|
125
|
-
supported_parameters:
|
|
126
|
-
model.supported_parameters ?? (supportsReasoning ? ["reasoning"] : []),
|
|
127
|
-
architecture: model.architecture ?? {
|
|
128
|
-
input_modalities: supportsVision ? ["text", "image"] : ["text"],
|
|
129
|
-
output_modalities: ["text"],
|
|
130
|
-
},
|
|
131
|
-
pricing: model.pricing
|
|
132
|
-
? {
|
|
133
|
-
prompt: normalizePrice(model.pricing.prompt),
|
|
134
|
-
completion: normalizePrice(model.pricing.completion),
|
|
135
|
-
input_cache_read: normalizePrice(model.pricing.input_cache_read),
|
|
136
|
-
input_cache_write: normalizePrice(model.pricing.input_cache_write),
|
|
137
|
-
}
|
|
138
|
-
: undefined,
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
...mapped,
|
|
143
|
-
_pricingKnown: pricingKnown,
|
|
144
|
-
...(freeKnown && {
|
|
145
|
-
_freeKnown: true,
|
|
146
|
-
_isFree: isAnyApiFreeModel(model),
|
|
147
|
-
}),
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function isTextModel(model: AnyApiModel): boolean {
|
|
152
|
-
const outputModalities = model.architecture?.output_modalities ?? [];
|
|
153
|
-
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const tags = model.tags;
|
|
158
|
-
if (!tags || tags.length === 0) return true;
|
|
159
|
-
return tags.some((tag) => tag.startsWith("chat_completions:"));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function fetchAnyApiModels(
|
|
163
|
-
apiKey: string,
|
|
164
|
-
): Promise<AnyApiProviderModel[]> {
|
|
165
|
-
const response = await fetchWithRetry(
|
|
166
|
-
`${BASE_URL_ANYAPI}/models`,
|
|
167
|
-
{
|
|
168
|
-
headers: {
|
|
169
|
-
Authorization: `Bearer ${apiKey}`,
|
|
170
|
-
Accept: "application/json",
|
|
171
|
-
"Content-Type": "application/json",
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
3,
|
|
175
|
-
1000,
|
|
176
|
-
DEFAULT_FETCH_TIMEOUT_MS,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
if (!response.ok) {
|
|
180
|
-
throw new Error(
|
|
181
|
-
`AnyAPI API error: ${response.status} ${response.statusText}`,
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const json = (await response.json()) as { data?: AnyApiModel[] };
|
|
186
|
-
const models = (json.data ?? []).flatMap((model) => {
|
|
187
|
-
if (!model.id || !isTextModel(model)) return [];
|
|
188
|
-
return [mapAnyApiModel(model)];
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
_logger.info(`[anyapi] Fetched ${models.length} text models`);
|
|
192
|
-
|
|
193
|
-
// AnyAPI's /models response omits context and output limits for its
|
|
194
|
-
// catalog. Use the global models.dev catalog so canonical model IDs such
|
|
195
|
-
// as qwen/qwen3-coder:free do not fall back to the generic 4096-token
|
|
196
|
-
// defaults in mapOpenRouterModel. The AnyAPI-scoped models.dev entry only
|
|
197
|
-
// contains a small paid subset and does not cover the free catalog.
|
|
198
|
-
const enriched = await safeEnrichModelsWithModelsDev(models);
|
|
199
|
-
return applyHidden(
|
|
200
|
-
enriched.map((model) => ({
|
|
201
|
-
...model,
|
|
202
|
-
_anyapiMetadataVersion: ANYAPI_METADATA_VERSION,
|
|
203
|
-
})),
|
|
204
|
-
PROVIDER_ANYAPI,
|
|
205
|
-
) as AnyApiProviderModel[];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export default async function anyapiProvider(pi: ExtensionAPI) {
|
|
209
|
-
const apiKey = getAnyapiApiKey();
|
|
210
|
-
if (!apiKey) {
|
|
211
|
-
_logger.info("[anyapi] Skipping — ANYAPI_API_KEY not set.");
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const cachedModels = loadProviderCache(PROVIDER_ANYAPI) as
|
|
216
|
-
| AnyApiProviderModel[]
|
|
217
|
-
| undefined;
|
|
218
|
-
const needsMetadataMigration = cachedModels?.some(
|
|
219
|
-
(model) => model._anyapiMetadataVersion !== ANYAPI_METADATA_VERSION,
|
|
220
|
-
);
|
|
221
|
-
const allModels = await loadCachedOrFetchModels(
|
|
222
|
-
PROVIDER_ANYAPI,
|
|
223
|
-
() => fetchAnyApiModels(apiKey),
|
|
224
|
-
// Force one refresh for caches written before context metadata was added;
|
|
225
|
-
// subsequent warm startups continue using the normal one-hour cache.
|
|
226
|
-
needsMetadataMigration ? { ttlMs: -1 } : undefined,
|
|
227
|
-
);
|
|
228
|
-
if (allModels.length === 0) {
|
|
229
|
-
_logger.warn("[anyapi] No text models available");
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const freeModels = allModels.filter((model) =>
|
|
234
|
-
isFreeModel({ ...model, provider: PROVIDER_ANYAPI }, allModels),
|
|
235
|
-
);
|
|
236
|
-
const stored = { free: freeModels, all: allModels };
|
|
237
|
-
|
|
238
|
-
_logger.info(
|
|
239
|
-
`[anyapi] Registered ${allModels.length} models (${freeModels.length} free)`,
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
const reRegister = createReRegister(pi, {
|
|
243
|
-
providerId: PROVIDER_ANYAPI,
|
|
244
|
-
baseUrl: BASE_URL_ANYAPI,
|
|
245
|
-
apiKey,
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
registerWithGlobalToggle(PROVIDER_ANYAPI, stored, reRegister, true);
|
|
249
|
-
|
|
250
|
-
setupProvider(
|
|
251
|
-
pi,
|
|
252
|
-
{
|
|
253
|
-
providerId: PROVIDER_ANYAPI,
|
|
254
|
-
initialShowPaid: getAnyapiShowPaid(),
|
|
255
|
-
hasKey: true,
|
|
256
|
-
tosUrl: "https://anyapi.ai/terms-of-service",
|
|
257
|
-
reRegister: (models, current) => {
|
|
258
|
-
if (current) {
|
|
259
|
-
stored.free = current.free;
|
|
260
|
-
stored.all = current.all;
|
|
261
|
-
}
|
|
262
|
-
reRegister(models);
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
stored,
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
const showPaid = getAnyapiShowPaid();
|
|
269
|
-
reRegister(showPaid ? stored.all : stored.free);
|
|
270
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* AnyAPI provider extension.
|
|
3
|
+
*
|
|
4
|
+
* AnyAPI is an OpenAI-compatible gateway with a free plan and a catalog of
|
|
5
|
+
* explicitly free models. It exposes the catalog at /v1/models and routes
|
|
6
|
+
* chat requests through /v1/chat/completions.
|
|
7
|
+
*
|
|
8
|
+
* Setup:
|
|
9
|
+
* ANYAPI_API_KEY=...
|
|
10
|
+
* # or add anyapi_api_key to ~/.pi/free.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ProviderModelConfig,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import {
|
|
18
|
+
applyHidden,
|
|
19
|
+
getAnyapiApiKey,
|
|
20
|
+
getAnyapiShowPaid,
|
|
21
|
+
} from "../../config.ts";
|
|
22
|
+
import {
|
|
23
|
+
BASE_URL_ANYAPI,
|
|
24
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
25
|
+
PROVIDER_ANYAPI,
|
|
26
|
+
} from "../../constants.ts";
|
|
27
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
28
|
+
import { loadProviderCache } from "../../lib/provider-cache.ts";
|
|
29
|
+
import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
30
|
+
import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
|
|
31
|
+
import { fetchWithRetry, mapOpenRouterModel } from "../../lib/util.ts";
|
|
32
|
+
import {
|
|
33
|
+
createReRegister,
|
|
34
|
+
loadCachedOrFetchModels,
|
|
35
|
+
setupProvider,
|
|
36
|
+
} from "../../provider-helper.ts";
|
|
37
|
+
|
|
38
|
+
const _logger = createLogger("anyapi");
|
|
39
|
+
|
|
40
|
+
interface AnyApiModel {
|
|
41
|
+
id: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
context_length?: number;
|
|
44
|
+
max_completion_tokens?: number | null;
|
|
45
|
+
top_provider?: {
|
|
46
|
+
context_length?: number | null;
|
|
47
|
+
max_completion_tokens?: number | null;
|
|
48
|
+
};
|
|
49
|
+
pricing?: {
|
|
50
|
+
prompt?: string | number | null;
|
|
51
|
+
completion?: string | number | null;
|
|
52
|
+
input_cache_read?: string | number | null;
|
|
53
|
+
input_cache_write?: string | number | null;
|
|
54
|
+
};
|
|
55
|
+
architecture?: {
|
|
56
|
+
input_modalities?: string[] | null;
|
|
57
|
+
output_modalities?: string[] | null;
|
|
58
|
+
};
|
|
59
|
+
supported_parameters?: string[] | null;
|
|
60
|
+
tags?: string[];
|
|
61
|
+
isFree?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ANYAPI_METADATA_VERSION = 1;
|
|
65
|
+
|
|
66
|
+
type AnyApiProviderModel = ProviderModelConfig & {
|
|
67
|
+
_pricingKnown?: boolean;
|
|
68
|
+
_freeKnown?: boolean;
|
|
69
|
+
_isFree?: boolean;
|
|
70
|
+
_anyapiMetadataVersion?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function hasPricing(model: AnyApiModel): boolean {
|
|
74
|
+
return (
|
|
75
|
+
(model.pricing?.prompt !== null && model.pricing?.prompt !== undefined) ||
|
|
76
|
+
(model.pricing?.completion !== null &&
|
|
77
|
+
model.pricing?.completion !== undefined) ||
|
|
78
|
+
(model.pricing?.input_cache_read !== null &&
|
|
79
|
+
model.pricing?.input_cache_read !== undefined) ||
|
|
80
|
+
(model.pricing?.input_cache_write !== null &&
|
|
81
|
+
model.pricing?.input_cache_write !== undefined)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizePrice(
|
|
86
|
+
value: string | number | null | undefined,
|
|
87
|
+
): string | null {
|
|
88
|
+
return value === null || value === undefined ? null : String(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect AnyAPI's explicitly free model labels without treating every model
|
|
93
|
+
* with omitted pricing as free. The API may also expose an authoritative flag
|
|
94
|
+
* or zero pricing, both of which are handled here.
|
|
95
|
+
*/
|
|
96
|
+
export function isAnyApiFreeModel(model: AnyApiModel): boolean {
|
|
97
|
+
if (typeof model.isFree === "boolean") return model.isFree;
|
|
98
|
+
|
|
99
|
+
const label = `${model.id} ${model.name ?? ""}`.toLowerCase();
|
|
100
|
+
if (/\bfree\b/.test(label)) return true;
|
|
101
|
+
|
|
102
|
+
if (!hasPricing(model)) return false;
|
|
103
|
+
const input = Number(model.pricing?.prompt);
|
|
104
|
+
const output = Number(model.pricing?.completion);
|
|
105
|
+
return input === 0 && output === 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mapAnyApiModel(model: AnyApiModel): AnyApiProviderModel {
|
|
109
|
+
const name = model.name ?? model.id;
|
|
110
|
+
const pricingKnown = hasPricing(model);
|
|
111
|
+
const freeKnown =
|
|
112
|
+
typeof model.isFree === "boolean" ||
|
|
113
|
+
/\bfree\b/i.test(`${model.id} ${name}`) ||
|
|
114
|
+
(pricingKnown && isAnyApiFreeModel(model));
|
|
115
|
+
|
|
116
|
+
const tags = model.tags ?? [];
|
|
117
|
+
const supportsReasoning =
|
|
118
|
+
tags.includes("reasoning") || tags.includes("chat_completions:reasoning");
|
|
119
|
+
const supportsVision =
|
|
120
|
+
tags.includes("vision") || tags.includes("chat_completions:vision");
|
|
121
|
+
|
|
122
|
+
const mapped = mapOpenRouterModel({
|
|
123
|
+
...model,
|
|
124
|
+
name,
|
|
125
|
+
supported_parameters:
|
|
126
|
+
model.supported_parameters ?? (supportsReasoning ? ["reasoning"] : []),
|
|
127
|
+
architecture: model.architecture ?? {
|
|
128
|
+
input_modalities: supportsVision ? ["text", "image"] : ["text"],
|
|
129
|
+
output_modalities: ["text"],
|
|
130
|
+
},
|
|
131
|
+
pricing: model.pricing
|
|
132
|
+
? {
|
|
133
|
+
prompt: normalizePrice(model.pricing.prompt),
|
|
134
|
+
completion: normalizePrice(model.pricing.completion),
|
|
135
|
+
input_cache_read: normalizePrice(model.pricing.input_cache_read),
|
|
136
|
+
input_cache_write: normalizePrice(model.pricing.input_cache_write),
|
|
137
|
+
}
|
|
138
|
+
: undefined,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...mapped,
|
|
143
|
+
_pricingKnown: pricingKnown,
|
|
144
|
+
...(freeKnown && {
|
|
145
|
+
_freeKnown: true,
|
|
146
|
+
_isFree: isAnyApiFreeModel(model),
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isTextModel(model: AnyApiModel): boolean {
|
|
152
|
+
const outputModalities = model.architecture?.output_modalities ?? [];
|
|
153
|
+
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tags = model.tags;
|
|
158
|
+
if (!tags || tags.length === 0) return true;
|
|
159
|
+
return tags.some((tag) => tag.startsWith("chat_completions:"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function fetchAnyApiModels(
|
|
163
|
+
apiKey: string,
|
|
164
|
+
): Promise<AnyApiProviderModel[]> {
|
|
165
|
+
const response = await fetchWithRetry(
|
|
166
|
+
`${BASE_URL_ANYAPI}/models`,
|
|
167
|
+
{
|
|
168
|
+
headers: {
|
|
169
|
+
Authorization: `Bearer ${apiKey}`,
|
|
170
|
+
Accept: "application/json",
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
3,
|
|
175
|
+
1000,
|
|
176
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`AnyAPI API error: ${response.status} ${response.statusText}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const json = (await response.json()) as { data?: AnyApiModel[] };
|
|
186
|
+
const models = (json.data ?? []).flatMap((model) => {
|
|
187
|
+
if (!model.id || !isTextModel(model)) return [];
|
|
188
|
+
return [mapAnyApiModel(model)];
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
_logger.info(`[anyapi] Fetched ${models.length} text models`);
|
|
192
|
+
|
|
193
|
+
// AnyAPI's /models response omits context and output limits for its
|
|
194
|
+
// catalog. Use the global models.dev catalog so canonical model IDs such
|
|
195
|
+
// as qwen/qwen3-coder:free do not fall back to the generic 4096-token
|
|
196
|
+
// defaults in mapOpenRouterModel. The AnyAPI-scoped models.dev entry only
|
|
197
|
+
// contains a small paid subset and does not cover the free catalog.
|
|
198
|
+
const enriched = await safeEnrichModelsWithModelsDev(models);
|
|
199
|
+
return applyHidden(
|
|
200
|
+
enriched.map((model) => ({
|
|
201
|
+
...model,
|
|
202
|
+
_anyapiMetadataVersion: ANYAPI_METADATA_VERSION,
|
|
203
|
+
})),
|
|
204
|
+
PROVIDER_ANYAPI,
|
|
205
|
+
) as AnyApiProviderModel[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default async function anyapiProvider(pi: ExtensionAPI) {
|
|
209
|
+
const apiKey = getAnyapiApiKey();
|
|
210
|
+
if (!apiKey) {
|
|
211
|
+
_logger.info("[anyapi] Skipping — ANYAPI_API_KEY not set.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cachedModels = loadProviderCache(PROVIDER_ANYAPI) as
|
|
216
|
+
| AnyApiProviderModel[]
|
|
217
|
+
| undefined;
|
|
218
|
+
const needsMetadataMigration = cachedModels?.some(
|
|
219
|
+
(model) => model._anyapiMetadataVersion !== ANYAPI_METADATA_VERSION,
|
|
220
|
+
);
|
|
221
|
+
const allModels = await loadCachedOrFetchModels(
|
|
222
|
+
PROVIDER_ANYAPI,
|
|
223
|
+
() => fetchAnyApiModels(apiKey),
|
|
224
|
+
// Force one refresh for caches written before context metadata was added;
|
|
225
|
+
// subsequent warm startups continue using the normal one-hour cache.
|
|
226
|
+
needsMetadataMigration ? { ttlMs: -1 } : undefined,
|
|
227
|
+
);
|
|
228
|
+
if (allModels.length === 0) {
|
|
229
|
+
_logger.warn("[anyapi] No text models available");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const freeModels = allModels.filter((model) =>
|
|
234
|
+
isFreeModel({ ...model, provider: PROVIDER_ANYAPI }, allModels),
|
|
235
|
+
);
|
|
236
|
+
const stored = { free: freeModels, all: allModels };
|
|
237
|
+
|
|
238
|
+
_logger.info(
|
|
239
|
+
`[anyapi] Registered ${allModels.length} models (${freeModels.length} free)`,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const reRegister = createReRegister(pi, {
|
|
243
|
+
providerId: PROVIDER_ANYAPI,
|
|
244
|
+
baseUrl: BASE_URL_ANYAPI,
|
|
245
|
+
apiKey,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
registerWithGlobalToggle(PROVIDER_ANYAPI, stored, reRegister, true);
|
|
249
|
+
|
|
250
|
+
setupProvider(
|
|
251
|
+
pi,
|
|
252
|
+
{
|
|
253
|
+
providerId: PROVIDER_ANYAPI,
|
|
254
|
+
initialShowPaid: getAnyapiShowPaid(),
|
|
255
|
+
hasKey: true,
|
|
256
|
+
tosUrl: "https://anyapi.ai/terms-of-service",
|
|
257
|
+
reRegister: (models, current) => {
|
|
258
|
+
if (current) {
|
|
259
|
+
stored.free = current.free;
|
|
260
|
+
stored.all = current.all;
|
|
261
|
+
}
|
|
262
|
+
reRegister(models);
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
stored,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const showPaid = getAnyapiShowPaid();
|
|
269
|
+
reRegister(showPaid ? stored.all : stored.free);
|
|
270
|
+
}
|