Provider Adapters
Normalizing disparate LLM APIs into a unified runtime streaming contract.
Every AI provider uses a distinct API schema, authorization header scheme, and streaming event layout. To handle this without filling our orchestration code with switch statements, Arbiter defines a strict interface contract.
The AgentAdapter Contract
All adapters under lib/agents/types.ts must satisfy the following TypeScript signature:
export interface AgentAdapter {
provider: 'openai' | 'claude' | 'gemini' | 'deepseek' | 'mistral';
streamWorker(input: WorkerInput): AsyncGenerator<AgentStreamEvent>;
streamEvaluator(input: EvaluatorInput): AsyncGenerator<AgentStreamEvent>;
runTitle(input: TitleInput): Promise<string>;
}Adapter Example: OpenAI
The OpenAI adapter (located in lib/agents/openai.ts) intercepts SSE stream lines. Since OpenAI does not yield token counts on streaming chunks by default, we configure stream_options: { include_usage: true } and parse the final chunk. Here is a stripped-down look at how delta parsing is normalized:
// From openAiAdapter.streamWorker:
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${input.apiKey}`
},
body: JSON.stringify({
model: input.modelId,
messages,
stream: true,
stream_options: { include_usage: true }
})
});
const reader = response.body.getReader();
const lineIterator = makeLineIterator(reader);
for await (const line of lineIterator) {
if (!line.startsWith('data: ')) continue;
const dataStr = line.slice(6).trim();
if (dataStr === '[DONE]') continue;
const parsed = JSON.parse(dataStr);
const delta = parsed.choices?.[0]?.delta?.content || '';
if (delta) {
yield { type: 'delta', text: delta };
}
}This design lets us swap or add providers (like Anthropic Claude or Google Gemini) easily by adding their respective files in lib/agents/ and registering them in lib/agents/registry.ts.