@rizal72/oc-models-pro 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/tags.ts +127 -0
  3. package/tui.tsx +9 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizal72/oc-models-pro",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "OpenCode v2 model picker with prices, context, intelligence, Go credit and the cost of a request",
6
6
  "repository": {
package/tags.ts ADDED
@@ -0,0 +1,127 @@
1
+ // Two letters per provider, so a row says where its model comes from without spending a column.
2
+ //
3
+ // The host's own two providers keep a tag chosen by hand, and those two are reserved: they exist in
4
+ // every installation, so nobody else may take their letters. Everything else is derived from the
5
+ // provider's display name — the only place where a merged name still keeps its seams ("DeepSeek" is
6
+ // Deep·Seek; the id `deepseek` no longer says so).
7
+ //
8
+ // Runnable standalone for its self-check: node plugins/oc-models-pro/tags.ts --self-check
9
+
10
+ /** Tags that belong to the host's own providers, present or not. */
11
+ const RESERVED: Record<string, string> = {
12
+ opencode: "OC",
13
+ "opencode-go": "GO",
14
+ }
15
+
16
+ /**
17
+ * The words of a display name: `OpenCode Go` is two, `OpenRouter` is two as well — but a camelCase
18
+ * split only when the name has no separator, so a brand like `GitHub Copilot` keeps `GitHub` whole.
19
+ */
20
+ export function words(name: string): string[] {
21
+ const split = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
22
+ if (split.length > 1) return split
23
+ const camel = (split[0] ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(" ").filter(Boolean)
24
+ return camel.length > 1 ? camel : split
25
+ }
26
+
27
+ /** What the name asks for: the initials of its words, or two letters of a single one. */
28
+ function preferred(name: string): string {
29
+ const parts = words(name)
30
+ const letters = parts.length > 1 ? parts.map((part) => part[0]).join("") : (parts[0] ?? "?")
31
+ return letters.toUpperCase().padEnd(2, "?").slice(0, 2)
32
+ }
33
+
34
+ /**
35
+ * A tag per provider id, unique and order-independent. Derived in alphabetical order of id, so the
36
+ * same catalogue always yields the same tags whatever order the providers arrive in.
37
+ */
38
+ export function tagsFor(providers: Array<{ id: string; name: string }>): Map<string, string> {
39
+ const taken = new Set(Object.values(RESERVED))
40
+ const tags = new Map<string, string>()
41
+ const order = [...providers].sort((a, b) => a.id.localeCompare(b.id))
42
+
43
+ for (const provider of order) {
44
+ const fixed = RESERVED[provider.id]
45
+ if (fixed) tags.set(provider.id, fixed)
46
+ }
47
+
48
+ for (const provider of order) {
49
+ if (tags.has(provider.id)) continue
50
+ const letters = (words(provider.name).join("") || "?").toUpperCase()
51
+ const first = letters[0] ?? "?"
52
+ let tag = preferred(provider.name)
53
+ if (taken.has(tag)) {
54
+ // Walk the name for a free pair, so `Google` steps aside from the reserved GO.
55
+ tag = ""
56
+ for (const letter of letters.slice(1)) {
57
+ // A doubled letter reads as a typo ("GG"): prefer anything else.
58
+ if (letter === first) continue
59
+ const candidate = first + letter
60
+ if (!taken.has(candidate)) {
61
+ tag = candidate
62
+ break
63
+ }
64
+ }
65
+ }
66
+ if (taken.has(tag) || !tag) {
67
+ tag = ""
68
+ for (let digit = 2; digit <= 9; digit++) {
69
+ if (!taken.has(first + digit)) {
70
+ tag = first + digit
71
+ break
72
+ }
73
+ }
74
+ }
75
+ if (!tag) tag = "?" + (tags.size % 10)
76
+ taken.add(tag)
77
+ tags.set(provider.id, tag)
78
+ }
79
+ return tags
80
+ }
81
+
82
+ // Self-check: node plugins/oc-models-pro/tags.ts --self-check
83
+ if (process.argv[2] === "--self-check") {
84
+ const fail = (message: string): never => {
85
+ throw new Error("self-check: " + message)
86
+ }
87
+ const at = (providers: Array<{ id: string; name: string }>, id: string): string =>
88
+ tagsFor(providers).get(id) ?? fail("no tag for " + id)
89
+
90
+ if (at([{ id: "opencode", name: "anything" }], "opencode") !== "OC") fail("opencode must stay OC")
91
+ if (at([{ id: "opencode-go", name: "whatever" }], "opencode-go") !== "GO") fail("opencode-go must stay GO")
92
+ if (at([{ id: "openrouter", name: "OpenRouter" }], "openrouter") !== "OR") fail("OpenRouter must be OR")
93
+ if (at([{ id: "deepseek", name: "DeepSeek" }], "deepseek") !== "DS") fail("DeepSeek must be DS")
94
+ if (at([{ id: "github-copilot", name: "GitHub Copilot" }], "github-copilot") !== "GC") {
95
+ fail("GitHub Copilot must be GC")
96
+ }
97
+ // Reserved letters are reserved even when the host's provider is not in the list.
98
+ if (at([{ id: "google", name: "Google" }], "google") !== "GL") fail("Google must step aside from GO")
99
+
100
+ const twins = tagsFor([
101
+ { id: "acme-one", name: "Acme" },
102
+ { id: "acme-two", name: "Acme" },
103
+ ])
104
+ if (new Set(twins.values()).size !== 2) fail("two providers share a tag")
105
+
106
+ const forward = tagsFor([
107
+ { id: "beta", name: "Beta" },
108
+ { id: "alpha", name: "Alpha" },
109
+ { id: "google", name: "Google" },
110
+ ])
111
+ const backward = tagsFor([
112
+ { id: "google", name: "Google" },
113
+ { id: "alpha", name: "Alpha" },
114
+ { id: "beta", name: "Beta" },
115
+ ])
116
+ if ([...forward].map(String).sort().join() !== [...backward].map(String).sort().join()) {
117
+ fail("the input order changes the tags")
118
+ }
119
+
120
+ console.log(
121
+ "self-check ok: " +
122
+ [...forward].map(([id, tag]) => id + "=" + tag).join(" ") +
123
+ " (" +
124
+ words("OpenCode Go").join("|") +
125
+ ")",
126
+ )
127
+ }
package/tui.tsx CHANGED
@@ -15,12 +15,15 @@ import { join } from "node:path"
15
15
  import { benchFor, benchIndex, type Bench } from "./bench.ts"
16
16
  import { credits, goPlan, key, type Credits } from "./multipliers.ts"
17
17
  import { LEGEND_MIN, fit, hintLines, rowWidth, type Fit } from "./fit.ts"
18
+ import { tagsFor } from "./tags.ts"
18
19
 
19
20
  interface Row {
20
21
  name: string
21
22
  providerID: string
22
23
  modelID: string
23
24
  provider: string
25
+ /** Two letters from `tags.ts`: the provider's, unique in the catalogue. */
26
+ tag: string
24
27
  /** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
25
28
  input?: number
26
29
  output?: number
@@ -179,17 +182,9 @@ function cell(text: string, width: number, align: "start" | "end" = "end"): stri
179
182
  return align === "end" ? cut.padStart(width) : cut.padEnd(width)
180
183
  }
181
184
 
182
- /** Two letters, so a row carries its provider without a column that eats the width. */
183
- const TAGS: Record<string, string> = {
184
- opencode: "OC",
185
- "opencode-go": "GO",
186
- openrouter: "OR",
187
- deepseek: "DS",
188
- }
189
-
190
- function tag(providerID: string): string {
191
- // Always four characters, even for a one-letter provider id, or the name column shifts left.
192
- return ((TAGS[providerID] ?? providerID.slice(0, 2)).toUpperCase().padEnd(2) + " ").slice(0, 4)
185
+ /** Two letters and the gap after them: always four characters, or the name column shifts left. */
186
+ function padTag(letters: string): string {
187
+ return (letters.padEnd(2) + " ").slice(0, 4)
193
188
  }
194
189
 
195
190
  /**
@@ -235,7 +230,7 @@ function modelRow(row: Row, mine: boolean, favourite: boolean, f: Fit): string {
235
230
  const notes = [row.peak ? "peak*" + row.peak : "", row.promo ? "+" + row.promo + "x" : ""].filter(Boolean)
236
231
  return [
237
232
  (mine ? "●" : " ") + (favourite ? "*" : " "),
238
- tag(row.providerID),
233
+ padTag(row.tag),
239
234
  label(row.name, f.name),
240
235
  price(row.input, 6),
241
236
  price(row.output, 6),
@@ -355,6 +350,7 @@ export default Plugin.define({
355
350
  const providers = new Map<string, string>(
356
351
  listProviders().map((provider: any) => [provider.id, provider.name]),
357
352
  )
353
+ const tags = tagsFor([...providers].map(([id, name]) => ({ id, name })))
358
354
  const result: Row[] = []
359
355
  for (const model of listModels()) {
360
356
  const cost = (model.cost ?? []).find((entry: any) => !entry.tier) ?? model.cost?.[0]
@@ -367,6 +363,7 @@ export default Plugin.define({
367
363
  // The host names an account-bound provider `<account> / <Provider>`, which in a group
368
364
  // header reads as if the account were the thing: provider first, account in brackets.
369
365
  provider: (providers.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
366
+ tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
370
367
  // A missing figure stays missing: a 0 here would put an unknown model at the top of the
371
368
  // cheapest list and claim a context window it never declared.
372
369
  input: typeof cost?.input === "number" ? cost.input : undefined,