|
| 1 | +import type { CatalogManifest, CatalogStatus, CostSources } from "./manifest.js" |
| 2 | + |
| 3 | +export type PricedModel = { |
| 4 | + id: string |
| 5 | + name: string |
| 6 | + cost: { input: number; output: number } |
| 7 | +} |
| 8 | + |
| 9 | +export type CatalogReleaseInput = { |
| 10 | + pluginVersion: string |
| 11 | + commandCodeVersion: string |
| 12 | + modelCount: number |
| 13 | + reasoningModelCount: number |
| 14 | + status: CatalogStatus |
| 15 | + costSources: CostSources |
| 16 | + models: PricedModel[] |
| 17 | + fallbackCosts: Record<string, { input: number; output: number }> |
| 18 | +} |
| 19 | + |
| 20 | +const DEFAULT_COST = { input: 0.5, output: 2 } |
| 21 | + |
| 22 | +export function partitionPrices( |
| 23 | + models: PricedModel[], |
| 24 | + fallbackCosts: Record<string, { input: number; output: number }>, |
| 25 | +): { official: PricedModel[]; fallback: PricedModel[]; unmatched: PricedModel[] } { |
| 26 | + const official: PricedModel[] = [] |
| 27 | + const fallback: PricedModel[] = [] |
| 28 | + const unmatched: PricedModel[] = [] |
| 29 | + for (const model of models) { |
| 30 | + const known = fallbackCosts[model.id] |
| 31 | + if (known && known.input === model.cost.input && known.output === model.cost.output) { |
| 32 | + fallback.push(model) |
| 33 | + } else if (model.cost.input === DEFAULT_COST.input && model.cost.output === DEFAULT_COST.output) { |
| 34 | + unmatched.push(model) |
| 35 | + } else { |
| 36 | + official.push(model) |
| 37 | + } |
| 38 | + } |
| 39 | + return { official, fallback, unmatched } |
| 40 | +} |
| 41 | + |
| 42 | +function n(count: number, one: string, many: string): string { |
| 43 | + return `${count} ${count === 1 ? one : many}` |
| 44 | +} |
| 45 | + |
| 46 | +function headline(status: CatalogStatus): string { |
| 47 | + if (status === "healthy") { |
| 48 | + return "**Catalog is complete.** All listed prices come from Command Code or official docs." |
| 49 | + } |
| 50 | + if (status === "broken") { |
| 51 | + return "**Do not use this catalog.** Model extraction failed; OpenCode would not get a current model list." |
| 52 | + } |
| 53 | + return "**Safe to use.** Every model is listed. Some prices are estimates — expand the sections below to review them." |
| 54 | +} |
| 55 | + |
| 56 | +function priceSummary(sources: CostSources): string { |
| 57 | + const parts: string[] = [] |
| 58 | + if (sources.cli > 0) parts.push(`${sources.cli} CLI`) |
| 59 | + if (sources.officialDocs > 0) parts.push(`${sources.officialDocs} official docs`) |
| 60 | + if (sources.thirdParty > 0) parts.push(`${sources.thirdParty} third-party`) |
| 61 | + if (sources.fallback > 0) parts.push(`${sources.fallback} known fallback${sources.fallback === 1 ? "" : "s"}`) |
| 62 | + if (sources.unmatched > 0) parts.push(`${sources.unmatched} no listed price`) |
| 63 | + return parts.join(" · ") || "none" |
| 64 | +} |
| 65 | + |
| 66 | +function money(n: number): string { |
| 67 | + return `$${n}` |
| 68 | +} |
| 69 | + |
| 70 | +function modelTable(models: PricedModel[]): string { |
| 71 | + const rows = models |
| 72 | + .map((m) => `| ${m.name} | \`${m.id}\` | ${money(m.cost.input)} / ${money(m.cost.output)} |`) |
| 73 | + .join("\n") |
| 74 | + return `| Model | Id | In / out per 1M |\n| --- | --- | --- |\n${rows}` |
| 75 | +} |
| 76 | + |
| 77 | +function details(summary: string, body: string): string { |
| 78 | + return `<details>\n<summary>${summary}</summary>\n\n${body}\n\n</details>` |
| 79 | +} |
| 80 | + |
| 81 | +export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { |
| 82 | + const { fallback, unmatched } = partitionPrices(input.models, input.fallbackCosts) |
| 83 | + const sections = [ |
| 84 | + headline(input.status), |
| 85 | + "", |
| 86 | + "| | |", |
| 87 | + "| --- | --- |", |
| 88 | + `| Plugin | ${input.pluginVersion} |`, |
| 89 | + `| Command Code | ${input.commandCodeVersion} |`, |
| 90 | + `| Models | ${input.modelCount} (${input.reasoningModelCount} with reasoning) |`, |
| 91 | + `| Prices | ${priceSummary(input.costSources)} |`, |
| 92 | + ] |
| 93 | + |
| 94 | + if (fallback.length > 0) { |
| 95 | + sections.push( |
| 96 | + "", |
| 97 | + details( |
| 98 | + n(fallback.length, "model with a known fallback price", "models with known fallback prices"), |
| 99 | + modelTable(fallback), |
| 100 | + ), |
| 101 | + ) |
| 102 | + } |
| 103 | + if (unmatched.length > 0) { |
| 104 | + sections.push( |
| 105 | + "", |
| 106 | + details( |
| 107 | + n(unmatched.length, "model with no official price ($0.50 / $2)", "models with no official price ($0.50 / $2)"), |
| 108 | + modelTable(unmatched), |
| 109 | + ), |
| 110 | + ) |
| 111 | + } |
| 112 | + |
| 113 | + sections.push( |
| 114 | + "", |
| 115 | + details( |
| 116 | + "Machine-readable catalog", |
| 117 | + "```json\n" + |
| 118 | + JSON.stringify( |
| 119 | + { |
| 120 | + status: input.status, |
| 121 | + pluginVersion: input.pluginVersion, |
| 122 | + commandCodeVersion: input.commandCodeVersion, |
| 123 | + modelCount: input.modelCount, |
| 124 | + reasoningModelCount: input.reasoningModelCount, |
| 125 | + costSources: input.costSources, |
| 126 | + }, |
| 127 | + null, |
| 128 | + 2, |
| 129 | + ) + |
| 130 | + "\n```", |
| 131 | + ), |
| 132 | + ) |
| 133 | + |
| 134 | + return sections.join("\n") + "\n" |
| 135 | +} |
| 136 | + |
| 137 | +export function catalogReleaseNotesFromFiles(input: { |
| 138 | + manifest: CatalogManifest |
| 139 | + models: PricedModel[] |
| 140 | + fallbackCosts: Record<string, { input: number; output: number }> |
| 141 | +}): string { |
| 142 | + return renderCatalogReleaseNotes({ |
| 143 | + pluginVersion: input.manifest.pluginVersion, |
| 144 | + commandCodeVersion: input.manifest.commandCodeVersion, |
| 145 | + modelCount: input.manifest.modelCount, |
| 146 | + reasoningModelCount: input.manifest.reasoningModelCount, |
| 147 | + status: input.manifest.status, |
| 148 | + costSources: input.manifest.costSources, |
| 149 | + models: input.models, |
| 150 | + fallbackCosts: input.fallbackCosts, |
| 151 | + }) |
| 152 | +} |
0 commit comments