omp-llm-role 1.1.0 → 1.2.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/README.md +30 -8
- package/docs/dev/architecture.md +25 -4
- package/docs/dev/ci.md +12 -1
- package/docs/dev/current-state.md +63 -26
- package/docs/dev/data-sources.md +33 -4
- package/docs/dev/explorer.md +22 -4
- package/docs/dev/quirks.md +32 -0
- package/docs/dev/releasing.md +31 -30
- package/docs/dev/scoring.md +98 -35
- package/docs/dev/testing.md +70 -23
- package/docs/dev/writes-and-state.md +56 -0
- package/docs/llm-role-rankings.md +127 -127
- package/package.json +82 -22
- package/src/availability.ts +87 -9
- package/src/cli/create-role.ts +1 -1
- package/src/engine.ts +157 -76
- package/src/explorer/explain.ts +24 -6
- package/src/explorer/server.ts +15 -2
- package/src/extension.ts +58 -15
- package/src/features.ts +9 -7
- package/src/provider-display.ts +67 -0
- package/src/settings.ts +8 -2
- package/src/updater.ts +91 -13
- package/web/app.js +24 -1
package/src/settings.ts
CHANGED
|
@@ -359,7 +359,7 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
359
359
|
}
|
|
360
360
|
schema[`${p}.preferOwnProvider`] = {
|
|
361
361
|
type: "boolean",
|
|
362
|
-
description: `
|
|
362
|
+
description: `Bind ${name} requests to each model's best provider route (soft preference; never drops a model)`,
|
|
363
363
|
default: false,
|
|
364
364
|
};
|
|
365
365
|
schema[`${p}.required`] = {
|
|
@@ -395,6 +395,12 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
|
|
|
395
395
|
default: def.filters?.maxPriceUsdPerM ?? 0,
|
|
396
396
|
min: 0,
|
|
397
397
|
};
|
|
398
|
+
schema[`${p}.filters.preferredMinThroughput`] = {
|
|
399
|
+
type: "number",
|
|
400
|
+
description: `Soft throughput floor for ${name} routes (tok/s; 0 = off) — a below-floor route sorts last but is never dropped`,
|
|
401
|
+
default: def.filters?.preferredMinThroughput ?? 0,
|
|
402
|
+
min: 0,
|
|
403
|
+
};
|
|
398
404
|
schema[`${p}.lambda`] = {
|
|
399
405
|
type: "number",
|
|
400
406
|
description: `Explicit λ ($ per quality point) override for ${name}`,
|
|
@@ -647,7 +653,7 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
|
|
|
647
653
|
if (rdef.filters.tools !== undefined && typeof rdef.filters.tools !== "boolean") {
|
|
648
654
|
errors.push(`role ${name}: filters.tools must be a boolean`);
|
|
649
655
|
}
|
|
650
|
-
for (const key of ["minContextTokens", "minOutputTokens", "maxPriceUsdPerM"] as const) {
|
|
656
|
+
for (const key of ["minContextTokens", "minOutputTokens", "maxPriceUsdPerM", "preferredMinThroughput"] as const) {
|
|
651
657
|
const v = rdef.filters[key];
|
|
652
658
|
if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) {
|
|
653
659
|
errors.push(`role ${name}: filters.${key} must be a number ≥ 0`);
|
package/src/updater.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { readFileSync, statSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
|
-
import { computeRankings, endpointFilterDrops, loadRankData, META_LEVELS, providerPinDrops, type Ranked, type RankData } from "./engine.ts";
|
|
19
|
-
import { currentRankingId, enrichThinkingLevels, fetchKeyAvailability, fetchKeyMeta, filterCatalog, probeModel, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyAvailability, type KeyMeta, type ProbeVerdict } from "./availability.ts";
|
|
18
|
+
import { autoPinApplies, bestRoute, computeRankings, endpointFilterDrops, loadRankData, META_LEVELS, providerPinDrops, rankedRoutes, type Ranked, type RankData, type RoleDef } from "./engine.ts";
|
|
19
|
+
import { currentRankingId, enrichThinkingLevels, fetchAllowedProviders, fetchKeyAvailability, fetchKeyMeta, filterCatalog, probeModel, providerAllowed, resolveVariant, THINKING_LEVELS, tierGate, type CatalogEntry, type KeyAvailability, type KeyMeta, type ProbeVerdict } from "./availability.ts";
|
|
20
20
|
import { ConfigEditError, parseConfig, patchConfig, writeConfigAtomic, type ConfigPatch } from "./config-edit.ts";
|
|
21
21
|
import { PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings, roleUniverse, type ResolvedSettings } from "./settings.ts";
|
|
22
22
|
import { discoverAgentPins } from "./agent-pins.ts";
|
|
@@ -68,6 +68,11 @@ export type Trigger = "session-start" | "manual";
|
|
|
68
68
|
/** A ranked model resolved to a concrete catalog id. */
|
|
69
69
|
type Candidate = { ranked: Ranked; catalogId: string };
|
|
70
70
|
|
|
71
|
+
/** Per managed role: its bare chain key, role suffix, provider pin, the pool it
|
|
72
|
+
* was chosen from, and the inputs the auto-pin chain builder needs (the expanded
|
|
73
|
+
* def for `rankedRoutes`, and whether the pin was auto-derived). */
|
|
74
|
+
type ChainPlan = { key: string; suffix: string | undefined; pin: string; pool: Candidate[]; chosenIdx: number; def: RoleDef; autoPin: boolean };
|
|
75
|
+
|
|
71
76
|
export type Deps = {
|
|
72
77
|
getToken(): Promise<string>;
|
|
73
78
|
getCatalog(): Promise<CatalogEntry[]>;
|
|
@@ -80,6 +85,13 @@ export type Deps = {
|
|
|
80
85
|
probeModel?(token: string, catalogId: string): Promise<ProbeVerdict>;
|
|
81
86
|
/** DI seam like `getKeyMeta`/`probeModel`; production defaults to `fetchKeyAvailability` (never throws). */
|
|
82
87
|
getKeyAvailability?(token: string): Promise<KeyAvailability>;
|
|
88
|
+
/**
|
|
89
|
+
* DI seam like `getKeyAvailability`; production defaults to
|
|
90
|
+
* `fetchAllowedProviders` (never throws). Returns the account's
|
|
91
|
+
* allowed-providers privacy whitelist, or null when there is none (or the
|
|
92
|
+
* harvest failed) — a null result leaves every route pool untouched.
|
|
93
|
+
*/
|
|
94
|
+
getAllowedProviders?(token: string, modelId: string): Promise<Set<string> | null>;
|
|
83
95
|
/**
|
|
84
96
|
* Host coupling (extension only): apply a concrete selector to the live
|
|
85
97
|
* session model. Receives the final `default` selector (with thinking
|
|
@@ -319,9 +331,38 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
319
331
|
`omp-llm-role: OpenRouter keyed catalog ${availability.reason === "no-filter" ? "not filtering" : "unavailable"} — enable OpenRouter → Settings → "Filter the model catalog for API keys" to skip probing key-blocked models; the probe walk is still in use`,
|
|
320
332
|
);
|
|
321
333
|
}
|
|
334
|
+
|
|
335
|
+
// Allowed-providers privacy whitelist (spec #65): harvested once per run
|
|
336
|
+
// with a valid catalog model id (the canary 404s, so it bills no tokens).
|
|
337
|
+
// The whitelist is invisible to /api/v1/key and the catalog endpoints, so a
|
|
338
|
+
// route on a provider outside it would be written into config.yml and 404
|
|
339
|
+
// at request time. Pruning every model's route pool to allowed providers
|
|
340
|
+
// before ranking makes the soft basis, the auto-pin and the fallback chains
|
|
341
|
+
// whitelist-aware through the single `m.routes` seam. A null result (no
|
|
342
|
+
// whitelist set / harvest failed) leaves the pools untouched.
|
|
343
|
+
const canaryModelId = eligible[0]?.id;
|
|
344
|
+
const allowed =
|
|
345
|
+
canaryModelId === undefined
|
|
346
|
+
? null
|
|
347
|
+
: await (deps.getAllowedProviders ? deps.getAllowedProviders(token, canaryModelId) : fetchAllowedProviders(token, canaryModelId));
|
|
348
|
+
if (allowed !== null) {
|
|
349
|
+
let prunedCount = 0;
|
|
350
|
+
const pruned = rank.models.map((m) => {
|
|
351
|
+
const routes = m.routes;
|
|
352
|
+
if (routes === undefined) return m;
|
|
353
|
+
const kept = routes.filter((r) => providerAllowed(allowed, r.providerSlug));
|
|
354
|
+
prunedCount += routes.length - kept.length;
|
|
355
|
+
return { ...m, routes: kept };
|
|
356
|
+
});
|
|
357
|
+
rank = { ...rank, models: pruned };
|
|
358
|
+
notes.push(
|
|
359
|
+
`omp-llm-role: OpenRouter allowed-providers whitelist active — ${allowed.size} providers; ${prunedCount} routes pruned`,
|
|
360
|
+
);
|
|
361
|
+
} else {
|
|
362
|
+
notes.push(`omp-llm-role: OpenRouter allowed-providers whitelist: no whitelist`);
|
|
363
|
+
}
|
|
322
364
|
const poolByRole: Record<string, Candidate[]> = {};
|
|
323
|
-
|
|
324
|
-
const chainPlanByRole: Record<string, { key: string; suffix: string | undefined; pin: string; pool: Candidate[]; chosenIdx: number }> = {};
|
|
365
|
+
const chainPlanByRole: Record<string, ChainPlan> = {};
|
|
325
366
|
/** Roles whose `providerPin` matched no route: left unchanged, their current chain preserved. */
|
|
326
367
|
const pinUnmatched: string[] = [];
|
|
327
368
|
const probe = deps.probeModel ?? probeModel;
|
|
@@ -436,7 +477,16 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
436
477
|
// thinking level (`openrouter/<id>@<slug>:<level>`) — omp's
|
|
437
478
|
// `splitUpstreamRouting` parses the trailing `@<slug>` and applies
|
|
438
479
|
// `compat.openRouterRouting = { only: [slug] }`.
|
|
439
|
-
|
|
480
|
+
//
|
|
481
|
+
// Auto-pin (spec #58): with the soft `preferOwnProvider` capability on and
|
|
482
|
+
// no manual pin, the chosen model's best route by the role's own value
|
|
483
|
+
// rides as that suffix, so the price the ranking assumed is the price the
|
|
484
|
+
// request pays. A manual `providerPin` is a hard gate and always wins; a
|
|
485
|
+
// model with no candidate route keeps default routing (bare selector).
|
|
486
|
+
const manualPin = def.providerPin;
|
|
487
|
+
const autoRoute = autoPinApplies(def) ? bestRoute(chosen.ranked.model, def) : null;
|
|
488
|
+
const pinSlug = manualPin ?? autoRoute?.providerSlug;
|
|
489
|
+
const pin = pinSlug !== undefined ? `@${pinSlug}` : "";
|
|
440
490
|
const chosenRow = rowById.get(chosen.catalogId);
|
|
441
491
|
// Append only a level the model's catalog thinking[] actually supports —
|
|
442
492
|
// omp clamps unsupported levels, so an unsupported pin would run at a
|
|
@@ -463,7 +513,7 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
463
513
|
// came from. Values are built after the loop, once every key claim is known.
|
|
464
514
|
if (settings.writeFallbackChains) {
|
|
465
515
|
const key = suffix !== undefined && finalSelector.endsWith(`:${suffix}`) ? finalSelector.slice(0, -(suffix.length + 1)) : finalSelector;
|
|
466
|
-
chainPlanByRole[role] = { key, suffix, pin, pool, chosenIdx: pool.indexOf(chosen) };
|
|
516
|
+
chainPlanByRole[role] = { key, suffix, pin, pool, chosenIdx: pool.indexOf(chosen), def, autoPin: autoRoute !== null };
|
|
467
517
|
}
|
|
468
518
|
}
|
|
469
519
|
|
|
@@ -492,13 +542,41 @@ export async function runUpdater(trigger: Trigger, deps: Deps, opts?: { force?:
|
|
|
492
542
|
const cheaper = after.filter((p) => p.ranked.priceEff <= chosenPrice);
|
|
493
543
|
const pricier = after.filter((p) => p.ranked.priceEff > chosenPrice);
|
|
494
544
|
const chainPool = [...cheaper, ...pricier].slice(0, settings.fallbackChainDepth);
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
545
|
+
// A chain value's thinking level is appended only when the target's
|
|
546
|
+
// catalog entry advertises support (or the level is a meta level).
|
|
547
|
+
const levelFor = (catalogId: string): string => {
|
|
548
|
+
const row = rowById.get(catalogId);
|
|
549
|
+
return suffix !== undefined && row !== undefined && (META_LEVELS[suffix] === true || row.thinking.includes(suffix)) ? `:${suffix}` : "";
|
|
550
|
+
};
|
|
551
|
+
const values: string[] = [];
|
|
552
|
+
if (plan.autoPin) {
|
|
553
|
+
// Auto-pin chain (spec #58, expanded by #69): every gate-passing route
|
|
554
|
+
// of the primary model (all but the pinned best), then every
|
|
555
|
+
// gate-passing route of each fallback model, in the role's
|
|
556
|
+
// value-for-money score order (`rankedRoutes`, spec #71) — every entry
|
|
557
|
+
// bound to its route's provider, so a down primary
|
|
558
|
+
// provider falls back to the same model on another provider rather
|
|
559
|
+
// than a different model. `rankedRoutes` already applies every gate
|
|
560
|
+
// (non-degraded, endpoint filters, usable billed price, the
|
|
561
|
+
// maxPriceUsdPerM cap, the allowed-providers whitelist), so the chain
|
|
562
|
+
// carries exactly the routes the role can use. The dedup below
|
|
563
|
+
// collapses two routes that share a provider slug (same chain value),
|
|
564
|
+
// so the chain length is (R_primary − 1) + Σ R_fallback over distinct
|
|
565
|
+
// gate-passing provider slugs.
|
|
566
|
+
const chosenCandidate = plan.pool[plan.chosenIdx];
|
|
567
|
+
for (const r of rankedRoutes(chosenCandidate.ranked.model, plan.def).slice(1)) {
|
|
568
|
+
values.push(`openrouter/${chosenCandidate.catalogId}@${r.providerSlug}${levelFor(chosenCandidate.catalogId)}`);
|
|
569
|
+
}
|
|
570
|
+
for (const p of chainPool) {
|
|
571
|
+
for (const r of rankedRoutes(p.ranked.model, plan.def)) {
|
|
572
|
+
values.push(`openrouter/${p.catalogId}@${r.providerSlug}${levelFor(p.catalogId)}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
// Manual pin (every value carries it) or no pin (bare values).
|
|
577
|
+
for (const p of chainPool) values.push(`openrouter/${p.catalogId}${plan.pin}${levelFor(p.catalogId)}`);
|
|
578
|
+
}
|
|
579
|
+
chainUpserts[plan.key] = [...new Set(values)].filter((v) => v !== plan.key);
|
|
502
580
|
referenced.add(plan.key);
|
|
503
581
|
}
|
|
504
582
|
// Locked roles keep their current chain: the plugin neither rewrites nor
|
package/web/app.js
CHANGED
|
@@ -154,6 +154,7 @@ const TIPS = {
|
|
|
154
154
|
focusProvenance: "Provenance: the benchmark's owner (the catalog row's dataset_org_id, falling back to dataset_slug) and the source's own per-entry org mix (the dominant lab and its share). A cross-check on the pool-derived composition axis: agree/disagree records whether the two concur. An annotation, never a gate — a vendor-populated source is warned but never drops a candidate; unknown means there is neither an owner nor a payload org mix.",
|
|
155
155
|
focusCrossSource: "Cross-source: the zeroeval per-entry price/context compared against the plugin's own price/context for the models carrying the metric (the median relative divergence, within a 0.5 tolerance). The speed figure is informational only (rps vs tok/s). An annotation, never a gate — it never feeds the 1/price² blend; unknown means no model joined or the payload carried none of the four fields.",
|
|
156
156
|
imageFilter: "Require image input (filters.image) — the gate that shrinks the vision role's eligible set.",
|
|
157
|
+
providerPin: "Hard provider pin: the OpenRouter provider slug this role's requests are routed to (a tiered slug like deepinfra/fp8 is accepted verbatim). Setting it opts the role out of the automatic best-route pin; empty = default OpenRouter routing.",
|
|
157
158
|
features: "Opt-in capability presets (issue #40). Each flag applies the plugin's recommended settings for that capability to this role; an explicit knob value always wins. Export writes the flag, not the expanded values, so a future change to the recommendation keeps reaching the role.",
|
|
158
159
|
featureState: "inherit = use the global flag (features.<id> in the lock file); on/off = a per-role override (roles.<role>.features.<id>) that beats the global in either direction.",
|
|
159
160
|
featureKnob: "An individual knob the capability's recommended bundle fills. Leave it at inherit to take the recommendation; set a value to override it (an explicit value always wins over the flag).",
|
|
@@ -507,6 +508,15 @@ function renderExplain() {
|
|
|
507
508
|
if (ex.keyReason !== "active") keyLine += " (reason: " + ex.keyReason + ")";
|
|
508
509
|
panel.append(el("p", { class: "sub key-line", "data-tip": TIPS.key, text: keyLine }));
|
|
509
510
|
|
|
511
|
+
// The pin basis: a manual pin (from the edited def) overrides the automatic
|
|
512
|
+
// best-route pin the updater would write. Read-only here.
|
|
513
|
+
const manualPin = state.defs[state.role]?.providerPin;
|
|
514
|
+
if (manualPin) {
|
|
515
|
+
panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "pin: " + manualPin + " (manual)" }));
|
|
516
|
+
} else if (ex.autoPin) {
|
|
517
|
+
panel.append(el("p", { class: "sub pin-line", "data-tip": TIPS.providerPin, text: "auto pin: " + ex.autoPin }));
|
|
518
|
+
}
|
|
519
|
+
|
|
510
520
|
panel.append(el("h3", { "data-tip": TIPS.composition, text: "Value composition" }));
|
|
511
521
|
const comp = el("table", { class: "comp" });
|
|
512
522
|
comp.append(
|
|
@@ -549,7 +559,7 @@ function renderExplain() {
|
|
|
549
559
|
panel.append(el("p", { class: "cost", "data-tip": TIPS.lambda, text: "λ = " + ex.role.lambda.toFixed(5) + " $/quality-point" + (derived ? " (derived = " + LAMBDA_DERIVATION + ")" : " (override)") }));
|
|
550
560
|
const thinkNote = ex.role.thinking === undefined ? "bare" : ":" + ex.role.thinking;
|
|
551
561
|
const cacheNote = ex.role.cacheHitRate > 0 ? " · cache " + ex.role.cacheHitRate : "";
|
|
552
|
-
const ownNote = ex.ownProvider ? " ·
|
|
562
|
+
const ownNote = ex.ownProvider ? " · best route" : "";
|
|
553
563
|
panel.append(el("p", { class: "cost", "data-tip": TIPS.costPenalty, text: "eff price $" + fmt(ex.cost.priceEff, 2) + "/M" + (ex.cost.priceEff !== ex.cost.billedPrice ? " (billed $" + fmt(ex.cost.billedPrice, 2) + " × " + thinkNote + ")" : " (" + thinkNote + ")") + cacheNote + ownNote + " · penalty = λ·price = " + fmt(ex.cost.penalty, 4) + " · value = q − penalty = " + fmt(ex.cost.value, 4) }));
|
|
554
564
|
|
|
555
565
|
panel.append(el("h3", { "data-tip": TIPS.whyNotHigher, text: "Why not higher" }));
|
|
@@ -1011,6 +1021,19 @@ function renderEditor() {
|
|
|
1011
1021
|
});
|
|
1012
1022
|
panel.append(el("label", { class: "check" }, [imgCb, el("span", { "data-tip": TIPS.imageFilter, text: "requires image input (filters.image)" })]));
|
|
1013
1023
|
|
|
1024
|
+
panel.append(el("h3", { "data-tip": TIPS.providerPin, text: "Provider pin" }));
|
|
1025
|
+
const pin = el("input", { type: "text", class: "desc", "data-tip": TIPS.providerPin, value: def.providerPin || "", placeholder: "provider slug, e.g. deepinfra/fp8" });
|
|
1026
|
+
pin.addEventListener("change", () => {
|
|
1027
|
+
// Empty clears the key (default routing); a tiered slug is kept verbatim.
|
|
1028
|
+
const value = pin.value.trim();
|
|
1029
|
+
if (value === "") delete def.providerPin;
|
|
1030
|
+
else def.providerPin = value;
|
|
1031
|
+
renderEditor();
|
|
1032
|
+
renderRoles();
|
|
1033
|
+
scheduleRecompute();
|
|
1034
|
+
});
|
|
1035
|
+
panel.append(el("div", { class: "row-controls" }, [pin]));
|
|
1036
|
+
|
|
1014
1037
|
renderFeatures(panel);
|
|
1015
1038
|
|
|
1016
1039
|
panel.append(el("h3", { "data-tip": TIPS.thinking, text: "Thinking level" }));
|