@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
251 lines • 7.34 kB
JavaScript
import { CACHE_MODELS_EXPIRATION_MS } from '../constants.js';
import { logError } from '../utils/message-queue.js';
const OPENROUTER_API = 'https://openrouter.ai/api/v1/models';
let modelCache = null;
/**
* Known open-weight model prefixes/patterns
*/
const OPEN_WEIGHT_PATTERNS = [
'meta-llama/',
'mistralai/',
'qwen/',
'deepseek/',
'google/gemma',
'microsoft/phi',
'nvidia/',
'cohere/command-r',
'databricks/',
'allenai/',
'huggingfaceh4/',
'openchat/',
'teknium/',
'nousresearch/',
'cognitivecomputations/',
'thebloke/',
'codellama/',
'/llama',
'/mistral',
'/qwen',
'/deepseek',
'/gemma',
'/phi-',
'/wizardlm',
'/vicuna',
'/falcon',
'/starcoder',
'/codestral',
'/devstral',
];
/**
* Check if a model is open-weight based on ID
*/
function isOpenWeight(modelId) {
const lowerId = modelId.toLowerCase();
return OPEN_WEIGHT_PATTERNS.some(pattern => lowerId.includes(pattern));
}
/**
* Extract author from model ID
*/
function extractAuthor(modelId) {
const parts = modelId.split('/');
if (parts.length >= 2) {
const author = parts[0];
// Capitalize first letter
return author.charAt(0).toUpperCase() + author.slice(1);
}
return 'Unknown';
}
/**
* Check if model supports text input and output
*/
function supportsTextInTextOut(model) {
const inputMods = model.architecture?.input_modalities || [];
const outputMods = model.architecture?.output_modalities || [];
const hasTextInput = inputMods.length === 0 || inputMods.includes('text');
const hasTextOutput = outputMods.length === 0 || outputMods.includes('text');
return hasTextInput && hasTextOutput;
}
/**
* Check if model is relevant for coding (has tool support or is a known coding model)
*/
function isRelevantForCoding(model) {
const modelId = model.id.toLowerCase();
const modelName = model.name.toLowerCase();
const description = (model.description || '').toLowerCase();
// Must support text in/out
if (!supportsTextInTextOut(model))
return false;
// Skip embedding models
if (modelId.includes('embed') || modelName.includes('embed'))
return false;
// Skip image/audio only models
if (modelId.includes('dall-e') ||
modelId.includes('stable-diffusion') ||
modelId.includes('whisper') ||
modelId.includes('tts') ||
modelId.includes('imagen')) {
return false;
}
// Has tool calling support
if (model.supported_parameters?.includes('tools'))
return true;
// Known coding-capable models
const codingPatterns = [
'gpt',
'claude',
'gemini',
'deepseek',
'qwen',
'coder',
'codestral',
'devstral',
'llama',
'mistral',
'phi',
'command',
'grok',
'codex',
];
if (codingPatterns.some(p => modelId.includes(p) || modelName.includes(p))) {
return true;
}
// Description mentions coding
if (description.includes('code') ||
description.includes('programming') ||
description.includes('developer')) {
return true;
}
return false;
}
/**
* Calculate cost score (0-10 scale, 10 = free/cheapest)
*/
function calculateCostScore(promptPrice, completionPrice) {
const input = parseFloat(promptPrice) || 0;
const output = parseFloat(completionPrice) || 0;
// Price per million tokens (prices are per token)
const inputPerMillion = input * 1_000_000;
const outputPerMillion = output * 1_000_000;
// Weighted average (output typically more important)
const avgCost = inputPerMillion * 0.3 + outputPerMillion * 0.7;
if (avgCost === 0)
return 10;
if (avgCost < 0.1)
return 9;
if (avgCost < 0.5)
return 8;
if (avgCost < 1)
return 7;
if (avgCost < 2)
return 6;
if (avgCost < 5)
return 5;
if (avgCost < 10)
return 4;
if (avgCost < 20)
return 3;
if (avgCost < 50)
return 2;
return 1;
}
/**
* Format cost details string
*/
function formatCostDetails(promptPrice, completionPrice, isLocal) {
const input = parseFloat(promptPrice) || 0;
const output = parseFloat(completionPrice) || 0;
if (input === 0 && output === 0) {
return isLocal ? 'Free (open weights)' : 'Free';
}
// Convert to per million tokens
const inputPerMillion = input * 1_000_000;
const outputPerMillion = output * 1_000_000;
return `$${inputPerMillion.toFixed(2)}/M in, $${outputPerMillion.toFixed(2)}/M out`;
}
/**
* Format context length for display
*/
function formatContextLength(contextLength) {
if (contextLength >= 1_000_000) {
return `${(contextLength / 1_000_000).toFixed(1)}M`;
}
if (contextLength >= 1000) {
return `${Math.round(contextLength / 1000)}K`;
}
return `${contextLength}`;
}
/**
* Fetch models from OpenRouter API
*/
async function fetchOpenRouterModels() {
try {
const response = await fetch(OPENROUTER_API);
if (!response.ok) {
throw new Error(`OpenRouter API returned ${response.status}`);
}
const data = (await response.json());
return data.data || [];
}
catch (error) {
logError('Failed to fetch OpenRouter models', true, { error });
return [];
}
}
/**
* Fetch and process models from OpenRouter
*/
export async function fetchModels() {
// Check cache first
if (modelCache &&
Date.now() - modelCache.timestamp < CACHE_MODELS_EXPIRATION_MS) {
return modelCache.models;
}
const openRouterModels = await fetchOpenRouterModels();
const models = [];
for (const model of openRouterModels) {
// Skip if not relevant for coding
if (!isRelevantForCoding(model))
continue;
const isLocal = isOpenWeight(model.id);
const costScore = calculateCostScore(model.pricing.prompt, model.pricing.completion);
const entry = {
id: model.id,
name: model.name,
author: extractAuthor(model.id),
size: formatContextLength(model.context_length),
local: isLocal,
api: true,
contextLength: model.context_length,
created: model.created,
quality: {
cost: costScore,
},
costType: costScore >= 9 ? 'free' : 'paid',
costDetails: formatCostDetails(model.pricing.prompt, model.pricing.completion, isLocal),
hasToolSupport: model.supported_parameters?.includes('tools') || false,
};
models.push(entry);
}
// Sort by created date (newest first)
models.sort((a, b) => (b.created || 0) - (a.created || 0));
// Update cache
modelCache = {
models,
timestamp: Date.now(),
};
return models;
}
/**
* Clear the model cache (useful for forcing a refresh)
*/
export function clearModelCache() {
modelCache = null;
}
/**
* Check if models are currently cached
*/
export function isModelsCached() {
return (modelCache !== null &&
Date.now() - modelCache.timestamp < CACHE_MODELS_EXPIRATION_MS);
}
//# sourceMappingURL=model-fetcher.js.map