feat: validate model IDs before processing requests

Add model validation cache with 5-minute TTL to reject invalid model IDs
upfront instead of sending them to the API. This provides better error
messages and avoids unnecessary API calls.

- Add MODEL_VALIDATION_CACHE_TTL_MS constant (5 min)
- Add isValidModel() with lazy cache population
- Warm cache when listModels() is called
- Validate model ID in /v1/messages before processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2026-02-01 19:07:23 +05:30
parent 2ab0c7943d
commit 90b38bbb56
4 changed files with 102 additions and 5 deletions

View File

@@ -12,12 +12,12 @@
// Re-export public API
export { sendMessage } from './message-handler.js';
export { sendMessageStream } from './streaming-handler.js';
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js';
// Default export for backwards compatibility
import { sendMessage } from './message-handler.js';
import { sendMessageStream } from './streaming-handler.js';
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js';
export default {
sendMessage,
@@ -25,5 +25,6 @@ export default {
listModels,
fetchAvailableModels,
getModelQuotas,
getSubscriptionTier
getSubscriptionTier,
isValidModel
};

View File

@@ -9,10 +9,18 @@ import {
ANTIGRAVITY_HEADERS,
LOAD_CODE_ASSIST_ENDPOINTS,
LOAD_CODE_ASSIST_HEADERS,
getModelFamily
getModelFamily,
MODEL_VALIDATION_CACHE_TTL_MS
} from '../constants.js';
import { logger } from '../utils/logger.js';
// Model validation cache
const modelCache = {
validModels: new Set(),
lastFetched: 0,
fetchPromise: null // Prevents concurrent fetches
};
/**
* Check if a model is supported (Claude or Gemini)
* @param {string} modelId - Model ID to check
@@ -46,6 +54,10 @@ export async function listModels(token) {
description: modelData.displayName || modelId
}));
// Warm the model validation cache
modelCache.validModels = new Set(modelList.map(m => m.id));
modelCache.lastFetched = Date.now();
return {
object: 'list',
data: modelList
@@ -246,3 +258,71 @@ export async function getSubscriptionTier(token) {
logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
return { tier: 'free', projectId: null };
}
/**
* Populate the model validation cache
* @param {string} token - OAuth access token
* @param {string} [projectId] - Optional project ID
* @returns {Promise<void>}
*/
async function populateModelCache(token, projectId = null) {
const now = Date.now();
// Check if cache is fresh
if (modelCache.validModels.size > 0 && (now - modelCache.lastFetched) < MODEL_VALIDATION_CACHE_TTL_MS) {
return;
}
// If already fetching, wait for it
if (modelCache.fetchPromise) {
await modelCache.fetchPromise;
return;
}
// Start fetch
modelCache.fetchPromise = (async () => {
try {
const data = await fetchAvailableModels(token, projectId);
if (data && data.models) {
const validIds = Object.keys(data.models).filter(modelId => isSupportedModel(modelId));
modelCache.validModels = new Set(validIds);
modelCache.lastFetched = Date.now();
logger.debug(`[CloudCode] Model cache populated with ${validIds.length} models`);
}
} catch (error) {
logger.warn(`[CloudCode] Failed to populate model cache: ${error.message}`);
// Don't throw - validation should degrade gracefully
} finally {
modelCache.fetchPromise = null;
}
})();
await modelCache.fetchPromise;
}
/**
* Check if a model ID is valid (exists in the available models list)
* Uses a cached model list with TTL-based refresh
* @param {string} modelId - Model ID to validate
* @param {string} token - OAuth access token for cache population
* @param {string} [projectId] - Optional project ID
* @returns {Promise<boolean>} True if model is valid
*/
export async function isValidModel(modelId, token, projectId = null) {
try {
// Populate cache if needed
await populateModelCache(token, projectId);
// If cache is populated, validate against it
if (modelCache.validModels.size > 0) {
return modelCache.validModels.has(modelId);
}
// Cache empty (fetch failed) - fail open, let API validate
return true;
} catch (error) {
logger.debug(`[CloudCode] Model validation error: ${error.message}`);
// Fail open - let the API validate
return true;
}
}

View File

@@ -156,6 +156,9 @@ export const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
// Cache TTL for Gemini thoughtSignatures (2 hours)
export const GEMINI_SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
// Cache TTL for model validation (5 minutes)
export const MODEL_VALIDATION_CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Get the model family from model name (dynamic detection, no hardcoded list).
* @param {string} modelName - The model name from the request
@@ -295,6 +298,7 @@ export default {
GEMINI_MAX_OUTPUT_TOKENS,
GEMINI_SKIP_SIGNATURE,
GEMINI_SIGNATURE_CACHE_TTL_MS,
MODEL_VALIDATION_CACHE_TTL_MS,
getModelFamily,
isThinkingModel,
OAUTH_CONFIG,

View File

@@ -8,7 +8,7 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier, isValidModel } from './cloudcode/index.js';
import { mountWebUI } from './webui/index.js';
import { config } from './config.js';
@@ -720,6 +720,18 @@ app.post('/v1/messages', async (req, res) => {
const modelId = requestedModel;
// Validate model ID before processing
const { account: validationAccount } = accountManager.selectAccount();
if (validationAccount) {
const token = await accountManager.getTokenForAccount(validationAccount);
const projectId = validationAccount.subscription?.projectId || null;
const valid = await isValidModel(modelId, token, projectId);
if (!valid) {
throw new Error(`invalid_request_error: Invalid model: ${modelId}. Use /v1/models to see available models.`);
}
}
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
// If we have some available accounts, we try them first.
if (accountManager.isAllRateLimited(modelId)) {