瀏覽代碼

feat: Add OpenRouter provider support

This commit adds OpenRouter as a provider to the Chrome extension with the following changes:

1. Added OpenRouter to LLMProviderEnum in types.ts
2. Added OpenRouter models to llmProviderModelNames with dynamic fetching from API
3. Added OpenRouter API interfaces and fetchOpenRouterModels function to llmProviderStore
4. Implemented OpenRouter support in createChatModel function with proper headers
5. Added UI section for OpenRouter in ModelSettings component
6. Fixed boolean validation issues in agent schemas to support string representations
7. Updated prompts to explicitly instruct models to return boolean values

These changes allow users to:
- Configure OpenRouter API key in settings
- Fetch available models from OpenRouter API
- Select OpenRouter models for different agents
- Use OpenRouter as an LLM provider with proper API configuration
HK 5 月之前
父節點
當前提交
a8b2a45ea6

+ 16 - 2
chrome-extension/src/background/agent/agents/planner.ts

@@ -12,10 +12,24 @@ const logger = createLogger('PlannerAgent');
 export const plannerOutputSchema = z.object({
   observation: z.string(),
   challenges: z.string(),
-  done: z.boolean(),
+  done: z.union([
+    z.boolean(),
+    z.string().transform(val => {
+      if (val.toLowerCase() === 'true') return true;
+      if (val.toLowerCase() === 'false') return false;
+      throw new Error('Invalid boolean string');
+    }),
+  ]),
   next_steps: z.string(),
   reasoning: z.string(),
-  web_task: z.boolean(),
+  web_task: z.union([
+    z.boolean(),
+    z.string().transform(val => {
+      if (val.toLowerCase() === 'true') return true;
+      if (val.toLowerCase() === 'false') return false;
+      throw new Error('Invalid boolean string');
+    }),
+  ]),
 });
 
 export type PlannerOutput = z.infer<typeof plannerOutputSchema>;

+ 8 - 1
chrome-extension/src/background/agent/agents/validator.ts

@@ -10,7 +10,14 @@ const logger = createLogger('ValidatorAgent');
 
 // Define Zod schema for validator output
 export const validatorOutputSchema = z.object({
-  is_valid: z.boolean(), // indicates if the output is correct
+  is_valid: z.union([
+    z.boolean(),
+    z.string().transform(val => {
+      if (val.toLowerCase() === 'true') return true;
+      if (val.toLowerCase() === 'false') return false;
+      throw new Error('Invalid boolean string');
+    }),
+  ]), // indicates if the output is correct
   reason: z.string(), // explains why it is valid or not
   answer: z.string(), // the final answer to the task if it is valid
 });

+ 30 - 0
chrome-extension/src/background/agent/helper.ts

@@ -43,6 +43,36 @@ export function createChatModel(
       }
       return new ChatOpenAI(args);
     }
+    case LLMProviderEnum.OpenRouter: {
+      if (agentName === AgentNameEnum.Planner) {
+        temperature = 0.02;
+      }
+      const args: any = {
+        model: modelName, // OpenRouter model ID (e.g., 'openai/gpt-4o')
+        apiKey: providerConfig.apiKey,
+        configuration: {
+          baseURL: providerConfig.baseUrl || 'https://openrouter.ai/api/v1',
+          defaultHeaders: {
+            'HTTP-Referer': 'https://nanobrowser.extension', // Required for OpenRouter
+            'X-Title': 'Nanobrowser Extension',
+          },
+        },
+      };
+
+      // Add parameters based on the model type
+      if (modelName.startsWith('openai/o')) {
+        // For OpenAI O-series models
+        args.modelKwargs = {
+          max_completion_tokens: maxCompletionTokens,
+        };
+      } else {
+        args.topP = topP;
+        args.temperature = temperature;
+        args.maxTokens = maxTokens;
+      }
+
+      return new ChatOpenAI(args);
+    }
     case LLMProviderEnum.Anthropic: {
       temperature = 0.1;
       topP = 0.1;

+ 2 - 2
chrome-extension/src/background/agent/prompts/planner.ts

@@ -35,11 +35,11 @@ RESPONSIBILITIES:
 RESPONSE FORMAT: Your must always respond with a valid JSON object with the following fields:
 {
     "observation": "Brief analysis of the current state and what has been done so far",
-    "done": "true or false, whether further steps are needed to complete the ultimate task",
+    "done": true or false, // Boolean value indicating whether further steps are needed to complete the ultimate task
     "challenges": "List any potential challenges or roadblocks",
     "next_steps": "List 2-3 high-level next steps to take, each step should start with a new line",
     "reasoning": "Explain your reasoning for the suggested next steps",
-    "web_task": "true or false, whether the ultimate task is related to browsing the web"
+    "web_task": true or false // Boolean value indicating whether the ultimate task is related to browsing the web
 }
 
 NOTE:

+ 3 - 3
chrome-extension/src/background/agent/prompts/validator.ts

@@ -60,9 +60,9 @@ SPECIAL CASES:
 
 RESPONSE FORMAT: You must ALWAYS respond with valid JSON in this exact format:
 {
-  "is_valid": boolean,  // true if task is completed correctly
-  "reason": string      // clear explanation of validation result
-  "answer": string      // empty string if is_valid is false; human-readable final answer and should not be empty if is_valid is true
+  "is_valid": true or false,  // Boolean value (not a string) indicating if task is completed correctly
+  "reason": string,           // clear explanation of validation result
+  "answer": string            // empty string if is_valid is false; human-readable final answer and should not be empty if is_valid is true
 }
 
 ANSWER FORMATTING GUIDELINES:

+ 47 - 1
packages/storage/lib/settings/llmProviders.ts

@@ -1,7 +1,23 @@
 import { StorageEnum } from '../base/enums';
 import { createStorage } from '../base/base';
 import type { BaseStorage } from '../base/types';
-import type { LLMProviderEnum } from './types';
+import { LLMProviderEnum } from './types';
+
+// Interface for OpenRouter model information
+export interface OpenRouterModel {
+  id: string;
+  name: string;
+  description: string;
+  pricing: {
+    prompt: number;
+    completion: number;
+  };
+}
+
+// Interface for OpenRouter API response
+export interface OpenRouterModelsResponse {
+  data: OpenRouterModel[];
+}
 
 // Interface for a single provider configuration
 export interface ProviderConfig {
@@ -21,6 +37,7 @@ export type LLMProviderStorage = BaseStorage<LLMKeyRecord> & {
   hasProvider: (provider: LLMProviderEnum) => Promise<boolean>;
   getConfiguredProviders: () => Promise<LLMProviderEnum[]>;
   getAllProviders: () => Promise<Record<LLMProviderEnum, ProviderConfig>>;
+  fetchOpenRouterModels: () => Promise<OpenRouterModel[]>;
 };
 
 const storage = createStorage<LLMKeyRecord>(
@@ -80,4 +97,33 @@ export const llmProviderStore: LLMProviderStorage = {
     const data = await storage.get();
     return data.providers;
   },
+
+  async fetchOpenRouterModels() {
+    try {
+      const openRouterConfig = await this.getProvider(LLMProviderEnum.OpenRouter);
+      if (!openRouterConfig || !openRouterConfig.apiKey) {
+        throw new Error('OpenRouter API key not configured');
+      }
+
+      const baseUrl = openRouterConfig.baseUrl || 'https://openrouter.ai/api/v1';
+      const response = await fetch(`${baseUrl}/models`, {
+        method: 'GET',
+        headers: {
+          Authorization: `Bearer ${openRouterConfig.apiKey}`,
+          'HTTP-Referer': 'https://nanobrowser.extension',
+          'X-Title': 'Nanobrowser Extension',
+        },
+      });
+
+      if (!response.ok) {
+        throw new Error(`Failed to fetch OpenRouter models: ${response.statusText}`);
+      }
+
+      const data: OpenRouterModelsResponse = await response.json();
+      return data.data;
+    } catch (error) {
+      console.error('Error fetching OpenRouter models:', error);
+      return [];
+    }
+  },
 };

+ 3 - 1
packages/storage/lib/settings/types.ts

@@ -9,9 +9,10 @@ export enum LLMProviderEnum {
   OpenAI = 'openai',
   Anthropic = 'anthropic',
   Gemini = 'gemini',
+  OpenRouter = 'openrouter',
 }
 
-export const llmProviderModelNames = {
+export const llmProviderModelNames: Record<LLMProviderEnum, string[]> = {
   [LLMProviderEnum.OpenAI]: ['gpt-4o', 'gpt-4o-mini', 'o1', 'o1-mini', 'o3-mini'],
   [LLMProviderEnum.Anthropic]: ['claude-3-7-sonnet-latest', 'claude-3-5-haiku-latest'],
   [LLMProviderEnum.Gemini]: [
@@ -20,6 +21,7 @@ export const llmProviderModelNames = {
     'gemini-2.0-pro-exp-02-05',
     // 'gemini-2.0-flash-thinking-exp-01-21', // TODO: not support function calling for now
   ],
+  [LLMProviderEnum.OpenRouter]: [], // Will be populated dynamically from the API
 };
 
 /**

+ 119 - 2
pages/options/src/components/ModelSettings.tsx

@@ -7,6 +7,7 @@ import {
   LLMProviderEnum,
   llmProviderModelNames,
 } from '@extension/storage';
+import type { OpenRouterModel } from '@extension/storage';
 
 export const ModelSettings = () => {
   const [apiKeys, setApiKeys] = useState<Record<LLMProviderEnum, { apiKey: string; baseUrl?: string }>>(
@@ -18,6 +19,8 @@ export const ModelSettings = () => {
     [AgentNameEnum.Planner]: '',
     [AgentNameEnum.Validator]: '',
   });
+  const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
+  const [isLoadingModels, setIsLoadingModels] = useState(false);
 
   useEffect(() => {
     const loadApiKeys = async () => {
@@ -89,11 +92,44 @@ export const ModelSettings = () => {
         next.delete(provider);
         return next;
       });
+
+      // If OpenRouter provider was saved, fetch the models
+      if (provider === LLMProviderEnum.OpenRouter) {
+        fetchOpenRouterModels();
+      }
     } catch (error) {
       console.error('Error saving API key:', error);
     }
   };
 
+  // Function to fetch OpenRouter models
+  const fetchOpenRouterModels = async () => {
+    if (!apiKeys[LLMProviderEnum.OpenRouter]?.apiKey) {
+      return;
+    }
+
+    setIsLoadingModels(true);
+    try {
+      const models = await llmProviderStore.fetchOpenRouterModels();
+      setOpenRouterModels(models);
+
+      // Update the llmProviderModelNames with the fetched models
+      const modelIds = models.map(model => model.id);
+      llmProviderModelNames[LLMProviderEnum.OpenRouter] = modelIds;
+    } catch (error) {
+      console.error('Error fetching OpenRouter models:', error);
+    } finally {
+      setIsLoadingModels(false);
+    }
+  };
+
+  // Fetch OpenRouter models when the component mounts if the API key is configured
+  useEffect(() => {
+    if (apiKeys[LLMProviderEnum.OpenRouter]?.apiKey) {
+      fetchOpenRouterModels();
+    }
+  }, [apiKeys[LLMProviderEnum.OpenRouter]?.apiKey]);
+
   const handleDelete = async (provider: LLMProviderEnum) => {
     try {
       await llmProviderStore.removeProvider(provider);
@@ -131,12 +167,35 @@ export const ModelSettings = () => {
     const models: string[] = [];
     Object.entries(apiKeys).forEach(([provider, config]) => {
       if (config.apiKey) {
-        models.push(...(llmProviderModelNames[provider as LLMProviderEnum] || []));
+        if (provider === LLMProviderEnum.OpenRouter) {
+          // Use the dynamically fetched OpenRouter models
+          models.push(...(llmProviderModelNames[LLMProviderEnum.OpenRouter] || []));
+        } else {
+          models.push(...(llmProviderModelNames[provider as LLMProviderEnum] || []));
+        }
       }
     });
     return models.length ? models : [''];
   };
 
+  // Helper function to get model display name
+  const getModelDisplayName = (modelId: string) => {
+    if (
+      modelId.startsWith('openai/') ||
+      modelId.startsWith('anthropic/') ||
+      modelId.startsWith('meta-llama/') ||
+      modelId.startsWith('google/') ||
+      modelId.startsWith('mistral/')
+    ) {
+      // For OpenRouter models, find the model in the fetched list
+      const openRouterModel = openRouterModels.find(model => model.id === modelId);
+      if (openRouterModel) {
+        return `${openRouterModel.name} (${modelId})`;
+      }
+    }
+    return modelId;
+  };
+
   const handleModelChange = async (agentName: AgentNameEnum, model: string) => {
     setSelectedModels(prev => ({
       ...prev,
@@ -187,7 +246,7 @@ export const ModelSettings = () => {
           model =>
             model && (
               <option key={model} value={model}>
-                {model}
+                {getModelDisplayName(model)}
               </option>
             ),
         )}
@@ -305,6 +364,64 @@ export const ModelSettings = () => {
               />
             </div>
           </div>
+
+          <div className="border-t border-gray-200" />
+
+          {/* OpenRouter Section */}
+          <div className="space-y-4">
+            <div className="flex items-center justify-between">
+              <h3 className="text-lg font-medium text-gray-700">OpenRouter</h3>
+              <div className="flex items-center space-x-2">
+                {isLoadingModels && <span className="text-sm text-gray-500">Loading models...</span>}
+                <Button
+                  {...getButtonProps(LLMProviderEnum.OpenRouter)}
+                  size="sm"
+                  onClick={() =>
+                    apiKeys[LLMProviderEnum.OpenRouter]?.apiKey && !modifiedProviders.has(LLMProviderEnum.OpenRouter)
+                      ? handleDelete(LLMProviderEnum.OpenRouter)
+                      : handleSave(LLMProviderEnum.OpenRouter)
+                  }
+                />
+              </div>
+            </div>
+            <div className="space-y-3">
+              <input
+                type="password"
+                placeholder="OpenRouter API key"
+                value={apiKeys[LLMProviderEnum.OpenRouter]?.apiKey || ''}
+                onChange={e => handleApiKeyChange(LLMProviderEnum.OpenRouter, e.target.value)}
+                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
+              />
+              <input
+                type="text"
+                placeholder="Custom Base URL (Optional)"
+                value={apiKeys[LLMProviderEnum.OpenRouter]?.baseUrl || ''}
+                onChange={e =>
+                  handleApiKeyChange(
+                    LLMProviderEnum.OpenRouter,
+                    apiKeys[LLMProviderEnum.OpenRouter]?.apiKey || '',
+                    e.target.value,
+                  )
+                }
+                className="w-full p-2 rounded-md bg-gray-50 border border-gray-200 focus:border-blue-400 focus:ring-2 focus:ring-blue-200 outline-none"
+              />
+              {openRouterModels.length > 0 && (
+                <div className="mt-2 p-2 bg-gray-50 rounded-md">
+                  <p className="text-sm font-medium text-gray-700 mb-1">Available Models: {openRouterModels.length}</p>
+                  <div className="text-xs text-gray-500 max-h-24 overflow-y-auto">
+                    {openRouterModels.slice(0, 5).map(model => (
+                      <div key={model.id} className="mb-1">
+                        {model.name} ({model.id})
+                      </div>
+                    ))}
+                    {openRouterModels.length > 5 && (
+                      <div className="text-xs text-gray-500">...and {openRouterModels.length - 5} more</div>
+                    )}
+                  </div>
+                </div>
+              )}
+            </div>
+          </div>
         </div>
       </div>