|
| 1 | +/** |
| 2 | + * Catalog classification and ordering. |
| 3 | + * |
| 4 | + * The OpenCode model picker sorts the entries of a provider with this comparator |
| 5 | + * (verified in the v2.0.32 TUI): |
| 6 | + * |
| 7 | + * 1. provider grouping (opencode-go and opencode last, then provider name) |
| 8 | + * 2. entries whose `footer` is "Free" come first |
| 9 | + * 3. `time.released` descending |
| 10 | + * 4. `title` ascending |
| 11 | + * |
| 12 | + * "Free" is granted by the picker when `cost` is a non-empty array whose entries |
| 13 | + * all have `input === 0`. `time.released` is the release timestamp in epoch ms. |
| 14 | + * |
| 15 | + * Both are writable from a plugin through the model editor, so the whole order |
| 16 | + * of the Command Code catalog can be driven from here: |
| 17 | + * |
| 18 | + * - free / promo models get `cost` with zeroed input, which also renders the |
| 19 | + * "Free" badge and reports their real cost as zero |
| 20 | + * - every model gets a release timestamp that encodes (family rank, version |
| 21 | + * recency), so families stay grouped and the newest model of each family |
| 22 | + * comes first |
| 23 | + */ |
| 24 | + |
| 25 | +export interface CatalogModel { |
| 26 | + id: string; |
| 27 | + name?: string; |
| 28 | + supported_endpoints?: string[]; |
| 29 | +} |
| 30 | + |
| 31 | +/** Ordered list of model families. The position in this list is the group order. */ |
| 32 | +const FAMILY_ORDER: Array<{ match: RegExp; family: string }> = [ |
| 33 | + { match: /^claude/i, family: "claude" }, |
| 34 | + { match: /^gpt/i, family: "gpt" }, |
| 35 | + { match: /^gemini/i, family: "gemini" }, |
| 36 | + { match: /^deepseek/i, family: "deepseek" }, |
| 37 | + { match: /kimi/i, family: "kimi" }, |
| 38 | + { match: /^(glm|zai|z-ai)/i, family: "glm" }, |
| 39 | + { match: /^minimax/i, family: "minimax" }, |
| 40 | + { match: /^qwen/i, family: "qwen" }, |
| 41 | + { match: /^grok/i, family: "grok" }, |
| 42 | + { match: /mistral/i, family: "mistral" }, |
| 43 | + { match: /nemotron/i, family: "nemotron" }, |
| 44 | + { match: /mimo/i, family: "mimo" }, |
| 45 | + { match: /muse/i, family: "muse" }, |
| 46 | + { match: /inkling/i, family: "inkling" }, |
| 47 | + { match: /^step/i, family: "step" }, |
| 48 | + { match: /^(hy|hunyuan)/i, family: "hunyuan" }, |
| 49 | + { match: /ling/i, family: "ling" }, |
| 50 | + { match: /longcat/i, family: "longcat" }, |
| 51 | + { match: /laguna/i, family: "laguna" }, |
| 52 | + { match: /fugu/i, family: "fugu" }, |
| 53 | + { match: /beam/i, family: "beam" }, |
| 54 | + { match: /cat-jam/i, family: "cat-jam" }, |
| 55 | + { match: /glyph/i, family: "glyph" }, |
| 56 | + { match: /fable/i, family: "fable" }, |
| 57 | +]; |
| 58 | + |
| 59 | +const FAMILY_RANK = new Map(FAMILY_ORDER.map((f, i) => [f.family, i])); |
| 60 | +const UNKNOWN_FAMILY_RANK = FAMILY_ORDER.length; |
| 61 | + |
| 62 | +/** Strips the vendor prefix: "deepseek/deepseek-v4.1-flash" -> "deepseek-v4.1-flash". */ |
| 63 | +export function modelName(id: string): string { |
| 64 | + const idx = id.lastIndexOf("/"); |
| 65 | + return idx === -1 ? id : id.slice(idx + 1); |
| 66 | +} |
| 67 | + |
| 68 | +/** Detects the family of a model id. Unknown families sort last, before the promo tier. */ |
| 69 | +export function detectFamily(id: string): string { |
| 70 | + const name = modelName(id); |
| 71 | + for (const { match, family } of FAMILY_ORDER) { |
| 72 | + if (match.test(name)) return family; |
| 73 | + } |
| 74 | + const vendor = id.includes("/") ? id.split("/")[0]!.toLowerCase() : ""; |
| 75 | + return vendor || "other"; |
| 76 | +} |
| 77 | + |
| 78 | +/** Numeric rank of a family inside the group order. */ |
| 79 | +export function familyRank(id: string): number { |
| 80 | + const family = detectFamily(id); |
| 81 | + return FAMILY_RANK.get(family) ?? UNKNOWN_FAMILY_RANK; |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Extracts the version tuple of a model id, e.g. "gpt-6.1-sol" -> [6, 1], |
| 86 | + * "deepseek-v4.1-flash" -> [4, 1], "cat-jam" -> []. |
| 87 | + */ |
| 88 | +export function parseVersion(id: string): number[] { |
| 89 | + const name = modelName(id); |
| 90 | + // Drop the family token so digits inside the family name are not read as a version |
| 91 | + // ("qwen3.8-max" must yield [3, 8] and not be confused with the family prefix). |
| 92 | + const withoutFamily = name.replace( |
| 93 | + new RegExp(`^(${FAMILY_ORDER.map((f) => f.family).join("|")})[-_]?`, "i"), |
| 94 | + "" |
| 95 | + ); |
| 96 | + const match = withoutFamily.match(/(\d+)(?:\.(\d+))?/); |
| 97 | + if (!match) return []; |
| 98 | + const major = Number(match[1]); |
| 99 | + const minor = match[2] !== undefined ? Number(match[2]) : 0; |
| 100 | + return [major, minor]; |
| 101 | +} |
| 102 | + |
| 103 | +/** Compares two version tuples numerically. Missing components count as zero. */ |
| 104 | +export function compareVersions(a: number[], b: number[]): number { |
| 105 | + const len = Math.max(a.length, b.length, 2); |
| 106 | + for (let i = 0; i < len; i++) { |
| 107 | + const diff = (a[i] ?? 0) - (b[i] ?? 0); |
| 108 | + if (diff !== 0) return diff; |
| 109 | + } |
| 110 | + return 0; |
| 111 | +} |
| 112 | + |
| 113 | +/** Sorts a version tuple into a single comparable score. */ |
| 114 | +export function versionScore(version: number[]): number { |
| 115 | + const [major = 0, minor = 0] = version; |
| 116 | + return major * 1000 + minor; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Encodes the group order into a release timestamp. |
| 121 | + * |
| 122 | + * The picker only knows one descending number, so the family rank has to dominate |
| 123 | + * and the version only breaks ties inside a family. The result is a date inside |
| 124 | + * 2026 that grows with the group and with the version, which keeps the value |
| 125 | + * plausible if the UI ever renders it. |
| 126 | + */ |
| 127 | +const BASE_DATE = Date.parse("2026-01-01"); |
| 128 | +const GROUP_STRIDE_MS = 7 * 24 * 60 * 60 * 1000; |
| 129 | + |
| 130 | +export function buildReleaseDate(id: string): number { |
| 131 | + const rank = familyRank(id); |
| 132 | + const invertedRank = UNKNOWN_FAMILY_RANK - rank; |
| 133 | + const score = versionScore(parseVersion(id)); |
| 134 | + return BASE_DATE + invertedRank * GROUP_STRIDE_MS + score * 1000; |
| 135 | +} |
| 136 | + |
| 137 | +/** True for models currently offered on a free or promo tier. */ |
| 138 | +export function isFreeModel(model: { id?: string; name?: string }): boolean { |
| 139 | + const id = (model?.id ?? "").toLowerCase(); |
| 140 | + const name = (model?.name ?? "").toLowerCase(); |
| 141 | + if (/(:free|-free|_free|\.free)$/.test(id) || id.includes(":free")) return true; |
| 142 | + return /\b(free|promo|promotional|trial)\b/.test(name); |
| 143 | +} |
| 144 | + |
| 145 | +/** Cost entry that makes the picker render the "Free" badge for a promo model. */ |
| 146 | +export function freeCostEntry() { |
| 147 | + return { |
| 148 | + input: 0, |
| 149 | + output: 0, |
| 150 | + cache: { |
| 151 | + read: 0, |
| 152 | + write: 0, |
| 153 | + }, |
| 154 | + }; |
| 155 | +} |
0 commit comments