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/
|
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