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:
Jerome 2025-06-10 17:37:11 +01:00
commit 438ceb333c
No known key found for this signature in database
6 changed files with 8564 additions and 0 deletions

3
src/providers/index.ts Normal file
View file

@ -0,0 +1,3 @@
// All inference providers
export * from './openrouter';

View 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;
}
}

View 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,
};
}
}

View 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';

View 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;
}
}

View 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;
};
}