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
26 changed files with 8564 additions and 0 deletions
19
.eslintrc.cjs
Normal file
19
.eslintrc.cjs
Normal 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
72
.gitignore
vendored
|
|
@ -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/
|
||||
65
README.md
Normal file
65
README.md
Normal 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
|
||||
338
docs/inference_provider_interface.md
Normal file
338
docs/inference_provider_interface.md
Normal 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
13
index.html
Normal 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
6104
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
32
src/App.tsx
Normal file
32
src/App.tsx
Normal 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
|
||||
368
src/components/InferenceTest.tsx
Normal file
368
src/components/InferenceTest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/OAuthCallback.tsx
Normal file
72
src/components/OAuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
src/contexts/InferenceContext.tsx
Normal file
144
src/contexts/InferenceContext.tsx
Normal 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
38
src/index.css
Normal 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
10
src/main.tsx
Normal 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
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;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue