@oh-my-pi/pi-catalog 18.2.1 → 18.2.2
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/dist/types/compat/cascade.d.ts +10 -0
- package/dist/types/compat/collapse.d.ts +8 -0
- package/dist/types/compat/resolve.d.ts +10 -0
- package/dist/types/types.d.ts +10 -0
- package/package.json +4 -4
- package/src/build.ts +59 -0
- package/src/compat/axes.ts +1 -0
- package/src/compat/cascade.ts +24 -0
- package/src/compat/collapse.ts +100 -8
- package/src/compat/resolve.ts +14 -1
- package/src/compat/rules/providers/devin.kdl +46 -0
- package/src/compat/rules/providers/kimi-code.kdl +48 -0
- package/src/compat/rules.json +1 -1
- package/src/discovery/devin.ts +22 -4
- package/src/model-cache.ts +40 -84
- package/src/provider-models/openai-compat.ts +203 -13
- package/src/types.ts +10 -0
package/src/discovery/devin.ts
CHANGED
|
@@ -76,6 +76,8 @@ function supportsDevinThinking(config: ClientModelConfig): boolean {
|
|
|
76
76
|
const DEVIN_COST_LABEL_INPUT = "input";
|
|
77
77
|
const DEVIN_COST_LABEL_CACHE_READ = "cached input";
|
|
78
78
|
const DEVIN_COST_LABEL_OUTPUT = "output";
|
|
79
|
+
/** Normalized label of the marker dimension separating composite rate cards. */
|
|
80
|
+
const DEVIN_SIDEKICK_LABEL = "sidekick";
|
|
79
81
|
|
|
80
82
|
/** Leading token count of a cost denominator ("1M tokens", "1K tokens"). */
|
|
81
83
|
const DEVIN_COST_DENOMINATOR_PATTERN = /(\d+(?:\.\d+)?)\s*([kmb])?/i;
|
|
@@ -104,10 +106,20 @@ function devinCostDenominatorTokens(denominator: string): number {
|
|
|
104
106
|
* an estimated rate, not a different unit, so both kinds are read. `cacheWrite`
|
|
105
107
|
* has no Cascade dimension — Devin bills cache writes at the input rate — and
|
|
106
108
|
* stays 0.
|
|
109
|
+
*
|
|
110
|
+
* Composite configs (`fusion`) flatten their own rate card plus every
|
|
111
|
+
* dispatched component's card into one `modelDimensions` list. A `Sidekick`
|
|
112
|
+
* marker dimension separates the composite's own card from the component
|
|
113
|
+
* cards, so reading stops there: a headline card may omit dimensions a
|
|
114
|
+
* component includes, which makes repeated-label detection unreliable.
|
|
107
115
|
*/
|
|
108
116
|
function devinModelCost(config: ClientModelConfig): ModelCost {
|
|
109
117
|
const cost: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
110
118
|
for (const dimension of config.modelDimensions) {
|
|
119
|
+
const label = dimension.label.trim().toLowerCase();
|
|
120
|
+
if (label === DEVIN_SIDEKICK_LABEL) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
111
123
|
if (dimension.kind !== ModelDimensionKind.COST && dimension.kind !== ModelDimensionKind.COST_FUZZY) {
|
|
112
124
|
continue;
|
|
113
125
|
}
|
|
@@ -115,7 +127,7 @@ function devinModelCost(config: ClientModelConfig): ModelCost {
|
|
|
115
127
|
// (0.1 decodes as 0.10000000149011612) at sub-cent precision.
|
|
116
128
|
const perMillion =
|
|
117
129
|
Math.round(((dimension.value * 1_000_000) / devinCostDenominatorTokens(dimension.denominator)) * 1e6) / 1e6;
|
|
118
|
-
switch (
|
|
130
|
+
switch (label) {
|
|
119
131
|
case DEVIN_COST_LABEL_INPUT:
|
|
120
132
|
cost.input = perMillion;
|
|
121
133
|
break;
|
|
@@ -379,14 +391,14 @@ function devinModelSpec(
|
|
|
379
391
|
config: ClientModelConfig,
|
|
380
392
|
uid: string,
|
|
381
393
|
baseUrl: string,
|
|
382
|
-
|
|
394
|
+
isAssignModelRouter: boolean,
|
|
383
395
|
): ModelSpec<"devin-agent"> {
|
|
384
396
|
const features = config.modelInfo?.modelFeatures;
|
|
385
397
|
const supportsImages =
|
|
386
398
|
(features !== undefined ? features.supportsImages : config.supportsImages) && !DEVIN_IMAGE_BLIND_UIDS.has(uid);
|
|
387
399
|
const input: ("text" | "image")[] = supportsImages ? ["text", "image"] : ["text"];
|
|
388
400
|
const compat: DevinCompat = {};
|
|
389
|
-
if (
|
|
401
|
+
if (isAssignModelRouter) compat.modelRouter = true;
|
|
390
402
|
if (features?.supportsParallelToolCalls === true) compat.supportsParallelToolCalls = true;
|
|
391
403
|
const maxOutputTokens = config.modelInfo?.maxOutputTokens ?? 0;
|
|
392
404
|
const spec: ModelSpec<"devin-agent"> = {
|
|
@@ -439,7 +451,13 @@ function normalizeDevinModels(
|
|
|
439
451
|
}
|
|
440
452
|
seen.add(uid);
|
|
441
453
|
const isRouter = displayOption === DisplayOption.MODEL_ROUTER || config.modelInfo?.isModelRouter === true;
|
|
442
|
-
|
|
454
|
+
// `isModelRouter` marks two different things: harness-less routing slots
|
|
455
|
+
// (`adaptive`, `subagent-default`) that `AssignModel` resolves into a
|
|
456
|
+
// concrete model, and harness-backed composites (`fusion`,
|
|
457
|
+
// `fusion-sidekick-*`) that are themselves valid chat uids. Only the
|
|
458
|
+
// former take the `AssignModel` path — sending a composite uid there 404s.
|
|
459
|
+
const isAssignModelRouter = isRouter && (config.modelInfo?.harnessUids.length ?? 0) === 0;
|
|
460
|
+
specs.push(devinModelSpec(config, uid, baseUrl, isAssignModelRouter));
|
|
443
461
|
// A router is a server-side dispatcher, not an effort tier: it stays a
|
|
444
462
|
// standalone model even when upstream files it under a family.
|
|
445
463
|
if (!isRouter) {
|
package/src/model-cache.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* SQLite-backed model cache for atomic cross-process access.
|
|
3
3
|
* Replaces per-provider JSON files with a single cache.db.
|
|
4
4
|
*/
|
|
5
|
-
import { Database } from "bun:sqlite";
|
|
6
|
-
import {
|
|
7
|
-
import { getModelDbPath, isEnoent, isSqliteCorruptionError, logger, VERSION } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import type { Database } from "bun:sqlite";
|
|
6
|
+
import { getModelDbPath, isSqliteCorruptionError, openSqliteDatabaseSync, VERSION } from "@oh-my-pi/pi-utils";
|
|
8
7
|
import RULES from "./compat/rules.json" with { type: "json" };
|
|
9
8
|
import type { Api, Model } from "./types";
|
|
10
9
|
|
|
@@ -135,14 +134,10 @@ function invalidateReadPath(resolvedPath: string): void {
|
|
|
135
134
|
}
|
|
136
135
|
}
|
|
137
136
|
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
db.run("PRAGMA busy_timeout = 3000");
|
|
143
|
-
// Schema invalidation can delete rows containing credentials written by old
|
|
144
|
-
// versions. Overwrite deleted SQLite cells instead of leaving their bytes in
|
|
145
|
-
// free pages where a raw scan of models.db can still recover them (#5780).
|
|
137
|
+
function initializeDb(db: Database): void {
|
|
138
|
+
// The shared opener installs the busy handler before any lock-taking
|
|
139
|
+
// statement. Schema invalidation can delete rows containing credentials
|
|
140
|
+
// written by old versions, so scrub deleted cells (#5780).
|
|
146
141
|
db.run("PRAGMA secure_delete = ON");
|
|
147
142
|
db.run("PRAGMA journal_mode = WAL");
|
|
148
143
|
db.run(`
|
|
@@ -160,92 +155,53 @@ function openDb(resolvedPath: string): Database {
|
|
|
160
155
|
)
|
|
161
156
|
`);
|
|
162
157
|
migrateCacheSchema(db);
|
|
163
|
-
return db;
|
|
164
158
|
}
|
|
165
159
|
|
|
166
|
-
function
|
|
167
|
-
if (sharedDb
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
sharedDb.close();
|
|
172
|
-
sharedDb = null;
|
|
173
|
-
sharedDbPath = null;
|
|
174
|
-
}
|
|
175
|
-
const db = openDb(resolvedPath);
|
|
176
|
-
sharedDb = db;
|
|
177
|
-
sharedDbPath = resolvedPath;
|
|
178
|
-
return db;
|
|
160
|
+
function closeSharedDb(): void {
|
|
161
|
+
if (!sharedDb) return;
|
|
162
|
+
sharedDb.close();
|
|
163
|
+
sharedDb = null;
|
|
164
|
+
sharedDbPath = null;
|
|
179
165
|
}
|
|
180
166
|
|
|
181
167
|
function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: Database) => T): T {
|
|
182
|
-
if (shared
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
return useDb(db);
|
|
186
|
-
} finally {
|
|
187
|
-
db.close();
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Paths already reported corrupt this process: the first unrecoverable failure
|
|
192
|
-
// is logged at `error`, later heals at `debug`, so a dying disk cannot spam.
|
|
193
|
-
const reportedCorruptPaths = new Set<string>();
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Move a physically corrupt `models.db` (plus its `-wal`/`-shm` sidecars) aside
|
|
197
|
-
* so {@link openDb} can recreate a fresh cache at the original path. Renames are
|
|
198
|
-
* best-effort: a vanished sidecar (already healed by a peer process) is fine,
|
|
199
|
-
* and any other rename failure is left for {@link openDb} to surface.
|
|
200
|
-
*/
|
|
201
|
-
function quarantineCorruptModelCache(resolvedPath: string): void {
|
|
202
|
-
const stamp = Date.now();
|
|
203
|
-
for (const suffix of ["", "-wal", "-shm"]) {
|
|
168
|
+
if (shared && sharedDb && sharedDbPath !== resolvedPath) closeSharedDb();
|
|
169
|
+
if (shared && sharedDb) {
|
|
204
170
|
try {
|
|
205
|
-
|
|
206
|
-
} catch (
|
|
207
|
-
if (!
|
|
208
|
-
|
|
209
|
-
|
|
171
|
+
return useDb(sharedDb);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (!isSqliteCorruptionError(error)) throw error;
|
|
174
|
+
// The opener owns recovery for new handles. Drop this stale handle
|
|
175
|
+
// first so its WAL cannot remain attached to the replacement.
|
|
176
|
+
closeSharedDb();
|
|
177
|
+
invalidateReadPath(resolvedPath);
|
|
178
|
+
return runModelCacheDb(resolvedPath, shared, useDb);
|
|
210
179
|
}
|
|
211
180
|
}
|
|
212
|
-
}
|
|
213
181
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
logger.debug("model cache: re-healed corrupt database", { path: resolvedPath, code });
|
|
233
|
-
} else {
|
|
234
|
-
reportedCorruptPaths.add(resolvedPath);
|
|
235
|
-
logger.error("model cache corrupt; quarantined and recreated a fresh cache", { path: resolvedPath, code });
|
|
236
|
-
}
|
|
182
|
+
return openSqliteDatabaseSync(
|
|
183
|
+
resolvedPath,
|
|
184
|
+
db => {
|
|
185
|
+
initializeDb(db);
|
|
186
|
+
const result = useDb(db);
|
|
187
|
+
if (shared) {
|
|
188
|
+
sharedDb = db;
|
|
189
|
+
sharedDbPath = resolvedPath;
|
|
190
|
+
} else {
|
|
191
|
+
db.close();
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
recoverCorruption: true,
|
|
197
|
+
onCorruptionPreserved: () => invalidateReadPath(resolvedPath),
|
|
198
|
+
},
|
|
199
|
+
);
|
|
237
200
|
}
|
|
238
201
|
|
|
239
202
|
function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
|
|
240
203
|
const resolvedPath = dbPath ?? getModelDbPath();
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
return runModelCacheDb(resolvedPath, shared, useDb);
|
|
244
|
-
} catch (err) {
|
|
245
|
-
if (!isSqliteCorruptionError(err)) throw err;
|
|
246
|
-
healCorruptModelCache(resolvedPath, shared, err);
|
|
247
|
-
return runModelCacheDb(resolvedPath, shared, useDb);
|
|
248
|
-
}
|
|
204
|
+
return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
|
|
249
205
|
}
|
|
250
206
|
|
|
251
207
|
function migrateCacheSchema(db: Database): void {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
pricingPeerFor,
|
|
13
13
|
} from "../compat/behavior";
|
|
14
14
|
import { xaiResponsesReasoningEffortMap } from "../compat/openai";
|
|
15
|
-
import { resolveModelPolicy } from "../compat/resolve";
|
|
15
|
+
import { hasModelScopedEffortLadder, resolveModelPolicy } from "../compat/resolve";
|
|
16
16
|
import { compareRevision, parseRevision } from "../compat/revision";
|
|
17
17
|
import { seedModels } from "../compat/providers";
|
|
18
18
|
import { billingVariantPlain, classifyModel, discoveryVocabulary } from "../compat/taxonomy";
|
|
@@ -241,6 +241,192 @@ async function fetchCatalogPayload(
|
|
|
241
241
|
return payload;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The wire effort tiers the catalog publishes for a model, in canonical order.
|
|
246
|
+
* Undefined when the row has no effort-addressed thinking, or names no tier
|
|
247
|
+
* omp knows.
|
|
248
|
+
*/
|
|
249
|
+
function publishedEffortLadder(model: ModelsDevModel): Effort[] | undefined {
|
|
250
|
+
const values = model.reasoning_options?.find(option => option?.type === "effort")?.values;
|
|
251
|
+
if (!Array.isArray(values)) return undefined;
|
|
252
|
+
const ladder = THINKING_EFFORTS.filter(effort => values.includes(effort));
|
|
253
|
+
return ladder.length > 0 ? ladder : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Published ladders, addressable both by the host that published them and by
|
|
258
|
+
* bare id.
|
|
259
|
+
*
|
|
260
|
+
* `byHost` (keyed `provider\0id`) is authoritative: a ladder is only valid for
|
|
261
|
+
* the deployment that published it, so the endpoint's own catalog identity is
|
|
262
|
+
* consulted first. `byId` answers when the host is unknown — a gateway id with
|
|
263
|
+
* no catalog provider of its own — and only while every host publishing that
|
|
264
|
+
* id agrees that it takes an effort dial and on which tiers; an id whose hosts
|
|
265
|
+
* disagree, or that any host publishes with no dial at all, is dropped from
|
|
266
|
+
* it, so the ladder stays unknown instead of borrowing an arbitrary host's.
|
|
267
|
+
*
|
|
268
|
+
* `withoutLadder` carries the same `provider\0id` key for a row the catalog
|
|
269
|
+
* does publish but with no effort dial on it. The host serving an id outranks
|
|
270
|
+
* every other host on the question of what that deployment accepts, so its
|
|
271
|
+
* silence blocks the bare-id fallback for that id rather than letting a
|
|
272
|
+
* foreign ladder answer in its place. When the serving host is unknown that
|
|
273
|
+
* per-host veto cannot fire, which is why a dialless row also disqualifies the
|
|
274
|
+
* bare id outright.
|
|
275
|
+
*/
|
|
276
|
+
interface PublishedEffortLadders {
|
|
277
|
+
byHost: ReadonlyMap<string, readonly Effort[]>;
|
|
278
|
+
byId: ReadonlyMap<string, readonly Effort[]>;
|
|
279
|
+
withoutLadder: ReadonlySet<string>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const EMPTY_PUBLISHED_EFFORT_LADDERS: PublishedEffortLadders = {
|
|
283
|
+
byHost: new Map(),
|
|
284
|
+
byId: new Map(),
|
|
285
|
+
withoutLadder: new Set(),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function indexPublishedEffortLadders(payload: unknown): PublishedEffortLadders {
|
|
289
|
+
const byHost = new Map<string, readonly Effort[]>();
|
|
290
|
+
const byId = new Map<string, readonly Effort[]>();
|
|
291
|
+
const withoutLadder = new Set<string>();
|
|
292
|
+
const index: PublishedEffortLadders = { byHost, byId, withoutLadder };
|
|
293
|
+
const unshareable = new Set<string>();
|
|
294
|
+
if (!isRecord(payload)) return index;
|
|
295
|
+
for (const [providerKey, provider] of Object.entries(payload)) {
|
|
296
|
+
if (!isRecord(provider) || !isRecord(provider.models)) continue;
|
|
297
|
+
for (const [modelId, rawModel] of Object.entries(provider.models)) {
|
|
298
|
+
if (!isRecord(rawModel)) continue;
|
|
299
|
+
const key = `${providerKey}\u0000${modelId}`;
|
|
300
|
+
const ladder = publishedEffortLadder(rawModel as ModelsDevModel);
|
|
301
|
+
if (!ladder) {
|
|
302
|
+
withoutLadder.add(key);
|
|
303
|
+
// Some deployment of this id rejects an effort dial; without
|
|
304
|
+
// knowing which host serves a bare id, none may claim one.
|
|
305
|
+
byId.delete(modelId);
|
|
306
|
+
unshareable.add(modelId);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
byHost.set(key, ladder);
|
|
310
|
+
if (unshareable.has(modelId)) continue;
|
|
311
|
+
const shared = byId.get(modelId);
|
|
312
|
+
if (shared === undefined) {
|
|
313
|
+
byId.set(modelId, ladder);
|
|
314
|
+
} else if (shared.length !== ladder.length || shared.some((effort, index) => effort !== ladder[index])) {
|
|
315
|
+
byId.delete(modelId);
|
|
316
|
+
unshareable.add(modelId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return index;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The index is tagged onto the catalog payload it was built from, so each
|
|
325
|
+
* catalog version is indexed once. {@link fetchWellKnownModels} already scopes
|
|
326
|
+
* payloads by fetch context, coalesces concurrent requests, answers a `304`
|
|
327
|
+
* with the same object, and hands back the last good payload when a refresh
|
|
328
|
+
* fails, so the tag inherits all of that lifetime behaviour for free.
|
|
329
|
+
*/
|
|
330
|
+
const kPublishedEffortLadders = Symbol("catalog.publishedEffortLadders");
|
|
331
|
+
|
|
332
|
+
interface IndexedCatalogPayload {
|
|
333
|
+
[kPublishedEffortLadders]?: PublishedEffortLadders;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function loadPublishedEffortLadders(fetchImpl?: FetchImpl): Promise<PublishedEffortLadders> {
|
|
337
|
+
const payload = await fetchWellKnownModels(fetchImpl);
|
|
338
|
+
if (!isRecord(payload)) return EMPTY_PUBLISHED_EFFORT_LADDERS;
|
|
339
|
+
const tagged = payload as IndexedCatalogPayload;
|
|
340
|
+
return (tagged[kPublishedEffortLadders] ??= indexPublishedEffortLadders(payload));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The catalog provider keys this endpoint publishes under. A provider whose
|
|
345
|
+
* catalog identity differs from its omp id (`moonshot` → `moonshotai`) is
|
|
346
|
+
* resolved through its descriptors; the omp id stays as a candidate for
|
|
347
|
+
* providers the descriptors do not cover.
|
|
348
|
+
*/
|
|
349
|
+
function catalogProviderKeys(providerId: string): readonly string[] {
|
|
350
|
+
const keys = new Set<string>();
|
|
351
|
+
for (const descriptor of MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId] ?? []) keys.add(descriptor.modelsDevKey);
|
|
352
|
+
keys.add(providerId);
|
|
353
|
+
return [...keys];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The ladder published for a discovered id, preferring the host that serves it.
|
|
358
|
+
*
|
|
359
|
+
* Gateway prefixes are peeled (`deepseek/deepseek-v4` → `deepseek-v4`), and
|
|
360
|
+
* each peeled segment joins the host candidates ahead of the endpoint's own
|
|
361
|
+
* keys: on an aggregator the prefix names the real upstream. All host-scoped
|
|
362
|
+
* candidates are checked before accepting any shared-id fallback.
|
|
363
|
+
* A bare id is only accepted from {@link PublishedEffortLadders.byId}, which
|
|
364
|
+
* holds it only while every publishing host agrees that it takes an effort
|
|
365
|
+
* dial and on which tiers. A host serving the id that published it without a
|
|
366
|
+
* dial is this deployment's own answer and outranks any other host's ladder,
|
|
367
|
+
* so it vetoes the candidate here; a dialless host omp cannot recognize as the
|
|
368
|
+
* server already kept the id out of `byId` when the index was built.
|
|
369
|
+
*/
|
|
370
|
+
function lookupPublishedEffortLadder(
|
|
371
|
+
ladders: PublishedEffortLadders,
|
|
372
|
+
providerKeys: readonly string[],
|
|
373
|
+
modelId: string,
|
|
374
|
+
): readonly Effort[] | undefined {
|
|
375
|
+
const hosts = [...providerKeys];
|
|
376
|
+
let shared: readonly Effort[] | undefined;
|
|
377
|
+
for (let candidate = modelId; ;) {
|
|
378
|
+
let dialless = false;
|
|
379
|
+
for (const host of hosts) {
|
|
380
|
+
const key = `${host}\u0000${candidate}`;
|
|
381
|
+
const scoped = ladders.byHost.get(key);
|
|
382
|
+
if (scoped) return scoped;
|
|
383
|
+
dialless ||= ladders.withoutLadder.has(key);
|
|
384
|
+
}
|
|
385
|
+
if (dialless) return undefined;
|
|
386
|
+
shared ??= ladders.byId.get(candidate);
|
|
387
|
+
const slash = candidate.indexOf("/");
|
|
388
|
+
if (slash < 0) return shared;
|
|
389
|
+
hosts.unshift(candidate.slice(0, slash));
|
|
390
|
+
candidate = candidate.slice(slash + 1);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Fill the effort ladder of discovered reasoning models whose tiers omp would
|
|
396
|
+
* otherwise guess from the neutral wire or provider-wide unknown-class default.
|
|
397
|
+
*
|
|
398
|
+
* Source precedence is unchanged: a provider that reports its own thinking
|
|
399
|
+
* surface, and any model whose ladder reviewed rules declare, are left exactly
|
|
400
|
+
* as they are. Only the guess is corrected, and only for ids the catalog
|
|
401
|
+
* actually publishes for this endpoint (or publishes unambiguously), so no
|
|
402
|
+
* request is made when every discovered model is already covered.
|
|
403
|
+
*/
|
|
404
|
+
async function applyPublishedEffortLadders<TApi extends Api>(
|
|
405
|
+
models: readonly ModelSpec<TApi>[] | null,
|
|
406
|
+
providerId: string,
|
|
407
|
+
fetchImpl?: FetchImpl,
|
|
408
|
+
): Promise<readonly ModelSpec<TApi>[] | null> {
|
|
409
|
+
if (models === null) return null;
|
|
410
|
+
const guessed = new Set(
|
|
411
|
+
models
|
|
412
|
+
.filter(
|
|
413
|
+
model => model.reasoning === true && model.thinking === undefined && !hasModelScopedEffortLadder(model),
|
|
414
|
+
)
|
|
415
|
+
.map(model => model.id),
|
|
416
|
+
);
|
|
417
|
+
if (guessed.size === 0) return models;
|
|
418
|
+
// An unreachable catalog with no prior payload is not a discovery failure:
|
|
419
|
+
// the endpoint's own listing stands and the guess stays in place.
|
|
420
|
+
const ladders = await loadPublishedEffortLadders(fetchImpl).catch(() => EMPTY_PUBLISHED_EFFORT_LADDERS);
|
|
421
|
+
if (ladders.byHost.size === 0) return models;
|
|
422
|
+
const providerKeys = catalogProviderKeys(providerId);
|
|
423
|
+
return models.map(model => {
|
|
424
|
+
if (!guessed.has(model.id)) return model;
|
|
425
|
+
const efforts = lookupPublishedEffortLadder(ladders, providerKeys, model.id);
|
|
426
|
+
return efforts ? { ...model, thinking: { mode: "effort" as const, efforts } } : model;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
244
430
|
function mapAnthropicModelsDev(payload: unknown, baseUrl: string): ModelSpec<"anthropic-messages">[] {
|
|
245
431
|
if (!isRecord(payload)) {
|
|
246
432
|
return [];
|
|
@@ -609,19 +795,23 @@ function createOpenAICompatibleModelManagerOptions<TApi extends Api>(
|
|
|
609
795
|
dropCachedModelIdsOnStaticMismatch: options.dropCachedModelIdsOnStaticMismatch,
|
|
610
796
|
}),
|
|
611
797
|
...((!options.requireApiKey || apiKey) && {
|
|
612
|
-
fetchDynamicModels: () =>
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
798
|
+
fetchDynamicModels: async () =>
|
|
799
|
+
applyPublishedEffortLadders(
|
|
800
|
+
await fetchOpenAICompatibleModels({
|
|
801
|
+
api: options.api,
|
|
802
|
+
provider: options.providerId,
|
|
803
|
+
baseUrl,
|
|
804
|
+
apiKey,
|
|
805
|
+
...(options.headers && { headers: resolveSimpleProviderHeaders(options.headers) }),
|
|
806
|
+
...(filterModel && {
|
|
807
|
+
filterModel: (entry, model) => filterModel(entry, model, references),
|
|
808
|
+
}),
|
|
809
|
+
mapModel: (entry, defaults) => options.mapModel(entry, defaults, references.get(defaults.id)),
|
|
810
|
+
fetch: options.config?.fetch,
|
|
621
811
|
}),
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
812
|
+
options.providerId,
|
|
813
|
+
options.config?.fetch,
|
|
814
|
+
),
|
|
625
815
|
}),
|
|
626
816
|
};
|
|
627
817
|
}
|
package/src/types.ts
CHANGED
|
@@ -1196,6 +1196,16 @@ export interface Model<TApi extends Api = Api> {
|
|
|
1196
1196
|
gitlabDuoWorkflowRootNamespaceId?: string;
|
|
1197
1197
|
/** Cursor `max_mode` request flag returned by `GetUsableModels` for premium models that require max mode. */
|
|
1198
1198
|
cursorMaxMode?: boolean;
|
|
1199
|
+
/**
|
|
1200
|
+
* Per-wire-id `max_mode` markers for the members a collapsed Cursor row
|
|
1201
|
+
* routes to, recorded by `collapseVariants` from live `GetUsableModels`
|
|
1202
|
+
* rows. {@link cursorMaxMode} on a collapsed row is an OR across members,
|
|
1203
|
+
* so it cannot tell a `-low` route that needs no max mode from an Opus
|
|
1204
|
+
* `-fast` route that does; transports look the routed wire id up here
|
|
1205
|
+
* first. Absent on raw rows (their own `cursorMaxMode` already describes
|
|
1206
|
+
* their single wire id) and on bundled snapshots that predate discovery.
|
|
1207
|
+
*/
|
|
1208
|
+
cursorMaxModeRoutes?: Readonly<Record<string, boolean>>;
|
|
1199
1209
|
cost: ModelCost;
|
|
1200
1210
|
/** Premium Copilot requests charged per user-initiated request (defaults to 1). */
|
|
1201
1211
|
premiumMultiplier?: number;
|