From 02254db611087c48ae44e1d2decf5d2ab1bfb04c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 00:57:20 -0400 Subject: [PATCH] fix: resolve ProviderModelNotFoundError on non-Claude runtimes (#1156) Extend resolve_model_ids to accept "omit" value: returns empty string so non-Claude runtimes (OpenCode, Codex, Gemini, etc.) use their configured default model instead of unresolvable Claude aliases. - resolve_model_ids: "omit" short-circuits before alias resolution - model_overrides still respected (checked first) for explicit IDs - Installer sets resolve_model_ids: "omit" in ~/.gsd/defaults.json for non-Claude runtimes during install - 4 new tests covering omit behavior and override passthrough - Fix websearch test mocks for fs.writeSync output change (#1276) Closes #1156 Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/install.js | 21 +++++++++++++++++++++ get-shit-done/bin/lib/core.cjs | 16 ++++++++++++---- tests/commands.test.cjs | 9 +++++---- tests/core.test.cjs | 25 +++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/bin/install.js b/bin/install.js index 4f8eeced7..d4ded0b27 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4171,6 +4171,27 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS configureOpencodePermissions(isGlobal); } + // For non-Claude runtimes, set resolve_model_ids: "omit" in ~/.gsd/defaults.json + // so resolveModelInternal() returns '' instead of Claude aliases (opus/sonnet/haiku) + // that the runtime can't resolve. Users can still use model_overrides for explicit IDs. + // See #1156. + if (runtime !== 'claude') { + const gsdDir = path.join(os.homedir(), '.gsd'); + const defaultsPath = path.join(gsdDir, 'defaults.json'); + try { + fs.mkdirSync(gsdDir, { recursive: true }); + let defaults = {}; + try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ } + if (defaults.resolve_model_ids !== 'omit') { + defaults.resolve_model_ids = 'omit'; + fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n'); + console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`); + } + } catch (e) { + console.log(` ${yellow}⚠${reset} Could not write ~/.gsd/defaults.json: ${e.message}`); + } + } + let program = 'Claude Code'; if (runtime === 'opencode') program = 'OpenCode'; if (runtime === 'gemini') program = 'Gemini'; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 1fba6f877..61b069401 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -205,7 +205,7 @@ function loadConfig(cwd) { exa_search: false, text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus sub_repos: [], - resolve_model_ids: false, // when true, resolve aliases (opus/sonnet/haiku) to full model IDs + resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default) context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) }; @@ -885,12 +885,20 @@ const MODEL_ALIAS_MAP = { function resolveModelInternal(cwd, agentType) { const config = loadConfig(cwd); - // Check per-agent override first + // Check per-agent override first — always respected regardless of resolve_model_ids. + // Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that. const override = config.model_overrides?.[agentType]; if (override) { return override; } + // resolve_model_ids: "omit" — return empty string so the runtime uses its configured + // default model. For non-Claude runtimes (OpenCode, Codex, etc.) that don't recognize + // Claude aliases (opus/sonnet/haiku/inherit). Set automatically during install. See #1156. + if (config.resolve_model_ids === 'omit') { + return ''; + } + // Fall back to profile lookup const profile = String(config.model_profile || 'balanced').toLowerCase(); const agentModels = MODEL_PROFILES[agentType]; @@ -898,8 +906,8 @@ function resolveModelInternal(cwd, agentType) { if (profile === 'inherit') return 'inherit'; const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet'; - // If resolve_model_ids is true, map alias to full model ID - // This prevents 404s when the Task tool passes aliases directly to the API + // resolve_model_ids: true — map alias to full Claude model ID + // Prevents 404s when the Task tool passes aliases directly to the API if (config.resolve_model_ids) { return MODEL_ALIAS_MAP[alias] || alias; } diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 4e4c74216..43fd08eb5 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1195,15 +1195,16 @@ describe('websearch command', () => { const { cmdWebsearch } = require('../get-shit-done/bin/lib/commands.cjs'); let origFetch; let origApiKey; - let origStdoutWrite; + let origWriteSync; let captured; beforeEach(() => { origFetch = global.fetch; origApiKey = process.env.BRAVE_API_KEY; - origStdoutWrite = process.stdout.write; + origWriteSync = fs.writeSync; captured = ''; - process.stdout.write = (chunk) => { captured += chunk; return true; }; + // output() uses fs.writeSync(1, data) since #1276 — mock it to capture output + fs.writeSync = (fd, data) => { if (fd === 1) captured += data; return Buffer.byteLength(String(data)); }; }); afterEach(() => { @@ -1213,7 +1214,7 @@ describe('websearch command', () => { } else { delete process.env.BRAVE_API_KEY; } - process.stdout.write = origStdoutWrite; + fs.writeSync = origWriteSync; }); test('returns available=false when BRAVE_API_KEY is unset', async () => { diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 2463e30b5..a1b31074e 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -281,6 +281,31 @@ describe('resolveModelInternal', () => { assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus'); }); }); + + describe('resolve_model_ids: "omit"', () => { + test('returns empty string for known agents', () => { + writeConfig({ resolve_model_ids: 'omit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), ''); + }); + + test('returns empty string for unknown agents', () => { + writeConfig({ resolve_model_ids: 'omit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), ''); + }); + + test('still respects model_overrides even when omit', () => { + writeConfig({ + resolve_model_ids: 'omit', + model_overrides: { 'gsd-planner': 'openai/gpt-5.4' }, + }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'openai/gpt-5.4'); + }); + + test('returns empty string with inherit profile', () => { + writeConfig({ resolve_model_ids: 'omit', model_profile: 'inherit' }); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), ''); + }); + }); }); // ─── escapeRegex ───────────────────────────────────────────────────────────────