A Chrome Extension (Manifest V3) that lets any webpage call an LLM API while keeping the API key completely secure. The key is stored only in the extension's background service worker and is never exposed to:
- The webpage JavaScript runtime
- The webpage network panel
- Any injected or content script
All HTTP requests to the LLM provider are executed exclusively in the extension's service worker.
- Manifest V3 with service worker background
- TypeScript + Vite + CRXJS
- 16 providers (including Custom OpenAI-compatible): OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistral, Moonshot, Azure OpenAI, Fireworks, Anyscale, Perplexity, SiliconFlow, Zhipu, MiniMax, Lingyi, Custom
- Streaming via Port-based messaging with
AbortControllersupport - Rate limiting — 60 requests/minute per tab
- AI Context Provider — store structured data locally (themes, prompts, JSON) and inject it into LLM requests as system messages; webpages can request read access with user approval
- Schema system — group context entries by structural type; pages can register schemas via API
- Secure confirmation popup —
saveContext()opens a native extension popup so users grant permission inside the extension boundary
pnpm install
pnpm build # production build → dist/
pnpm dev # watch mode
pnpm test # run unit tests (Vitest)
pnpm test:watch # Vitest watch modeLoad the extension:
- Open Chrome →
chrome://extensions/ - Enable Developer mode
- Click Load unpacked → select the
dist/folder
- Click the extension icon in the toolbar → Options
- Select provider and enter your API key
- Optionally set a custom base URL and model
- Save
All methods are available on window.ThemedLLM. The object is frozen and non-configurable — page code cannot replace or delete it.
// Non-streaming — returns full response as a string
const reply = await window.ThemedLLM.chat([
{ role: 'user', content: 'Hello!' }
]);
// Streaming — calls onToken for each token as it arrives
await window.ThemedLLM.chatStream(
[{ role: 'user', content: 'Count to 5' }],
(token) => process.stdout.write(token)
);
// With options
await window.ThemedLLM.chat(messages, {
temperature: 0.8,
max_tokens: 1024,
signal: abortController.signal, // cancellation support
contextKeys: ['my_prompt', 'theme_data'] // inject stored context entries
});Context entries are key-value pairs stored locally in the extension. When a request includes contextKeys, the matching entries are injected as system messages before the user's messages. Webpages can read keys or content only after the user approves access.
// Save a context entry — opens a native confirm popup for user approval
await window.ThemedLLM.saveContext('my_prompt', 'You are a helpful assistant.');
// Save with a schema tag
await window.ThemedLLM.saveContext('dark_theme', JSON.stringify(themeObj), {
schemaId: '@my-app/theme'
});
// List all stored entry keys
const keys = await window.ThemedLLM.listContextKeys();
// Filter by schema
const themeKeys = await window.ThemedLLM.listContextKeys({ schemaId: '@my-app/theme' });
// Read back the content of an entry (prompts for permission)
const content = await window.ThemedLLM.getContext('my_prompt');Note:
saveContext()rejects duplicate keys unless you pass{ overwrite: true }. You can also edit entries in Options.
Context keys: 1–100 characters; Unicode is allowed. Control characters, line breaks, bidi overrides, zero-width space, and BOM are rejected;
__proto__,constructor, andprototypeare reserved.
Schemas group context entries (e.g. all themes of one shape). Schema IDs use a namespaced pattern such as @my-app/theme. Optional fields may mark keys as required: true — then any saveContext(..., { schemaId }) must supply JSON object content that includes those keys with the declared type (string / number / boolean / object). Fields without required are documentation-only.
// Register a schema (idempotent — safe to call on every page load)
await window.ThemedLLM.registerSchema({
id: '@my-app/theme',
name: 'App Theme',
description: 'Color and typography tokens',
autoInject: true, // all entries of this schema are injected into every chat() call
fields: [
{ key: 'colors.primary', type: 'string', required: true, description: 'Primary brand color' },
{ key: 'fonts.body', type: 'string', description: 'Body font family' },
]
});
// List all registered schemas
const schemas = await window.ThemedLLM.listSchemas();
// → [{ id: '@my-app/theme', name: 'App Theme', description: '...' }]Note: Pages can only create or update schemas with
source: 'page'. Schemas created manually in the Options page (source: 'user') cannot be overwritten by page code.
autoInject: true— when set, all entries belonging to this schema are automatically injected as system messages in everychat()/chatStream()call, without needing to passcontextKeysexplicitly. ExplicitcontextKeysalways take priority; auto-injected entries fill up to the 20-key cap. The toggle can also be set from the Options page.
const llm = await window.ThemedLLM.getCurrentLLM();
// → { provider: 'openai', model: 'gpt-4o-mini' } or null if not configuredPage (window.ThemedLLM)
→ postMessage
Content Script (ISOLATED world — bridge only)
→ chrome.runtime Port / sendMessage
Background Service Worker (API key lives here)
→ fetch → LLM provider HTTPS API
| Layer | API Key | Role |
|---|---|---|
| Injected script | Never | Exposes window.ThemedLLM, no chrome APIs |
| Content script | Never | Bridges postMessage ↔ chrome.runtime |
| Background SW | At request time | Storage read, all HTTP requests |
| Options page | Write-only (masked) | Config + context management |
For a full technical reference including message protocol, error codes, and data flow diagrams, see docs/ARCHITECTURE.md.
Themed.js integration demo — registers a schema, saves theme entries, uses the extension as an LLM backend with no API key on the page:
pnpm example:themedOpen http://localhost:5174. This demo is a local web page; the extension must be loaded from dist/ and configured in Options for it to work. You can also open the built-in Demo page from the Options footer.
Chrome 120+