pi-free 2.2.2 → 2.2.4
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 +18 -39
- package/README.md +41 -532
- package/banner.svg +23 -20
- package/config.ts +774 -702
- package/constants.ts +11 -1
- package/index.ts +432 -419
- package/lib/model-detection.ts +296 -296
- package/lib/model-metadata.ts +10 -3
- package/lib/telemetry.ts +36 -44
- package/package.json +3 -2
- package/provider-failover/benchmark-lookup.ts +30 -15
- package/provider-helper.ts +27 -8
- package/providers/bai/bai.ts +232 -237
- package/providers/cline/cline-xml-bridge.ts +31 -25
- package/providers/cline/cline.ts +17 -8
- package/providers/kilo/kilo.ts +11 -6
- package/providers/model-fetcher.ts +1 -1
- package/providers/opencode-session.ts +2 -2
- package/providers/openmodel/openmodel.ts +525 -0
- package/providers/qoder/auth.ts +548 -0
- package/providers/qoder/cosy.ts +236 -0
- package/providers/qoder/encoding.ts +48 -0
- package/providers/qoder/models.ts +321 -0
- package/providers/qoder/qoder.ts +154 -0
- package/providers/qoder/stream.ts +677 -0
- package/providers/qoder/thinking-parser.ts +251 -0
- package/providers/qoder/transform.ts +189 -0
- package/providers/tokenrouter/tokenrouter.ts +3 -6
package/providers/kilo/kilo.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ProviderModelConfig,
|
|
19
19
|
} from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import {
|
|
21
|
+
getKiloApiKey,
|
|
21
22
|
getKiloFreeOnly,
|
|
22
23
|
getKiloShowPaid,
|
|
23
24
|
PROVIDER_KILO,
|
|
@@ -152,7 +153,7 @@ function parseXmlToolCalls(
|
|
|
152
153
|
const KILO_PROVIDER_CONFIG = {
|
|
153
154
|
providerId: PROVIDER_KILO,
|
|
154
155
|
baseUrl: KILO_GATEWAY_BASE,
|
|
155
|
-
apiKey: "$KILO_API_KEY",
|
|
156
|
+
apiKey: getKiloApiKey() || "$KILO_API_KEY",
|
|
156
157
|
headers: {
|
|
157
158
|
"X-KILOCODE-EDITORNAME": "Pi",
|
|
158
159
|
},
|
|
@@ -172,14 +173,17 @@ function applyKiloCompat<T extends { compat?: ProviderModelConfig["compat"] }>(
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
export default async function kiloProvider(pi: ExtensionAPI) {
|
|
176
|
+
// Resolve API key (env var or ~/.pi/free.json)
|
|
177
|
+
const kiloApiKey = getKiloApiKey();
|
|
178
|
+
|
|
175
179
|
// Try to fetch ALL models at startup (like Cline/OpenRouter)
|
|
176
|
-
//
|
|
180
|
+
// With API key: returns all models; without: returns free-only
|
|
177
181
|
let allModels: ProviderModelConfig[] = [];
|
|
178
182
|
let freeModels: ProviderModelConfig[] = [];
|
|
179
183
|
|
|
180
184
|
try {
|
|
181
185
|
// Fetch all models (returns free-only if no auth, all if auth available)
|
|
182
|
-
allModels = await fetchKiloModels({ freeOnly: false });
|
|
186
|
+
allModels = await fetchKiloModels({ token: kiloApiKey, freeOnly: false });
|
|
183
187
|
// Derive free list using isFreeModel with allModels for detection
|
|
184
188
|
freeModels = allModels.filter((m) =>
|
|
185
189
|
isFreeModel({ ...m, provider: PROVIDER_KILO }, allModels),
|
|
@@ -211,11 +215,12 @@ export default async function kiloProvider(pi: ExtensionAPI) {
|
|
|
211
215
|
baseReRegister(applyKiloCompat(models));
|
|
212
216
|
|
|
213
217
|
// Register with global toggle system
|
|
218
|
+
const hasKiloKey = !!kiloApiKey;
|
|
214
219
|
registerWithGlobalToggle(
|
|
215
220
|
PROVIDER_KILO,
|
|
216
221
|
stored,
|
|
217
222
|
reRegister,
|
|
218
|
-
|
|
223
|
+
hasKiloKey,
|
|
219
224
|
);
|
|
220
225
|
|
|
221
226
|
// OAuth config for Kilo
|
|
@@ -283,14 +288,14 @@ export default async function kiloProvider(pi: ExtensionAPI) {
|
|
|
283
288
|
const modelsWithCompat = applyKiloCompat(currentModels);
|
|
284
289
|
pi.registerProvider(PROVIDER_KILO, {
|
|
285
290
|
baseUrl: KILO_GATEWAY_BASE,
|
|
286
|
-
apiKey: "$KILO_API_KEY",
|
|
291
|
+
apiKey: kiloApiKey || "$KILO_API_KEY",
|
|
287
292
|
api: "openai-completions" as const,
|
|
288
293
|
headers: {
|
|
289
294
|
"X-KILOCODE-EDITORNAME": "Pi",
|
|
290
295
|
"User-Agent": "pi-free-providers",
|
|
291
296
|
},
|
|
292
297
|
models: enhanceWithCI(modelsWithCompat),
|
|
293
|
-
oauth: oauthConfig,
|
|
298
|
+
...(!!kiloApiKey ? {} : { oauth: oauthConfig }),
|
|
294
299
|
});
|
|
295
300
|
|
|
296
301
|
// Registration complete - models registered silently (use LOG_LEVEL=info to see details)
|
|
@@ -138,7 +138,7 @@ export async function fetchOpenRouterModelsWithFree(
|
|
|
138
138
|
|
|
139
139
|
const free = all.filter((m) => {
|
|
140
140
|
const cost = m.cost;
|
|
141
|
-
return cost && cost.input === 0 && cost.output === 0;
|
|
141
|
+
return cost != null && cost.input === 0 && cost.output === 0;
|
|
142
142
|
});
|
|
143
143
|
|
|
144
144
|
return { free, all };
|
|
@@ -226,8 +226,8 @@ function resolvePiAiSubpathFromPackage(specifier: string): string | undefined {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
class DeferredAssistantMessageEventStream {
|
|
229
|
-
private queue: AssistantMessageEvent[] = [];
|
|
230
|
-
private waiting: Array<
|
|
229
|
+
private readonly queue: AssistantMessageEvent[] = [];
|
|
230
|
+
private readonly waiting: Array<
|
|
231
231
|
(result: IteratorResult<AssistantMessageEvent>) => void
|
|
232
232
|
> = [];
|
|
233
233
|
private done = false;
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenModel Provider Extension
|
|
3
|
+
*
|
|
4
|
+
* OpenModel (https://openmodel.ai) is a multi-model LLM gateway exposing
|
|
5
|
+
* ~40 models through three protocols:
|
|
6
|
+
* - /v1/messages (Anthropic-compatible) ← we use this
|
|
7
|
+
* - /v1/responses (OpenAI Responses API)
|
|
8
|
+
* - /v1/gemini (Gemini-compatible)
|
|
9
|
+
* - /v1/images (image generation)
|
|
10
|
+
*
|
|
11
|
+
* The /v1/messages endpoint serves DeepSeek, Anthropic (Claude), DashScope
|
|
12
|
+
* (Qwen), Xiaomi (MiMo), Moonshot (Kimi), MiniMax, and Zai models with a
|
|
13
|
+
* standard Anthropic Messages request/response shape.
|
|
14
|
+
*
|
|
15
|
+
* Pricing is exposed via the public, no-auth catalog endpoint
|
|
16
|
+
* GET /web/v1/models?page=N (paginated, 20 per page)
|
|
17
|
+
* Effective cost = `prices.input_cost_per_token × price_multiplier`.
|
|
18
|
+
* A `price_multiplier` of 0 makes a model free.
|
|
19
|
+
*
|
|
20
|
+
* Current DeepSeek V4 Flash Free Event: the `deepseek-v4-flash` model has
|
|
21
|
+
* price_multiplier=0 (input $0 / output $0), 10 RPM / 100K TPM, up to
|
|
22
|
+
* 1M-token context. See https://docs.openmodel.ai/en/docs/event.
|
|
23
|
+
*
|
|
24
|
+
* Setup:
|
|
25
|
+
* OPENMODEL_API_KEY=om-...
|
|
26
|
+
* # or add openmodel_api_key to ~/.pi/free.json
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
ExtensionAPI,
|
|
31
|
+
ProviderModelConfig,
|
|
32
|
+
} from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import {
|
|
34
|
+
getOpenmodelApiKey,
|
|
35
|
+
getOpenmodelShowPaid,
|
|
36
|
+
applyHidden,
|
|
37
|
+
} from "../../config.ts";
|
|
38
|
+
import {
|
|
39
|
+
BASE_URL_OPENMODEL,
|
|
40
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
41
|
+
PROVIDER_OPENMODEL,
|
|
42
|
+
} from "../../constants.ts";
|
|
43
|
+
import { createLogger } from "../../lib/logger.ts";
|
|
44
|
+
import { safeEnrichModelsWithModelsDev } from "../../lib/model-metadata.ts";
|
|
45
|
+
import { isLikelyReasoningModel } from "../../lib/provider-compat.ts";
|
|
46
|
+
import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
47
|
+
import { fetchWithRetry } from "../../lib/util.ts";
|
|
48
|
+
import { createReRegister, setupProvider } from "../../provider-helper.ts";
|
|
49
|
+
|
|
50
|
+
const _logger = createLogger("openmodel");
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// Types
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
/** A model item from the public, no-auth catalog endpoint /web/v1/models. */
|
|
57
|
+
interface OpenModelCatalogItem {
|
|
58
|
+
key: string;
|
|
59
|
+
provider_key: string;
|
|
60
|
+
provider_name: string;
|
|
61
|
+
prices: {
|
|
62
|
+
input_cost_per_token?: number;
|
|
63
|
+
output_cost_per_token?: number;
|
|
64
|
+
cache_read_input_token_cost?: number;
|
|
65
|
+
cache_creation_input_token_cost?: number;
|
|
66
|
+
input_cost_per_image?: number;
|
|
67
|
+
[key: string]: number | undefined;
|
|
68
|
+
};
|
|
69
|
+
max: {
|
|
70
|
+
max_input_tokens?: number;
|
|
71
|
+
max_output_tokens?: number;
|
|
72
|
+
max_tokens?: number;
|
|
73
|
+
};
|
|
74
|
+
supports: {
|
|
75
|
+
supports_vision?: boolean;
|
|
76
|
+
supports_reasoning?: boolean;
|
|
77
|
+
supports_function_calling?: boolean;
|
|
78
|
+
supports_native_streaming?: boolean;
|
|
79
|
+
supports_prompt_caching?: boolean;
|
|
80
|
+
supports_system_messages?: boolean;
|
|
81
|
+
supports_tool_choice?: boolean;
|
|
82
|
+
supports_image_generation?: boolean;
|
|
83
|
+
supports_audio_input?: boolean;
|
|
84
|
+
supports_audio_output?: boolean;
|
|
85
|
+
supports_video_input?: boolean;
|
|
86
|
+
supports_pdf_input?: boolean;
|
|
87
|
+
supports_url_context?: boolean;
|
|
88
|
+
supports_web_search?: boolean;
|
|
89
|
+
supports_assistant_prefill?: boolean;
|
|
90
|
+
supports_computer_use?: boolean;
|
|
91
|
+
supports_parallel_function_calling?: boolean;
|
|
92
|
+
supports_response_schema?: boolean;
|
|
93
|
+
supports_service_tier?: boolean;
|
|
94
|
+
};
|
|
95
|
+
price_multiplier: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A model item from the OpenAI-compatible /v1/models endpoint (auth required). */
|
|
99
|
+
interface OpenModelProtocolItem {
|
|
100
|
+
id: string;
|
|
101
|
+
object?: string;
|
|
102
|
+
created?: number;
|
|
103
|
+
owned_by?: string;
|
|
104
|
+
supported_protocols?: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface OpenModelWebResponse<T> {
|
|
108
|
+
success: boolean;
|
|
109
|
+
meta?: {
|
|
110
|
+
pagination?: {
|
|
111
|
+
page: number;
|
|
112
|
+
pageSize: number;
|
|
113
|
+
total: number;
|
|
114
|
+
totalPages: number;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
data: T[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Source of a model in the merged result.
|
|
122
|
+
* "priced" — model has real pricing from /web/v1/models (Route A free detection)
|
|
123
|
+
* "unpriced" — model has no web pricing; conservatively treated as paid
|
|
124
|
+
*/
|
|
125
|
+
type ModelSource = "priced" | "unpriced";
|
|
126
|
+
|
|
127
|
+
interface MergedOpenModelModel {
|
|
128
|
+
item: OpenModelCatalogItem;
|
|
129
|
+
source: ModelSource;
|
|
130
|
+
/** True when the model's protocol set includes "messages". */
|
|
131
|
+
supportsMessages: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// Helpers (exported for testing)
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
/** Strip the "free" alias suffix that the catalog uses for promo models. */
|
|
139
|
+
function cleanModelName(id: string): string {
|
|
140
|
+
// The catalog returns ids like "1024-x-1024/gpt-image-1.5" — keep as-is.
|
|
141
|
+
// For chat models like "deepseek-v4-flash", return the id directly.
|
|
142
|
+
return id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toNumber(value: number | undefined): number {
|
|
146
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compute the effective per-token cost for a catalog item.
|
|
151
|
+
* `price_multiplier` of 0 → all costs become 0 (free).
|
|
152
|
+
*/
|
|
153
|
+
export function effectiveCost(
|
|
154
|
+
prices: OpenModelCatalogItem["prices"],
|
|
155
|
+
multiplier: number,
|
|
156
|
+
): { input: number; output: number; cacheRead: number; cacheWrite: number } {
|
|
157
|
+
if (multiplier === 0) {
|
|
158
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
input: toNumber(prices.input_cost_per_token) * multiplier,
|
|
162
|
+
output: toNumber(prices.output_cost_per_token) * multiplier,
|
|
163
|
+
cacheRead: toNumber(prices.cache_read_input_token_cost) * multiplier,
|
|
164
|
+
cacheWrite: toNumber(prices.cache_creation_input_token_cost) * multiplier,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function effectiveInputModalities(
|
|
169
|
+
supports: OpenModelCatalogItem["supports"],
|
|
170
|
+
): readonly ("text" | "image")[] {
|
|
171
|
+
const hasImage =
|
|
172
|
+
supports.supports_vision ||
|
|
173
|
+
supports.supports_pdf_input ||
|
|
174
|
+
supports.supports_image_generation;
|
|
175
|
+
return hasImage ? (["text", "image"] as const) : (["text"] as const);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function effectiveMaxTokens(item: OpenModelCatalogItem): number {
|
|
179
|
+
return (
|
|
180
|
+
item.max.max_output_tokens ??
|
|
181
|
+
item.max.max_tokens ??
|
|
182
|
+
// Anthropic Messages API requires max_tokens; fall back to a safe default.
|
|
183
|
+
16_384
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function effectiveContextWindow(item: OpenModelCatalogItem): number {
|
|
188
|
+
return item.max.max_input_tokens ?? 128_000;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Detect whether a model is a reasoning model.
|
|
193
|
+
* Prefer the explicit `supports.supports_reasoning` flag from the catalog;
|
|
194
|
+
* fall back to name-based heuristics from provider-compat.
|
|
195
|
+
*/
|
|
196
|
+
function detectReasoning(item: OpenModelCatalogItem): boolean {
|
|
197
|
+
if (item.supports.supports_reasoning === true) return true;
|
|
198
|
+
return isLikelyReasoningModel({
|
|
199
|
+
id: item.key,
|
|
200
|
+
name: cleanModelName(item.key),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build a {@link ProviderModelConfig} from a merged catalog item.
|
|
206
|
+
* Pure function — no I/O — exported for unit testing.
|
|
207
|
+
*/
|
|
208
|
+
export function mapOpenModelModel(
|
|
209
|
+
merged: MergedOpenModelModel,
|
|
210
|
+
): ProviderModelConfig & {
|
|
211
|
+
_pricingKnown?: boolean;
|
|
212
|
+
_freeKnown?: boolean;
|
|
213
|
+
_isFree?: boolean;
|
|
214
|
+
} {
|
|
215
|
+
const { item, source } = merged;
|
|
216
|
+
const reasoning = detectReasoning(item);
|
|
217
|
+
const multiplier = item.price_multiplier;
|
|
218
|
+
const cost = effectiveCost(item.prices, multiplier);
|
|
219
|
+
|
|
220
|
+
// The catalog returns multipliers in [0, 1]. If a model has input+output
|
|
221
|
+
// costs > 0 but multiplier === 0, it is an explicit promo/free model.
|
|
222
|
+
// Only set the authoritative _freeKnown override in two cases:
|
|
223
|
+
// 1. priced + multiplier=0 → bulletproof free (handles edge case where
|
|
224
|
+
// every priced model happens to be cost-0, which would flip
|
|
225
|
+
// isFreeModel to Route B / name-based and hide this model).
|
|
226
|
+
// 2. unpriced → conservatively paid (no data to know).
|
|
227
|
+
// For other priced models (multiplier>0 with real prices, OR missing
|
|
228
|
+
// per-token prices), let isFreeModel's Route A decide from effective cost.
|
|
229
|
+
const isAuthoritativelyFree = source === "priced" && multiplier === 0;
|
|
230
|
+
const isAuthoritativelyPaid = source === "unpriced";
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: item.key,
|
|
234
|
+
name: cleanModelName(item.key),
|
|
235
|
+
reasoning,
|
|
236
|
+
input: effectiveInputModalities(item.supports),
|
|
237
|
+
cost,
|
|
238
|
+
contextWindow: effectiveContextWindow(item),
|
|
239
|
+
maxTokens: effectiveMaxTokens(item),
|
|
240
|
+
_pricingKnown: source === "priced",
|
|
241
|
+
...(isAuthoritativelyFree && {
|
|
242
|
+
_freeKnown: true as const,
|
|
243
|
+
_isFree: true as const,
|
|
244
|
+
}),
|
|
245
|
+
...(isAuthoritativelyPaid && {
|
|
246
|
+
_freeKnown: true as const,
|
|
247
|
+
_isFree: false as const,
|
|
248
|
+
}),
|
|
249
|
+
} as ProviderModelConfig & {
|
|
250
|
+
_pricingKnown?: boolean;
|
|
251
|
+
_freeKnown?: boolean;
|
|
252
|
+
_isFree?: boolean;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Merge the priced catalog (with real pricing) and the protocol list
|
|
258
|
+
* (for protocol filtering), then return only the models whose protocol
|
|
259
|
+
* set includes `"messages"`. Unpriced messages-protocol models are
|
|
260
|
+
* included with source `"unpriced"` so the user can still see them
|
|
261
|
+
* under /toggle-openmodel — we just don't claim they're free.
|
|
262
|
+
*
|
|
263
|
+
* Pure function — exported for unit testing.
|
|
264
|
+
*/
|
|
265
|
+
export function mergeOpenModelModels(
|
|
266
|
+
catalog: OpenModelCatalogItem[],
|
|
267
|
+
protocolItems: OpenModelProtocolItem[],
|
|
268
|
+
): MergedOpenModelModel[] {
|
|
269
|
+
const protocolsById = new Map<string, string[]>();
|
|
270
|
+
for (const item of protocolItems) {
|
|
271
|
+
if (item.id) protocolsById.set(item.id, item.supported_protocols ?? []);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const seen = new Set<string>();
|
|
275
|
+
const result: MergedOpenModelModel[] = [];
|
|
276
|
+
|
|
277
|
+
// 1) Priced catalog models, filtered to "messages" support.
|
|
278
|
+
for (const item of catalog) {
|
|
279
|
+
if (!item.key) continue;
|
|
280
|
+
const protocols = protocolsById.get(item.key) ?? [];
|
|
281
|
+
if (!protocols.includes("messages")) continue;
|
|
282
|
+
seen.add(item.key);
|
|
283
|
+
result.push({
|
|
284
|
+
item,
|
|
285
|
+
source: "priced",
|
|
286
|
+
supportsMessages: true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 2) Unpriced messages-protocol models (e.g. MiniMax, MiMo, Kimi, Qwen).
|
|
291
|
+
for (const item of protocolItems) {
|
|
292
|
+
if (!item.id) continue;
|
|
293
|
+
if (seen.has(item.id)) continue;
|
|
294
|
+
const protocols = item.supported_protocols ?? [];
|
|
295
|
+
if (!protocols.includes("messages")) continue;
|
|
296
|
+
|
|
297
|
+
// Synthesize a minimal catalog item so mapOpenModelModel has
|
|
298
|
+
// uniform input shape. All cost fields default to 0 and
|
|
299
|
+
// reasoning is detected name-based.
|
|
300
|
+
result.push({
|
|
301
|
+
item: {
|
|
302
|
+
key: item.id,
|
|
303
|
+
provider_key: item.owned_by ?? "unknown",
|
|
304
|
+
provider_name: item.owned_by ?? "unknown",
|
|
305
|
+
prices: {},
|
|
306
|
+
max: {},
|
|
307
|
+
supports: {},
|
|
308
|
+
price_multiplier: 1,
|
|
309
|
+
},
|
|
310
|
+
source: "unpriced",
|
|
311
|
+
supportsMessages: true,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// =============================================================================
|
|
319
|
+
// Fetch
|
|
320
|
+
// =============================================================================
|
|
321
|
+
|
|
322
|
+
const OPENMODEL_PAGINATION_DELAY_MS = 200;
|
|
323
|
+
|
|
324
|
+
async function fetchOpenModelWebCatalog(
|
|
325
|
+
baseUrl: string,
|
|
326
|
+
): Promise<OpenModelCatalogItem[]> {
|
|
327
|
+
const items: OpenModelCatalogItem[] = [];
|
|
328
|
+
let cleanBase = baseUrl;
|
|
329
|
+
while (cleanBase.endsWith("/")) cleanBase = cleanBase.slice(0, -1);
|
|
330
|
+
let page = 1;
|
|
331
|
+
|
|
332
|
+
while (true) {
|
|
333
|
+
const url = `${cleanBase}/web/v1/models?page=${page}`;
|
|
334
|
+
_logger.info(`[openmodel] Fetching public catalog page ${page}: ${url}`);
|
|
335
|
+
|
|
336
|
+
const response = await fetchWithRetry(
|
|
337
|
+
url,
|
|
338
|
+
{ headers: { Accept: "application/json" } },
|
|
339
|
+
3,
|
|
340
|
+
1000,
|
|
341
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (!response.ok) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`OpenModel web catalog error: ${response.status} ${response.statusText}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const json =
|
|
351
|
+
(await response.json()) as OpenModelWebResponse<OpenModelCatalogItem>;
|
|
352
|
+
if (json.success !== true || !Array.isArray(json.data)) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
"OpenModel web catalog: unexpected response shape (missing success/data)",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
items.push(...json.data);
|
|
359
|
+
|
|
360
|
+
const pagination = json.meta?.pagination;
|
|
361
|
+
if (
|
|
362
|
+
!pagination ||
|
|
363
|
+
page >= pagination.totalPages ||
|
|
364
|
+
json.data.length === 0
|
|
365
|
+
) {
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
page += 1;
|
|
369
|
+
// Be polite to the public endpoint — small delay between pages.
|
|
370
|
+
await new Promise((resolve) =>
|
|
371
|
+
setTimeout(resolve, OPENMODEL_PAGINATION_DELAY_MS),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_logger.info(
|
|
376
|
+
`[openmodel] Fetched ${items.length} models from public catalog`,
|
|
377
|
+
);
|
|
378
|
+
return items;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function fetchOpenModelProtocols(
|
|
382
|
+
apiKey: string,
|
|
383
|
+
baseUrl: string,
|
|
384
|
+
): Promise<OpenModelProtocolItem[]> {
|
|
385
|
+
let cleanBase = baseUrl;
|
|
386
|
+
while (cleanBase.endsWith("/")) cleanBase = cleanBase.slice(0, -1);
|
|
387
|
+
const response = await fetchWithRetry(
|
|
388
|
+
`${cleanBase}/v1/models`,
|
|
389
|
+
{
|
|
390
|
+
headers: {
|
|
391
|
+
Authorization: `Bearer ${apiKey}`,
|
|
392
|
+
Accept: "application/json",
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
3,
|
|
396
|
+
1000,
|
|
397
|
+
DEFAULT_FETCH_TIMEOUT_MS,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (!response.ok) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`OpenModel /v1/models error: ${response.status} ${response.statusText}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const json = (await response.json()) as
|
|
407
|
+
| { data?: OpenModelProtocolItem[] }
|
|
408
|
+
| OpenModelProtocolItem[];
|
|
409
|
+
const items = Array.isArray(json) ? json : (json.data ?? []);
|
|
410
|
+
_logger.info(`[openmodel] Fetched ${items.length} protocol entries`);
|
|
411
|
+
return items;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function fetchOpenModelModels(
|
|
415
|
+
apiKey: string,
|
|
416
|
+
): Promise<ProviderModelConfig[]> {
|
|
417
|
+
const [catalog, protocols] = await Promise.all([
|
|
418
|
+
fetchOpenModelWebCatalog(BASE_URL_OPENMODEL).catch((error) => {
|
|
419
|
+
_logger.error("[openmodel] Failed to fetch public catalog", {
|
|
420
|
+
error: error instanceof Error ? error.message : String(error),
|
|
421
|
+
});
|
|
422
|
+
return [] as OpenModelCatalogItem[];
|
|
423
|
+
}),
|
|
424
|
+
fetchOpenModelProtocols(apiKey, BASE_URL_OPENMODEL).catch((error) => {
|
|
425
|
+
_logger.error("[openmodel] Failed to fetch /v1/models", {
|
|
426
|
+
error: error instanceof Error ? error.message : String(error),
|
|
427
|
+
});
|
|
428
|
+
return [] as OpenModelProtocolItem[];
|
|
429
|
+
}),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
if (catalog.length === 0 && protocols.length === 0) {
|
|
433
|
+
_logger.warn(
|
|
434
|
+
"[openmodel] Both catalog and protocol fetch failed — no models to register",
|
|
435
|
+
);
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const merged = mergeOpenModelModels(catalog, protocols);
|
|
440
|
+
const mapped = merged.map(mapOpenModelModel);
|
|
441
|
+
|
|
442
|
+
const pricedCount = merged.filter((m) => m.source === "priced").length;
|
|
443
|
+
const unpricedCount = merged.length - pricedCount;
|
|
444
|
+
_logger.info(
|
|
445
|
+
`[openmodel] ${merged.length} messages-protocol models (${pricedCount} priced, ${unpricedCount} unpriced)`,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const enriched = await safeEnrichModelsWithModelsDev(mapped, {
|
|
449
|
+
providerId: PROVIDER_OPENMODEL,
|
|
450
|
+
});
|
|
451
|
+
return applyHidden(enriched, PROVIDER_OPENMODEL);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// =============================================================================
|
|
455
|
+
// Extension Entry Point
|
|
456
|
+
// =============================================================================
|
|
457
|
+
|
|
458
|
+
export default async function openmodelProvider(pi: ExtensionAPI) {
|
|
459
|
+
const apiKey = getOpenmodelApiKey();
|
|
460
|
+
|
|
461
|
+
if (!apiKey) {
|
|
462
|
+
_logger.info(
|
|
463
|
+
"[openmodel] Skipping — OPENMODEL_API_KEY not set. Sign up at https://openmodel.ai/",
|
|
464
|
+
);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const allModels = await fetchOpenModelModels(apiKey);
|
|
469
|
+
|
|
470
|
+
if (allModels.length === 0) {
|
|
471
|
+
_logger.warn(
|
|
472
|
+
"[openmodel] No models available — verify OPENMODEL_API_KEY is valid and see ~/.pi/free.log for details",
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// isFreeModel handles the heavy lifting:
|
|
478
|
+
// - Priced models (multiplier>0): Route A — free if effective cost is 0.
|
|
479
|
+
// - Free-event models (multiplier=0, e.g. deepseek-v4-flash): Route A
|
|
480
|
+
// sees cost 0 → free. This is the headline free model.
|
|
481
|
+
// - Unpriced models: _freeKnown=true, _isFree=false → definitively paid.
|
|
482
|
+
const freeModels = allModels.filter((m) =>
|
|
483
|
+
isFreeModel({ ...m, provider: PROVIDER_OPENMODEL }, allModels),
|
|
484
|
+
);
|
|
485
|
+
const stored = { free: freeModels, all: allModels };
|
|
486
|
+
|
|
487
|
+
_logger.info(
|
|
488
|
+
`[openmodel] Registered ${allModels.length} models (${freeModels.length} free)`,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const reRegister = createReRegister(pi, {
|
|
492
|
+
providerId: PROVIDER_OPENMODEL,
|
|
493
|
+
baseUrl: BASE_URL_OPENMODEL,
|
|
494
|
+
apiKey,
|
|
495
|
+
// OpenModel is an Anthropic-protocol gateway — /v1/chat/completions
|
|
496
|
+
// does not exist on it. Without `api: "anthropic-messages"`, the
|
|
497
|
+
// helper defaults to openai-completions and pi-ai POSTs to a 404
|
|
498
|
+
// path. Pin the wire format so it dispatches to the Anthropic SDK.
|
|
499
|
+
api: "anthropic-messages",
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
registerWithGlobalToggle(PROVIDER_OPENMODEL, stored, reRegister, true);
|
|
503
|
+
|
|
504
|
+
setupProvider(
|
|
505
|
+
pi,
|
|
506
|
+
{
|
|
507
|
+
providerId: PROVIDER_OPENMODEL,
|
|
508
|
+
initialShowPaid: getOpenmodelShowPaid(),
|
|
509
|
+
tosUrl: "https://docs.openmodel.ai/en/docs",
|
|
510
|
+
reRegister: (models, _stored) => {
|
|
511
|
+
if (_stored) {
|
|
512
|
+
stored.free = _stored.free;
|
|
513
|
+
stored.all = _stored.all;
|
|
514
|
+
}
|
|
515
|
+
reRegister(models);
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
stored,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const showPaid = getOpenmodelShowPaid();
|
|
522
|
+
const initialModels =
|
|
523
|
+
showPaid && stored.all.length > 0 ? stored.all : freeModels;
|
|
524
|
+
reRegister(initialModels);
|
|
525
|
+
}
|