-
Notifications
You must be signed in to change notification settings - Fork 7
fix: fall back to direct provider API when OpenRouter key is missing #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8518baf
cfbf5ba
9a84730
8a7419e
7da6918
1001fa0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
|
@@ -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); | ||
|
|
@@ -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 { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the persisted key store when deciding direct fallback.
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 |
||
| } | ||
| return model; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a model argument to a full model identifier | ||
| * | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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) { | ||
|
|
@@ -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(); | ||
|
|
||
|
|
@@ -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, | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Persist a provider-qualified model ID.
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 |
||
| ); | ||
| } | ||
|
|
||
| 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 }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initialize
deepseekin the default return objects.readApiKeys()andreadApiKeyHints()still seed only four providers. With this map change, both helpers returnundefinedinstead offalsefor DeepSeek until a key is set, and callers iteratingObject.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