@xynogen/pix-models 0.1.8 → 0.1.10

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 CHANGED
@@ -4,7 +4,7 @@ Pi extension — enhanced `/models` picker with coding score/rank.
4
4
 
5
5
  ## What it does
6
6
 
7
- Registers a `/models` slash command that replaces Pi's built-in `/model` selector with a richer TUI picker. Each row shows model name, provider, context window, cost per million tokens, and a coding-focused score/rank when available. The list is sorted by coding score (best first), then alphabetically for unscored models. Fuzzy search filters the list as you type. Selecting a model switches the active model for the session. Model metadata is sourced from `~/.cache/pi/` via `pix-data`; the coding score/rank is computed locally from the modelgrep catalog (best = #1). Requires `@xynogen/pix-data` as a dependency.
7
+ Registers a `/models` slash command that replaces Pi's built-in `/model` selector with a richer TUI picker. Each row shows the model id, context window, per-million-token cost, and a coding-focused score/rank (with star bar) when available. The list is sorted by coding score (best first), then alphabetically for unscored models. Fuzzy search filters the list as you type. Selecting a model switches the active model for the session. Model metadata is sourced from `~/.cache/pi/` via `pix-data`; the coding score/rank is computed locally from the modelgrep catalog (best = #1). Requires `@xynogen/pix-data` as a dependency.
8
8
 
9
9
  ## Install
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-models",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Pi extension — enhanced /models picker with BenchLM ranks",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -92,4 +92,36 @@ describe("sortModels", () => {
92
92
  sortModels(models);
93
93
  expect(models).toEqual(original);
94
94
  });
95
+
96
+ it("puts null score models after all scored models regardless of source order", () => {
97
+ const shuffled = [
98
+ { provider: "a", id: "m1", name: "Zeta", score: null },
99
+ { provider: "a", id: "m2", name: "Beta", score: 80 },
100
+ { provider: "a", id: "m3", name: "Alpha", score: 95 },
101
+ { provider: "a", id: "m4", name: "Gamma", score: null },
102
+ { provider: "a", id: "m5", name: "Delta", score: 60 },
103
+ ];
104
+ const sorted = sortModels(shuffled);
105
+ const lastTwo = sorted.slice(-2).map((m) => m.name);
106
+ expect(lastTwo).toEqual(["Gamma", "Zeta"]); // nulls last, stable within
107
+ });
108
+
109
+ it("sinks tier 2 (off-catalog) below tier 1 (benched-but-unscored)", () => {
110
+ // Mirrors the openrouter/owl-alpha bug: a model with no bench entry at
111
+ // all must not interleave with benched models that happen to have a
112
+ // null score.
113
+ const mixed = [
114
+ { provider: "a", id: "m1", name: "Alpha", score: 95, tier: 0 },
115
+ { provider: "a", id: "m2", name: "Beta", score: null, tier: 1 },
116
+ { provider: "a", id: "m3", name: "Gamma", score: undefined, tier: 2 },
117
+ { provider: "a", id: "m4", name: "Delta", score: 60, tier: 0 },
118
+ ];
119
+ const sorted = sortModels(mixed);
120
+ expect(sorted.map((m) => m.name)).toEqual([
121
+ "Alpha",
122
+ "Delta",
123
+ "Beta",
124
+ "Gamma",
125
+ ]);
126
+ });
95
127
  });
package/src/models.ts CHANGED
@@ -69,13 +69,28 @@ export type SortableModel = {
69
69
  id: string;
70
70
  name?: string;
71
71
  score?: number | null;
72
+ /**
73
+ * Sort tier. Lower = earlier. Default 0.
74
+ * 0 = scored (sort by `score` desc)
75
+ * 1 = benched but unscored (sort by name, between scored and off-catalog)
76
+ * 2 = off-catalog (no bench entry at all → always last)
77
+ * The off-catalog tier exists for models like `openrouter/owl-alpha` that
78
+ * are present in the router but missing from every benchmark source —
79
+ * they should never interleave with the benched-but-unscored tail.
80
+ */
81
+ tier?: number;
72
82
  };
73
83
 
74
84
  export function sortModels<T extends SortableModel>(models: T[]): T[] {
75
85
  return [...models].sort((a, b) => {
76
- const sa = a.score ?? -1;
77
- const sb = b.score ?? -1;
78
- if (sa !== sb) return sb - sa;
86
+ const ta = a.tier ?? 0;
87
+ const tb = b.tier ?? 0;
88
+ if (ta !== tb) return ta - tb;
89
+ if (ta === 0) {
90
+ const sa = a.score ?? -1;
91
+ const sb = b.score ?? -1;
92
+ if (sa !== sb) return sb - sa;
93
+ }
79
94
  return (a.name ?? a.id).localeCompare(b.name ?? b.id);
80
95
  });
81
96
  }
@@ -119,19 +134,35 @@ async function showEnhancedPicker(
119
134
  bench: ReturnType<typeof lookupBenchmark>;
120
135
  // Rank among the user's *available* models, not the global catalog.
121
136
  localRank: number | null;
137
+ // Sort tier: 0 scored, 1 benched-but-unscored, 2 off-catalog.
138
+ tier: 0 | 1 | 2;
122
139
  };
123
- const rows: Row[] = available.map((m) => ({
124
- m,
125
- dev: lookupModelsDev(m.provider, m.id),
126
- bench: lookupBenchmark(m.id),
127
- localRank: null,
128
- }));
129
-
130
- // Sort: by score desc (highest first), unscored last alphabetical
140
+ const rows: Row[] = available.map((m) => {
141
+ const bench = lookupBenchmark(m.id);
142
+ const tier = !bench
143
+ ? 2 // off-catalog → absolute bottom (no rank)
144
+ : bench.overallScore == null
145
+ ? 1 // benched, unscored → middle
146
+ : 0; // scored → top
147
+ return {
148
+ m,
149
+ dev: lookupModelsDev(m.provider, m.id),
150
+ bench,
151
+ localRank: null,
152
+ tier,
153
+ };
154
+ });
155
+
156
+ // Mirror sortModels() — score-desc within tier 0, name-asc otherwise.
131
157
  rows.sort((a, b) => {
132
- const sa = a.bench?.overallScore ?? -1;
133
- const sb = b.bench?.overallScore ?? -1;
134
- if (sa !== sb) return sb - sa;
158
+ const ta = a.tier;
159
+ const tb = b.tier;
160
+ if (ta !== tb) return ta - tb;
161
+ if (ta === 0) {
162
+ const sa = a.bench?.overallScore ?? -1;
163
+ const sb = b.bench?.overallScore ?? -1;
164
+ if (sa !== sb) return sb - sa;
165
+ }
135
166
  return (a.m.name ?? a.m.id).localeCompare(b.m.name ?? b.m.id);
136
167
  });
137
168
 
@@ -171,7 +202,10 @@ async function showEnhancedPicker(
171
202
  const isCurrent =
172
203
  current && m.provider === current.provider && m.id === current.id;
173
204
 
174
- // Label: marker + muted '#' + bright rank + accent-colored model name
205
+ // Label: marker + rank cell + accent-colored model name.
206
+ // Ranked models show muted '#' + colored rank. Unranked (no
207
+ // modelgrep entry) show a muted em-dash sized to the rank
208
+ // column, so the model name aligns across rows.
175
209
  const marker = isCurrent ? theme.fg(accent, "▶") : " ";
176
210
  let rankPrefix: string;
177
211
  if (localRank) {
@@ -181,7 +215,9 @@ async function showEnhancedPicker(
181
215
  const rankColor = benchScoreColor(bench?.overallScore);
182
216
  rankPrefix = mute("#") + theme.fg(rankColor, rankStr);
183
217
  } else {
184
- rankPrefix = " ".repeat(maxRankWidth + 1);
218
+ // Width = "#" + maxRankWidth chars (e.g. "# " or "#——" for 2-digit ranks).
219
+ const dash = "—".padEnd(maxRankWidth, " ");
220
+ rankPrefix = mute("#") + mute(dash);
185
221
  }
186
222
  // Display model id only; m.provider is routing provider, not part of id.
187
223
  const idColored = theme.fg(accent, m.id);