Implement complete InferenceProvider system with OpenRouter integration
## Features - 🔧 InferenceProvider interface with pluggable architecture - 🔑 OpenRouter API key and OAuth PKCE authentication providers - 🛠️ Tool calling support with automatic agent loop execution - 📱 Interactive test UI with model selection and chat interface - 🎯 Provider-level filtering to tool-capable models only - ⚛️ React context with proper state management for UI reactivity ## Components - Core types and interfaces in src/types/inference.ts - OpenRouter client with shared utilities for both auth methods - Two separate providers (composition over inheritance): - OpenRouterApiProvider for API key authentication - OpenRouterOAuthProvider for OAuth PKCE flow - React InferenceContext with useInference hook - Interactive test UI with tool calling demonstration - OAuth callback routing with proper namespacing ## Tool Calling - 3 test tools: weather, calculator, time - Automatic tool execution and agent loop - Visual tool call display with arguments and results - Error handling for malformed tool calls ## Bug Fixes - Fixed model selection UI reactivity issue - Fixed OAuth popup race condition causing "cancelled" errors - Proper cleanup of event listeners and intervals ## Documentation - Comprehensive interface design document - Implementation strategy and key design decisions - Testing approach and security considerations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
aa97d655aa
commit
438ceb333c
6 changed files with 8564 additions and 0 deletions
3
src/providers/index.ts
Normal file
3
src/providers/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// All inference providers
|
||||
|
||||
export * from './openrouter';
|
||||
155
src/providers/openrouter/api-provider.ts
Normal file
155
src/providers/openrouter/api-provider.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// OpenRouter API Key Provider
|
||||
|
||||
import { InferenceProvider } from '@/types/inference';
|
||||
import type {
|
||||
Model,
|
||||
InferenceRequest,
|
||||
InferenceResponse,
|
||||
AuthConfig,
|
||||
ProviderCapabilities,
|
||||
InferenceError,
|
||||
} from '@/types/inference';
|
||||
import { OpenRouterClient } from './client';
|
||||
import type { OpenRouterApiConfig } from './types';
|
||||
|
||||
export class OpenRouterApiProvider extends InferenceProvider {
|
||||
readonly name = 'OpenRouter (API Key)';
|
||||
readonly id = 'openrouter-api';
|
||||
|
||||
private client: OpenRouterClient;
|
||||
private apiKey?: string;
|
||||
private _models: Model[] = [];
|
||||
private _selectedModel?: Model;
|
||||
private _authError?: string;
|
||||
|
||||
constructor(config?: Partial<OpenRouterApiConfig>) {
|
||||
super();
|
||||
this.client = new OpenRouterClient(config || {});
|
||||
if (config?.apiKey) {
|
||||
this.apiKey = config.apiKey;
|
||||
}
|
||||
if (config?.defaultModel) {
|
||||
// We'll set this after loading models
|
||||
}
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return !!this.apiKey && !this._authError;
|
||||
}
|
||||
|
||||
get authError(): string | undefined {
|
||||
return this._authError;
|
||||
}
|
||||
|
||||
get models(): Model[] {
|
||||
return this._models;
|
||||
}
|
||||
|
||||
get selectedModel(): Model | undefined {
|
||||
return this._selectedModel;
|
||||
}
|
||||
|
||||
async authenticate(config: AuthConfig): Promise<void> {
|
||||
if (config.type !== 'api_key' || !config.apiKey) {
|
||||
throw new Error('OpenRouterApiProvider requires API key authentication');
|
||||
}
|
||||
|
||||
this.apiKey = config.apiKey;
|
||||
this._authError = undefined;
|
||||
|
||||
try {
|
||||
// Test the API key by loading models
|
||||
await this.loadModels();
|
||||
} catch (error) {
|
||||
this._authError = error instanceof Error ? error.message : 'Authentication failed';
|
||||
this.apiKey = undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.apiKey = undefined;
|
||||
this._authError = undefined;
|
||||
this._models = [];
|
||||
this._selectedModel = undefined;
|
||||
}
|
||||
|
||||
async loadModels(): Promise<Model[]> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
this._models = await this.client.fetchModels(this.apiKey);
|
||||
|
||||
// Check if currently selected model is still in the filtered list
|
||||
if (this._selectedModel && !this._models.find(m => m.id === this._selectedModel!.id)) {
|
||||
this._selectedModel = undefined;
|
||||
}
|
||||
|
||||
// Set default model if none selected
|
||||
if (!this._selectedModel && this._models.length > 0) {
|
||||
// Try to find a good default model
|
||||
const defaultModel = this._models.find(m =>
|
||||
m.id.includes('gpt-4') ||
|
||||
m.id.includes('claude') ||
|
||||
m.id.includes('llama')
|
||||
) || this._models[0];
|
||||
|
||||
this._selectedModel = defaultModel;
|
||||
}
|
||||
|
||||
return this._models;
|
||||
} catch (error) {
|
||||
if (this.isInferenceError(error) && error.type === 'auth') {
|
||||
this._authError = error.message;
|
||||
this.apiKey = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
selectModel(modelId: string): void {
|
||||
const model = this._models.find(m => m.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Model ${modelId} not found`);
|
||||
}
|
||||
this._selectedModel = model;
|
||||
}
|
||||
|
||||
async generateResponse(request: InferenceRequest): Promise<InferenceResponse> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
if (!this._selectedModel) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.generateResponse(
|
||||
request,
|
||||
this._selectedModel.id,
|
||||
this.apiKey
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isInferenceError(error) && error.type === 'auth') {
|
||||
this._authError = error.message;
|
||||
this.apiKey = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): ProviderCapabilities {
|
||||
return {
|
||||
authMethods: ['api_key'],
|
||||
supportsModelListing: true,
|
||||
requiresAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
private isInferenceError(error: any): error is InferenceError {
|
||||
return error && typeof error === 'object' && 'type' in error && 'message' in error;
|
||||
}
|
||||
}
|
||||
295
src/providers/openrouter/client.ts
Normal file
295
src/providers/openrouter/client.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Shared OpenRouter HTTP client and utilities
|
||||
|
||||
import type {
|
||||
InferenceRequest,
|
||||
InferenceResponse,
|
||||
InferenceError,
|
||||
ChatMessage,
|
||||
ToolCall,
|
||||
Model,
|
||||
TokenUsage,
|
||||
} from '@/types/inference';
|
||||
import type {
|
||||
OpenRouterBaseConfig,
|
||||
OpenRouterChatRequest,
|
||||
OpenRouterChatResponse,
|
||||
OpenRouterModelsResponse,
|
||||
OpenRouterModel,
|
||||
OpenRouterMessage,
|
||||
OpenRouterToolCall,
|
||||
OpenRouterErrorResponse,
|
||||
} from './types';
|
||||
|
||||
export class OpenRouterClient {
|
||||
private baseUrl: string;
|
||||
private httpReferrer?: string;
|
||||
private appName?: string;
|
||||
|
||||
constructor(config: OpenRouterBaseConfig) {
|
||||
this.baseUrl = config.baseUrl || 'https://openrouter.ai/api/v1';
|
||||
this.httpReferrer = config.httpReferrer;
|
||||
this.appName = config.appName;
|
||||
}
|
||||
|
||||
async makeRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit,
|
||||
authToken: string
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.httpReferrer) {
|
||||
headers['HTTP-Referer'] = this.httpReferrer;
|
||||
}
|
||||
|
||||
if (this.appName) {
|
||||
headers['X-Title'] = this.appName;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json() as OpenRouterErrorResponse;
|
||||
throw this.createInferenceError(response.status, errorData);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TypeError') {
|
||||
// Network error
|
||||
throw {
|
||||
type: 'network',
|
||||
message: 'Network request failed',
|
||||
details: error,
|
||||
retryable: true,
|
||||
} as InferenceError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchModels(authToken: string): Promise<Model[]> {
|
||||
const response = await this.makeRequest<OpenRouterModelsResponse>(
|
||||
'/models',
|
||||
{ method: 'GET' },
|
||||
authToken
|
||||
);
|
||||
|
||||
// Only return models that support tools
|
||||
return response.data
|
||||
.filter(model => model.supported_parameters?.includes('tools'))
|
||||
.map(this.parseModel);
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
request: InferenceRequest,
|
||||
model: string,
|
||||
authToken: string
|
||||
): Promise<InferenceResponse> {
|
||||
const openRouterRequest: OpenRouterChatRequest = {
|
||||
model,
|
||||
messages: request.messages.map(this.formatMessage),
|
||||
max_tokens: request.maxTokens,
|
||||
temperature: request.temperature,
|
||||
stop: request.stopSequences,
|
||||
};
|
||||
|
||||
// Add tools if provided
|
||||
if (request.tools && request.tools.length > 0) {
|
||||
openRouterRequest.tools = request.tools;
|
||||
openRouterRequest.tool_choice = 'auto';
|
||||
}
|
||||
|
||||
const response = await this.makeRequest<OpenRouterChatResponse>(
|
||||
'/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(openRouterRequest),
|
||||
},
|
||||
authToken
|
||||
);
|
||||
|
||||
return this.parseResponse(response);
|
||||
}
|
||||
|
||||
private parseModel(openRouterModel: OpenRouterModel): Model {
|
||||
const inputCost = parseFloat(openRouterModel.pricing.prompt);
|
||||
const outputCost = parseFloat(openRouterModel.pricing.completion);
|
||||
|
||||
// Check if model supports tools by looking at supported_parameters
|
||||
const supportsTools = openRouterModel.supported_parameters?.includes('tools') || false;
|
||||
|
||||
return {
|
||||
id: openRouterModel.id,
|
||||
name: openRouterModel.name,
|
||||
description: openRouterModel.description,
|
||||
contextLength: openRouterModel.context_length,
|
||||
inputCost: isNaN(inputCost) ? undefined : inputCost * 1000000, // Convert to per-token
|
||||
outputCost: isNaN(outputCost) ? undefined : outputCost * 1000000,
|
||||
provider: 'openrouter',
|
||||
capabilities: {
|
||||
supportsVision: openRouterModel.architecture.modality.includes('vision'),
|
||||
maxTokens: openRouterModel.top_provider.max_completion_tokens || 4096,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private formatMessage(message: ChatMessage): OpenRouterMessage {
|
||||
const openRouterMessage: OpenRouterMessage = {
|
||||
role: message.role,
|
||||
content: '',
|
||||
};
|
||||
|
||||
// Handle content
|
||||
if (typeof message.content === 'string') {
|
||||
openRouterMessage.content = message.content;
|
||||
} else {
|
||||
// Handle content blocks
|
||||
openRouterMessage.content = message.content.map(block => {
|
||||
if (block.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: block.text || '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: block.imageUrl || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (message.toolCalls) {
|
||||
openRouterMessage.tool_calls = message.toolCalls.map(toolCall => ({
|
||||
id: toolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: JSON.stringify(toolCall.function.arguments),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle tool call ID for tool response messages
|
||||
if (message.toolCallId) {
|
||||
openRouterMessage.tool_call_id = message.toolCallId;
|
||||
}
|
||||
|
||||
return openRouterMessage;
|
||||
}
|
||||
|
||||
private parseResponse(response: OpenRouterChatResponse): InferenceResponse {
|
||||
const choice = response.choices[0];
|
||||
const message = choice.message;
|
||||
|
||||
const chatMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: message.content || '',
|
||||
};
|
||||
|
||||
// Parse tool calls if present
|
||||
if (message.tool_calls) {
|
||||
chatMessage.toolCalls = message.tool_calls.map(this.parseToolCall);
|
||||
}
|
||||
|
||||
const usage: TokenUsage = {
|
||||
promptTokens: response.usage.prompt_tokens,
|
||||
completionTokens: response.usage.completion_tokens,
|
||||
totalTokens: response.usage.total_tokens,
|
||||
};
|
||||
|
||||
let stopReason: InferenceResponse['stopReason'];
|
||||
switch (choice.finish_reason) {
|
||||
case 'stop':
|
||||
stopReason = 'stop';
|
||||
break;
|
||||
case 'length':
|
||||
stopReason = 'max_tokens';
|
||||
break;
|
||||
case 'tool_calls':
|
||||
stopReason = 'tool_calls';
|
||||
break;
|
||||
default:
|
||||
stopReason = 'error';
|
||||
}
|
||||
|
||||
return {
|
||||
message: chatMessage,
|
||||
usage,
|
||||
stopReason,
|
||||
};
|
||||
}
|
||||
|
||||
private parseToolCall(openRouterToolCall: OpenRouterToolCall): ToolCall {
|
||||
let parsedArguments: Record<string, any>;
|
||||
|
||||
try {
|
||||
parsedArguments = JSON.parse(openRouterToolCall.function.arguments);
|
||||
} catch (error) {
|
||||
// If JSON parsing fails, create an error object
|
||||
parsedArguments = {
|
||||
_parseError: 'Invalid JSON in tool call arguments',
|
||||
_rawArguments: openRouterToolCall.function.arguments,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: openRouterToolCall.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: openRouterToolCall.function.name,
|
||||
arguments: parsedArguments,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createInferenceError(status: number, errorData: OpenRouterErrorResponse): InferenceError {
|
||||
const error = errorData.error;
|
||||
|
||||
let type: InferenceError['type'];
|
||||
let retryable = false;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
type = 'auth';
|
||||
break;
|
||||
case 429:
|
||||
type = 'rate_limit';
|
||||
retryable = true;
|
||||
break;
|
||||
case 400:
|
||||
type = 'invalid_request';
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
type = 'provider_error';
|
||||
retryable = true;
|
||||
break;
|
||||
default:
|
||||
type = 'provider_error';
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
message: error.message || `HTTP ${status} error`,
|
||||
details: error,
|
||||
retryable,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/providers/openrouter/index.ts
Normal file
10
src/providers/openrouter/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// OpenRouter provider exports
|
||||
|
||||
export { OpenRouterApiProvider } from './api-provider';
|
||||
export { OpenRouterOAuthProvider } from './oauth-provider';
|
||||
export { OpenRouterClient } from './client';
|
||||
export type {
|
||||
OpenRouterBaseConfig,
|
||||
OpenRouterApiConfig,
|
||||
OpenRouterOAuthConfig,
|
||||
} from './types';
|
||||
356
src/providers/openrouter/oauth-provider.ts
Normal file
356
src/providers/openrouter/oauth-provider.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
// OpenRouter OAuth Provider
|
||||
|
||||
import { InferenceProvider } from '@/types/inference';
|
||||
import type {
|
||||
Model,
|
||||
InferenceRequest,
|
||||
InferenceResponse,
|
||||
AuthConfig,
|
||||
ProviderCapabilities,
|
||||
InferenceError,
|
||||
} from '@/types/inference';
|
||||
import { OpenRouterClient } from './client';
|
||||
import type { OpenRouterOAuthConfig } from './types';
|
||||
|
||||
interface OAuthState {
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class OpenRouterOAuthProvider extends InferenceProvider {
|
||||
readonly name = 'OpenRouter (OAuth)';
|
||||
readonly id = 'openrouter-oauth';
|
||||
|
||||
private client: OpenRouterClient;
|
||||
private accessToken?: string;
|
||||
private refreshToken?: string;
|
||||
private _models: Model[] = [];
|
||||
private _selectedModel?: Model;
|
||||
private _authError?: string;
|
||||
private oauthConfig: OpenRouterOAuthConfig;
|
||||
|
||||
constructor(config?: OpenRouterOAuthConfig) {
|
||||
super();
|
||||
this.oauthConfig = {
|
||||
redirectUri: `${window.location.origin}/oauth/inference/callback`,
|
||||
...config,
|
||||
};
|
||||
this.client = new OpenRouterClient(config || {});
|
||||
this.loadStoredTokens();
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return !!this.accessToken && !this._authError;
|
||||
}
|
||||
|
||||
get authError(): string | undefined {
|
||||
return this._authError;
|
||||
}
|
||||
|
||||
get models(): Model[] {
|
||||
return this._models;
|
||||
}
|
||||
|
||||
get selectedModel(): Model | undefined {
|
||||
return this._selectedModel;
|
||||
}
|
||||
|
||||
async authenticate(config: AuthConfig): Promise<void> {
|
||||
if (config.type !== 'oauth') {
|
||||
throw new Error('OpenRouterOAuthProvider requires OAuth authentication');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.startOAuthFlow();
|
||||
} catch (error) {
|
||||
this._authError = error instanceof Error ? error.message : 'OAuth authentication failed';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.accessToken = undefined;
|
||||
this.refreshToken = undefined;
|
||||
this._authError = undefined;
|
||||
this._models = [];
|
||||
this._selectedModel = undefined;
|
||||
this.clearStoredTokens();
|
||||
}
|
||||
|
||||
async loadModels(): Promise<Model[]> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
this._models = await this.client.fetchModels(this.accessToken);
|
||||
|
||||
// Check if currently selected model is still in the filtered list
|
||||
if (this._selectedModel && !this._models.find(m => m.id === this._selectedModel!.id)) {
|
||||
this._selectedModel = undefined;
|
||||
}
|
||||
|
||||
// Set default model if none selected
|
||||
if (!this._selectedModel && this._models.length > 0) {
|
||||
const defaultModel = this._models.find(m =>
|
||||
m.id.includes('gpt-4') ||
|
||||
m.id.includes('claude') ||
|
||||
m.id.includes('llama')
|
||||
) || this._models[0];
|
||||
|
||||
this._selectedModel = defaultModel;
|
||||
}
|
||||
|
||||
return this._models;
|
||||
} catch (error) {
|
||||
if (this.isInferenceError(error) && error.type === 'auth') {
|
||||
// Try to refresh token
|
||||
if (this.refreshToken) {
|
||||
try {
|
||||
await this.refreshAccessToken();
|
||||
return await this.loadModels(); // Retry with new token
|
||||
} catch (refreshError) {
|
||||
this._authError = 'Token refresh failed';
|
||||
this.logout();
|
||||
}
|
||||
} else {
|
||||
this._authError = error.message;
|
||||
this.accessToken = undefined;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
selectModel(modelId: string): void {
|
||||
const model = this._models.find(m => m.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Model ${modelId} not found`);
|
||||
}
|
||||
this._selectedModel = model;
|
||||
}
|
||||
|
||||
async generateResponse(request: InferenceRequest): Promise<InferenceResponse> {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
if (!this._selectedModel) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.generateResponse(
|
||||
request,
|
||||
this._selectedModel.id,
|
||||
this.accessToken
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isInferenceError(error) && error.type === 'auth') {
|
||||
// Try to refresh token
|
||||
if (this.refreshToken) {
|
||||
try {
|
||||
await this.refreshAccessToken();
|
||||
return await this.generateResponse(request); // Retry with new token
|
||||
} catch (refreshError) {
|
||||
this._authError = 'Token refresh failed';
|
||||
this.logout();
|
||||
}
|
||||
} else {
|
||||
this._authError = error.message;
|
||||
this.accessToken = undefined;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): ProviderCapabilities {
|
||||
return {
|
||||
authMethods: ['oauth'],
|
||||
supportsModelListing: true,
|
||||
requiresAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async startOAuthFlow(): Promise<void> {
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = this.generateCodeVerifier();
|
||||
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
||||
const state = this.generateState();
|
||||
|
||||
// Store OAuth state
|
||||
const oauthState: OAuthState = {
|
||||
codeVerifier,
|
||||
state,
|
||||
expiresAt: Date.now() + (10 * 60 * 1000), // 10 minutes
|
||||
};
|
||||
localStorage.setItem('openrouter_oauth_state', JSON.stringify(oauthState));
|
||||
|
||||
// Build authorization URL
|
||||
const authUrl = new URL('https://openrouter.ai/auth');
|
||||
authUrl.searchParams.append('callback_url', this.oauthConfig.redirectUri || '');
|
||||
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.append('state', state);
|
||||
|
||||
// Open popup for OAuth flow
|
||||
const popup = window.open(
|
||||
authUrl.toString(),
|
||||
'openrouter_oauth',
|
||||
'width=600,height=700,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Failed to open OAuth popup. Please allow popups for this site.');
|
||||
}
|
||||
|
||||
// Wait for OAuth callback
|
||||
return new Promise((resolve, reject) => {
|
||||
let isResolved = false;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === 'oauth_callback') {
|
||||
if (isResolved) return; // Prevent double resolution
|
||||
isResolved = true;
|
||||
|
||||
clearInterval(checkClosed);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
popup.close();
|
||||
|
||||
if (event.data.error) {
|
||||
reject(new Error(event.data.error));
|
||||
} else {
|
||||
this.handleOAuthCallback(event.data.code, event.data.state)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
// Check if popup was closed manually
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed && !isResolved) {
|
||||
isResolved = true;
|
||||
clearInterval(checkClosed);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
reject(new Error('OAuth flow was cancelled'));
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOAuthCallback(code: string, state: string): Promise<void> {
|
||||
// Verify state
|
||||
const storedStateJson = localStorage.getItem('openrouter_oauth_state');
|
||||
if (!storedStateJson) {
|
||||
throw new Error('Invalid OAuth state');
|
||||
}
|
||||
|
||||
const storedState: OAuthState = JSON.parse(storedStateJson);
|
||||
if (storedState.state !== state || Date.now() > storedState.expiresAt) {
|
||||
localStorage.removeItem('openrouter_oauth_state');
|
||||
throw new Error('Invalid or expired OAuth state');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/auth/keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
code_verifier: storedState.codeVerifier,
|
||||
code_challenge_method: 'S256',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.accessToken = data.key; // OpenRouter returns 'key' field
|
||||
|
||||
// Store tokens
|
||||
this.storeTokens();
|
||||
localStorage.removeItem('openrouter_oauth_state');
|
||||
|
||||
// Load models to verify authentication
|
||||
await this.loadModels();
|
||||
} catch (error) {
|
||||
localStorage.removeItem('openrouter_oauth_state');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<void> {
|
||||
// OpenRouter's OAuth implementation doesn't currently support refresh tokens
|
||||
// If this changes in the future, implement refresh logic here
|
||||
throw new Error('Token refresh not supported by OpenRouter');
|
||||
}
|
||||
|
||||
private generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(array)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
private async generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(digest))))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
private generateState(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode.apply(null, Array.from(array)));
|
||||
}
|
||||
|
||||
private storeTokens(): void {
|
||||
if (this.accessToken) {
|
||||
localStorage.setItem('openrouter_access_token', this.accessToken);
|
||||
}
|
||||
if (this.refreshToken) {
|
||||
localStorage.setItem('openrouter_refresh_token', this.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
private loadStoredTokens(): void {
|
||||
this.accessToken = localStorage.getItem('openrouter_access_token') || undefined;
|
||||
this.refreshToken = localStorage.getItem('openrouter_refresh_token') || undefined;
|
||||
|
||||
// If we have tokens, try to load models to verify they're still valid
|
||||
if (this.accessToken) {
|
||||
this.loadModels().catch(() => {
|
||||
// If loading fails, clear invalid tokens
|
||||
this.logout();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearStoredTokens(): void {
|
||||
localStorage.removeItem('openrouter_access_token');
|
||||
localStorage.removeItem('openrouter_refresh_token');
|
||||
localStorage.removeItem('openrouter_oauth_state');
|
||||
}
|
||||
|
||||
private isInferenceError(error: any): error is InferenceError {
|
||||
return error && typeof error === 'object' && 'type' in error && 'message' in error;
|
||||
}
|
||||
}
|
||||
113
src/providers/openrouter/types.ts
Normal file
113
src/providers/openrouter/types.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// OpenRouter-specific types and configurations
|
||||
|
||||
export interface OpenRouterBaseConfig {
|
||||
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
|
||||
defaultModel?: string;
|
||||
httpReferrer?: string;
|
||||
appName?: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterApiConfig extends OpenRouterBaseConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterOAuthConfig extends OpenRouterBaseConfig {
|
||||
clientId?: string; // for custom OAuth apps
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
// OpenRouter API response types
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
context_length: number;
|
||||
pricing: {
|
||||
prompt: string;
|
||||
completion: string;
|
||||
};
|
||||
top_provider: {
|
||||
max_completion_tokens?: number;
|
||||
};
|
||||
architecture: {
|
||||
modality: string;
|
||||
tokenizer: string;
|
||||
instruct_type?: string;
|
||||
};
|
||||
supported_parameters?: string[];
|
||||
}
|
||||
|
||||
export interface OpenRouterModelsResponse {
|
||||
data: OpenRouterModel[];
|
||||
}
|
||||
|
||||
export interface OpenRouterChatRequest {
|
||||
model: string;
|
||||
messages: OpenRouterMessage[];
|
||||
tools?: OpenRouterTool[];
|
||||
tool_choice?: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
stop?: string[];
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenRouterMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string | OpenRouterContentBlock[];
|
||||
tool_calls?: OpenRouterToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterContentBlock {
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenRouterToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string; // JSON string from API
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenRouterTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenRouterChatResponse {
|
||||
id: string;
|
||||
choices: {
|
||||
index: number;
|
||||
message: {
|
||||
role: 'assistant';
|
||||
content: string | null;
|
||||
tool_calls?: OpenRouterToolCall[];
|
||||
};
|
||||
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
||||
}[];
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterErrorResponse {
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue