LLM adapter registry
A tiny façade for "which vendor handles this model?" — one stable id per vendor, one
create()factory per id, one process-wide map. Hosts can register their own (self-hosted vLLM, Bedrock, Vertex, private OpenAI-compatible endpoints) without forking the package.
The pieces
import {
seedDefaultAdapters,
registerAdapter,
getAdapter,
listAdapters,
unregisterAdapter,
createProvider,
openaiAdapter,
googleAdapter,
proxyAdapter,
type LLMAdapter,
type AdapterConfig,
} from '@perhapxin/dddk';
| Symbol | What it does |
|---|---|
LLMAdapter |
Interface a vendor adapter implements (id, optional matchesModel, required create). |
AdapterConfig |
Free-form config bag passed into create() (apiKey, baseURL, model, plus vendor extras). |
registerAdapter(a) |
Insert / replace an adapter in the registry by a.id. Idempotent. |
getAdapter(id) |
Lookup by id, undefined if missing. |
listAdapters() |
All registered adapters, in insertion order. |
unregisterAdapter(id) |
Remove one. Returns whether it existed. |
createProvider(spec, extra?) |
Build an LLMProvider from "openai:gpt-5.4-mini" or { adapter: 'openai', model, ... }. Throws if the adapter isn't registered. |
seedDefaultAdapters() |
Register the three built-ins. You must call this. See below. |
openaiAdapter / googleAdapter / proxyAdapter |
The three built-ins, also exported directly for selective registration. |
CRITICAL: call seedDefaultAdapters() at boot
The registry module does not auto-register the built-ins. If it did, a side-effect import would seed them — and tsup tree-shakes side-effect imports out of the bundle. You'd get a working dev build and a broken production build with no error until the first createProvider('openai:...') throws.
So the seed is an explicit function call:
import { seedDefaultAdapters } from '@perhapxin/dddk';
seedDefaultAdapters(); // run once at app boot
It's idempotent (registerAdapter just overwrites by id), so calling it twice is fine. Returns the list of registered ids for diagnostic logging.
Skip this step and createProvider('openai:gpt-5.4-mini') throws:
No LLM adapter registered for "openai". Did you call seedDefaultAdapters()?
The error mentions the function on purpose — it's almost always a boot-order bug.
Bundled adapters
openaiAdapter
id:'openai'matchesModel:/^(gpt-|o[1-9]|text-)/i— first-party OpenAI model families.create: wrapsOpenAIProviderwith{ apiKey, model, baseURL, organization, headers }.- Quirks (handled inside the provider):
gpt-5+and-mini/-nanoneedmax_completion_tokensnotmax_tokens; true reasoning models reject customtemperature.
googleAdapter
id:'google'matchesModel:/^(gemini-|gemma-)/i— covers both families on the same key.create: wrapsGoogleProviderwith{ apiKey, model, baseURL }.- Quirks (handled inside the provider): Gemini 3.x / Gemma 4 use
thinkingConfig.thinkingLevel(string), Gemini 2.5 usesthinkingConfig.thinkingBudget(number). Sending the wrong field returns HTTP 400.
proxyAdapter
id:'proxy'matchesModel: omitted on purpose — proxy endpoints don't carry a model-id naming convention.create: wrapsProxyProviderwith{ endpoint, method, headers, credentials, buildBody, parseResult, timeoutMs, name }. Throws ifendpointis missing.- The production-safe shape: client → your
/api/llm→ vendor. Client bundle never sees a vendor key. See security.
createProvider — two call shapes
String spec — "<adapter>:<model>":
const llm = createProvider('openai:gpt-5.4-mini', { apiKey });
const gem = createProvider('google:gemini-3.1-flash-lite-preview', { apiKey: googleKey });
Object form — explicit adapter field, useful when other config is dynamic:
const llm = createProvider({
adapter: 'proxy',
endpoint: '/api/llm',
credentials: 'include',
headers: { 'X-CSRF-Token': csrf },
});
The string form is just sugar over the object form. Anything after the colon is the model; everything else (apiKey, baseURL, headers, …) goes through the second arg.
Writing a custom adapter
The LLMAdapter interface is intentionally tiny:
interface LLMAdapter {
readonly id: string;
matchesModel?(modelId: string): boolean;
create(config: AdapterConfig): LLMProvider;
}
Self-hosted vLLM (OpenAI-compatible)
vLLM speaks OpenAI's wire format — so the adapter is a one-liner that points OpenAIProvider at your internal URL:
import { registerAdapter, OpenAIProvider, type LLMAdapter } from '@perhapxin/dddk';
const vllmAdapter: LLMAdapter = {
id: 'company-vllm',
matchesModel: (m) => m.startsWith('vllm/'),
create: (c) => new OpenAIProvider({
apiKey: String(c.apiKey ?? 'unused'),
baseURL: 'https://llm.internal.acme.com/v1',
model: c.model as string | undefined,
}),
};
registerAdapter(vllmAdapter);
const llm = createProvider('company-vllm:vllm/llama-3-70b-instruct');
Bedrock / Vertex / Azure OpenAI
These don't speak vanilla OpenAI on the wire — implement LLMProvider directly inside create:
registerAdapter({
id: 'bedrock',
matchesModel: (m) => m.startsWith('anthropic.') || m.startsWith('amazon.'),
create: (c) => ({
name: 'bedrock',
async complete(opts) {
const res = await bedrockClient.converse({
modelId: opts.model ?? (c.model as string),
messages: toBedrockMessages(opts.messages),
toolConfig: opts.tools && { tools: opts.tools.map(toBedrockTool) },
});
return {
content: extractText(res),
toolCalls: extractToolCalls(res),
usage: res.usage && {
promptTokens: res.usage.inputTokens,
completionTokens: res.usage.outputTokens,
},
finishReason: mapStopReason(res.stopReason),
};
},
}),
});
Three rules to stay compatible:
- Return
CompleteResultshape (content, optionaltoolCalls, optionalusage,finishReason). - Normalize tool calls to OpenAI shape (
{ id, name, arguments }). The agent loop expects that contract. - Respect
opts.signalsoagent.stop()can actually abort.
Replacing a built-in
Re-register the same id and you swap the implementation everywhere:
seedDefaultAdapters();
registerAdapter({
id: 'openai',
create: (c) => new OpenAIProvider({
...c,
apiKey: String(c.apiKey ?? ''),
baseURL: 'https://gateway.ai.cloudflare.com/v1/acct/gw/openai', // route via AI Gateway
}),
});
registerAdapter silently overwrites — order matters. This is the supported way to wrap the default in your own concerns (logging, retries, header injection).