opencode-onboard 0.3.1 → 0.4.1

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 (79) hide show
  1. package/README.md +266 -214
  2. package/content/.agents/agents/basic-engineer.md +30 -0
  3. package/content/.agents/agents/devops-manager.md +38 -29
  4. package/content/.agents/session-log.json +41 -0
  5. package/content/.agents/skills/ob-default/SKILL.md +21 -0
  6. package/content/.agents/skills/ob-generic-guardrails/SKILL.md +32 -0
  7. package/content/.agents/skills/ob-global/SKILL.md +49 -0
  8. package/content/.agents/skills/ob-pullrequest-az/SKILL.md +11 -21
  9. package/content/.agents/skills/ob-pullrequest-gh/SKILL.md +14 -24
  10. package/content/.agents/skills/ob-userstory-az/SKILL.md +8 -14
  11. package/content/.agents/skills/ob-userstory-gh/SKILL.md +6 -14
  12. package/content/.opencode/commands/opsx-apply.md +50 -33
  13. package/content/.opencode/opencode.json +3 -3
  14. package/content/.opencode/plugins/session-log.js +1 -1
  15. package/content/.opencode/skills/openspec-apply-change/SKILL.md +50 -33
  16. package/content/AGENTS.md +95 -141
  17. package/content/skills-lock.json +4 -0
  18. package/package.json +6 -1
  19. package/src/index.js +112 -191
  20. package/src/presets/browser.json +18 -0
  21. package/src/presets/clean.json +21 -0
  22. package/src/presets/models.json +33 -0
  23. package/src/presets/optimization.json +22 -0
  24. package/src/presets/platforms.json +29 -2
  25. package/src/presets/quota.json +14 -0
  26. package/src/presets/source.json +17 -0
  27. package/src/steps/browser/browser.test.js +81 -0
  28. package/src/steps/{install-browser.js → browser/index.js} +12 -15
  29. package/src/steps/{__tests__/clean-ai-files.test.js → clean/clean.test.js} +28 -13
  30. package/src/steps/{clean-ai-files.js → clean/index.js} +32 -30
  31. package/src/steps/{patch-agents-md.js → copy/agents.js} +41 -20
  32. package/src/steps/{__tests__/copy-content.test.js → copy/copy.test.js} +10 -1
  33. package/src/steps/copy/index.js +33 -0
  34. package/src/steps/copy/skills.js +55 -0
  35. package/src/steps/{write-onboard-config.js → metadata/index.js} +3 -3
  36. package/src/steps/metadata/metadata.test.js +96 -0
  37. package/src/steps/models/format.js +60 -0
  38. package/src/steps/models/format.test.js +74 -0
  39. package/src/steps/models/index.js +52 -0
  40. package/src/steps/models/write.js +54 -0
  41. package/src/steps/models/write.test.js +119 -0
  42. package/src/steps/{init-openspec.js → openspec/ensemble.js} +27 -61
  43. package/src/steps/openspec/ensemble.test.js +79 -0
  44. package/src/steps/openspec/index.js +32 -0
  45. package/src/steps/optimization/caveman-guidance.js +11 -0
  46. package/src/steps/{install-caveman.js → optimization/caveman.js} +5 -19
  47. package/src/steps/optimization/global.js +64 -0
  48. package/src/steps/optimization/index.js +101 -0
  49. package/src/steps/{__tests__/token-optimization.test.js → optimization/optimization.test.js} +19 -24
  50. package/src/steps/{install-quota.js → optimization/quota.js} +12 -10
  51. package/src/steps/platform/index.js +81 -0
  52. package/src/steps/platform/platform.test.js +129 -0
  53. package/src/steps/{choose-source-scope.js → source/index.js} +11 -17
  54. package/src/steps/source/source.test.js +89 -0
  55. package/src/utils/__tests__/copy.test.js +12 -5
  56. package/src/utils/copy.js +4 -24
  57. package/src/utils/exec-spinner.js +47 -0
  58. package/src/utils/exec.js +120 -162
  59. package/src/utils/models-cache.js +25 -68
  60. package/src/utils/models-pricing.js +42 -0
  61. package/src/utils/models-pricing.test.js +94 -0
  62. package/content/.agents/agents/back-engineer.md +0 -87
  63. package/content/.agents/agents/front-engineer.md +0 -86
  64. package/content/.agents/agents/infra-engineer.md +0 -85
  65. package/content/.agents/agents/quality-engineer.md +0 -86
  66. package/content/.agents/agents/security-auditor.md +0 -86
  67. package/src/steps/__tests__/check-env.test.js +0 -70
  68. package/src/steps/__tests__/check-platform.test.js +0 -104
  69. package/src/steps/__tests__/check-rtk.test.js +0 -38
  70. package/src/steps/__tests__/choose-platform.test.js +0 -38
  71. package/src/steps/check-env.js +0 -26
  72. package/src/steps/check-platform.js +0 -80
  73. package/src/steps/check-rtk.js +0 -38
  74. package/src/steps/choose-models.js +0 -163
  75. package/src/steps/choose-platform.js +0 -22
  76. package/src/steps/choose-skills-provider.js +0 -79
  77. package/src/steps/copy-content.js +0 -89
  78. package/src/steps/enable-caveman-guidance.js +0 -93
  79. package/src/steps/token-optimization.js +0 -59
package/src/utils/copy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fse from 'fs-extra'
2
2
  import path from 'path'
3
3
 
4
- // Folders never copied (skills handled separately by chooseSkillsProvider, .bootstrap is internal tooling)
4
+ // Folders never copied (skills handled separately by installSkills, .bootstrap is internal tooling)
5
5
  // These are excluded from the general content copy, they are installed separately
6
6
  // by initOpenspec after openspec init runs, so our versions win over the generated ones.
7
7
  const ALWAYS_EXCLUDE = ['.bootstrap', 'skills', 'node_modules']
@@ -29,7 +29,7 @@ export async function copyContent(contentDir, destDir, platform, ctx = {}) {
29
29
  filter: (src) => {
30
30
  const rel = path.relative(contentDir, src)
31
31
  const parts = rel.split(path.sep)
32
- if (parts.some(part => ALWAYS_EXCLUDE.some(pattern => part.includes(pattern)))) return false
32
+ if (parts.some(part => ALWAYS_EXCLUDE.includes(part))) return false
33
33
  if (OPENSPEC_APPLY_FILES.some(f => rel === f)) return false
34
34
  if (ctx.hasDesign && rel === 'DESIGN.md') return false
35
35
  if (ctx.hasArchitecture && rel === 'ARCHITECTURE.md') return false
@@ -38,29 +38,9 @@ export async function copyContent(contentDir, destDir, platform, ctx = {}) {
38
38
  })
39
39
  }
40
40
 
41
- /**
42
- * Scan a directory for known AI config files.
43
- * Returns array of absolute paths found.
44
- */
45
- const AI_FILES = [
46
- 'AGENTS.md',
47
- 'CLAUDE.md',
48
- 'ARCHITECTURE.md',
49
- 'DESIGN.md',
50
- '.cursorrules',
51
- '.clinerules',
52
- '.windsurfrules',
53
- '.github/copilot-instructions.md',
54
- 'copilot-instructions.md',
55
- '.aider.conf.yml',
56
- '.aider',
57
- '.opencode',
58
- '.agents'
59
- ]
60
-
61
- export async function findAiFiles(dir) {
41
+ export async function findAiFiles(dir, files) {
62
42
  const found = []
63
- for (const file of AI_FILES) {
43
+ for (const file of files) {
64
44
  const fullPath = path.join(dir, file)
65
45
  if (await fse.pathExists(fullPath)) {
66
46
  found.push(fullPath)
@@ -0,0 +1,47 @@
1
+ import chalk from 'chalk'
2
+ import ora from 'ora'
3
+
4
+ // ── Screen / step state ──────────────────────────────────────────────────────
5
+
6
+ const previousSteps = []; // up to 2 completed steps, each is an array of lines
7
+ let currentStepLines = []; // lines accumulated in the current step
8
+ let stepSpinner = null; // ora spinner shown while step is working
9
+
10
+ export function appendLine(line) {
11
+ currentStepLines.push(line);
12
+ }
13
+
14
+ export function stopSpinner() {
15
+ if (stepSpinner) {
16
+ stepSpinner.stop();
17
+ stepSpinner = null;
18
+ }
19
+ }
20
+
21
+ export function startSpinner(text = 'working...') {
22
+ stopSpinner();
23
+ stepSpinner = ora({ text: chalk.dim(text), color: 'red' }).start();
24
+ }
25
+
26
+ export function redraw() {
27
+ if (process.stdout.isTTY) console.clear();
28
+
29
+ // Show up to 2 previous steps dimmed
30
+ for (const stepLines of previousSteps) {
31
+ for (const line of stepLines) {
32
+ process.stdout.write(chalk.dim(line) + '\n');
33
+ }
34
+ process.stdout.write('\n');
35
+ }
36
+
37
+ // Current step output
38
+ for (const line of currentStepLines) {
39
+ process.stdout.write(line + '\n');
40
+ }
41
+ }
42
+
43
+ export function rotateStep() {
44
+ previousSteps.push(currentStepLines);
45
+ if (previousSteps.length > 2) previousSteps.shift();
46
+ currentStepLines = [];
47
+ }
package/src/utils/exec.js CHANGED
@@ -1,173 +1,131 @@
1
- import chalk from 'chalk'
2
- import { execa } from 'execa'
3
- import ora from 'ora'
4
-
5
- // ── Screen / step state ──────────────────────────────────────────────────────
6
-
7
- const previousSteps = [] // up to 2 completed steps, each is an array of lines
8
- let currentStepLines = [] // lines accumulated in the current step
9
- let stepSpinner = null // ora spinner shown while step is working
10
-
11
- function appendLine(line) {
12
- currentStepLines.push(line)
13
- }
14
-
15
- function stopSpinner() {
16
- if (stepSpinner) {
17
- stepSpinner.stop()
18
- stepSpinner = null
19
- }
1
+ import chalk from 'chalk'
2
+ import { execa } from 'execa'
3
+ import ora from 'ora'
4
+ import { appendLine, redraw, rotateStep, startSpinner, stopSpinner } from './exec-spinner.js'
5
+
6
+ // ── Public API ───────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Run a shell command with a spinner.
10
+ * Returns { success, stdout, stderr }
11
+ */
12
+ export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
13
+ const spinner = ora(label ?? `${command} ${args.join(' ')}`).start();
14
+ try {
15
+ const result = await execa(command, args, { cwd, reject: false });
16
+ if (result.exitCode === 0) {
17
+ spinner.succeed();
18
+ } else {
19
+ spinner.fail();
20
+ }
21
+ return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr };
22
+ } catch (err) {
23
+ spinner.fail();
24
+ return { success: false, stdout: '', stderr: err.message };
25
+ }
20
26
  }
21
27
 
22
- function startSpinner(text = 'working...') {
23
- stopSpinner()
24
- stepSpinner = ora({ text: chalk.dim(text), color: 'red' }).start()
28
+ /**
29
+ * Check if a command is available on PATH.
30
+ * Returns true/false.
31
+ */
32
+ export async function commandExists(command) {
33
+ try {
34
+ const result = await execa(command, ['--version'], { reject: false });
35
+ return result.exitCode === 0;
36
+ } catch {
37
+ return false;
38
+ }
25
39
  }
26
-
27
- function redraw() {
28
- if (process.stdout.isTTY) console.clear()
29
-
30
- // Show up to 2 previous steps dimmed
31
- for (const stepLines of previousSteps) {
32
- for (const line of stepLines) {
33
- process.stdout.write(chalk.dim(line) + '\n')
34
- }
35
- process.stdout.write('\n')
36
- }
37
-
38
- // Current step output
39
- for (const line of currentStepLines) {
40
- process.stdout.write(line + '\n')
41
- }
42
- }
43
-
44
- // ── Public API ───────────────────────────────────────────────────────────────
45
-
46
- /**
47
- * Run a shell command with a spinner.
48
- * Returns { success, stdout, stderr }
49
- */
50
- export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
51
- const spinner = ora(label ?? `${command} ${args.join(' ')}`).start()
52
- try {
53
- const result = await execa(command, args, { cwd, reject: false })
54
- if (result.exitCode === 0) {
55
- spinner.succeed()
56
- } else {
57
- spinner.fail()
58
- }
59
- return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }
60
- } catch (err) {
61
- spinner.fail()
62
- return { success: false, stdout: '', stderr: err.message }
63
- }
64
- }
65
-
66
- /**
67
- * Check if a command is available on PATH.
68
- * Returns true/false.
69
- */
70
- export async function commandExists(command) {
71
- try {
72
- const result = await execa(command, ['--version'], { reject: false })
73
- return result.exitCode === 0
74
- } catch {
75
- return false
76
- }
77
- }
78
-
79
- /**
80
- * Print a section header, clears screen, shows previous step dimmed, starts new step.
81
- */
82
- export function header(text) {
83
- // Rotate buffers, keep last 2 completed steps
84
- previousSteps.push(currentStepLines)
85
- if (previousSteps.length > 2) previousSteps.shift()
86
- currentStepLines = []
87
-
88
- const line1 = ''
89
- const line2 = chalk.bold.hex('#fe3d57')(`━━ ${text}`)
90
- const line3 = ''
91
-
92
- appendLine(line1)
93
- appendLine(line2)
94
- appendLine(line3)
95
-
96
- redraw()
97
40
 
98
- // Start a spinner while the step is working
99
- startSpinner('working...')
41
+ /**
42
+ * Print a section header, clears screen, shows previous step dimmed, starts new step.
43
+ */
44
+ export function header(text) {
45
+ rotateStep();
46
+
47
+ const line1 = '';
48
+ const line2 = chalk.bold.hex('#fe3d57')(`━━ ${text}`);
49
+ const line3 = '';
50
+
51
+ appendLine(line1);
52
+ appendLine(line2);
53
+ appendLine(line3);
54
+
55
+ redraw();
56
+
57
+ startSpinner('working...');
100
58
  }
101
59
 
102
60
  /**
103
61
  * Restart the step spinner after prompts or logs.
104
62
  */
105
63
  export function loading(text = 'working...') {
106
- startSpinner(text)
64
+ startSpinner(text);
65
+ }
66
+
67
+ /**
68
+ * Print a success line.
69
+ */
70
+ export function success(text) {
71
+ stopSpinner();
72
+ const line = chalk.green('✓ ') + text;
73
+ appendLine(line);
74
+ console.log(line);
75
+ }
76
+
77
+ /**
78
+ * Print a warning line.
79
+ */
80
+ export function warn(text) {
81
+ stopSpinner();
82
+ const line = chalk.yellow('⚠ ') + text;
83
+ appendLine(line);
84
+ console.log(line);
85
+ }
86
+
87
+ /**
88
+ * Print an error line.
89
+ */
90
+ export function error(text) {
91
+ stopSpinner();
92
+ const line = chalk.red('✗ ') + text;
93
+ appendLine(line);
94
+ console.log(line);
95
+ }
96
+
97
+ /**
98
+ * Print an info line.
99
+ */
100
+ export function info(text) {
101
+ stopSpinner();
102
+ const line = chalk.dim(' ' + text);
103
+ appendLine(line);
104
+ console.log(line);
105
+ }
106
+
107
+ /**
108
+ * Print an action prompt line (white bold, requires user interaction).
109
+ */
110
+ export function prompt(text) {
111
+ stopSpinner();
112
+ const line = chalk.bold(' ' + text);
113
+ appendLine(line);
114
+ console.log(line);
115
+ }
116
+
117
+ /**
118
+ * Print a code block.
119
+ */
120
+ export function code(lines) {
121
+ stopSpinner();
122
+ appendLine('');
123
+ console.log();
124
+ for (const line of lines) {
125
+ const formatted = chalk.bgGray.white(' ' + line + ' ');
126
+ appendLine(formatted);
127
+ console.log(formatted);
128
+ }
129
+ appendLine('');
130
+ console.log();
107
131
  }
108
-
109
- /**
110
- * Print a success line.
111
- */
112
- export function success(text) {
113
- stopSpinner()
114
- const line = chalk.green('✓ ') + text
115
- appendLine(line)
116
- console.log(line)
117
- }
118
-
119
- /**
120
- * Print a warning line.
121
- */
122
- export function warn(text) {
123
- stopSpinner()
124
- const line = chalk.yellow('⚠ ') + text
125
- appendLine(line)
126
- console.log(line)
127
- }
128
-
129
- /**
130
- * Print an error line.
131
- */
132
- export function error(text) {
133
- stopSpinner()
134
- const line = chalk.red('✗ ') + text
135
- appendLine(line)
136
- console.log(line)
137
- }
138
-
139
- /**
140
- * Print an info line.
141
- */
142
- export function info(text) {
143
- stopSpinner()
144
- const line = chalk.dim(' ' + text)
145
- appendLine(line)
146
- console.log(line)
147
- }
148
-
149
- /**
150
- * Print an action prompt line (white bold, requires user interaction).
151
- */
152
- export function prompt(text) {
153
- stopSpinner()
154
- const line = chalk.bold(' ' + text)
155
- appendLine(line)
156
- console.log(line)
157
- }
158
-
159
- /**
160
- * Print a code block.
161
- */
162
- export function code(lines) {
163
- stopSpinner()
164
- appendLine('')
165
- console.log()
166
- for (const line of lines) {
167
- const formatted = chalk.bgGray.white(' ' + line + ' ')
168
- appendLine(formatted)
169
- console.log(formatted)
170
- }
171
- appendLine('')
172
- console.log()
173
- }
@@ -1,73 +1,30 @@
1
1
  import fse from 'fs-extra'
2
2
  import os from 'os'
3
3
  import path from 'path'
4
+ import { parseModels } from './models-pricing.js'
4
5
 
5
- const CACHE_DIR = path.join(os.homedir(), '.config', 'opencode-onboard')
6
- const CACHE_FILE = path.join(CACHE_DIR, 'models-cache.json')
7
- const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
8
- const MODELS_URL = 'https://models.dev/api.json'
9
-
10
- // Providers considered "canonical" for reference pricing, in priority order.
11
- // When a model's own provider has no cost (e.g. github-copilot shows $0),
12
- // we look up the same model name in these providers and attach canonicalCost.
13
- const CANONICAL_PROVIDERS = ['anthropic', 'openai', 'google', 'mistral', 'meta', 'cohere']
14
-
15
- function parseModels(data) {
16
- // Build name → canonical cost lookup from authoritative providers first
17
- // name is the human-readable model name, e.g. "Claude Opus 4.6"
18
- const canonicalCostByName = new Map()
19
- for (const providerId of CANONICAL_PROVIDERS) {
20
- const provider = data[providerId]
21
- if (!provider?.models) continue
22
- for (const model of Object.values(provider.models)) {
23
- if (!model.tool_call) continue
24
- const name = model.name
25
- if (name && model.cost?.input !== undefined && !canonicalCostByName.has(name)) {
26
- canonicalCostByName.set(name, model.cost.input)
27
- }
28
- }
29
- }
30
-
31
- const models = []
32
- for (const [providerId, provider] of Object.entries(data)) {
33
- if (!provider.models) continue
34
- for (const [modelId, model] of Object.entries(provider.models)) {
35
- if (!model.tool_call) continue
36
- const name = model.name || modelId
37
- const cost = model.cost?.input
38
- const canonicalCost = canonicalCostByName.get(name)
39
- models.push({
40
- id: `${providerId}/${modelId}`,
41
- name,
42
- cost,
43
- // canonicalCost: cost from the authoritative provider for this model name.
44
- // Defined when cost !== canonicalCost (different provider, reseller, or $0 subscription).
45
- canonicalCost: canonicalCost !== undefined && canonicalCost !== cost ? canonicalCost : undefined,
46
- context: model.limit?.context,
47
- })
48
- }
49
- }
50
- models.sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity))
51
- return models
52
- }
6
+ const CACHE_DIR = path.join(os.homedir(), '.config', 'opencode-onboard');
7
+ const CACHE_FILE = path.join(CACHE_DIR, 'models-cache.json');
8
+ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
9
+ const MODELS_URL = 'https://models.dev/api.json';
53
10
 
54
11
  async function loadCache() {
55
12
  try {
56
- if (!await fse.pathExists(CACHE_FILE)) return null
57
- const cache = await fse.readJson(CACHE_FILE)
58
- if (!cache.timestamp || !cache.models) return null
59
- const age = Date.now() - cache.timestamp
60
- if (age > CACHE_TTL_MS) return null // expired
61
- return cache.models
13
+ if (!await fse.pathExists(CACHE_FILE)) return null;
14
+ const cache = await fse.readJson(CACHE_FILE);
15
+ if (!cache.timestamp || !cache.models) return null;
16
+ const age = Date.now() - cache.timestamp;
17
+ if (age > CACHE_TTL_MS) return null; // expired
18
+ return cache.models;
62
19
  } catch {
63
- return null
20
+ return null;
64
21
  }
65
22
  }
66
23
 
67
24
  async function saveCache(models) {
68
25
  try {
69
- await fse.ensureDir(CACHE_DIR)
70
- await fse.writeJson(CACHE_FILE, { timestamp: Date.now(), models })
26
+ await fse.ensureDir(CACHE_DIR);
27
+ await fse.writeJson(CACHE_FILE, { timestamp: Date.now(), models });
71
28
  } catch {
72
29
  // cache write failure is non-fatal
73
30
  }
@@ -75,27 +32,27 @@ async function saveCache(models) {
75
32
 
76
33
  export async function fetchModels() {
77
34
  // 1. Try cache first (fresh)
78
- const cached = await loadCache()
79
- if (cached) return { models: cached, source: 'cache' }
35
+ const cached = await loadCache();
36
+ if (cached) return { models: cached, source: 'cache' };
80
37
 
81
38
  // 2. Try network
82
39
  try {
83
- const res = await fetch(MODELS_URL, { signal: AbortSignal.timeout(8000) })
84
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
85
- const data = await res.json()
86
- const models = parseModels(data)
87
- await saveCache(models)
88
- return { models, source: 'network' }
40
+ const res = await fetch(MODELS_URL, { signal: AbortSignal.timeout(8000) });
41
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
42
+ const data = await res.json();
43
+ const models = parseModels(data);
44
+ await saveCache(models);
45
+ return { models, source: 'network' };
89
46
  } catch {
90
47
  // 3. Network failed, fall back to stale cache if available
91
48
  try {
92
49
  if (await fse.pathExists(CACHE_FILE)) {
93
- const cache = await fse.readJson(CACHE_FILE)
94
- if (cache.models?.length) return { models: cache.models, source: 'stale-cache' }
50
+ const cache = await fse.readJson(CACHE_FILE);
51
+ if (cache.models?.length) return { models: cache.models, source: 'stale-cache' };
95
52
  }
96
53
  } catch {
97
54
  // ignore
98
55
  }
99
- return { models: null, source: 'unavailable' }
56
+ return { models: null, source: 'unavailable' };
100
57
  }
101
58
  }
@@ -0,0 +1,42 @@
1
+ // Providers considered "canonical" for reference pricing, in priority order.
2
+ // When a model's own provider has no cost (e.g. github-copilot shows $0),
3
+ // we look up the same model name in these providers and attach canonicalCost.
4
+ export const CANONICAL_PROVIDERS = ['anthropic', 'openai', 'google', 'mistral', 'meta', 'cohere'];
5
+
6
+ export function parseModels(data) {
7
+ // Build name → canonical cost lookup from authoritative providers first
8
+ const canonicalCostByName = new Map();
9
+ for (const providerId of CANONICAL_PROVIDERS) {
10
+ const provider = data[providerId];
11
+ if (!provider?.models) continue;
12
+ for (const model of Object.values(provider.models)) {
13
+ if (!model.tool_call) continue;
14
+ const name = model.name;
15
+ if (name && model.cost?.input !== undefined && !canonicalCostByName.has(name)) {
16
+ canonicalCostByName.set(name, model.cost.input);
17
+ }
18
+ }
19
+ }
20
+
21
+ const models = [];
22
+ for (const [providerId, provider] of Object.entries(data)) {
23
+ if (!provider.models) continue;
24
+ for (const [modelId, model] of Object.entries(provider.models)) {
25
+ if (!model.tool_call) continue;
26
+ const name = model.name || modelId;
27
+ const cost = model.cost?.input;
28
+ const canonicalCost = canonicalCostByName.get(name);
29
+ models.push({
30
+ id: `${providerId}/${modelId}`,
31
+ name,
32
+ cost,
33
+ // canonicalCost: cost from the authoritative provider for this model name.
34
+ // Defined when cost !== canonicalCost (different provider, reseller, or $0 subscription).
35
+ canonicalCost: canonicalCost !== undefined && canonicalCost !== cost ? canonicalCost : undefined,
36
+ context: model.limit?.context,
37
+ });
38
+ }
39
+ }
40
+ models.sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity));
41
+ return models;
42
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { CANONICAL_PROVIDERS, parseModels } from './models-pricing.js'
3
+
4
+ describe('CANONICAL_PROVIDERS', () => {
5
+ it('contains expected major providers', () => {
6
+ expect(CANONICAL_PROVIDERS).toContain('anthropic')
7
+ expect(CANONICAL_PROVIDERS).toContain('openai')
8
+ expect(CANONICAL_PROVIDERS).toContain('google')
9
+ })
10
+ })
11
+
12
+ describe('parseModels()', () => {
13
+ it('filters to tool-calling models only', () => {
14
+ const data = {
15
+ anthropic: {
16
+ models: {
17
+ 'claude-3-sonnet': { name: 'Claude 3 Sonnet', tool_call: true, cost: { input: 3 } },
18
+ 'claude-3-haiku': { name: 'Claude 3 Haiku', tool_call: false, cost: { input: 0.25 } },
19
+ },
20
+ },
21
+ }
22
+
23
+ const result = parseModels(data)
24
+
25
+ expect(result).toHaveLength(1)
26
+ expect(result[0].id).toBe('anthropic/claude-3-sonnet')
27
+ })
28
+
29
+ it('builds canonical cost lookup from authoritative providers', () => {
30
+ const data = {
31
+ anthropic: {
32
+ models: {
33
+ 'claude-3-5-sonnet': { name: 'Claude 3.5 Sonnet', tool_call: true, cost: { input: 3 } },
34
+ },
35
+ },
36
+ 'some-reseller': {
37
+ models: {
38
+ 'claude-3-5-sonnet': { name: 'Claude 3.5 Sonnet', tool_call: true, cost: { input: 0 } },
39
+ },
40
+ },
41
+ }
42
+
43
+ const result = parseModels(data)
44
+
45
+ expect(result).toHaveLength(2)
46
+ const reseller = result.find(m => m.id.startsWith('some-reseller'))
47
+ expect(reseller?.canonicalCost).toBe(3)
48
+ })
49
+
50
+ it('sorts by cost ascending', () => {
51
+ const data = {
52
+ openai: {
53
+ models: {
54
+ 'gpt-4': { name: 'GPT-4', tool_call: true, cost: { input: 30 } },
55
+ 'gpt-3.5-turbo': { name: 'GPT-3.5 Turbo', tool_call: true, cost: { input: 0.5 } },
56
+ },
57
+ },
58
+ }
59
+
60
+ const result = parseModels(data)
61
+
62
+ expect(result[0].cost).toBe(0.5)
63
+ expect(result[1].cost).toBe(30)
64
+ })
65
+
66
+ it('handles missing cost', () => {
67
+ const data = {
68
+ test: {
69
+ models: {
70
+ 'unknown-model': { name: 'Unknown', tool_call: true },
71
+ },
72
+ },
73
+ }
74
+
75
+ const result = parseModels(data)
76
+
77
+ expect(result[0].cost).toBeUndefined()
78
+ expect(result[0].description).toContain('cost: ?')
79
+ })
80
+
81
+ it('extracts context limit', () => {
82
+ const data = {
83
+ anthropic: {
84
+ models: {
85
+ 'claude-3-opus': { name: 'Claude 3 Opus', tool_call: true, cost: { input: 15 }, limit: { context: 200000 } },
86
+ },
87
+ },
88
+ }
89
+
90
+ const result = parseModels(data)
91
+
92
+ expect(result[0].context).toBe(200000)
93
+ })
94
+ })