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
26 changed files with 8564 additions and 0 deletions

19
.eslintrc.cjs Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
}

72
.gitignore vendored
View file

@ -1 +1,73 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependency directories
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
dist-ssr/
build/
.next/
out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
.nyc_output/
# Vite
.vite/
# Testing
.vitest/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
Thumbs.db
ehthumbs.db
# Temporary files
*.tmp
*.temp
.cache/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Reference materials (keeping this as requested)
reference_info/ reference_info/

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# Example Remote MCP Client
A React TypeScript application for connecting to multiple MCP (Model Context Protocol) servers and providing a conversational interface with tool calling capabilities.
## Features
- 🔗 Multi-server MCP connections (HTTP/SSE transports)
- 🤖 Inference provider abstraction (starting with OpenRouter)
- 💬 Conversational interface with agent loops
- 🛠️ Real-time tool call visualization
- 🔍 MCP debugging and message tracing
- 📱 Responsive UI with left sidebar and chat interface
## Development
### Prerequisites
- Node.js (v18 or higher)
- npm, yarn, or pnpm
### Getting Started
1. Install dependencies:
```bash
npm install
```
2. Start the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) in your browser
### Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
- `npm run test` - Run tests
- `npm run test:ui` - Run tests with UI
## Architecture
The application is built with a modular architecture using React hooks and providers:
- **InferenceProvider** - Abstraction for LLM inference (OpenRouter, etc.)
- **MCPProvider** - Multi-server MCP connection management
- **AgentLoop** - Tool calling and conversation flow
- **UI Components** - Modular, reusable interface components
## Technology Stack
- **React 18** with TypeScript
- **Vite** for build tooling
- **Tailwind CSS** for styling
- **MCP TypeScript SDK** for protocol implementation
- **Vitest** for testing
## Project Status
This is an active development project serving as both:
- A public example implementation of MCP client features
- A prototyping testbed for MCP protocol changes

View file

@ -0,0 +1,338 @@
# InferenceProvider Interface Design
This document outlines the design for the InferenceProvider interface and its implementations, which abstracts LLM inference capabilities for the MCP client.
## Goals
1. **Provider Agnostic**: Support multiple inference providers (OpenRouter, Anthropic, OpenAI, Google, etc.)
2. **Tool Calling**: Native support for function/tool calling with MCP tools
3. **Authentication**: Flexible auth patterns (API keys, OAuth, etc.)
4. **Model Selection**: Dynamic model listing and selection
5. **Error Handling**: Consistent error handling across providers
## Core Interface
```typescript
interface InferenceProvider {
// Provider identification
readonly name: string;
readonly id: string;
// Authentication state
readonly isAuthenticated: boolean;
readonly authError?: string;
// Available models
readonly models: Model[];
readonly selectedModel?: Model;
// Core inference method
generateResponse(request: InferenceRequest): Promise<InferenceResponse>;
// Model management
loadModels(): Promise<Model[]>;
selectModel(modelId: string): void;
// Authentication
authenticate(config: AuthConfig): Promise<void>;
logout(): void;
// Provider-specific capabilities
getCapabilities(): ProviderCapabilities;
}
```
## Supporting Types
### Model
```typescript
interface Model {
id: string;
name: string;
description?: string;
contextLength: number;
inputCost?: number; // per token
outputCost?: number; // per token
provider: string;
capabilities: ModelCapabilities;
}
interface ModelCapabilities {
supportsTools: boolean;
supportsVision: boolean;
maxTokens: number;
}
```
### Inference Request/Response
```typescript
interface InferenceRequest {
messages: ChatMessage[];
tools?: Tool[]; // toolChoice defaults to 'auto' when tools provided
maxTokens?: number;
temperature?: number;
stopSequences?: string[];
}
interface InferenceResponse {
message: ChatMessage;
usage: TokenUsage;
stopReason: 'stop' | 'max_tokens' | 'tool_calls' | 'error';
error?: string;
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | ContentBlock[];
toolCalls?: ToolCall[];
toolCallId?: string; // for tool response messages
}
interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string
};
}
```
### Authentication
```typescript
interface AuthConfig {
type: 'api_key' | 'oauth';
apiKey?: string;
oauthConfig?: {
clientId?: string;
redirectUri?: string;
scopes?: string[];
};
}
interface ProviderCapabilities {
authMethods: ('api_key' | 'oauth')[];
supportsModelListing: boolean;
supportsToolCalling: boolean;
requiresAuth: boolean;
}
```
## Provider Implementations
### OpenRouter Providers
For simplicity, we implement two separate providers using composition:
**OpenRouterApiProvider:**
- API key authentication only
- Simpler auth flow and error handling
- Direct API access
**OpenRouterOAuthProvider:**
- OAuth PKCE authentication only
- Browser-based auth popup flow
- Token refresh handling
**Shared Configuration:**
```typescript
interface OpenRouterBaseConfig {
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
defaultModel?: string;
httpReferrer?: string;
appName?: string;
}
interface OpenRouterApiConfig extends OpenRouterBaseConfig {
apiKey: string;
}
interface OpenRouterOAuthConfig extends OpenRouterBaseConfig {
clientId?: string; // for custom OAuth apps
redirectUri?: string;
}
```
**Shared Logic:**
Both providers use composition with shared utilities:
- `OpenRouterClient` - HTTP client and request formatting
- `OpenRouterModelParser` - Model list parsing and capabilities
- `OpenRouterToolFormatter` - Tool call formatting
### Future Providers
**AnthropicProvider:**
- OAuth integration with Anthropic Console
- Claude model family support
- Message batching optimization
**OpenAIProvider:**
- OpenAI API key or OAuth
- GPT model family support
- Function calling optimization
**GoogleProvider:**
- Google Cloud OAuth
- Gemini model family support
- Tool calling with Google AI format
## React Integration
### Provider Context
```typescript
interface InferenceContextValue {
provider: InferenceProvider | null;
isLoading: boolean;
error: string | null;
// Actions
setProvider: (provider: InferenceProvider) => void;
clearProvider: () => void;
generateResponse: (request: InferenceRequest) => Promise<InferenceResponse>;
selectModel: (modelId: string) => void;
loadModels: () => Promise<Model[]>;
// Convenience getters
models: Model[];
selectedModel: Model | undefined;
isAuthenticated: boolean;
}
const InferenceContext = createContext<InferenceContextValue | null>(null);
```
### Hook Usage
```typescript
const useInference = () => {
const context = useContext(InferenceContext);
if (!context) {
throw new Error('useInference must be used within InferenceProvider');
}
return context;
};
```
## Error Handling
### Error Types
```typescript
interface InferenceError {
type: 'auth' | 'network' | 'rate_limit' | 'invalid_request' | 'provider_error';
message: string;
details?: any;
retryable: boolean;
}
```
### Error Scenarios
- **Authentication failures**: Invalid API key, expired tokens
- **Rate limiting**: Provider-specific rate limits
- **Network errors**: Connection issues, timeouts
- **Validation errors**: Invalid tool schemas, malformed requests
- **Provider errors**: Service unavailable, model not found
## Implementation Strategy
### Phase 1: Basic OpenRouter Support ✅
1. Implement core InferenceProvider interface
2. Create OpenRouterApiProvider with API key auth
3. Basic model listing and selection (filtered to tool-capable models only)
4. Simple tool calling support (toolChoice=auto)
5. React context with proper state management for UI reactivity
### Phase 2: OAuth and Enhanced Features ✅
1. Create OpenRouterOAuthProvider with PKCE auth
2. Implement error recovery and retries
3. Add usage tracking and cost estimation
4. Improve tool call validation
### Current Status
- ✅ Two OpenRouter providers (API key & OAuth) implemented
- ✅ Provider-level model filtering (only tool-capable models)
- ✅ React context with proper UI state synchronization
- ✅ Interactive test UI with tool calling demonstration
- ✅ OAuth callback routing for future MCP server integration
### Phase 3: Multi-Provider Support
1. Add AnthropicProvider
2. Add OpenAIProvider
3. Provider switching UI
4. Unified configuration management
## Testing Strategy
### Unit Tests
- Mock provider implementations for testing
- Tool call serialization/deserialization
- Error handling scenarios
- Authentication flows
### Integration Tests
- Real provider API integration (with test keys)
- End-to-end tool calling workflows
- Provider switching scenarios
- Authentication persistence
## Security Considerations
1. **API Key Storage**: Secure storage in browser (encrypted localStorage)
2. **OAuth Flow**: Secure popup-based OAuth with PKCE
3. **Token Refresh**: Automatic token refresh handling
4. **Rate Limiting**: Client-side rate limiting to prevent abuse
5. **Input Validation**: Strict validation of tool calls and responses
## Key Design Decisions
### 1. Provider-Level Model Filtering
All providers filter models to only return those that support tool calling. This simplifies consumers and prevents tool-calling errors:
```typescript
// In OpenRouterClient.fetchModels()
return response.data
.filter(model => model.supported_parameters?.includes('tools'))
.map(this.parseModel);
```
**Benefits:**
- No tool capability checking needed in UI components
- Eliminates "model doesn't support tools" errors
- Cleaner separation of concerns
### 2. React State Management for UI Reactivity
The React context tracks model selection in React state to ensure UI updates when models are selected:
```typescript
const [selectedModelId, setSelectedModelId] = useState<string | undefined>();
// In selectModel callback
provider.selectModel(modelId); // Update provider internal state
setSelectedModelId(modelId); // Update React state → triggers re-render
// In context value - compute selectedModel from React-tracked ID
selectedModel: selectedModelId ? provider?.models.find(m => m.id === selectedModelId) : undefined
```
**Why this pattern:**
- Provider internal state changes don't trigger React re-renders automatically
- React needs to track state changes to update the UI
- Keeps provider logic decoupled from React specifics
### 3. Composition Over Inheritance
Two separate OpenRouter providers (API & OAuth) use shared utilities rather than complex inheritance:
- `OpenRouterClient` - HTTP client and request formatting
- Shared model parsing and tool formatting logic
- Clean separation of authentication concerns
**Benefits:**
- Simpler code paths (no branching auth logic)
- Easier testing and maintenance
- Clear separation of API vs OAuth concerns
## Future Enhancements
1. **Caching**: Response caching for repeated requests
2. **Load Balancing**: Multi-provider load balancing
3. **Analytics**: Usage analytics and monitoring
4. **Custom Models**: Support for custom/fine-tuned models
5. **Advanced Tool Choice**: Support for specific tool selection (beyond auto)
6. **Streaming Support**: Real-time response streaming (if needed later)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Example Remote MCP Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6104
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "example-remote-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"uuid": "^10.0.0",
"clsx": "^2.0.0",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.0",
"vitest": "^1.2.0",
"@vitest/ui": "^1.2.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

32
src/App.tsx Normal file
View file

@ -0,0 +1,32 @@
import React from 'react'
import { InferenceProvider } from '@/contexts/InferenceContext'
import { InferenceTest } from '@/components/InferenceTest'
import { OAuthCallback } from '@/components/OAuthCallback'
function App() {
// Simple routing based on pathname
const pathname = window.location.pathname;
const isInferenceOAuthCallback = pathname === '/oauth/inference/callback';
const isMcpOAuthCallback = pathname.startsWith('/oauth/mcp/');
if (isInferenceOAuthCallback) {
return <OAuthCallback type="inference" />;
}
if (isMcpOAuthCallback) {
// Extract server identifier from path like /oauth/mcp/server123/callback
const serverMatch = pathname.match(/^\/oauth\/mcp\/([^\/]+)\/callback$/);
const serverId = serverMatch?.[1];
return <OAuthCallback type="mcp" serverId={serverId} />;
}
return (
<InferenceProvider>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
<InferenceTest />
</div>
</InferenceProvider>
)
}
export default App

View file

@ -0,0 +1,368 @@
// Test UI for inference provider functionality
import React, { useState, useCallback } from 'react';
import { useInference } from '@/contexts/InferenceContext';
import { OpenRouterApiProvider, OpenRouterOAuthProvider } from '@/providers/openrouter';
import type { ChatMessage, InferenceRequest } from '@/types/inference';
import { testTools, executeTestTool } from '@/utils/testTools';
export function InferenceTest() {
const {
provider,
isLoading,
error,
setProvider,
clearProvider,
generateResponse,
selectModel,
loadModels,
models,
selectedModel,
isAuthenticated,
} = useInference();
const [apiKey, setApiKey] = useState('');
const [message, setMessage] = useState('');
const [response, setResponse] = useState<string>('');
const [conversation, setConversation] = useState<ChatMessage[]>([]);
const [enableTools, setEnableTools] = useState(false);
const handleApiAuth = useCallback(async () => {
if (!apiKey.trim()) {
alert('Please enter an API key');
return;
}
try {
const apiProvider = new OpenRouterApiProvider();
await apiProvider.authenticate({ type: 'api_key', apiKey: apiKey.trim() });
setProvider(apiProvider);
} catch (err) {
console.error('API authentication failed:', err);
alert(`Authentication failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}, [apiKey, setProvider]);
const handleOAuthAuth = useCallback(async () => {
try {
const oauthProvider = new OpenRouterOAuthProvider();
await oauthProvider.authenticate({ type: 'oauth' });
setProvider(oauthProvider);
} catch (err) {
console.error('OAuth authentication failed:', err);
alert(`OAuth failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}, [setProvider]);
const handleLoadModels = useCallback(async () => {
try {
await loadModels();
} catch (err) {
console.error('Failed to load models:', err);
alert(`Failed to load models: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}, [loadModels]);
const handleSendMessage = useCallback(async () => {
if (!message.trim() || !provider || !selectedModel) {
alert('Please enter a message and ensure a model is selected');
return;
}
const userMessage: ChatMessage = {
role: 'user',
content: message.trim(),
};
let currentConversation = [...conversation, userMessage];
setConversation(currentConversation);
setMessage('');
setResponse('');
const request: InferenceRequest = {
messages: currentConversation,
maxTokens: 500,
temperature: 0.7,
tools: enableTools ? testTools : undefined,
};
try {
const result = await generateResponse(request);
let assistantMessage = result.message;
// Handle tool calls
if (assistantMessage.toolCalls && assistantMessage.toolCalls.length > 0) {
// Add the assistant message with tool calls
currentConversation = [...currentConversation, assistantMessage];
setConversation(currentConversation);
// Execute each tool call and add tool results
for (const toolCall of assistantMessage.toolCalls) {
const toolResult = executeTestTool(toolCall.function.name, toolCall.function.arguments);
const toolMessage: ChatMessage = {
role: 'tool',
content: toolResult,
toolCallId: toolCall.id,
};
currentConversation = [...currentConversation, toolMessage];
setConversation(currentConversation);
}
// Make another request with the tool results
const followUpRequest: InferenceRequest = {
messages: currentConversation,
maxTokens: 500,
temperature: 0.7,
tools: enableTools ? testTools : undefined,
};
const followUpResult = await generateResponse(followUpRequest);
assistantMessage = followUpResult.message;
setResponse(JSON.stringify(followUpResult, null, 2));
} else {
setResponse(JSON.stringify(result, null, 2));
}
setConversation([...currentConversation, assistantMessage]);
} catch (err) {
console.error('Inference failed:', err);
alert(`Inference failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}, [message, provider, selectedModel, conversation, generateResponse, enableTools]);
const handleClearConversation = useCallback(() => {
setConversation([]);
setResponse('');
}, []);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">
Inference Provider Test
</h1>
{/* Authentication Section */}
<div className="space-y-4 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Authentication
</h2>
{!isAuthenticated ? (
<div className="space-y-4">
{/* API Key Auth */}
<div className="flex gap-2">
<input
type="password"
placeholder="Enter OpenRouter API Key"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
<button
onClick={handleApiAuth}
disabled={isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
API Key Auth
</button>
</div>
{/* OAuth Auth */}
<div>
<button
onClick={handleOAuthAuth}
disabled={isLoading}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
>
OAuth Auth
</button>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<span className="text-green-600 dark:text-green-400">
✓ Authenticated with {provider?.name}
</span>
<button
onClick={clearProvider}
className="px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm"
>
Logout
</button>
</div>
)}
</div>
{/* Model Selection */}
{isAuthenticated && (
<div className="space-y-4 mb-6">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Model Selection
</h2>
<button
onClick={handleLoadModels}
disabled={isLoading}
className="px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm disabled:opacity-50"
>
Reload Models
</button>
</div>
{models.length > 0 ? (
<div className="space-y-2">
<select
value={selectedModel?.id || ''}
onChange={(e) => selectModel(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">Select a model</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.id})
</option>
))}
</select>
{selectedModel && (
<div className="text-sm text-gray-600 dark:text-gray-400">
Context: {selectedModel.contextLength} tokens |
Max output: {selectedModel.capabilities.maxTokens} tokens |
Tools: ✓ (all models support tools)
</div>
)}
</div>
) : (
<p className="text-gray-600 dark:text-gray-400">No models loaded</p>
)}
</div>
)}
{/* Chat Interface */}
{isAuthenticated && selectedModel && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Chat Test
</h2>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={enableTools}
onChange={(e) => setEnableTools(e.target.checked)}
className="rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enable Tools</span>
</label>
<button
onClick={handleClearConversation}
className="px-3 py-1 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm"
>
Clear
</button>
</div>
{/* Tool Test Suggestions */}
{enableTools && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
Tool Test Suggestions:
</h3>
<div className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<div>• "What's the weather in San Francisco?"</div>
<div>• "Calculate 123 + 456"</div>
<div>• "What time is it in London?"</div>
<div>• "Get weather for Tokyo and add 10 + 20"</div>
</div>
<div className="mt-2 text-xs text-blue-600 dark:text-blue-400">
Available tools: get_weather, calculate_sum, get_current_time
</div>
</div>
)}
{/* Conversation */}
{conversation.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-md p-4 max-h-80 overflow-y-auto space-y-3">
{conversation.map((msg, index) => (
<div key={index} className="border-l-2 border-gray-300 dark:border-gray-600 pl-3">
<div className="font-semibold text-sm text-gray-600 dark:text-gray-400 mb-1">
{msg.role === 'user' && '👤 You'}
{msg.role === 'assistant' && '🤖 Assistant'}
{msg.role === 'tool' && '🛠️ Tool Result'}
</div>
<div className="text-gray-900 dark:text-gray-100">
{typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)}
</div>
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="mt-2 space-y-2">
{msg.toolCalls.map((toolCall, tcIndex) => (
<div key={tcIndex} className="bg-blue-100 dark:bg-blue-900/30 rounded-md p-2 text-sm">
<div className="font-medium text-blue-800 dark:text-blue-200">
🔧 {toolCall.function.name}
</div>
<div className="text-blue-700 dark:text-blue-300 mt-1">
{JSON.stringify(toolCall.function.arguments, null, 2)}
</div>
</div>
))}
</div>
)}
{msg.toolCallId && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
↳ Response to tool call: {msg.toolCallId}
</div>
)}
</div>
))}
</div>
)}
{/* Message Input */}
<div className="flex gap-2">
<input
type="text"
placeholder="Enter your message..."
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<button
onClick={handleSendMessage}
disabled={isLoading || !message.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Sending...' : 'Send'}
</button>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-red-700 dark:text-red-400">Error: {error}</p>
</div>
)}
{/* Response Debug */}
{response && (
<div className="mt-4">
<h3 className="text-md font-semibold text-gray-900 dark:text-gray-100 mb-2">
Last Response (Debug):
</h3>
<pre className="bg-gray-100 dark:bg-gray-700 p-4 rounded-md text-xs overflow-x-auto text-gray-900 dark:text-gray-100">
{response}
</pre>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
// OAuth callback handler for popup-based OAuth flows
import React, { useEffect } from 'react';
interface OAuthCallbackProps {
type: 'inference' | 'mcp';
serverId?: string; // Required when type is 'mcp'
}
export function OAuthCallback({ type, serverId }: OAuthCallbackProps) {
useEffect(() => {
// Extract OAuth parameters from URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
const errorDescription = urlParams.get('error_description');
// Send result to parent window
if (window.opener) {
const messageType = type === 'inference' ? 'oauth_callback' : 'mcp_oauth_callback';
if (error) {
window.opener.postMessage({
type: messageType,
callbackType: type,
serverId,
error: errorDescription || error,
}, window.location.origin);
} else if (code && state) {
window.opener.postMessage({
type: messageType,
callbackType: type,
serverId,
code,
state,
}, window.location.origin);
} else {
window.opener.postMessage({
type: messageType,
callbackType: type,
serverId,
error: 'Invalid OAuth callback - missing code or state',
}, window.location.origin);
}
// Close the popup after a small delay to ensure message is processed
setTimeout(() => {
window.close();
}, 100);
} else {
// Fallback if not in a popup - redirect to main app
window.location.href = '/';
}
}, []);
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8 max-w-md">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Processing OAuth callback...
</h2>
<p className="text-gray-600 dark:text-gray-400">
This window will close automatically.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,144 @@
// React context for inference provider management
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import type {
InferenceProvider,
InferenceRequest,
InferenceResponse,
Model,
} from '@/types/inference';
interface InferenceContextValue {
// Current provider state
provider: InferenceProvider | null;
isLoading: boolean;
error: string | null;
// Provider actions
setProvider: (provider: InferenceProvider) => void;
clearProvider: () => void;
// Inference actions
generateResponse: (request: InferenceRequest) => Promise<InferenceResponse>;
selectModel: (modelId: string) => void;
loadModels: () => Promise<Model[]>;
// Convenience getters
models: Model[];
selectedModel: Model | undefined;
isAuthenticated: boolean;
}
const InferenceContext = createContext<InferenceContextValue | null>(null);
interface InferenceProviderProps {
children: ReactNode;
}
export function InferenceProvider({ children }: InferenceProviderProps) {
const [provider, setProviderState] = useState<InferenceProvider | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string | undefined>(undefined);
const setProvider = useCallback((newProvider: InferenceProvider) => {
setProviderState(newProvider);
setSelectedModelId(undefined);
setError(null);
}, []);
const clearProvider = useCallback(() => {
if (provider) {
provider.logout();
}
setProviderState(null);
setSelectedModelId(undefined);
setError(null);
}, [provider]);
const generateResponse = useCallback(async (request: InferenceRequest): Promise<InferenceResponse> => {
if (!provider) {
throw new Error('No inference provider configured');
}
setIsLoading(true);
setError(null);
try {
const response = await provider.generateResponse(request);
return response;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Inference request failed';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, [provider]);
const selectModel = useCallback((modelId: string) => {
if (!provider) {
throw new Error('No inference provider configured');
}
try {
provider.selectModel(modelId);
setSelectedModelId(modelId);
setError(null);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to select model';
setError(errorMessage);
throw err;
}
}, [provider]);
const loadModels = useCallback(async (): Promise<Model[]> => {
if (!provider) {
throw new Error('No inference provider configured');
}
setIsLoading(true);
setError(null);
try {
const models = await provider.loadModels();
// Update selectedModelId to match what the provider selected as default
setSelectedModelId(provider.selectedModel?.id);
return models;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load models';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
}, [provider]);
const contextValue: InferenceContextValue = {
provider,
isLoading,
error,
setProvider,
clearProvider,
generateResponse,
selectModel,
loadModels,
models: provider?.models || [],
selectedModel: selectedModelId ? provider?.models.find(m => m.id === selectedModelId) : undefined,
isAuthenticated: provider?.isAuthenticated || false,
};
return (
<InferenceContext.Provider value={contextValue}>
{children}
</InferenceContext.Provider>
);
}
export function useInference(): InferenceContextValue {
const context = useContext(InferenceContext);
if (!context) {
throw new Error('useInference must be used within an InferenceProvider');
}
return context;
}

38
src/index.css Normal file
View file

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
height: 100vh;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

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

Some files were not shown because too many files have changed in this diff Show more