NOTES & IDEAS
Four Different AIs, One Wall Socket
My AI PR reviewer lets each user pick their own AI - OpenAI, Gemini, Claude, or Grok - and the rest of the app doesn't know or care which. Here's the simple "wall socket" idea that makes adding a new one a one-file change.
AI PR Reviewer lets every user bring their own AI. One person hooks up OpenAI, another Gemini, another Claude or Grok. But the actual job - read the code change, get feedback, post comments on GitHub - has to work the same no matter which AI they picked.
There's a wrong way and a right way to build that, and the difference is the same one between rewiring your house for every appliance and just… using a wall socket.
The wrong way: wires everywhere
The tempting approach is to scatter "which AI is it?" checks all over the code:
if (provider === 'openai') { /* ... */ }
else if (provider === 'gemini') { /* ... */ }
else if (provider === 'claude') { /* ... */ }
// ...and again, in every file that touches the AI
This is like hard-wiring every appliance directly into your walls. Add a new appliance and you're back inside the walls, running new wires in a dozen places. Miss one and something breaks.
The right way: put a socket on the wall
In your house, the wall has a standard socket. Every appliance - toaster, lamp, charger - has a plug that fits that same socket. The house doesn't know or care what's plugged in; it just provides power through one shape of hole. You can swap a lamp for a toaster and the wall doesn't change at all.
So I gave the app one "socket" that every AI has to plug into. The rest of the app only ever talks to the socket - never to a specific AI brand.
Step 1: define the socket shape
Each AI brand speaks its own language and hands back differently-shaped answers. The socket's whole job is to flatten all of that into one method that always returns one shape:
export interface AiSuggestion {
filePath: string;
lineNumber: number;
agentType: 'SECURITY' | 'PERFORMANCE' | 'ARCHITECTURE';
issue: string;
suggestion: string;
priority: 'HIGH' | 'MEDIUM' | 'LOW';
}
export abstract class BaseAiProvider {
protected apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
// Each AI fills this in its own way, but ALL of them return the same shape.
abstract analyzeCode(diff: string, customRules?: string[]): Promise<AiSuggestion[]>;
}
That analyzeCode method is the socket. GeminiProvider, OpenAiProvider,
ClaudeProvider, and GrokProvider are the four plugs - each one talks to its own
AI inside, but each one hands back the identical AiSuggestion[] shape. The caller
genuinely cannot tell them apart.
Step 2: share the wiring all plugs need
The base class isn't just the socket shape - it's also a home for the stuff every AI needs. The big one is the instructions we send the AI, including a strict rule that it must answer in clean JSON so we can read every brand's reply the same way:
protected getSystemPrompt(customRules: string[] = []): string {
const basePrompt = `
You are a strict, world-class software security and code quality auditor.
Examine the following code diff and return structural improvements.
OUTPUT FORMAT RULES:
You MUST output STRICT, RAW JSON only. Do not output markdown (like \`\`\`json).
The response must be an array of objects:
{ "filePath": string, "lineNumber": number,
"agentType": "SECURITY" | "PERFORMANCE" | "ARCHITECTURE",
"issue": string, "suggestion": string,
"priority": "HIGH" | "MEDIUM" | "LOW" }
`;
const rulesInjection = customRules.length
? `\nADDITIONAL USER RULES TO ENFORCE:\n${customRules.join('\n')}`
: '';
return basePrompt + rulesInjection;
}
Because this lives on the base class, there's exactly one place that defines what a review looks like (plus a slot for a user's own rules), and all four plugs inherit it for free.
Step 3: the one spot that picks the plug
Something still has to take the user's choice and plug in the matching appliance. That's one small function - and it's the only place in the whole codebase that checks which brand it is:
export class AiProviderFactory {
static getProvider(providerType: AiProvider, apiKey: string): BaseAiProvider {
switch (providerType) {
case AiProvider.GEMINI: return new GeminiProvider(apiKey);
case AiProvider.OPENAI: return new OpenAiProvider(apiKey);
case AiProvider.CLAUDE: return new ClaudeProvider(apiKey);
case AiProvider.GROK: return new GrokProvider(apiKey);
default:
throw new Error(`Unknown or unsupported AI Provider: ${providerType}`);
}
}
}
Everywhere else just holds "some plug" and calls analyzeCode. The worker that
reviews a pull request has zero brand-specific logic in it:
const provider = AiProviderFactory.getProvider(vault.provider, rawApiKey);
const suggestions = await provider.analyzeCode(diff, customRules);
// post `suggestions` back to GitHub - identical for every AI
Why it's worth the small bit of ceremony
- Adding a fifth AI is a one-file change. Write
MistralProvider(a new plug) and add one line to that switch. Nothing else in the app moves. - Unknown brand fails loudly. That
default: throwmeans an unsupported choice blows up right at the door, instead of quietly returning nothing deep in the pipeline where it's a nightmare to trace. - Testing gets easy. You can plug in a fake AI that returns canned feedback and test the whole review flow without calling a real model or spending a cent.
- One source of truth. The same list of supported brands guards the key vault, the factory, and the input validation - so "which AIs do we support?" has exactly one answer.
Takeaway
Whenever you have several versions of the same idea - payment providers, storage backends, notification channels, or here, AI models - resist the urge to check "which one is it?" all over your code. Define one socket shape, make every version fit it, and put the one spot that picks a version in a single place. The rest of your app gets to stay blissfully unaware of what's plugged in - which is exactly what "swappable" is supposed to mean.