246 lines
8.7 KiB
JavaScript
246 lines
8.7 KiB
JavaScript
// OpenRouter provider implementation
|
|
import axios from 'axios';
|
|
import { mapModelId } from '../model-mapping.js';
|
|
export class OpenRouterProvider {
|
|
name = 'openrouter';
|
|
type = 'openrouter';
|
|
supportsStreaming = true;
|
|
supportsTools = true;
|
|
supportsMCP = false; // Requires translation
|
|
client;
|
|
config;
|
|
constructor(config) {
|
|
this.config = config;
|
|
if (!config.apiKey) {
|
|
throw new Error('OpenRouter API key is required');
|
|
}
|
|
this.client = axios.create({
|
|
baseURL: config.baseUrl || 'https://openrouter.ai/api/v1',
|
|
headers: {
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
|
'HTTP-Referer': 'https://github.com/ruvnet/agentic-flow',
|
|
'X-Title': 'Agentic Flow Multi-Model Router',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: config.timeout || 180000
|
|
});
|
|
}
|
|
validateCapabilities(features) {
|
|
const supported = ['chat', 'streaming', 'tools'];
|
|
return features.every(f => supported.includes(f));
|
|
}
|
|
async chat(params) {
|
|
try {
|
|
const requestBody = this.formatRequest(params);
|
|
const response = await this.client.post('/chat/completions', requestBody);
|
|
return this.formatResponse(response.data, params.model);
|
|
}
|
|
catch (error) {
|
|
throw this.handleError(error);
|
|
}
|
|
}
|
|
async *stream(params) {
|
|
try {
|
|
const requestBody = this.formatRequest(params, true);
|
|
const response = await this.client.post('/chat/completions', requestBody, {
|
|
responseType: 'stream'
|
|
});
|
|
for await (const chunk of response.data) {
|
|
const lines = chunk.toString().split('\n').filter((line) => line.trim() !== '');
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6);
|
|
if (data === '[DONE]') {
|
|
yield { type: 'message_stop' };
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const streamChunk = this.formatStreamChunk(parsed);
|
|
if (streamChunk) {
|
|
yield streamChunk;
|
|
}
|
|
}
|
|
catch (e) {
|
|
// Skip invalid JSON
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
throw this.handleError(error);
|
|
}
|
|
}
|
|
formatRequest(params, stream = false) {
|
|
const messages = params.messages.map(msg => ({
|
|
role: msg.role,
|
|
content: typeof msg.content === 'string'
|
|
? msg.content
|
|
: msg.content.map(block => {
|
|
if (block.type === 'text')
|
|
return { type: 'text', text: block.text };
|
|
if (block.type === 'tool_use')
|
|
return {
|
|
type: 'function',
|
|
function: {
|
|
name: block.name,
|
|
arguments: JSON.stringify(block.input)
|
|
}
|
|
};
|
|
if (block.type === 'tool_result')
|
|
return {
|
|
type: 'function_result',
|
|
content: block.content,
|
|
is_error: block.is_error
|
|
};
|
|
return block;
|
|
})
|
|
}));
|
|
// Map model ID to OpenRouter format
|
|
const openrouterModel = mapModelId(params.model, 'openrouter');
|
|
const body = {
|
|
model: openrouterModel,
|
|
messages,
|
|
temperature: params.temperature ?? 0.7,
|
|
max_tokens: params.maxTokens,
|
|
stream
|
|
};
|
|
// Add tools if provided
|
|
if (params.tools && params.tools.length > 0) {
|
|
body.tools = params.tools.map(tool => ({
|
|
type: 'function',
|
|
function: {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: tool.input_schema
|
|
}
|
|
}));
|
|
if (params.toolChoice) {
|
|
if (params.toolChoice === 'auto' || params.toolChoice === 'none') {
|
|
body.tool_choice = params.toolChoice;
|
|
}
|
|
else if (typeof params.toolChoice === 'object') {
|
|
body.tool_choice = {
|
|
type: 'function',
|
|
function: { name: params.toolChoice.name }
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// Add OpenRouter-specific preferences
|
|
if (this.config.preferences) {
|
|
if (this.config.preferences.requireParameters) {
|
|
body.require_parameters = true;
|
|
}
|
|
if (this.config.preferences.dataCollection) {
|
|
body['X-Data-Collection'] = this.config.preferences.dataCollection;
|
|
}
|
|
if (this.config.preferences.order) {
|
|
body.provider = {
|
|
order: this.config.preferences.order
|
|
};
|
|
}
|
|
}
|
|
return body;
|
|
}
|
|
formatResponse(data, model) {
|
|
const choice = data.choices[0];
|
|
const message = choice.message;
|
|
const content = [];
|
|
// Handle text content
|
|
if (message.content) {
|
|
content.push({
|
|
type: 'text',
|
|
text: message.content
|
|
});
|
|
}
|
|
// Handle tool calls
|
|
if (message.tool_calls) {
|
|
for (const toolCall of message.tool_calls) {
|
|
content.push({
|
|
type: 'tool_use',
|
|
id: toolCall.id,
|
|
name: toolCall.function.name,
|
|
input: JSON.parse(toolCall.function.arguments)
|
|
});
|
|
}
|
|
}
|
|
const stopReason = this.mapFinishReason(choice.finish_reason);
|
|
return {
|
|
id: data.id,
|
|
model: data.model || model,
|
|
content,
|
|
stopReason,
|
|
usage: {
|
|
inputTokens: data.usage?.prompt_tokens || 0,
|
|
outputTokens: data.usage?.completion_tokens || 0
|
|
},
|
|
metadata: {
|
|
provider: 'openrouter',
|
|
cost: this.calculateCost(data.usage),
|
|
latency: 0 // Will be set by router
|
|
}
|
|
};
|
|
}
|
|
formatStreamChunk(data) {
|
|
const choice = data.choices?.[0];
|
|
if (!choice)
|
|
return null;
|
|
const delta = choice.delta;
|
|
if (delta?.content) {
|
|
return {
|
|
type: 'content_block_delta',
|
|
delta: {
|
|
type: 'text_delta',
|
|
text: delta.content
|
|
}
|
|
};
|
|
}
|
|
if (delta?.tool_calls) {
|
|
const toolCall = delta.tool_calls[0];
|
|
return {
|
|
type: 'content_block_delta',
|
|
delta: {
|
|
type: 'input_json_delta',
|
|
partial_json: toolCall.function?.arguments || ''
|
|
}
|
|
};
|
|
}
|
|
if (choice.finish_reason) {
|
|
return {
|
|
type: 'message_stop',
|
|
usage: data.usage ? {
|
|
inputTokens: data.usage.prompt_tokens || 0,
|
|
outputTokens: data.usage.completion_tokens || 0
|
|
} : undefined
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
mapFinishReason(reason) {
|
|
switch (reason) {
|
|
case 'stop': return 'end_turn';
|
|
case 'length': return 'max_tokens';
|
|
case 'tool_calls': return 'tool_use';
|
|
default: return 'end_turn';
|
|
}
|
|
}
|
|
calculateCost(usage) {
|
|
if (!usage)
|
|
return 0;
|
|
// OpenRouter pricing varies by model
|
|
// Using average pricing: $0.01/1K input, $0.03/1K output
|
|
const inputCost = (usage.prompt_tokens || 0) * 0.00001;
|
|
const outputCost = (usage.completion_tokens || 0) * 0.00003;
|
|
return inputCost + outputCost;
|
|
}
|
|
handleError(error) {
|
|
const providerError = new Error(error.response?.data?.error?.message || error.message || 'OpenRouter request failed');
|
|
providerError.provider = 'openrouter';
|
|
providerError.statusCode = error.response?.status;
|
|
providerError.retryable = error.response?.status >= 500 || error.code === 'ECONNABORTED';
|
|
return providerError;
|
|
}
|
|
}
|
|
//# sourceMappingURL=openrouter.js.map
|