@pixelbyte-software/pixcode 1.40.6 → 1.40.8

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.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ const page = readFileSync('src/components/orchestration/OrchestrationPage.tsx', 'utf8');
7
+ const runner = readFileSync('server/modules/orchestration/workflows/workflow-runner.ts', 'utf8');
8
+
9
+ assert.ok(
10
+ page.includes("from '../../hooks/useProviderModels'") || page.includes('from "@/hooks/useProviderModels"'),
11
+ 'Orchestration page should use the same live provider model catalog hook as chat.',
12
+ );
13
+
14
+ assert.ok(
15
+ page.includes('providerModelCatalogs') && page.includes('sanitizeAgentModel'),
16
+ 'Orchestration agents should sanitize stale selected models against the live catalog before starting runs.',
17
+ );
18
+
19
+ assert.ok(
20
+ runner.includes('resolveWorkflowModel') && runner.includes('modelCatalogsByProvider'),
21
+ 'Workflow runner should validate provider models server-side before submitting A2A tasks.',
22
+ );
23
+
24
+ assert.ok(
25
+ runner.includes('Original user request')
26
+ && runner.indexOf('Original user request') < runner.lastIndexOf('workspaceContextPrompt(workspaceTarget)'),
27
+ 'Workflow runner should place a labeled original user request before workspace context so agents do not answer the context header.',
28
+ );
29
+
30
+ console.log('orchestration model sync smoke passed');
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtemp } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const tempHome = await mkdtemp(path.join(os.tmpdir(), 'pixcode-provider-models-'));
9
+ process.env.HOME = tempHome;
10
+ process.env.OPENCODE_MODELS_URL = 'https://models.dev/test-api.json';
11
+
12
+ const calls = [];
13
+ globalThis.fetch = async (url) => {
14
+ calls.push(String(url));
15
+ return {
16
+ ok: true,
17
+ status: 200,
18
+ async json() {
19
+ return {
20
+ opencode: {
21
+ name: 'OpenCode Zen',
22
+ models: {
23
+ 'big-pickle': {
24
+ name: 'Big Pickle',
25
+ cost: { input: 0, output: 0 },
26
+ limit: { context: 128000 },
27
+ },
28
+ 'fresh-free': {
29
+ name: 'Fresh Free',
30
+ cost: { input: 0, output: 0 },
31
+ limit: { context: 64000 },
32
+ },
33
+ },
34
+ },
35
+ };
36
+ },
37
+ };
38
+ };
39
+
40
+ const { getProviderModels } = await import('../../server/services/provider-models.js');
41
+
42
+ const staleStatic = [
43
+ { value: 'opencode/hy3-preview-free', label: 'Stale Hy3 Preview' },
44
+ { value: 'opencode/ling-2.6-flash-free', label: 'Stale Ling 2.6 Flash' },
45
+ ];
46
+
47
+ const refreshed = await getProviderModels('opencode', {
48
+ forceRefresh: true,
49
+ staticList: staleStatic,
50
+ });
51
+ const refreshedValues = refreshed.models.map((model) => model.value);
52
+ assert.equal(calls.length, 1, 'force refresh should fetch the OpenCode live catalog once');
53
+ assert.ok(refreshedValues.includes('opencode/fresh-free'), 'live OpenCode models should be returned');
54
+ assert.ok(!refreshedValues.includes('opencode/hy3-preview-free'), 'stale static OpenCode models must not be merged into a successful live catalog');
55
+ assert.ok(!refreshedValues.includes('opencode/ling-2.6-flash-free'), 'stale static OpenCode models must not survive a successful live refresh');
56
+
57
+ const cached = await getProviderModels('opencode', {
58
+ staticList: staleStatic,
59
+ });
60
+ const cachedValues = cached.models.map((model) => model.value);
61
+ assert.equal(calls.length, 1, 'fresh cache should be used without another network request');
62
+ assert.ok(cachedValues.includes('opencode/fresh-free'), 'cached live OpenCode models should be returned');
63
+ assert.ok(!cachedValues.includes('opencode/hy3-preview-free'), 'cached live OpenCode catalog must stay free of stale static models');
64
+ assert.ok(!cachedValues.includes('opencode/ling-2.6-flash-free'), 'cached live OpenCode catalog must stay free of stale static models');
65
+
66
+ console.log('provider OpenCode live model catalog smoke passed');
@@ -13,6 +13,17 @@ import {
13
13
  workspaceTargetMetadata,
14
14
  } from '@/modules/orchestration/workflows/workspace-target.js';
15
15
  import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
16
+ // @ts-ignore — plain-JS service
17
+ import { getProviderModels } from '@/services/provider-models.js';
18
+
19
+ import {
20
+ CLAUDE_MODELS,
21
+ CODEX_MODELS,
22
+ CURSOR_MODELS,
23
+ GEMINI_MODELS,
24
+ OPENCODE_MODELS,
25
+ QWEN_MODELS,
26
+ } from '../../../../shared/modelConstants.js';
16
27
 
17
28
  const TERMINAL = new Set(['completed', 'failed', 'canceled']);
18
29
  const SKIPPED = 'skipped';
@@ -106,6 +117,34 @@ type AgentAssignment = {
106
117
 
107
118
  type KnownAgentRole = typeof KNOWN_AGENT_ROLES[number];
108
119
  type AgentRole = string;
120
+ type ProviderId = 'claude' | 'cursor' | 'codex' | 'gemini' | 'qwen' | 'opencode';
121
+ type ProviderModel = {
122
+ value: string;
123
+ label?: string;
124
+ source?: 'static' | 'api';
125
+ free?: boolean;
126
+ };
127
+
128
+ const adapterProviderMap: Record<string, ProviderId | undefined> = {
129
+ 'claude-code': 'claude',
130
+ cursor: 'cursor',
131
+ codex: 'codex',
132
+ gemini: 'gemini',
133
+ qwen: 'qwen',
134
+ opencode: 'opencode',
135
+ };
136
+
137
+ const modelCatalogsByProvider: Record<ProviderId, {
138
+ staticList: ProviderModel[];
139
+ defaultModel?: string;
140
+ }> = {
141
+ claude: { staticList: CLAUDE_MODELS.OPTIONS, defaultModel: CLAUDE_MODELS.DEFAULT },
142
+ cursor: { staticList: CURSOR_MODELS.OPTIONS, defaultModel: CURSOR_MODELS.DEFAULT },
143
+ codex: { staticList: CODEX_MODELS.OPTIONS, defaultModel: CODEX_MODELS.DEFAULT },
144
+ gemini: { staticList: GEMINI_MODELS.OPTIONS, defaultModel: GEMINI_MODELS.DEFAULT },
145
+ qwen: { staticList: QWEN_MODELS.OPTIONS, defaultModel: QWEN_MODELS.DEFAULT },
146
+ opencode: { staticList: OPENCODE_MODELS.OPTIONS, defaultModel: OPENCODE_MODELS.DEFAULT },
147
+ };
109
148
 
110
149
  function readAgentRole(value: unknown): AgentRole | undefined {
111
150
  return typeof value === 'string' && value.trim() && value.trim() !== 'auto'
@@ -133,6 +172,44 @@ function readBoolean(value: unknown): boolean | undefined {
133
172
  return typeof value === 'boolean' ? value : undefined;
134
173
  }
135
174
 
175
+ function modelValueSet(models: ProviderModel[]): Set<string> {
176
+ return new Set(models.map((model) => model.value).filter(Boolean));
177
+ }
178
+
179
+ function preferredFallbackModel(models: ProviderModel[], defaultModel?: string): string | undefined {
180
+ const values = modelValueSet(models);
181
+ if (defaultModel && values.has(defaultModel)) return defaultModel;
182
+ return models.find((model) => model.source === 'api' && model.free)?.value
183
+ ?? models.find((model) => model.source === 'api')?.value
184
+ ?? models.find((model) => model.free)?.value
185
+ ?? models[0]?.value
186
+ ?? defaultModel;
187
+ }
188
+
189
+ async function resolveWorkflowModel(adapterId: string, requestedModel?: string): Promise<string | undefined> {
190
+ const provider = adapterProviderMap[adapterId];
191
+ if (!provider) return requestedModel;
192
+
193
+ const catalog = modelCatalogsByProvider[provider];
194
+ if (!requestedModel) return catalog.defaultModel;
195
+
196
+ try {
197
+ const result = await getProviderModels(provider, {
198
+ staticList: catalog.staticList,
199
+ });
200
+ const models = Array.isArray(result?.models) ? result.models as ProviderModel[] : [];
201
+ if (modelValueSet(models).has(requestedModel)) {
202
+ return requestedModel;
203
+ }
204
+ return preferredFallbackModel(models, catalog.defaultModel) ?? requestedModel;
205
+ } catch {
206
+ const staticValues = modelValueSet(catalog.staticList);
207
+ return staticValues.has(requestedModel)
208
+ ? requestedModel
209
+ : preferredFallbackModel(catalog.staticList, catalog.defaultModel) ?? requestedModel;
210
+ }
211
+ }
212
+
136
213
  function readIsolation(value: unknown): 'host' | 'worktree' | 'docker' | undefined {
137
214
  return value === 'host' || value === 'worktree' || value === 'docker' ? value : undefined;
138
215
  }
@@ -1408,15 +1485,42 @@ class WorkflowRunner {
1408
1485
 
1409
1486
  const inputContext = node.inputs.map((input) => outputs.get(input)).filter(Boolean).join('\n\n');
1410
1487
  const workspaceTarget = resolveWorkflowWorkspace(run.metadata);
1411
- const prompt = [workspaceContextPrompt(workspaceTarget), run.input, inputContext, node.prompt]
1412
- .filter(Boolean)
1413
- .join('\n\n');
1488
+ const prompt = [
1489
+ 'Original user request (primary task; answer this directly even if the workspace is empty):',
1490
+ run.input?.trim() || '(No original user request was provided.)',
1491
+ inputContext
1492
+ ? `Upstream workflow context from prior agents:\n${inputContext}`
1493
+ : '',
1494
+ `Current workflow step instructions:\n${node.prompt}`,
1495
+ workspaceContextPrompt(workspaceTarget),
1496
+ ].filter(Boolean).join('\n\n');
1414
1497
  const settings = getMetadataRecord(run.metadata, 'settings');
1415
1498
  const projectPath = workspaceTarget.projectPath;
1416
1499
  const isolation = readIsolation(settings.isolation) ?? node.isolation ?? 'host';
1417
1500
  const keepAfterCompletion = readBoolean(settings.keepWorkspace) ?? true;
1418
1501
  const baseRef = readString(settings.baseRef) ?? 'HEAD';
1419
1502
  const effectivePermissionMode = resolveNodePermissionMode(node, workspaceTarget);
1503
+ const effectiveModel = await resolveWorkflowModel(node.adapterId, node.model);
1504
+ if (effectiveModel !== node.model) {
1505
+ nodeRun.model = effectiveModel;
1506
+ const modelFallbackEvents = Array.isArray(run.metadata?.modelFallbackEvents)
1507
+ ? run.metadata.modelFallbackEvents
1508
+ : [];
1509
+ run.metadata = {
1510
+ ...run.metadata,
1511
+ modelFallbackEvents: [
1512
+ ...modelFallbackEvents,
1513
+ {
1514
+ nodeId: node.id,
1515
+ adapterId: node.adapterId,
1516
+ requestedModel: node.model,
1517
+ effectiveModel,
1518
+ changedAt: Date.now(),
1519
+ },
1520
+ ],
1521
+ };
1522
+ workflowStore.setRun(run);
1523
+ }
1420
1524
  let body: { id?: string; error?: { message?: string } };
1421
1525
  try {
1422
1526
  const submit = await fetch(`${localA2ABaseUrl()}/tasks`, {
@@ -1436,7 +1540,7 @@ class WorkflowRunner {
1436
1540
  agentInstanceId: node.agentInstanceId,
1437
1541
  agentLabel: node.agentLabel,
1438
1542
  assignment: node.assignment,
1439
- model: node.model,
1543
+ model: effectiveModel,
1440
1544
  permissionMode: effectivePermissionMode,
1441
1545
  toolsSettings: node.toolsSettings,
1442
1546
  projectPath,
@@ -208,6 +208,120 @@ function buildManagedPackageCommand(command, runtimeStatus) {
208
208
  };
209
209
  }
210
210
 
211
+ function packageBinCandidates(binName) {
212
+ return process.platform === 'win32'
213
+ ? [binName, `${binName}.cmd`, `${binName}.ps1`, `${binName}.exe`]
214
+ : [binName];
215
+ }
216
+
217
+ function expectedPackageBin(command) {
218
+ if (command.framework === 'Vite' || command.id === 'npm-dev-vite') return 'vite';
219
+ if (command.framework === 'Next.js' || command.id === 'npm-dev-next') return 'next';
220
+ if (command.framework === 'Nuxt' || command.id === 'npm-dev-nuxt') return 'nuxt';
221
+ if (command.framework === 'Astro' || command.id === 'npm-dev-astro') return 'astro';
222
+ return null;
223
+ }
224
+
225
+ async function packageDependenciesReady(projectPath, command) {
226
+ if (!command?.scriptName && !command?.packageManager) return true;
227
+ if (!(await dirExists(path.join(projectPath, 'node_modules')))) return false;
228
+
229
+ const binName = expectedPackageBin(command);
230
+ if (!binName) return true;
231
+
232
+ const binDir = path.join(projectPath, 'node_modules', '.bin');
233
+ for (const candidate of packageBinCandidates(binName)) {
234
+ if (await fileExists(path.join(binDir, candidate))) return true;
235
+ }
236
+ return false;
237
+ }
238
+
239
+ function packageInstallInvocation(command) {
240
+ if (!command || command.packageManager !== 'npm') return null;
241
+
242
+ if (
243
+ command.managedRuntime?.id === 'npm'
244
+ && command.args?.[0]
245
+ && String(command.args[0]).endsWith('npm-cli.js')
246
+ ) {
247
+ return {
248
+ command: command.command,
249
+ args: [command.args[0], 'install', '--no-audit', '--no-fund'],
250
+ displayCommand: 'npm install --no-audit --no-fund',
251
+ };
252
+ }
253
+
254
+ if (command.command === 'npm' || path.basename(command.command || '').startsWith('npm')) {
255
+ return {
256
+ command: command.command,
257
+ args: ['install', '--no-audit', '--no-fund'],
258
+ displayCommand: 'npm install --no-audit --no-fund',
259
+ };
260
+ }
261
+
262
+ return null;
263
+ }
264
+
265
+ function runPrepProcess(command, args, options = {}) {
266
+ return new Promise((resolve, reject) => {
267
+ let settled = false;
268
+ const child = spawn(command, args, {
269
+ cwd: options.cwd,
270
+ env: options.env,
271
+ shell: shouldUseShell({ command }),
272
+ stdio: ['ignore', 'pipe', 'pipe'],
273
+ windowsHide: true,
274
+ });
275
+ const timeoutMs = Number.parseInt(process.env.PIXCODE_LIVE_VIEW_INSTALL_TIMEOUT_MS || '', 10) || 300000;
276
+ const finish = (callback, value) => {
277
+ if (settled) return;
278
+ settled = true;
279
+ clearTimeout(timer);
280
+ callback(value);
281
+ };
282
+ const timer = setTimeout(() => {
283
+ try {
284
+ child.kill();
285
+ } catch {
286
+ // Process may already be gone.
287
+ }
288
+ finish(reject, new Error(`Dependency install timed out after ${Math.round(timeoutMs / 1000)}s.`));
289
+ }, timeoutMs);
290
+
291
+ child.stdout.on('data', (chunk) => {
292
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
293
+ });
294
+ child.stderr.on('data', (chunk) => {
295
+ chunk.toString().split(/\r?\n/).forEach((line) => options.onLog?.(line));
296
+ });
297
+ child.on('error', (error) => finish(reject, error));
298
+ child.on('exit', (code, signal) => {
299
+ if (code === 0) {
300
+ finish(resolve);
301
+ return;
302
+ }
303
+ finish(reject, new Error(`Dependency install exited with ${signal || `code ${code}`}.`));
304
+ });
305
+ });
306
+ }
307
+
308
+ export async function preparePackageDependencies(projectPath, command, env = process.env, onLog = () => {}) {
309
+ if (!command || command.packageManager !== 'npm') return false;
310
+ if (await packageDependenciesReady(projectPath, command)) return false;
311
+
312
+ const install = packageInstallInvocation(command);
313
+ if (!install) return false;
314
+
315
+ onLog(`Installing project dependencies: ${install.displayCommand}`);
316
+ await runPrepProcess(install.command, install.args, {
317
+ cwd: projectPath,
318
+ env,
319
+ onLog,
320
+ });
321
+ onLog('Project dependencies are ready.');
322
+ return true;
323
+ }
324
+
211
325
  function buildManagedPhpCommand(runtimeStatus) {
212
326
  const executable = runtimeStatus?.executablePath || 'frankenphp';
213
327
  return {
@@ -718,6 +832,20 @@ export async function startLiveView(projectName, projectPath, options = {}) {
718
832
  BROWSER: 'none',
719
833
  NEXT_TELEMETRY_DISABLED: '1',
720
834
  };
835
+
836
+ sessionsByProject.set(projectName, session);
837
+ sessionsByShareId.set(shareId, session);
838
+
839
+ try {
840
+ await preparePackageDependencies(projectPath, command, env, (line) => appendLog(session, line));
841
+ } catch (error) {
842
+ session.status = 'error';
843
+ session.stoppedAt = new Date().toISOString();
844
+ session.error = error instanceof Error ? error.message : String(error);
845
+ appendLog(session, session.error);
846
+ return publicSession(session);
847
+ }
848
+
721
849
  const child = spawn(command.command, command.args, {
722
850
  cwd: projectPath,
723
851
  env,
@@ -726,8 +854,6 @@ export async function startLiveView(projectName, projectPath, options = {}) {
726
854
  });
727
855
 
728
856
  session.child = child;
729
- sessionsByProject.set(projectName, session);
730
- sessionsByShareId.set(shareId, session);
731
857
 
732
858
  child.stdout.on('data', (chunk) => {
733
859
  chunk.toString().split(/\r?\n/).forEach((line) => appendLog(session, line));
@@ -121,21 +121,39 @@ async function downloadFile(url, targetFile, env = process.env) {
121
121
  function runProcess(command, args, options = {}) {
122
122
  return new Promise((resolve, reject) => {
123
123
  let stderr = '';
124
+ let settled = false;
125
+ const { timeoutMs, ...spawnOptions } = options;
124
126
  const child = spawn(command, args, {
125
- ...options,
127
+ ...spawnOptions,
126
128
  stdio: ['ignore', 'ignore', 'pipe'],
127
129
  windowsHide: true,
128
130
  });
131
+ const finish = (callback, value) => {
132
+ if (settled) return;
133
+ settled = true;
134
+ if (timer) clearTimeout(timer);
135
+ callback(value);
136
+ };
137
+ const timer = timeoutMs
138
+ ? setTimeout(() => {
139
+ try {
140
+ child.kill();
141
+ } catch {
142
+ // Process may have exited between timeout scheduling and kill.
143
+ }
144
+ finish(reject, new Error(`${command} timed out after ${timeoutMs}ms`));
145
+ }, timeoutMs)
146
+ : null;
129
147
  child.stderr.on('data', (chunk) => {
130
148
  stderr += chunk.toString();
131
149
  });
132
- child.on('error', reject);
150
+ child.on('error', (error) => finish(reject, error));
133
151
  child.on('close', (code) => {
134
152
  if (code === 0) {
135
- resolve();
153
+ finish(resolve);
136
154
  return;
137
155
  }
138
- reject(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
156
+ finish(reject, new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
139
157
  });
140
158
  });
141
159
  }
@@ -200,6 +218,27 @@ async function findRuntimeExecutable(searchRoot, binaryName) {
200
218
  return null;
201
219
  }
202
220
 
221
+ async function copyRuntimeWithSidecars(executablePath, currentDir) {
222
+ const executableDir = path.dirname(executablePath);
223
+ await fs.rm(currentDir, { recursive: true, force: true });
224
+ await fs.mkdir(currentDir, { recursive: true });
225
+ await fs.cp(executableDir, currentDir, { recursive: true, force: true });
226
+ return path.join(currentDir, path.basename(executablePath));
227
+ }
228
+
229
+ async function validateManagedRuntimeExecutable(id, executablePath, env = process.env) {
230
+ if (id !== 'frankenphp') return true;
231
+ try {
232
+ await runProcess(executablePath, ['version'], {
233
+ env,
234
+ timeoutMs: 5000,
235
+ });
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
203
242
  async function installFrankenPhp(env = process.env) {
204
243
  const releaseApiUrl = env.PIXCODE_FRANKENPHP_RELEASE_API
205
244
  || 'https://api.github.com/repos/php/frankenphp/releases/latest';
@@ -245,12 +284,7 @@ async function installFrankenPhp(env = process.env) {
245
284
  await fs.chmod(executablePath, 0o755).catch(() => undefined);
246
285
  }
247
286
 
248
- await fs.rm(currentDir, { recursive: true, force: true });
249
- await fs.mkdir(currentDir, { recursive: true });
250
-
251
- const finalName = process.platform === 'win32' ? 'frankenphp.exe' : 'frankenphp';
252
- const finalExecutable = path.join(currentDir, finalName);
253
- await fs.copyFile(executablePath, finalExecutable);
287
+ const finalExecutable = await copyRuntimeWithSidecars(executablePath, currentDir);
254
288
  if (process.platform !== 'win32') {
255
289
  await fs.chmod(finalExecutable, 0o755).catch(() => undefined);
256
290
  }
@@ -379,6 +413,18 @@ export async function getManagedRuntimeStatus(id, options = {}) {
379
413
 
380
414
  const manifest = await readManifest(id, env);
381
415
  if (manifest) {
416
+ const valid = await validateManagedRuntimeExecutable(id, manifest.executablePath, env);
417
+ if (!valid) {
418
+ return {
419
+ id,
420
+ label: 'Pixcode PHP runtime',
421
+ provider: 'FrankenPHP',
422
+ status: 'missing',
423
+ installable: true,
424
+ reason: 'The existing Pixcode PHP runtime is incomplete or cannot start. Pixcode will reinstall it automatically.',
425
+ };
426
+ }
427
+
382
428
  return {
383
429
  id,
384
430
  label: manifest.label || 'Pixcode PHP runtime',
@@ -86,6 +86,20 @@ function mergeCatalogs(primary, secondary) {
86
86
  return Array.from(seen.values());
87
87
  }
88
88
 
89
+ function mergeProviderCatalogs(provider, primary, staticCatalog) {
90
+ const normalizedPrimary = normalizeList(primary);
91
+
92
+ // OpenCode Zen free models rotate often. When models.dev succeeds, treat
93
+ // that live catalog as authoritative; otherwise stale static freebies can
94
+ // leak back into the UI and fail later with ProviderModelNotFoundError.
95
+ if (provider === 'opencode') {
96
+ const liveModels = normalizedPrimary.filter((item) => item.source === 'api');
97
+ if (liveModels.length > 0) return liveModels;
98
+ }
99
+
100
+ return mergeCatalogs(normalizedPrimary, staticCatalog);
101
+ }
102
+
89
103
  // ---------------- Per-provider live discovery ----------------
90
104
 
91
105
  async function discoverAnthropic(apiKey, baseUrl) {
@@ -287,8 +301,9 @@ export async function getProviderModels(provider, opts = {}) {
287
301
  : false;
288
302
 
289
303
  if (!forceRefresh && cacheFresh && Array.isArray(cached?.models)) {
304
+ const merged = mergeProviderCatalogs(provider, cached.models, staticCatalog);
290
305
  return {
291
- models: mergeCatalogs(normalizeList(cached.models), staticCatalog),
306
+ models: merged,
292
307
  fetchedAt: cached.fetchedAt,
293
308
  error: cached.error,
294
309
  fromCache: true,
@@ -305,7 +320,7 @@ export async function getProviderModels(provider, opts = {}) {
305
320
  } catch (err) {
306
321
  error = err?.message || String(err);
307
322
  }
308
- const merged = mergeCatalogs(normalizeList(liveModels), staticCatalog);
323
+ const merged = mergeProviderCatalogs(provider, liveModels, staticCatalog);
309
324
  const entry = { models: merged, error };
310
325
  await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
311
326
  return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
@@ -367,7 +382,7 @@ export async function getProviderModels(provider, opts = {}) {
367
382
  }
368
383
  }
369
384
 
370
- const merged = mergeCatalogs(normalizeList(liveModels), staticCatalog);
385
+ const merged = mergeProviderCatalogs(provider, liveModels, staticCatalog);
371
386
  const entry = { models: merged, error };
372
387
  await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
373
388
  return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
@@ -110,11 +110,10 @@ export const QWEN_MODELS = {
110
110
  export const OPENCODE_MODELS = {
111
111
  OPTIONS: [
112
112
  // OpenCode Zen — free tier (no charge, may rate-limit). The "limited
113
- // time" Zen freebies rotate, so this is the safest small set.
113
+ // time" Zen freebies rotate, so keep this fallback conservative and let
114
+ // the live models.dev catalog populate the full current list.
114
115
  { value: "opencode/big-pickle", label: "OpenCode Zen · Big Pickle (Free)", free: true },
115
116
  { value: "opencode/minimax-m2.5-free", label: "OpenCode Zen · MiniMax M2.5 (Free)", free: true },
116
- { value: "opencode/hy3-preview-free", label: "OpenCode Zen · Hy3 Preview (Free)", free: true },
117
- { value: "opencode/ling-2.6-flash-free", label: "OpenCode Zen · Ling 2.6 Flash (Free)", free: true },
118
117
  { value: "opencode/nemotron-3-super-free", label: "OpenCode Zen · Nemotron 3 Super (Free)", free: true },
119
118
  { value: "opencode/gpt-5-nano", label: "OpenCode Zen · GPT-5 Nano (Free)", free: true },
120
119