diff --git a/agent/go.mod b/agent/go.mod index c81a2b6..c48a453 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -20,6 +20,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/stretchr/testify v1.11.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect diff --git a/agent/go.sum b/agent/go.sum index 7912f0f..cf57343 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -29,6 +29,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/agent/pkg/llm/openrouter.go b/agent/pkg/llm/openrouter.go new file mode 100644 index 0000000..83fc427 --- /dev/null +++ b/agent/pkg/llm/openrouter.go @@ -0,0 +1,431 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "log" + "regexp" + "strings" + + "github.com/sashabaranov/go-openai" +) + +// OpenrouterProvider implements the Provider interface for Openrouter +type OpenrouterProvider struct { + client *openai.Client + config Config + registeredTools map[string]bool + messages []openai.ChatCompletionMessage +} + +// NewOpenrouterProvider creates a new Openrouter provider +func NewOpenrouterProvider(apiKey string, modelName string) (*OpenrouterProvider, error) { + config := Config{ + Provider: "openrouter", + ModelName: modelName, + ResponseMIMEType: "application/json", + SystemPrompt: getOpenrouterSystemPrompt(), + MaxRetries: 3, + MaxJSONRetries: 2, + } + + if modelName == "" { + config.ModelName = "anthropic/claude-3.5-sonnet" + } + + return NewOpenrouterProviderWithConfig(apiKey, config) +} + +// getOpenrouterSystemPrompt returns an optimized system prompt for OpenRouter models +func getOpenrouterSystemPrompt() string { + return `You are a helpful AI assistant with access to various tools and functions. + +CRITICAL: You MUST respond with valid JSON only. No markdown, no explanations, no extra text. + +When you need to use a tool, respond with: +{"toolName": "tool_name", "arguments": {...}, "explanation": "why"} + +For answers, respond with: +{"answer": "your response here", "explanation": "context"} + +For questions, respond with: +{"question": "what you need to know"} + +Multiple responses can be in an array, but keep it simple and valid JSON.` +} + +// NewOpenrouterProviderWithConfig creates a new Openrouter provider with custom config +func NewOpenrouterProviderWithConfig(apiKey string, llmConfig Config) (*OpenrouterProvider, error) { + if llmConfig.ModelName == "" { + llmConfig.ModelName = "anthropic/claude-3.5-sonnet" + } + + openaiConfig := openai.DefaultConfig(apiKey) + openaiConfig.BaseURL = "https://openrouter.ai/api/v1" + client := openai.NewClientWithConfig(openaiConfig) + + provider := &OpenrouterProvider{ + client: client, + config: llmConfig, + registeredTools: make(map[string]bool), + messages: []openai.ChatCompletionMessage{}, + } + + // Populate registered tools map + for _, toolName := range llmConfig.RegisteredTools { + provider.registeredTools[toolName] = true + } + + // Initialize with system message + provider.messages = append(provider.messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: llmConfig.SystemPrompt, + }) + + return provider, nil +} + +// SendMessage sends a message to Openrouter +func (p *OpenrouterProvider) SendMessage(ctx context.Context, message string) (*Response, error) { + // Add user message + p.messages = append(p.messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: message, + }) + + var lastErr error + var currentMessages []openai.ChatCompletionMessage + + // Retry loop for JSON parsing errors + for attempt := 0; attempt <= p.config.MaxJSONRetries; attempt++ { + currentMessages = make([]openai.ChatCompletionMessage, len(p.messages)) + copy(currentMessages, p.messages) + + // Create chat completion + req := openai.ChatCompletionRequest{ + Model: p.config.ModelName, + Messages: currentMessages, + ResponseFormat: &openai.ChatCompletionResponseFormat{ + Type: openai.ChatCompletionResponseFormatTypeJSONObject, + }, + } + + resp, err := p.client.CreateChatCompletion(ctx, req) + if err != nil { + return nil, p.friendlyError(err) + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("empty response from Openrouter") + } + + content := resp.Choices[0].Message.Content + parsedResp, err := p.parseResponse(content) + if err == nil { + // Add assistant message to history + p.messages = append(p.messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: content, + }) + return parsedResp, nil + } + + // Check if it's a JSON parse error + if jsonErr, ok := err.(*JSONParseError); ok { + lastErr = err + if attempt < p.config.MaxJSONRetries { + log.Printf("JSON parse error (attempt %d/%d): %v. Retrying with feedback...", attempt+1, p.config.MaxJSONRetries+1, err) + + // Construct feedback message for the LLM + feedbackMsg := fmt.Sprintf("I received an error parsing your last response as JSON. Error: %v\n\nYour previous response was:\n%s\n\nPlease correct the format and respond ONLY with valid JSON matching the schema.", jsonErr.Err, jsonErr.OriginalText) + currentMessages = append(currentMessages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: feedbackMsg, + }) + continue + } + } else { + // legitimate other error + return nil, err + } + } + + // If we exhausted retries, fallback to returning the text from the last error if available + if jsonErr, ok := lastErr.(*JSONParseError); ok { + log.Printf("Exhausted JSON retries. Falling back to raw text.") + return &Response{Text: jsonErr.OriginalText}, nil + } + + return nil, lastErr +} + +// GetHistory returns the conversation history +func (p *OpenrouterProvider) GetHistory() []Message { + var history []Message + for _, msg := range p.messages { + role := "user" + if msg.Role == openai.ChatMessageRoleAssistant { + role = "assistant" + } else if msg.Role == openai.ChatMessageRoleSystem { + role = "system" + } + + history = append(history, Message{ + Role: role, + Content: msg.Content, + }) + } + return history +} + +// GetRawHistory returns the raw history for session carry-over +func (p *OpenrouterProvider) GetRawHistory() any { + return p.messages +} + +// AppendSystemNotice adds a system notice to the history +func (p *OpenrouterProvider) AppendSystemNotice(message string) error { + // For Openrouter, we can add a system message + p.messages = append(p.messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: message, + }) + return nil +} + +// Close closes the provider and releases resources +func (p *OpenrouterProvider) Close() error { + // OpenAI client doesn't require explicit closing + return nil +} + +// parseResponse converts Openrouter response to generic Response with enhanced flexibility +func (p *OpenrouterProvider) parseResponse(text string) (*Response, error) { + // Clean the text first + text = strings.TrimSpace(text) + + // Try multiple parsing strategies for better robustness + genericResp, err := p.tryParseStrategies(text) + if err != nil { + return nil, err + } + + return genericResp, nil +} + +// tryParseStrategies attempts multiple JSON parsing approaches +func (p *OpenrouterProvider) tryParseStrategies(text string) (*Response, error) { + // Strategy 1: Standard array/object parsing (original approach) + if resp, err := p.parseStandard(text); err == nil { + return resp, nil + } + + // Strategy 2: Extract JSON from markdown code blocks + if resp, err := p.parseFromMarkdown(text); err == nil { + return resp, nil + } + + // Strategy 3: Flexible key-based parsing (look for tool/action patterns) + if resp, err := p.parseFlexible(text); err == nil { + return resp, nil + } + + // Strategy 4: Try to extract any valid JSON object/array from the text + if resp, err := p.parseExtractedJSON(text); err == nil { + return resp, nil + } + + // All strategies failed + return nil, &JSONParseError{ + OriginalText: text, + Err: fmt.Errorf("all parsing strategies failed"), + } +} + +// parseStandard - original parsing approach +func (p *OpenrouterProvider) parseStandard(text string) (*Response, error) { + var outerResponses []LLMOuterResponse + if err := json.Unmarshal([]byte(text), &outerResponses); err != nil { + // Try single object + var single LLMOuterResponse + if err2 := json.Unmarshal([]byte(text), &single); err2 != nil { + return nil, err2 + } + outerResponses = []LLMOuterResponse{single} + } + + return p.processLLMResponses(outerResponses), nil +} + +// parseFromMarkdown - extract JSON from markdown code blocks +func (p *OpenrouterProvider) parseFromMarkdown(text string) (*Response, error) { + // Look for JSON in markdown code blocks + jsonRegex := regexp.MustCompile("```(?:json)?\\s*(\\{[\\s\\S]*?\\}|\\[[\\s\\S]*?\\])\\s*```") + matches := jsonRegex.FindStringSubmatch(text) + if len(matches) > 1 { + return p.parseStandard(matches[1]) + } + + // Also try without language specifier + jsonRegex2 := regexp.MustCompile("```\\s*(\\{[\\s\\S]*?\\}|\\[[\\s\\S]*?\\])\\s*```") + matches2 := jsonRegex2.FindStringSubmatch(text) + if len(matches2) > 1 { + return p.parseStandard(matches2[1]) + } + + return nil, fmt.Errorf("no JSON found in markdown") +} + +// parseFlexible - look for tool/action patterns in various formats +func (p *OpenrouterProvider) parseFlexible(text string) (*Response, error) { + resp := &Response{} + + // Look for tool call patterns + toolPatterns := []string{ + `"toolName"\s*:\s*"([^"]+)"`, + `"tool"\s*:\s*"([^"]+)"`, + `"action"\s*:\s*"([^"]+)"`, + `"function"\s*:\s*"([^"]+)"`, + } + + for _, pattern := range toolPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(text) + if len(matches) > 1 && p.registeredTools[matches[1]] { + // Found a valid tool call + resp.ToolCalls = append(resp.ToolCalls, ToolCall{ + ToolName: matches[1], + Arguments: make(map[string]interface{}), + Explanation: "Tool call detected", + }) + break + } + } + + // Look for answer patterns + answerPatterns := []string{ + `"answer"\s*:\s*"([^"]*(?:\\.[^"]*)*)"`, + `"response"\s*:\s*"([^"]*(?:\\.[^"]*)*)"`, + `"result"\s*:\s*"([^"]*(?:\\.[^"]*)*)"`, + } + + for _, pattern := range answerPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(text) + if len(matches) > 1 { + resp.Text = matches[1] + break + } + } + + // Look for question patterns + questionPatterns := []string{ + `"question"\s*:\s*"([^"]*(?:\\.[^"]*)*)"`, + } + + for _, pattern := range questionPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(text) + if len(matches) > 1 { + resp.Question = matches[1] + break + } + } + + // If we found any structured content, return it + if len(resp.ToolCalls) > 0 || resp.Text != "" || resp.Question != "" { + return resp, nil + } + + return nil, fmt.Errorf("no structured content found") +} + +// parseExtractedJSON - try to find and parse any valid JSON in the text +func (p *OpenrouterProvider) parseExtractedJSON(text string) (*Response, error) { + // Try to find JSON objects or arrays in the text + jsonPatterns := []string{ + `\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`, // Simple objects (may not handle nested) + `\[[\s\S]*?\]`, // Arrays + } + + for _, pattern := range jsonPatterns { + re := regexp.MustCompile(pattern) + matches := re.FindAllString(text, -1) + for _, match := range matches { + if resp, err := p.parseStandard(match); err == nil { + return resp, nil + } + } + } + + return nil, fmt.Errorf("no valid JSON found") +} + +// processLLMResponses - common processing logic for LLMOuterResponse arrays +func (p *OpenrouterProvider) processLLMResponses(outerResponses []LLMOuterResponse) *Response { + genericResp := &Response{} + var finalAnswer strings.Builder + + for _, r := range outerResponses { + if r.Answer != "" { + finalAnswer.WriteString(r.Answer + "\n") + } + if r.Question != "" { + genericResp.Question = r.Question + } + // Look for unified tool call format: toolName and arguments + if r.ToolName != "" { + // Check if this tool is registered + if !p.registeredTools[r.ToolName] { + continue // Tool not registered, skip + } + + // Get arguments (LLM should always provide them) + arguments := r.Arguments + + // Create tool call with its explanation + toolCall := ToolCall{ + ToolName: r.ToolName, + Arguments: arguments, + Explanation: r.Explanation, + } + + genericResp.ToolCalls = append(genericResp.ToolCalls, toolCall) + } + } + + genericResp.Text = strings.TrimSpace(finalAnswer.String()) + return genericResp +} + +// friendlyError converts Openrouter API errors to user-friendly messages +func (p *OpenrouterProvider) friendlyError(err error) error { + if err == nil { + return nil + } + + errStr := err.Error() + + // Check for common error patterns + if strings.Contains(errStr, "401") || strings.Contains(errStr, "unauthorized") || strings.Contains(errStr, "invalid_api_key") { + return fmt.Errorf("🔑 Invalid or expired API key. Please check your Openrouter API key in Settings.") + } + + // Quota/rate limit errors + if strings.Contains(errStr, "429") || strings.Contains(errStr, "rate limit") || strings.Contains(errStr, "quota") { + return fmt.Errorf("⏳ API quota exceeded. Please check your Openrouter account limits.") + } + + // Network/timeout errors + if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline") || strings.Contains(errStr, "context canceled") { + return fmt.Errorf("⏱️ Request timed out. Please try again.") + } + + if strings.Contains(errStr, "connection") || strings.Contains(errStr, "network") { + return fmt.Errorf("🌐 Network error. Please check your internet connection.") + } + + // Return original error if no match + return err +} diff --git a/grid-agent-gui/app.go b/grid-agent-gui/app.go index cdb45db..e3d6cc8 100644 --- a/grid-agent-gui/app.go +++ b/grid-agent-gui/app.go @@ -42,7 +42,9 @@ type App struct { type Settings struct { Mnemonics string `json:"mnemonics"` Network string `json:"network"` // mainnet, testnet, devnet + Provider string `json:"provider"` // "gemini", "openrouter", etc. GeminiAPIKey string `json:"geminiApiKey"` + OpenrouterAPIKey string `json:"openrouterApiKey"` Model string `json:"model"` Theme string `json:"theme"` // light, dark IsConfigured bool `json:"isConfigured"` @@ -692,6 +694,7 @@ func (a *App) initializeAgent(opts AgentInitOptions) error { // Create LLM provider with dynamic tool documentation providerConfig := llm.Config{ + Provider: a.settings.Provider, ModelName: modelName, ResponseMIMEType: "application/json", SystemPrompt: strings.Replace(internalConfig.GetSystemPrompt(a.settings.Network, a.getActiveInstructions()), "{{TOOL_DESCRIPTIONS}}", toolDocs, 1), @@ -701,7 +704,18 @@ func (a *App) initializeAgent(opts AgentInitOptions) error { History: history, // Pass previous history } - provider, err := llm.NewGeminiProviderWithConfig(a.settings.GeminiAPIKey, providerConfig) + var provider llm.Provider + var err error + + switch a.settings.Provider { + case "openrouter": + provider, err = llm.NewOpenrouterProviderWithConfig(a.settings.OpenrouterAPIKey, providerConfig) + case "gemini": + fallthrough + default: + provider, err = llm.NewGeminiProviderWithConfig(a.settings.GeminiAPIKey, providerConfig) + } + if err != nil { return err } @@ -815,13 +829,18 @@ func (a *App) DeleteProfile(id string) (*Settings, error) { return a.settings, nil } -// UpdateAdvancedSettings updates the API key, model, and export options -func (a *App) UpdateAdvancedSettings(apiKey, model string, enableExportSummary bool) (*Settings, error) { +// UpdateAdvancedSettings updates the provider, API keys, model, and export options +func (a *App) UpdateAdvancedSettings(provider, apiKey, model string, enableExportSummary bool) (*Settings, error) { if apiKey == "" { return nil, fmt.Errorf("API key cannot be empty") } - a.settings.GeminiAPIKey = apiKey + a.settings.Provider = provider + if provider == "gemini" { + a.settings.GeminiAPIKey = apiKey + } else if provider == "openrouter" { + a.settings.OpenrouterAPIKey = apiKey + } a.settings.Model = model a.settings.EnableExportSummary = enableExportSummary diff --git a/grid-agent-gui/frontend/src/components/Settings.svelte b/grid-agent-gui/frontend/src/components/Settings.svelte index 3f3168d..464163f 100644 --- a/grid-agent-gui/frontend/src/components/Settings.svelte +++ b/grid-agent-gui/frontend/src/components/Settings.svelte @@ -31,17 +31,24 @@ let formInstructions = ""; // Advanced Settings + let advancedProvider = "gemini"; let advancedModel = ""; let advancedApiKey = ""; let isEditingApiKey = false; let tempApiKey = ""; + let providerDropdownOpen = false; let modelDropdownOpen = false; let showApiKey = false; // Track original values for change detection + let originalProvider = "gemini"; let originalApiKey = ""; let originalModel = ""; + // Store API keys separately to prevent cross-contamination + let storedGeminiKey = ""; + let storedOpenrouterKey = ""; + // Grid Settings let gridMnemonics = ""; let gridNetwork = ""; @@ -66,13 +73,34 @@ // Track modal visibility for latch let prevShow = false; - const availableModels = [ - "gemini-3-pro-preview", - "gemini-3-flash-preview", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - "gemini-robotics-er-1.5-preview", + const availableModels = { + gemini: [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-robotics-er-1.5-preview", + ], + openrouter: [ + "google/gemini-3-flash-preview", + "mistralai/mistral-small-creative", + "xiaomi/mimo-v2-flash:free", + "nvidia/nemotron-3-nano-30b-a3b:free", + "nvidia/nemotron-3-nano-30b-a3b", + "openai/gpt-5.2-chat", + "mistralai/devstral-2512:free", + "nex-agi/deepseek-v3.1-nex-n1:free", + "arcee-ai/trinity-mini:free", + "tngtech/tng-r1t-chimera:free", + "x-ai/grok-4.1-fast", + "google/gemini-3-pro-preview", + ], + }; + + const availableProviders = [ + { id: "gemini", name: "Google Gemini" }, + { id: "openrouter", name: "OpenRouter" }, ]; // --- Reactivity --- @@ -86,10 +114,16 @@ if (show && settingsStore) { resetState(); if ($settingsStore) { - advancedApiKey = $settingsStore.geminiApiKey || ""; + // Load stored API keys + storedGeminiKey = $settingsStore.geminiApiKey || ""; + storedOpenrouterKey = $settingsStore.openrouterApiKey || ""; + + advancedProvider = $settingsStore.provider || "gemini"; + advancedApiKey = ($settingsStore.provider === "openrouter" ? storedOpenrouterKey : storedGeminiKey) || ""; advancedModel = $settingsStore.model || "gemini-3-flash-preview"; enableExportSummary = $settingsStore.enableExportSummary || false; // Store original values + originalProvider = advancedProvider; originalApiKey = advancedApiKey; originalModel = advancedModel; originalEnableExportSummary = enableExportSummary; @@ -107,12 +141,20 @@ prevShow = show; } + // Update API key when provider changes + $: if (advancedProvider !== originalProvider) { + // When provider changes, update the API key field to reflect the stored key for the new provider + // or clear it if no key is stored for the new provider + advancedApiKey = (advancedProvider === "openrouter" ? storedOpenrouterKey : storedGeminiKey) || ""; + } + // Detect if config has changed let enableExportSummary = false; let originalEnableExportSummary = false; $: configHasChanged = - (advancedApiKey !== originalApiKey || + (advancedProvider !== originalProvider || + advancedApiKey !== originalApiKey || advancedModel !== originalModel || enableExportSummary !== originalEnableExportSummary) && advancedApiKey.trim() !== ""; @@ -131,6 +173,7 @@ function resetState() { activeSection = "personas"; error = ""; + providerDropdownOpen = false; modelDropdownOpen = false; networkDropdownOpen = false; cancelApiKeyEdit(); @@ -140,23 +183,22 @@ function switchSection(section: string) { activeSection = section; error = ""; + providerDropdownOpen = false; modelDropdownOpen = false; cancelEdit(); } // Close dropdown when clicking outside function handleDropdownClickOutside(event: MouseEvent) { - if (modelDropdownOpen) { - const target = event.target as HTMLElement; - if (!target.closest(".custom-select")) { - modelDropdownOpen = false; - } + const target = event.target as HTMLElement; + if (providerDropdownOpen && !target.closest(".custom-select")) { + providerDropdownOpen = false; } - if (networkDropdownOpen) { - const target = event.target as HTMLElement; - if (!target.closest(".custom-select")) { - networkDropdownOpen = false; - } + if (modelDropdownOpen && !target.closest(".custom-select") && !target.closest(".custom-model-input")) { + modelDropdownOpen = false; + } + if (networkDropdownOpen && !target.closest(".custom-select")) { + networkDropdownOpen = false; } } @@ -259,12 +301,14 @@ } try { const newSettings = await UpdateAdvancedSettings( + advancedProvider, advancedApiKey, advancedModel, enableExportSummary, ); settingsStore.set(newSettings); // Update original values after successful save + originalProvider = advancedProvider; originalApiKey = advancedApiKey; originalModel = advancedModel; originalEnableExportSummary = enableExportSummary; @@ -658,14 +702,14 @@
+ Enter any OpenRouter model name (e.g., deepseek/deepseek-r1-0528:free) +
+ {/if} + {/if} +