@ateriss_/aiv-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/i18n/es.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { TranslationKeys } from './en';
2
+
3
+ export const es: TranslationKeys = {
4
+ // ── General ────────────────────────────────────────────────────────────────
5
+ notInitialized: 'No inicializado. Ejecuta `aiv init` primero.',
6
+ invalidProvider: 'Proveedor inválido. Elige: claude, openai, mock',
7
+ invalidPrNumber: 'Número de PR inválido.',
8
+ repoNotDetected: 'No se pudo detectar el repositorio. Usa --owner y --repo o configúralo en .aiv/config.yml',
9
+
10
+ // ── init ───────────────────────────────────────────────────────────────────
11
+ initAlreadyDone: 'aiv ya está inicializado. Usa --force para reinicializar.',
12
+ initTitle: ' aiv — Revisor de PRs con IA\n',
13
+ initWritingGlobalConfig: 'Escribiendo config global (~/.aiv/config.yml)...',
14
+ initGlobalConfigCreated: 'Config global creada (~/.aiv/config.yml)',
15
+ initGlobalConfigExists: 'La config global ya existe (~/.aiv/config.yml)',
16
+ initWritingConfig: 'Escribiendo config del repo (.aiv/config.yml)...',
17
+ initConfigCreated: 'Config del repo creada (.aiv/config.yml)',
18
+ initRulesCreated: 'rules.yml creado',
19
+ initScanningTree: 'Escaneando estructura del proyecto...',
20
+ initTreeCreated: 'tree.json creado',
21
+ initTreeSkipped: 'tree.json omitido (no se pudo escanear)',
22
+ initBuildingContext: 'Construyendo contexto del proyecto...',
23
+ initContextCreated: 'context.md creado',
24
+ initContextSkipped: 'context.md omitido (no se pudo analizar)',
25
+ initGitignoreUpdated: '.gitignore actualizado',
26
+ initSuccessTitle: '\n ¡Inicializado! Próximos pasos:\n',
27
+ initStep1: (envVar: string) => ` Configura tu API key: export ${envVar}=tu-clave`,
28
+ initStep2: (envVar: string) => ` Configura GitHub token: export ${envVar}=tu-token`,
29
+ initStep3: ' Listar PRs: aiv prs',
30
+ initStep4: ' Revisar un PR: aiv review <numero-pr>',
31
+ initEditContext: (path: string) => ` Edita ${path} para agregar contexto de negocio.`,
32
+ initEditRules: (path: string) => ` Edita ${path} para definir tus reglas.`,
33
+ initGlobalHint: ' Config global: ~/.aiv/config.yml',
34
+ initAddAccountHint: ' Agregar cuentas: aiv config add-account <nombre> --token-env <VAR>',
35
+
36
+ // ── selector ───────────────────────────────────────────────────────────────
37
+ selectorSelectPR: 'Selecciona un PR para revisar:',
38
+ selectorConfirmReview: 'Revisar',
39
+ selectorCancelled: ' Cancelado.\n',
40
+
41
+ // ── prs ────────────────────────────────────────────────────────────────────
42
+ prsFetching: (repo: string) => `Obteniendo PRs de ${repo}...`,
43
+ prsNoneFound: '\n No se encontraron pull requests abiertos.\n',
44
+ prsColPR: 'PR',
45
+ prsColTitle: 'Título',
46
+ prsColAuthor: 'Autor',
47
+ prsColBranch: 'Rama',
48
+ prsColChanges: 'Cambios',
49
+ prsColCreated: 'Creado',
50
+ prsFooter: (count: number) => ` Mostrando ${count} PR(s) abierto(s). Ejecuta aiv review <numero-pr> para analizar.\n`,
51
+ prsMissingToken: (envVar: string) => `Variable de entorno faltante: ${envVar}. Configura tu GitHub token.`,
52
+ prsFailed: (msg: string) => `Error: ${msg}`,
53
+
54
+ // ── review ─────────────────────────────────────────────────────────────────
55
+ reviewTitle: (n: number) => `\n aiv review — PR #${n}\n`,
56
+ reviewFetching: (n: number, repo: string) => `Obteniendo PR #${n} de ${repo}...`,
57
+ reviewLoaded: (title: string, files: number) => `PR cargado: ${title} (${files} archivos)`,
58
+ reviewFetchFailed: (msg: string) => `Error al obtener el PR: ${msg}`,
59
+ reviewLoadingContext: 'Cargando contexto del proyecto...',
60
+ reviewContextLoaded: 'Contexto cargado',
61
+ reviewRunningAgents: (agents: string) => `\n Ejecutando agentes: ${agents}\n`,
62
+ reviewFailed: (msg: string) => `\n Revisión fallida: ${msg}\n`,
63
+ reviewAccount: (name: string, envVar: string) => `Cuenta: ${name} (${envVar})`,
64
+
65
+ // ── context ────────────────────────────────────────────────────────────────
66
+ contextRefreshTitle: '\n Actualizando contexto...\n',
67
+ contextScanningTree: 'Escaneando estructura del proyecto...',
68
+ contextTreeUpdated: 'tree.json actualizado',
69
+ contextTreeFailed: (msg: string) => `tree.json falló: ${msg}`,
70
+ contextRebuildingCtx: 'Reconstruyendo context.md...',
71
+ contextCtxUpdated: 'context.md actualizado',
72
+ contextCtxFailed: (msg: string) => `context.md falló: ${msg}`,
73
+ contextEditHint: (path: string) => `\n Edita ${path} para agregar contexto personalizado.\n`,
74
+ contextNoFile: 'No se encontró context.md. Ejecuta `aiv context refresh`.',
75
+
76
+ // ── config ─────────────────────────────────────────────────────────────────
77
+ configGlobalTitle: ' ~/.aiv/config.yml (global)',
78
+ configNotCreated: ' (aún no creado)',
79
+ configRepoConfigTitle: ' .aiv/config.yml (repo)',
80
+ configRulesTitle: ' .aiv/rules.yml',
81
+ configProviderSet: (p: string) => ` Proveedor predeterminado: ${p}`,
82
+ configRepoSet: (o: string, r: string) => ` Repositorio configurado: ${o}/${r}`,
83
+ configNoRules: ' No se encontró rules.yml.',
84
+ configLangSet: (lang: string) => ` Idioma configurado: ${lang}`,
85
+ configInvalidLang: 'Idioma inválido. Elige: en, es',
86
+ configTokenEnvHint: (v: string) => ` Variable de token: ${v}`,
87
+ configUsernameHint: (u: string) => ` Usuario: ${u}`,
88
+ configRepoAccountHint: (a: string) => ` Cuenta del repo: ${a}`,
89
+ configSavedToRepo: ' Guardado en .aiv/config.yml',
90
+
91
+ // ── accounts ───────────────────────────────────────────────────────────────
92
+ accountsTitle: '\n Cuentas de GitHub\n',
93
+ accountsNone: ' Sin cuentas configuradas. Agrega una con: aiv config add-account <nombre>',
94
+ accountsColName: 'Nombre',
95
+ accountsColUser: 'Usuario',
96
+ accountsColEnvVar: 'Variable Token',
97
+ accountsColToken: 'Token',
98
+ accountsColDesc: 'Descripción',
99
+ accountsColDefault: 'Default',
100
+ accountsTokenFound: 'encontrado',
101
+ accountsTokenMissing: 'no encontrado',
102
+ accountsDefaultMark: '✔ default',
103
+ accountsAdded: (name: string) => ` Cuenta "${name}" agregada a la config global.`,
104
+ accountsRemoved: (name: string) => ` Cuenta "${name}" eliminada.`,
105
+ accountsDefaultSet: (name: string) => ` Cuenta predeterminada: ${name}`,
106
+ accountsRepoSet: (name: string) => ` Este repo usará la cuenta: ${name}`,
107
+ accountsNotFound: (name: string) => ` Cuenta "${name}" no encontrada.`,
108
+ accountsAlreadyExists: (name: string) => ` La cuenta "${name}" ya existe. Usa --force para sobreescribir.`,
109
+ accountsGlobalHint: '\n Config global: ~/.aiv/config.yml',
110
+ accountsRepoHint: ' Config repo: .aiv/config.yml\n',
111
+
112
+ // ── orchestrator ───────────────────────────────────────────────────────────
113
+ orchestratorRunning: (agent: string) => ` Ejecutando agente ${agent}...`,
114
+ orchestratorDone: (agent: string, count: number, score: number) => ` ${agent} — ${count} hallazgo(s) [puntuación: ${score}]`,
115
+ orchestratorAgentFailed: (agent: string, msg: string) => ` ${agent} falló: ${msg}`,
116
+ orchestratorFailedMsg: (msg: string) => `Agente falló: ${msg}`,
117
+ orchestratorFailedUnexpected: 'El agente falló inesperadamente.',
118
+ orchestratorOverallRisk: (label: string, score: number, total: number, agents: number) =>
119
+ `Riesgo general: ${label} (${score}/100). ${total} hallazgo(s) en ${agents} agente(s).`,
120
+ orchestratorCriticalFound: (count: number) => `${count} problema(s) de nivel alto/crítico requieren atención.`,
121
+ orchestratorNoCritical: 'No se detectaron problemas críticos.',
122
+
123
+ // ── renderer ───────────────────────────────────────────────────────────────
124
+ renderRiskScore: 'Riesgo:',
125
+ renderGenerated: 'Generado:',
126
+ renderExecutiveSummary: ' Resumen Ejecutivo',
127
+ renderSecurityIssues: ' Problemas de Seguridad',
128
+ renderBusinessRisks: ' Riesgos de Negocio',
129
+ renderArchitectureIssues: ' Problemas Arquitectónicos',
130
+ renderRegressions: ' Posibles Regresiones',
131
+ renderAgentSummaries: ' Resúmenes por Agente',
132
+ renderReviewTitle: (n: number, title: string) => ` Revisión: PR #${n} — ${title}`,
133
+ renderSuggestion: 'Sugerencia:',
134
+ severityCritical: 'CRÍTICO',
135
+ severityHigh: 'ALTO',
136
+ severityMedium: 'MEDIO',
137
+ severityLow: 'BAJO',
138
+ severityInfo: 'INFO',
139
+
140
+ // ── errors from config layer ────────────────────────────────────────────────
141
+ errorMissingToken: (envVar: string, account: string) => `Variable de entorno faltante: ${envVar} (cuenta: ${account})`,
142
+ errorAccountNotFound: (name: string) => `Cuenta "${name}" no encontrada.`,
143
+ errorAccountNotFoundGlobal: (name: string) =>
144
+ `Cuenta "${name}" no encontrada en la config global. Agrégala primero con: aiv config add-account ${name}`,
145
+
146
+ // ── custom providers ──────────────────────────────────────────────────────
147
+ customProviderAdded: (name: string) => ` Provider "${name}" agregado a la config global.`,
148
+ customProviderRemoved: (name: string) => ` Provider "${name}" eliminado.`,
149
+ customProviderNotFound: (name: string) => ` Provider "${name}" no encontrado en custom_providers.`,
150
+ customProviderAlreadyExists: (name: string) => ` Provider "${name}" ya existe. Usa --force para sobreescribir.`,
151
+ customProviderBaseUrlRequired: ' --base-url es requerido para providers personalizados.',
152
+ customProviderTitle: '\n Providers Personalizados (compatibles con OpenAI)\n',
153
+ customProviderNone: ' Sin providers personalizados. Agrega uno con: aiv config add-provider <nombre> --base-url <url> --api-key-env <VAR> --model <modelo>',
154
+ customProviderColName: 'Nombre',
155
+ customProviderColUrl: 'Base URL',
156
+ customProviderColModel: 'Modelo',
157
+ customProviderColEnvVar: 'Var Token',
158
+ customProviderColToken: 'Token',
159
+ builtinProviderSet: (name: string, model: string, envVar: string) => ` ${name}: model=${model}, key_env=${envVar}`,
160
+
161
+ // ── provider routing ───────────────────────────────────────────────────────
162
+ providerFallback: (from: string, to: string) => `Provider "${from}" excedió su cuota/límite — cambiando a "${to}"`,
163
+ providerAllFailed: 'Todos los providers en la cadena de fallback fallaron. Revisa tus API keys y cuotas.',
164
+ configAgentProviderSet: (agent: string, spec: string) => ` El agente "${agent}" usará: ${spec}`,
165
+ configFallbackSet: (chain: string) => ` Cadena de fallback configurada: ${chain}`,
166
+ configFallbackCleared: ' Cadena de fallback eliminada.',
167
+ configAgentProviderShow: '\n Providers por agente\n',
168
+ configAgentProviderNone: ' Sin providers por agente. Todos usan el provider predeterminado.',
169
+ configInvalidAgentName: (name: string) => ` Agente desconocido: "${name}". Válidos: business, architecture, security, context`,
170
+ configInvalidProviderSpec: (spec: string) => ` Spec inválido: "${spec}". Formato: provider o provider/modelo (ej. claude/claude-haiku-4-5)`,
171
+
172
+ // ── post-review action ─────────────────────────────────────────────────────
173
+ postReviewSelectAction: '¿Qué deseas hacer con este PR?',
174
+ postReviewSubmitting: 'Enviando revisión a GitHub...',
175
+ postReviewApproved: (n: number) => ` PR #${n} aprobado en GitHub.`,
176
+ postReviewChangesRequested: (n: number) => ` Se solicitaron cambios en el PR #${n}.`,
177
+ postReviewFailed: (msg: string) => `Error al enviar la revisión: ${msg}`,
178
+ postReviewRefreshing: 'Actualizando contexto del proyecto...',
179
+ postReviewRefreshed: 'Contexto actualizado.',
180
+
181
+ // ── context generate ───────────────────────────────────────────────────────
182
+ contextGenerateTitle: '\n Generando contexto y reglas con IA...\n',
183
+ contextGenerating: 'Analizando proyecto con IA...',
184
+ contextGenerateDone: 'Listo. Edita los archivos si es necesario.',
185
+ contextGenerateFailed: (msg: string) => `Generación fallida: ${msg}`,
186
+ contextGenerateConfirmOverwrite: (file: string) => `${file} ya existe. ¿Sobreescribir?`,
187
+ contextGenerateSkipped: (file: string) => ` ${file} omitido (se conservó el existente).`,
188
+ contextGenerateWritten: (file: string) => ` ${file} escrito.`,
189
+ contextGenerateProviderError: (envVar: string) => `Clave de IA faltante: ${envVar}. Revisa tu config de proveedor.`,
190
+
191
+ // ── agents command ─────────────────────────────────────────────────────────
192
+ agentsTitle: '\n aiv — Agentes Disponibles\n',
193
+ agentsColAgent: 'Agente',
194
+ agentsColDesc: 'Descripción',
195
+ agentsColFocus: 'Áreas de Enfoque',
196
+ agentsFooter: 'Ejecuta agentes específicos: aiv review <pr> --agent business security',
197
+ agentBusinessDesc: 'Analiza lógica de negocio, reglas de dominio y corrección funcional',
198
+ agentBusinessFocus: 'Lógica de negocio, invariantes de dominio, violaciones de reglas, regresiones funcionales',
199
+ agentArchDesc: 'Revisa patrones estructurales, acoplamiento de módulos y decisiones de diseño',
200
+ agentArchFocus: 'Violaciones de capas, acoplamiento, SRP, dirección de dependencias, calidad de abstracciones',
201
+ agentSecDesc: 'Detecta vulnerabilidades de seguridad y riesgos de exposición de datos',
202
+ agentSecFocus: 'Bypass de autenticación, inyección, filtración de datos, OWASP Top 10, datos sensibles',
203
+ };
@@ -0,0 +1,57 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { en, TranslationKeys } from './en';
5
+ import { es } from './es';
6
+
7
+ export type SupportedLang = 'en' | 'es';
8
+
9
+ const translations: Record<SupportedLang, TranslationKeys> = { en, es };
10
+
11
+ let _lang: SupportedLang = 'en';
12
+
13
+ export function initLang(): void {
14
+ // 1. AIV_LANG env var — instant override
15
+ const envLang = process.env['AIV_LANG'];
16
+ if (envLang && isSupported(envLang)) {
17
+ _lang = envLang;
18
+ return;
19
+ }
20
+
21
+ // 2. Global config (~/.aiv/config.yml) — lang is a global setting
22
+ try {
23
+ const globalConfig = path.join(os.homedir(), '.aiv', 'config.yml');
24
+ if (fs.existsSync(globalConfig)) {
25
+ const content = fs.readFileSync(globalConfig, 'utf8');
26
+ const match = /^\s*lang:\s*['"]?(\w+)['"]?\s*$/m.exec(content);
27
+ if (match && isSupported(match[1])) {
28
+ _lang = match[1];
29
+ return;
30
+ }
31
+ }
32
+ } catch { /* unreadable — continue */ }
33
+
34
+ // 3. System locale fallback
35
+ const sysLang = process.env['LANG'] ?? process.env['LANGUAGE'] ?? '';
36
+ if (sysLang.startsWith('es')) {
37
+ _lang = 'es';
38
+ }
39
+ }
40
+
41
+ export function setLang(lang: SupportedLang): void {
42
+ _lang = lang;
43
+ }
44
+
45
+ export function getLang(): SupportedLang {
46
+ return _lang;
47
+ }
48
+
49
+ export function t(): TranslationKeys {
50
+ return translations[_lang] ?? en;
51
+ }
52
+
53
+ export function isSupported(lang: string): lang is SupportedLang {
54
+ return lang === 'en' || lang === 'es';
55
+ }
56
+
57
+ export const SUPPORTED_LANGS: SupportedLang[] = ['en', 'es'];
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { initLang } from './i18n';
4
+ import { printBanner } from './cli/banner';
5
+ import { initCommand } from './cli/commands/init';
6
+ import { prsCommand } from './cli/commands/prs';
7
+ import { reviewCommand } from './cli/commands/review';
8
+ import { contextCommand } from './cli/commands/context';
9
+ import { configCommand } from './cli/commands/config';
10
+ import { agentsCommand } from './cli/commands/agents';
11
+
12
+ initLang();
13
+ printBanner();
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('aiv')
19
+ .description('AI-powered PR reviewer — local-first, multi-agent semantic analysis')
20
+ .version('0.1.0');
21
+
22
+ program.addCommand(initCommand());
23
+ program.addCommand(prsCommand());
24
+ program.addCommand(reviewCommand());
25
+ program.addCommand(contextCommand());
26
+ program.addCommand(configCommand());
27
+ program.addCommand(agentsCommand());
28
+
29
+ program.parse(process.argv);
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { ResolvedConfig, AivRules, PRDiff, ReviewResult, AgentFinding } from '../types';
4
+ import { createProviderFor } from '../providers/factory';
5
+ import { BusinessReviewer } from '../agents/business';
6
+ import { ArchitectureReviewer } from '../agents/architecture';
7
+ import { SecurityReviewer } from '../agents/security';
8
+ import { BaseAgent, AgentContext } from '../agents/base';
9
+ import { t } from '../i18n';
10
+
11
+ export class Orchestrator {
12
+ constructor(
13
+ private readonly config: ResolvedConfig,
14
+ private readonly rules: AivRules,
15
+ ) {}
16
+
17
+ async run(prDiff: PRDiff, projectContext: string, agentNames: string[]): Promise<ReviewResult> {
18
+ const agents = buildAgents(agentNames, this.config);
19
+
20
+ const agentCtx: AgentContext = { diff: prDiff, projectContext, rules: this.rules };
21
+ const agentResults = await runAgents(agents, agentCtx);
22
+
23
+ const overallScore = computeOverallScore(agentResults.map(r => r.riskScore));
24
+ const regressions = agentResults.flatMap(r => (r as any).possibleRegressions ?? []) as string[];
25
+
26
+ return {
27
+ prNumber: prDiff.pr.number,
28
+ prTitle: prDiff.pr.title,
29
+ executiveSummary: buildExecutiveSummary(agentResults, overallScore),
30
+ riskScore: overallScore,
31
+ riskLabel: scoreToLabel(overallScore),
32
+ agents: agentResults,
33
+ businessRisks: agentResults.find(r => r.agentName === 'business')?.findings ?? [],
34
+ architectureIssues: agentResults.find(r => r.agentName === 'architecture')?.findings ?? [],
35
+ securityIssues: agentResults.find(r => r.agentName === 'security')?.findings ?? [],
36
+ possibleRegressions: regressions,
37
+ generatedAt: new Date().toISOString(),
38
+ };
39
+ }
40
+ }
41
+
42
+ function buildAgents(names: string[], config: ResolvedConfig): BaseAgent[] {
43
+ const map: Record<string, (p: ReturnType<typeof createProviderFor>) => BaseAgent> = {
44
+ business: p => new BusinessReviewer(p),
45
+ architecture: p => new ArchitectureReviewer(p),
46
+ security: p => new SecurityReviewer(p),
47
+ };
48
+ return names.filter(n => n in map).map(n => map[n](createProviderFor(config, n)));
49
+ }
50
+
51
+ async function runAgents(agents: BaseAgent[], ctx: AgentContext) {
52
+ const results = await Promise.allSettled(
53
+ agents.map(async agent => {
54
+ const spinner = ora(t().orchestratorRunning(chalk.cyan(agent.agentName))).start();
55
+ try {
56
+ const result = await agent.run(ctx);
57
+ spinner.succeed(t().orchestratorDone(chalk.cyan(agent.agentName), result.findings.length, result.riskScore));
58
+ return result;
59
+ } catch (e: any) {
60
+ spinner.fail(chalk.red(t().orchestratorAgentFailed(chalk.cyan(agent.agentName), e.message)));
61
+ return {
62
+ agentName: agent.agentName,
63
+ findings: [],
64
+ summary: t().orchestratorFailedMsg(e.message),
65
+ riskScore: 0,
66
+ };
67
+ }
68
+ })
69
+ );
70
+
71
+ return results.map(r =>
72
+ r.status === 'fulfilled'
73
+ ? r.value
74
+ : { agentName: 'unknown', findings: [], summary: t().orchestratorFailedUnexpected, riskScore: 0 }
75
+ );
76
+ }
77
+
78
+ function computeOverallScore(scores: number[]): number {
79
+ if (scores.length === 0) return 0;
80
+ const max = Math.max(...scores);
81
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
82
+ return Math.round(max * 0.6 + avg * 0.4);
83
+ }
84
+
85
+ function buildExecutiveSummary(
86
+ agents: Array<{ agentName: string; summary: string; riskScore: number; findings: AgentFinding[] }>,
87
+ overallScore: number,
88
+ ): string {
89
+ const label = scoreToLabel(overallScore);
90
+ const totalFindings = agents.reduce((acc, a) => acc + a.findings.length, 0);
91
+ const critical = agents.flatMap(a => a.findings).filter(f => f.severity === 'critical' || f.severity === 'high').length;
92
+
93
+ const criticalLine = critical > 0
94
+ ? t().orchestratorCriticalFound(critical)
95
+ : t().orchestratorNoCritical;
96
+
97
+ return [
98
+ t().orchestratorOverallRisk(label, overallScore, totalFindings, agents.length),
99
+ criticalLine,
100
+ '',
101
+ ...agents.map(a => `[${a.agentName.toUpperCase()}] ${a.summary}`),
102
+ ].join('\n');
103
+ }
104
+
105
+ function scoreToLabel(score: number): 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' {
106
+ if (score >= 80) return 'CRITICAL';
107
+ if (score >= 60) return 'HIGH';
108
+ if (score >= 30) return 'MEDIUM';
109
+ return 'LOW';
110
+ }
@@ -0,0 +1,16 @@
1
+ export interface LLMMessage {
2
+ role: 'user' | 'assistant' | 'system';
3
+ content: string;
4
+ }
5
+
6
+ export interface LLMResponse {
7
+ content: string;
8
+ inputTokens?: number;
9
+ outputTokens?: number;
10
+ }
11
+
12
+ export interface LLMProvider {
13
+ complete(messages: LLMMessage[], systemPrompt?: string, maxTokens?: number): Promise<LLMResponse>;
14
+ readonly name: string;
15
+ readonly model: string;
16
+ }
@@ -0,0 +1,36 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { LLMProvider, LLMMessage, LLMResponse } from './base';
3
+
4
+ export class ClaudeProvider implements LLMProvider {
5
+ readonly name = 'claude';
6
+ readonly model: string;
7
+ private client: Anthropic;
8
+
9
+ constructor(apiKey: string, model: string = 'claude-sonnet-4-6') {
10
+ this.model = model;
11
+ this.client = new Anthropic({ apiKey });
12
+ }
13
+
14
+ async complete(messages: LLMMessage[], systemPrompt?: string, maxTokens: number = 4096): Promise<LLMResponse> {
15
+ const response = await this.client.messages.create({
16
+ model: this.model,
17
+ max_tokens: maxTokens,
18
+ system: systemPrompt,
19
+ messages: messages.map(m => ({
20
+ role: m.role === 'system' ? 'user' : m.role,
21
+ content: m.content,
22
+ })),
23
+ });
24
+
25
+ const content = response.content
26
+ .filter(b => b.type === 'text')
27
+ .map(b => (b as Anthropic.TextBlock).text)
28
+ .join('');
29
+
30
+ return {
31
+ content,
32
+ inputTokens: response.usage.input_tokens,
33
+ outputTokens: response.usage.output_tokens,
34
+ };
35
+ }
36
+ }
@@ -0,0 +1,84 @@
1
+ import chalk from 'chalk';
2
+ import { ResolvedConfig } from '../types';
3
+ import { LLMProvider } from './base';
4
+ import { ClaudeProvider } from './claude';
5
+ import { OpenAIProvider } from './openai';
6
+ import { GeminiProvider } from './gemini';
7
+ import { MockProvider } from './mock';
8
+ import { FallbackProvider } from './fallback';
9
+
10
+ export function createProvider(config: ResolvedConfig): LLMProvider {
11
+ return createProviderFor(config, '__default__');
12
+ }
13
+
14
+ export function createProviderFor(config: ResolvedConfig, role: string): LLMProvider {
15
+ const spec = config.providers.agents[role];
16
+ const primary = spec
17
+ ? resolveSpec(config, spec)
18
+ : resolveNamed(config, config.providers.default);
19
+
20
+ const fallbackNames = config.providers.fallback;
21
+ if (fallbackNames.length === 0) return primary;
22
+
23
+ const chain: LLMProvider[] = [primary];
24
+ for (const name of fallbackNames) {
25
+ const candidate = tryResolveNamed(config, name);
26
+ if (candidate && !isSameProvider(candidate, primary)) {
27
+ chain.push(candidate);
28
+ }
29
+ }
30
+
31
+ if (chain.length === 1) return primary;
32
+
33
+ return new FallbackProvider(chain, (from, to) => {
34
+ console.log(chalk.yellow(`\n ⚡ ${from} quota/rate limit — switching to ${to}`));
35
+ });
36
+ }
37
+
38
+ // Resolves "provider" or "provider/model" spec string
39
+ function resolveSpec(config: ResolvedConfig, spec: string): LLMProvider {
40
+ const slash = spec.indexOf('/');
41
+ const providerName = slash === -1 ? spec : spec.slice(0, slash);
42
+ const model = slash === -1 ? undefined : spec.slice(slash + 1);
43
+ return resolveNamed(config, providerName, model);
44
+ }
45
+
46
+ // Resolves a provider by name, with an optional model override
47
+ function resolveNamed(config: ResolvedConfig, name: string, modelOverride?: string): LLMProvider {
48
+ if (name === 'claude') {
49
+ const apiKey = process.env[config.claude.api_key_env];
50
+ if (!apiKey) throw new Error(`Missing env var: ${config.claude.api_key_env}`);
51
+ return new ClaudeProvider(apiKey, modelOverride ?? config.claude.model);
52
+ }
53
+
54
+ if (name === 'openai') {
55
+ const apiKey = process.env[config.openai.api_key_env];
56
+ if (!apiKey) throw new Error(`Missing env var: ${config.openai.api_key_env}`);
57
+ return new OpenAIProvider(apiKey, modelOverride ?? config.openai.model);
58
+ }
59
+
60
+ if (name === 'gemini') {
61
+ const apiKey = process.env[config.gemini.api_key_env];
62
+ if (!apiKey) throw new Error(`Missing env var: ${config.gemini.api_key_env}`);
63
+ return new GeminiProvider(apiKey, modelOverride ?? config.gemini.model);
64
+ }
65
+
66
+ if (name === 'mock') return new MockProvider();
67
+
68
+ // Custom OpenAI-compatible provider
69
+ const custom = config.custom_providers[name];
70
+ if (custom) {
71
+ const apiKey = custom.api_key_env ? (process.env[custom.api_key_env] ?? '') : '';
72
+ return new OpenAIProvider(apiKey, modelOverride ?? custom.model, custom.base_url, name);
73
+ }
74
+
75
+ throw new Error(`Unknown provider: "${name}". Add it with: aiv config add-provider ${name} --base-url <url> --api-key-env <VAR> --model <model>`);
76
+ }
77
+
78
+ function tryResolveNamed(config: ResolvedConfig, name: string): LLMProvider | null {
79
+ try { return resolveNamed(config, name); } catch { return null; }
80
+ }
81
+
82
+ function isSameProvider(a: LLMProvider, b: LLMProvider): boolean {
83
+ return a.name === b.name && a.model === b.model;
84
+ }
@@ -0,0 +1,47 @@
1
+ import { LLMProvider, LLMMessage, LLMResponse } from './base';
2
+
3
+ type FallbackCallback = (from: string, to: string) => void;
4
+
5
+ export class FallbackProvider implements LLMProvider {
6
+ private index = 0;
7
+
8
+ constructor(
9
+ private readonly chain: LLMProvider[],
10
+ private readonly onFallback?: FallbackCallback,
11
+ ) {
12
+ if (chain.length === 0) throw new Error('FallbackProvider requires at least one provider');
13
+ }
14
+
15
+ get name(): string { return this.chain[this.index].name; }
16
+ get model(): string { return this.chain[this.index].model; }
17
+
18
+ async complete(messages: LLMMessage[], systemPrompt?: string, maxTokens?: number): Promise<LLMResponse> {
19
+ for (let i = this.index; i < this.chain.length; i++) {
20
+ try {
21
+ const result = await this.chain[i].complete(messages, systemPrompt, maxTokens);
22
+ this.index = i;
23
+ return result;
24
+ } catch (e: unknown) {
25
+ if (!isRetriableError(e) || i + 1 >= this.chain.length) throw e;
26
+ this.onFallback?.(this.chain[i].name, this.chain[i + 1].name);
27
+ this.index = i + 1;
28
+ }
29
+ }
30
+ throw new Error('All providers in fallback chain exhausted');
31
+ }
32
+ }
33
+
34
+ function isRetriableError(e: unknown): boolean {
35
+ const status = (e as any)?.status ?? (e as any)?.statusCode ?? (e as any)?.httpStatus;
36
+ if (status === 429 || status === 529) return true;
37
+
38
+ const msg = e instanceof Error ? e.message.toLowerCase() : String(e).toLowerCase();
39
+ return (
40
+ msg.includes('rate_limit') ||
41
+ msg.includes('rate limit') ||
42
+ msg.includes('quota') ||
43
+ msg.includes('overloaded') ||
44
+ msg.includes('too many requests') ||
45
+ msg.includes('insufficient_quota')
46
+ );
47
+ }
@@ -0,0 +1,58 @@
1
+ import { LLMProvider, LLMMessage, LLMResponse } from './base';
2
+
3
+ const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
4
+
5
+ export class GeminiProvider implements LLMProvider {
6
+ readonly name = 'gemini';
7
+ readonly model: string;
8
+ private readonly apiKey: string;
9
+
10
+ constructor(apiKey: string, model: string = 'gemini-2.0-flash') {
11
+ this.apiKey = apiKey;
12
+ this.model = model;
13
+ }
14
+
15
+ async complete(messages: LLMMessage[], systemPrompt?: string, maxTokens: number = 4096): Promise<LLMResponse> {
16
+ const contents = messages
17
+ .filter(m => m.role !== 'system')
18
+ .map(m => ({
19
+ role: m.role === 'assistant' ? 'model' : 'user',
20
+ parts: [{ text: m.content }],
21
+ }));
22
+
23
+ const body: Record<string, unknown> = {
24
+ contents,
25
+ generationConfig: { maxOutputTokens: maxTokens },
26
+ };
27
+
28
+ if (systemPrompt) {
29
+ body['systemInstruction'] = { parts: [{ text: systemPrompt }] };
30
+ }
31
+
32
+ const url = `${GEMINI_BASE}/${this.model}:generateContent?key=${this.apiKey}`;
33
+ const { default: fetch } = await import('node-fetch');
34
+ const res = await fetch(url, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify(body),
38
+ });
39
+
40
+ if (!res.ok) {
41
+ const err = await res.json() as any;
42
+ const status = res.status;
43
+ const msg = err?.error?.message ?? 'unknown error';
44
+ throw Object.assign(new Error(`Gemini API error (${status}): ${msg}`), { status });
45
+ }
46
+
47
+ const data = await res.json() as any;
48
+ const content = (data?.candidates?.[0]?.content?.parts ?? [])
49
+ .map((p: any) => p.text ?? '')
50
+ .join('');
51
+
52
+ return {
53
+ content,
54
+ inputTokens: data?.usageMetadata?.promptTokenCount,
55
+ outputTokens: data?.usageMetadata?.candidatesTokenCount,
56
+ };
57
+ }
58
+ }