Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion bin/sidecar.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,37 @@ async function main() {
*/
async function handleStart(args) {
// Resolve model alias or use config default before validation
const { resolveModel } = require('../src/utils/config');
const { resolveModel, detectFallback, loadConfig } = require('../src/utils/config');
const rawAlias = args.model;
try {
args.model = resolveModel(args.model);
} catch (err) {
console.error(err.message);
process.exit(1);
}

// Determine the alias used (explicit or config default)
let alias = rawAlias;
if (alias === undefined) {
const cfg = loadConfig();
if (cfg && cfg.default && !cfg.default.includes('/')) {
alias = cfg.default;
}
}

// Validate direct-API fallback models exist on the provider (opt-in via --validate-model)
if (args['validate-model'] && alias && detectFallback(alias, args.model)) {
const { validateDirectModel } = require('../src/utils/model-validator');
try {
args.model = await validateDirectModel(args.model, alias, {
headless: args['no-ui'] || !process.stdin.isTTY
});
} catch (err) {
console.error(err.message);
process.exit(1);
}
}

// Normalize agent: --agent takes precedence, otherwise use --mode
// (Must happen before validation so --mode alias is also validated)
args.agent = args.agent || args.mode;
Expand Down
4 changes: 3 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ function isBooleanFlag(key) {
'json',
'version',
'help',
'api-keys'
'api-keys',
'validate-model'
];
return booleanFlags.includes(key);
}
Expand Down Expand Up @@ -313,6 +314,7 @@ Options for 'start':
--mcp-config <path> Path to opencode.json with MCP config
--no-mcp Don't inherit MCP servers from parent LLM
--exclude-mcp <name> Exclude specific MCP server (repeatable)
--validate-model Verify model exists on provider API (opt-in)
--position <pos> Window position: right (default), left, center

Options for 'list':
Expand Down
7 changes: 6 additions & 1 deletion src/utils/api-key-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const PROVIDER_ENV_MAP = {
openrouter: 'OPENROUTER_API_KEY',
google: 'GEMINI_API_KEY',
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY'
anthropic: 'ANTHROPIC_API_KEY',
deepseek: 'DEEPSEEK_API_KEY'
Comment on lines +14 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Initialize deepseek in the default return objects.

readApiKeys() and readApiKeyHints() still seed only four providers. With this map change, both helpers return undefined instead of false for DeepSeek until a key is set, and callers iterating Object.keys(result) will never see the new provider.

Suggested fix
 function readApiKeys() {
-  const result = { openrouter: false, google: false, openai: false, anthropic: false };
+  const result = Object.fromEntries(
+    Object.keys(PROVIDER_ENV_MAP).map(provider => [provider, false])
+  );
   const entries = loadEnvEntries();
   for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
     if (resolveKeyValue(entries, envVar)) { result[provider] = true; }
   }
   return result;
 }
@@
 function readApiKeyHints() {
-  const result = { openrouter: false, google: false, openai: false, anthropic: false };
+  const result = Object.fromEntries(
+    Object.keys(PROVIDER_ENV_MAP).map(provider => [provider, false])
+  );
   const entries = loadEnvEntries();
   for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
     const key = resolveKeyValue(entries, envVar);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/api-key-store.js` around lines 14 - 15, The default objects
returned by readApiKeys() and readApiKeyHints() currently only seed four
providers so the newly added provider key "deepseek" is missing; update both
readApiKeys and readApiKeyHints to include an explicit "deepseek" entry
(initialize to false or the same default value other providers use) so callers
iterating Object.keys(result) will see DeepSeek and callers get false rather
than undefined. Locate readApiKeys and readApiKeyHints in
src/utils/api-key-store.js and add the "deepseek" property to their
seeded/default return objects to match the updated provider map.

};

/** Validation endpoints per provider */
Expand All @@ -34,6 +35,10 @@ const VALIDATION_ENDPOINTS = {
google: {
url: 'https://generativelanguage.googleapis.com/v1beta/models',
authHeader: () => ({})
},
deepseek: {
url: 'https://api.deepseek.com/models',
authHeader: (key) => ({ 'Authorization': `Bearer ${key}` })
}
};

Expand Down
78 changes: 39 additions & 39 deletions src/utils/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { PROVIDER_ENV_MAP } = require('./api-key-store');
const { logger } = require('./logger');

/**
* Default model alias map
* Maps short alias names to full OpenRouter model identifiers.
*/
/** Default model alias map — short names to full OpenRouter model identifiers */
const DEFAULT_ALIASES = {
'gemini': 'openrouter/google/gemini-3.1-flash-lite-preview',
'gemini-pro': 'openrouter/google/gemini-3.1-pro-preview',
Expand All @@ -37,10 +36,7 @@ const DEFAULT_ALIASES = {
'seed': 'openrouter/bytedance-seed/seed-2.0-mini',
};

/**
* Get the sidecar configuration directory path
* @returns {string} Config directory path
*/
/** @returns {string} Config directory path */
function getConfigDir() {
if (process.env.SIDECAR_CONFIG_DIR) {
const resolved = path.resolve(process.env.SIDECAR_CONFIG_DIR);
Expand All @@ -53,18 +49,12 @@ function getConfigDir() {
return path.join(homeDir, '.config', 'sidecar');
}

/**
* Get the path to the config.json file
* @returns {string} Full path to config.json
*/
/** @returns {string} Full path to config.json */
function getConfigPath() {
return path.join(getConfigDir(), 'config.json');
}

/**
* Load and parse the config file
* @returns {object|null} Parsed config data, or null if missing/invalid
*/
/** @returns {object|null} Parsed config data, or null if missing/invalid */
function loadConfig() {
const configPath = getConfigPath();
try {
Expand All @@ -81,25 +71,37 @@ function loadConfig() {
}
}

/**
* Save config data to disk, creating the directory if needed
* @param {object} configData - Configuration object to persist
*/
/** Save config data to disk, creating the directory if needed */
function saveConfig(configData) {
const configDir = getConfigDir();
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
const configPath = getConfigPath();
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), { mode: 0o600 });
}

/**
* Get the default alias map
* @returns {object} Map of alias name to full model identifier
*/
/** @returns {object} Copy of the default alias map */
function getDefaultAliases() {
return { ...DEFAULT_ALIASES };
}

/** Strip openrouter/ prefix when direct provider API key is available but OPENROUTER_API_KEY is not */
function applyDirectApiFallback(model) {
if (!model.startsWith('openrouter/') || process.env.OPENROUTER_API_KEY) {
return model;
}
const direct = model.slice('openrouter/'.length);
const envVar = PROVIDER_ENV_MAP[direct.split('/')[0]];
if (envVar && process.env[envVar]) {
logger.warn({ msg: 'Using direct provider API (OPENROUTER_API_KEY not set)', original: model, resolved: direct });
process.stderr.write(
`Notice: Using direct ${direct.split('/')[0]} API (OPENROUTER_API_KEY not set). ` +
'Use --validate-model to verify model availability.\n'
);
return direct;
Comment on lines +87 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the persisted key store when deciding direct fallback.

applyDirectApiFallback() only checks process.env. Users who saved GEMINI_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY via Sidecar have those keys in ~/.config/sidecar/.env, not necessarily exported in the shell, so built-in aliases still resolve to openrouter/... and fail when OPENROUTER_API_KEY is absent.

Suggested fix
-const { PROVIDER_ENV_MAP } = require('./api-key-store');
+const { PROVIDER_ENV_MAP, readApiKeyValues } = require('./api-key-store');
@@
 function applyDirectApiFallback(model) {
   if (!model.startsWith('openrouter/') || process.env.OPENROUTER_API_KEY) {
     return model;
   }
   const direct = model.slice('openrouter/'.length);
-  const envVar = PROVIDER_ENV_MAP[direct.split('/')[0]];
-  if (envVar && process.env[envVar]) {
+  const provider = direct.split('/')[0];
+  const envVar = PROVIDER_ENV_MAP[provider];
+  const keyValues = readApiKeyValues();
+  if (envVar && keyValues[provider]) {
     logger.warn({ msg: 'Using direct provider API (OPENROUTER_API_KEY not set)', original: model, resolved: direct });
     process.stderr.write(
-      `Notice: Using direct ${direct.split('/')[0]} API (OPENROUTER_API_KEY not set). ` +
+      `Notice: Using direct ${provider} API (OPENROUTER_API_KEY not set). ` +
       'Use --validate-model to verify model availability.\n'
     );
     return direct;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/config.js` around lines 87 - 100, applyDirectApiFallback currently
only checks process.env for provider API keys, so saved keys in Sidecar's
persisted store (~/.config/sidecar/.env) are ignored; update
applyDirectApiFallback to consult the persisted key store in addition to
process.env when computing envVar (use PROVIDER_ENV_MAP[...]) — e.g., add a
helper (loadPersistedKeys/getPersistedKey) and treat a found persisted key as
equivalent to process.env[envVar] when deciding to return direct; keep the
existing logger.warn/process.stderr notice behavior when falling back and
reference the same symbols (applyDirectApiFallback, PROVIDER_ENV_MAP,
logger.warn) so resolution works when users saved keys via Sidecar.

}
return model;
}

/**
* Resolve a model argument to a full model identifier
*
Expand Down Expand Up @@ -128,7 +130,7 @@ function resolveModel(modelArg) {

// Try to resolve as alias (user config + defaults)
if (effectiveAliases[modelArg] !== undefined) {
return effectiveAliases[modelArg];
return applyDirectApiFallback(effectiveAliases[modelArg]);
}

// Unknown alias
Expand All @@ -153,7 +155,7 @@ function resolveModel(modelArg) {

// Default is an alias - resolve via user config + defaults
if (effectiveAliases[defaultValue] !== undefined) {
return effectiveAliases[defaultValue];
return applyDirectApiFallback(effectiveAliases[defaultValue]);
}

// Default alias not found anywhere
Expand All @@ -162,10 +164,7 @@ function resolveModel(modelArg) {
);
}

/**
* Compute SHA-256 hash of the config file content (first 8 hex chars)
* @returns {string|null} 8-char hex hash, or null if no config file
*/
/** @returns {string|null} 8-char hex hash of config file, or null if missing */
function computeConfigHash() {
const configPath = getConfigPath();
try {
Expand All @@ -179,10 +178,7 @@ function computeConfigHash() {
}
}

/**
* Format aliases as a markdown table with (default) marker
* @returns {string} Markdown-formatted alias table, or empty string if no aliases
*/
/** @returns {string} Markdown alias table with (default) marker, or empty string */
function buildAliasTable() {
const config = loadConfig();
if (!config || !config.aliases || Object.keys(config.aliases).length === 0) {
Expand All @@ -203,11 +199,7 @@ function buildAliasTable() {
return lines.join('\n');
}

/**
* Check whether the config file has changed compared to a known hash
* @param {string|null} currentHash - Previously known hash to compare
* @returns {{changed: boolean, newHash: string|null, updateData?: string}}
*/
/** Check whether the config file has changed compared to a known hash */
function checkConfigChanged(currentHash) {
const newHash = computeConfigHash();

Expand Down Expand Up @@ -281,13 +273,21 @@ function buildProviderModels() {
return providers;
}

/** Detect if direct API fallback was applied during alias resolution */
function detectFallback(alias, resolvedModel) {
if (!alias || alias.includes('/')) { return false; }
const val = getEffectiveAliases()[alias];
return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
}

module.exports = {
getConfigDir,
getConfigPath,
loadConfig,
saveConfig,
getDefaultAliases,
resolveModel,
detectFallback,
computeConfigHash,
buildAliasTable,
checkConfigChanged,
Expand Down
141 changes: 141 additions & 0 deletions src/utils/model-validator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Model Validator
*
* Validates that a direct-API fallback model exists on the provider.
* When a model is not found, prompts the user to pick an alternative
* (interactive) or fails with available models (headless).
*/

const readline = require('readline');
const { fetchModelsFromProvider } = require('./model-fetcher');
const { readApiKeyValues } = require('./api-key-store');
const { loadConfig, saveConfig, getConfigPath } = require('./config');
const { logger } = require('./logger');

/** Alias-to-search-term mapping for filtering provider model lists */
const ALIAS_SEARCH_TERMS = {
'gemini': 'gemini', 'gemini-pro': 'gemini',
'gpt': 'gpt', 'gpt-pro': 'gpt', 'codex': 'gpt',
'claude': 'claude', 'sonnet': 'claude', 'opus': 'claude', 'haiku': 'claude',
'deepseek': 'deepseek',
};

/**
* Validate a direct-API fallback model exists on the provider.
* Returns silently if valid. On failure: prompts (interactive) or throws (headless).
*
* @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
* @param {string} alias - Original alias name (e.g. 'gemini')
* @param {object} [options]
* @param {boolean} [options.headless] - If true, throw instead of prompting
* @returns {Promise<string>} Confirmed model string
*/
async function validateDirectModel(resolvedModel, alias, options = {}) {
const parts = resolvedModel.split('/');
if (parts.length < 2) { return resolvedModel; }

const provider = parts[0];
const modelId = parts.slice(1).join('/');

const keys = readApiKeyValues();
const providerKey = keys[provider];
if (!providerKey) { return resolvedModel; }

let models;
try {
models = await fetchModelsFromProvider(provider, providerKey);
} catch (err) {
logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
return resolvedModel;
}

if (!models || models.length === 0) { return resolvedModel; }

const found = models.some(m => m.id === resolvedModel || m.id === modelId);
if (found) { return resolvedModel; }

const relevant = filterRelevantModels(models, alias);

if (options.headless || !process.stdin.isTTY) {
const list = relevant.slice(0, 10).map(m => ` ${m.id}`).join('\n');
throw new Error(
`Model '${modelId}' not found on ${provider} API.\n` +
`Available models:\n${list}\n` +
`Fix with: sidecar setup --add-alias ${alias}=${relevant[0]?.id || 'provider/model'}`
Comment on lines +54 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist a provider-qualified model ID.

found already accepts both resolvedModel and bare modelId, so m.id is not guaranteed to include the provider prefix. Saving or suggesting raw selected.id can write values like gemini-2.5-flash into config.json, which later fails provider/model validation on the next run.

Suggested fix
+function normalizeModelId(provider, id) {
+  return id.includes('/') ? id : `${provider}/${id}`;
+}
+
 async function validateDirectModel(resolvedModel, alias, options = {}) {
@@
   if (options.headless || !process.stdin.isTTY) {
-    const list = relevant.slice(0, 10).map(m => `  ${m.id}`).join('\n');
+    const list = relevant
+      .slice(0, 10)
+      .map(m => `  ${normalizeModelId(provider, m.id)}`)
+      .join('\n');
     throw new Error(
       `Model '${modelId}' not found on ${provider} API.\n` +
       `Available models:\n${list}\n` +
-      `Fix with: sidecar setup --add-alias ${alias}=${relevant[0]?.id || 'provider/model'}`
+      `Fix with: sidecar setup --add-alias ${alias}=${
+        relevant[0] ? normalizeModelId(provider, relevant[0].id) : `${provider}/model`
+      }`
     );
   }
@@
-  const newModel = selected.id;
+  const newModel = normalizeModelId(provider, selected.id);

Also applies to: 113-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/model-validator.js` around lines 54 - 64, The code may persist or
suggest bare model IDs (e.g., 'gemini-2.5-flash') because found checks both
resolvedModel and modelId but m.id may lack a provider prefix; update places
that return or suggest model identifiers (the branch returning resolvedModel and
the suggestion using relevant[0]?.id in the headless/error message) to always
emit a provider-qualified ID: if the candidate id does not include a '/', prefix
it with `${provider}/` before returning or showing it. Apply the same
normalization logic wherever model IDs are persisted or suggested (including the
similar block around lines 113-136) and use filterRelevantModels/relevant as the
source but ensure provider-qualification before writing to config or showing the
fix hint.

);
}

return promptModelSelection(relevant, alias, provider, modelId);
}

/**
* Filter models to those relevant to the alias
* @param {Array<{id: string, name: string}>} models
* @param {string} alias - e.g. 'gemini', 'gpt', 'opus'
* @returns {Array<{id: string, name: string}>} Filtered, sorted, max 15
*/
function filterRelevantModels(models, alias) {
const term = (ALIAS_SEARCH_TERMS[alias] || alias).toLowerCase();

let filtered = models.filter(m =>
m.id.toLowerCase().includes(term) ||
m.name.toLowerCase().includes(term)
);

if (filtered.length === 0) { filtered = models; }

filtered.sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, 15);
}

/** Interactive prompt — ask user to pick from available models */
async function promptModelSelection(models, alias, provider, failedModelId) {
process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
process.stderr.write(' Available models:\n');
models.forEach((m, i) => {
const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
process.stderr.write(` ${i + 1}. ${label}\n`);
});
process.stderr.write('\n');

const rl = readline.createInterface({ input: process.stdin, output: process.stderr });

const answer = await new Promise(resolve => {
rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
});
rl.close();

const idx = parseInt(answer, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= models.length) {
throw new Error('Model selection cancelled.');
}

const selected = models[idx];
const newModel = selected.id;

let config = loadConfig();
if (!config) {
const fs = require('fs');
const configPath = getConfigPath();
if (fs.existsSync(configPath)) {
throw new Error(
`Cannot save model selection: config file at ${configPath} is malformed. ` +
'Fix it manually or run \'sidecar setup\'.'
);
}
config = {};
}
if (!config.aliases) { config.aliases = {}; }
config.aliases[alias] = newModel;
try {
saveConfig(config);
process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
} catch (err) {
process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
}
process.stderr.write(` (To change later: sidecar setup --add-alias ${alias}=...)\n\n`);

return newModel;
}

module.exports = { validateDirectModel, filterRelevantModels };
Loading