feat(providers): OpenRouter adapter + Ollama embedding support — M3-004/006 (#311)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #311.
This commit is contained in:
212
apps/gateway/src/agent/adapters/openrouter.adapter.ts
Normal file
212
apps/gateway/src/agent/adapters/openrouter.adapter.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import OpenAI from 'openai';
|
||||
import type {
|
||||
CompletionEvent,
|
||||
CompletionParams,
|
||||
IProviderAdapter,
|
||||
ModelInfo,
|
||||
ProviderHealth,
|
||||
} from '@mosaic/types';
|
||||
|
||||
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
interface OpenRouterModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
context_length?: number;
|
||||
top_provider?: {
|
||||
max_completion_tokens?: number;
|
||||
};
|
||||
pricing?: {
|
||||
prompt?: string | number;
|
||||
completion?: string | number;
|
||||
};
|
||||
architecture?: {
|
||||
input_modalities?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenRouterModelsResponse {
|
||||
data?: OpenRouterModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter provider adapter.
|
||||
*
|
||||
* Routes completions through OpenRouter's OpenAI-compatible API.
|
||||
* Configuration is driven by the OPENROUTER_API_KEY environment variable.
|
||||
*/
|
||||
export class OpenRouterAdapter implements IProviderAdapter {
|
||||
readonly name = 'openrouter';
|
||||
|
||||
private readonly logger = new Logger(OpenRouterAdapter.name);
|
||||
private client: OpenAI | null = null;
|
||||
private registeredModels: ModelInfo[] = [];
|
||||
|
||||
async register(): Promise<void> {
|
||||
const apiKey = process.env['OPENROUTER_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping OpenRouter provider registration: OPENROUTER_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: OPENROUTER_BASE_URL,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': 'https://mosaic.ai',
|
||||
'X-Title': 'Mosaic',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
this.registeredModels = await this.fetchModels(apiKey);
|
||||
this.logger.log(`OpenRouter provider registered with ${this.registeredModels.length} models`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`OpenRouter model discovery failed: ${err instanceof Error ? err.message : String(err)}. Registering with empty model list.`,
|
||||
);
|
||||
this.registeredModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
listModels(): ModelInfo[] {
|
||||
return this.registeredModels;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ProviderHealth> {
|
||||
const apiKey = process.env['OPENROUTER_API_KEY'];
|
||||
if (!apiKey) {
|
||||
return {
|
||||
status: 'down',
|
||||
lastChecked: new Date().toISOString(),
|
||||
error: 'OPENROUTER_API_KEY not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${OPENROUTER_BASE_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
latencyMs,
|
||||
lastChecked: new Date().toISOString(),
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() };
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - start;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a completion through OpenRouter's OpenAI-compatible API.
|
||||
*/
|
||||
async *createCompletion(params: CompletionParams): AsyncIterable<CompletionEvent> {
|
||||
if (!this.client) {
|
||||
throw new Error('OpenRouterAdapter is not initialized. Ensure OPENROUTER_API_KEY is set.');
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const choice = chunk.choices[0];
|
||||
if (!choice) continue;
|
||||
|
||||
const delta = choice.delta;
|
||||
|
||||
if (delta.content) {
|
||||
yield { type: 'text_delta', content: delta.content };
|
||||
}
|
||||
|
||||
if (choice.finish_reason === 'stop') {
|
||||
const usage = (chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } })
|
||||
.usage;
|
||||
if (usage) {
|
||||
inputTokens = usage.prompt_tokens ?? 0;
|
||||
outputTokens = usage.completion_tokens ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'done',
|
||||
usage: { inputTokens, outputTokens },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async fetchModels(apiKey: string): Promise<ModelInfo[]> {
|
||||
const res = await fetch(`${OPENROUTER_BASE_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenRouter models endpoint returned HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as OpenRouterModelsResponse;
|
||||
const data = json.data ?? [];
|
||||
|
||||
return data.map((model): ModelInfo => {
|
||||
const inputPrice = model.pricing?.prompt
|
||||
? parseFloat(String(model.pricing.prompt)) * 1000
|
||||
: 0;
|
||||
const outputPrice = model.pricing?.completion
|
||||
? parseFloat(String(model.pricing.completion)) * 1000
|
||||
: 0;
|
||||
|
||||
const inputModalities = model.architecture?.input_modalities ?? ['text'];
|
||||
const inputTypes = inputModalities.includes('image')
|
||||
? (['text', 'image'] as const)
|
||||
: (['text'] as const);
|
||||
|
||||
return {
|
||||
id: model.id,
|
||||
provider: 'openrouter',
|
||||
name: model.name ?? model.id,
|
||||
reasoning: false,
|
||||
contextWindow: model.context_length ?? 4096,
|
||||
maxTokens: model.top_provider?.max_completion_tokens ?? 4096,
|
||||
inputTypes: [...inputTypes],
|
||||
cost: {
|
||||
input: inputPrice,
|
||||
output: outputPrice,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user