Adding “AI” to a product usually means picking one provider and wiring its SDK through the codebase. That works until you need a second option - a cheaper model, a local one for privacy, a corporate proxy - and the provider-specific calls have leaked into a dozen files.
OmniBrief supports eight backends: on-device Gemini Nano, a local Ollama or LM Studio server, a corporate proxy, and the Gemini, OpenAI, Claude, and OpenRouter cloud APIs. Every one of them sits behind a single summarize() call. The trick is a thin adapter contract that the rest of the app never looks past.
Key Technical Concepts Covered
- A minimal adapter contract: each provider exposes the same
id,summarize, andchatshape. - A registry, not a switch: adapters register themselves into one lookup map.
- Boundary-enforced privacy: “local only” mode blocks non-local providers before any request leaves.
- Per-provider config: keys and endpoints resolve at call time, not at import.
Part of the engineering behind OmniBrief.
The Adapter Contract
Every provider is just an object with an id, a summarize(), an optional chat(), and a declared maxInputChars. Nothing else in the app knows how any given model actually works:
// providers/chrome-nano.js
export default {
id: 'chrome-nano',
maxInputChars: 9000, // Nano's context window is small
summarize, // async (text, opts) => brief
chat, // async (messages) => string
};
A cloud adapter implements the same shape over fetch; a local-server adapter points at localhost. Because they are interchangeable, the summarizer layer can treat them all identically.
A Registry, Not a Switch
Rather than a growing switch (provider), OmniBrief collects every adapter into one map keyed by id. Adding a provider is one import and one array entry:
import chromeNano from './providers/chrome-nano.js';
import gemini from './providers/gemini.js';
import openai from './providers/openai.js';
import claude from './providers/claude.js';
import ollama from './providers/ollama.js';
// ...lm-studio, openrouter, corporate
export const ADAPTERS = Object.fromEntries(
[chromeNano, gemini, openai, claude, ollama, lmStudio, openrouter, corporate]
.map((a) => [a.id, a])
);
Privacy Enforced at the Boundary
The most important job of the selection layer is safety. When the user turns on “local only,” OmniBrief must guarantee that no page text is sent to a cloud endpoint - so the check lives at the single choke point where the active adapter is resolved, not scattered through call sites:
export async function activeAdapter() {
const settings = await getSettings();
const adapter = ADAPTERS[settings.provider] || chromeNano;
const info = providerInfo(adapter.id);
if (settings.privacy.localOnly && !info.local) {
throw new Error(`Privacy setting "local only" blocks the ${info.label} provider`);
}
return {
adapter,
config: settings.providerConfig[adapter.id] || {},
apiKey: info.needsKey ? await getApiKey(adapter.id) : null,
};
}
Keys and endpoints resolve here, at call time, from encrypted storage - never captured in module scope at import. A cloud adapter simply never receives a request when local-only mode is on.
Conclusion: Swap the Model, Not the App
A one-object contract plus a registry turns “which AI provider?” into a runtime setting instead of an architectural decision. OmniBrief can offer private on-device inference by default and let power users bring their own cloud key - without a single if (provider === ...) branch in the feature code.