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/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: `Price ${name} models on their own lab's route where one exists (soft preference; never drops a model)`,
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
- /** Per managed role: its bare chain key, role suffix, provider pin, and the pool it was chosen from. */
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
- const pin = def.providerPin !== undefined ? `@${def.providerPin}` : "";
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
- chainUpserts[plan.key] = [
496
- ...new Set(chainPool.map((p) => {
497
- const row = rowById.get(p.catalogId);
498
- const level = suffix !== undefined && row !== undefined && (META_LEVELS[suffix] === true || row.thinking.includes(suffix)) ? `:${suffix}` : "";
499
- return `openrouter/${p.catalogId}${plan.pin}${level}`;
500
- })),
501
- ].filter((v) => v !== plan.key);
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 ? " · own lab" : "";
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" }));