From 8b09c95ce57858570e0d027d5be5305d22e10b37 Mon Sep 17 00:00:00 2001 From: mik-tf Date: Sat, 20 Dec 2025 09:09:47 -0500 Subject: [PATCH 1/5] feat: Add OpenRouter AI provider support with UI configuration --- agent/go.mod | 1 + agent/go.sum | 2 + agent/pkg/llm/openrouter.go | 266 +++++++++++++++++ grid-agent-gui/app.go | 27 +- .../frontend/src/components/Settings.svelte | 278 ++++++++++++++++-- .../frontend/wailsjs/go/main/App.d.ts | 2 +- .../frontend/wailsjs/go/main/App.js | 4 +- grid-agent-gui/frontend/wailsjs/go/models.ts | 4 + grid-agent-gui/go.mod | 1 + grid-agent-gui/go.sum | 2 + 10 files changed, 549 insertions(+), 38 deletions(-) create mode 100644 agent/pkg/llm/openrouter.go 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..c3641a5 --- /dev/null +++ b/agent/pkg/llm/openrouter.go @@ -0,0 +1,266 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "log" + "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: "You are a helpful AI assistant.", + MaxRetries: 3, + MaxJSONRetries: 2, + } + + if modelName == "" { + config.ModelName = "anthropic/claude-3.5-sonnet" + } + + return NewOpenrouterProviderWithConfig(apiKey, config) +} + +// 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 +func (p *OpenrouterProvider) parseResponse(text string) (*Response, error) { + // Try to parse JSON using the defined struct + 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 special error to trigger retry loop in SendMessage + return nil, &JSONParseError{ + OriginalText: text, + Err: err2, + } + } + outerResponses = []LLMOuterResponse{single} + } + + // Handle all responses in the list for multiple tool calls + 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, nil +} + +// 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..200b563 100644 --- a/grid-agent-gui/frontend/src/components/Settings.svelte +++ b/grid-agent-gui/frontend/src/components/Settings.svelte @@ -31,14 +31,17 @@ 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 = ""; @@ -66,13 +69,28 @@ // 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: [ + "anthropic/claude-3.5-sonnet", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "meta-llama/llama-3.1-405b-instruct", + "google/gemini-pro-1.5", + "deepseek/deepseek-r1-0528:free", + ], + }; + + const availableProviders = [ + { id: "gemini", name: "Google Gemini" }, + { id: "openrouter", name: "OpenRouter" }, ]; // --- Reactivity --- @@ -86,10 +104,12 @@ if (show && settingsStore) { resetState(); if ($settingsStore) { - advancedApiKey = $settingsStore.geminiApiKey || ""; + advancedProvider = $settingsStore.provider || "gemini"; + advancedApiKey = ($settingsStore.provider === "openrouter" ? $settingsStore.openrouterApiKey : $settingsStore.geminiApiKey) || ""; advancedModel = $settingsStore.model || "gemini-3-flash-preview"; enableExportSummary = $settingsStore.enableExportSummary || false; // Store original values + originalProvider = advancedProvider; originalApiKey = advancedApiKey; originalModel = advancedModel; originalEnableExportSummary = enableExportSummary; @@ -112,7 +132,8 @@ let originalEnableExportSummary = false; $: configHasChanged = - (advancedApiKey !== originalApiKey || + (advancedProvider !== originalProvider || + advancedApiKey !== originalApiKey || advancedModel !== originalModel || enableExportSummary !== originalEnableExportSummary) && advancedApiKey.trim() !== ""; @@ -131,6 +152,7 @@ function resetState() { activeSection = "personas"; error = ""; + providerDropdownOpen = false; modelDropdownOpen = false; networkDropdownOpen = false; cancelApiKeyEdit(); @@ -140,23 +162,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(".model-input-container")) { + modelDropdownOpen = false; + } + if (networkDropdownOpen && !target.closest(".custom-select")) { + networkDropdownOpen = false; } } @@ -259,12 +280,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 +681,14 @@
- -
+ +
- {#if modelDropdownOpen} + {#if providerDropdownOpen}
- {#each availableModels as m} + {#each availableProviders as p} {/each}
@@ -703,7 +730,109 @@
- + + {#if advancedProvider === "gemini"} + +
+ + {#if modelDropdownOpen} +
+ {#each availableModels[advancedProvider] || [] as m} + + {/each} +
+ {/if} +
+ {:else if advancedProvider === "openrouter"} + +
+ +
+ + {#if modelDropdownOpen} +
+ {#each availableModels[advancedProvider] || [] as m} + + {/each} +
+ {/if} +
+
+

+ Choose from suggestions or enter any OpenRouter model name manually (e.g., deepseek/deepseek-r1-0528:free) +

+ {/if} +
+ +
+ {#if isEditingApiKey}
; export function SetTheme(arg1:string):Promise; -export function UpdateAdvancedSettings(arg1:string,arg2:string,arg3:boolean):Promise; +export function UpdateAdvancedSettings(arg1:string,arg2:string,arg3:string,arg4:boolean):Promise; export function UpdateGridSettings(arg1:string,arg2:string):Promise; diff --git a/grid-agent-gui/frontend/wailsjs/go/main/App.js b/grid-agent-gui/frontend/wailsjs/go/main/App.js index ea37b06..f34cf32 100755 --- a/grid-agent-gui/frontend/wailsjs/go/main/App.js +++ b/grid-agent-gui/frontend/wailsjs/go/main/App.js @@ -62,8 +62,8 @@ export function SetTheme(arg1) { return window['go']['main']['App']['SetTheme'](arg1); } -export function UpdateAdvancedSettings(arg1, arg2, arg3) { - return window['go']['main']['App']['UpdateAdvancedSettings'](arg1, arg2, arg3); +export function UpdateAdvancedSettings(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['UpdateAdvancedSettings'](arg1, arg2, arg3, arg4); } export function UpdateGridSettings(arg1, arg2) { diff --git a/grid-agent-gui/frontend/wailsjs/go/models.ts b/grid-agent-gui/frontend/wailsjs/go/models.ts index 041fa3b..8c469a0 100755 --- a/grid-agent-gui/frontend/wailsjs/go/models.ts +++ b/grid-agent-gui/frontend/wailsjs/go/models.ts @@ -87,7 +87,9 @@ export namespace main { export class Settings { mnemonics: string; network: string; + provider: string; geminiApiKey: string; + openrouterApiKey: string; model: string; theme: string; isConfigured: boolean; @@ -103,7 +105,9 @@ export namespace main { if ('string' === typeof source) source = JSON.parse(source); this.mnemonics = source["mnemonics"]; this.network = source["network"]; + this.provider = source["provider"]; this.geminiApiKey = source["geminiApiKey"]; + this.openrouterApiKey = source["openrouterApiKey"]; this.model = source["model"]; this.theme = source["theme"]; this.isConfigured = source["isConfigured"]; diff --git a/grid-agent-gui/go.mod b/grid-agent-gui/go.mod index 1cce2b8..1b85a90 100644 --- a/grid-agent-gui/go.mod +++ b/grid-agent-gui/go.mod @@ -69,6 +69,7 @@ require ( github.com/rs/cors v1.10.1 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/sashabaranov/go-openai v1.41.2 // indirect github.com/threefoldtech/tfchain/clients/tfchain-client-go v0.0.0-20250929084418-b950278ead30 // indirect github.com/threefoldtech/tfgrid-sdk-go/grid-client v0.17.5 // indirect github.com/threefoldtech/tfgrid-sdk-go/grid-proxy v0.17.5 // indirect diff --git a/grid-agent-gui/go.sum b/grid-agent-gui/go.sum index 6cfbde2..df8e743 100644 --- a/grid-agent-gui/go.sum +++ b/grid-agent-gui/go.sum @@ -158,6 +158,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +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/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= From 5548ab5d0202cb6ccec5f5de5ab18a214c5e1239 Mon Sep 17 00:00:00 2001 From: mik-tf Date: Sat, 20 Dec 2025 09:22:22 -0500 Subject: [PATCH 2/5] feat: Replace OpenRouter model suggestions dropdown with select dropdown and custom input --- .../frontend/src/components/Settings.svelte | 190 +++++++++--------- 1 file changed, 99 insertions(+), 91 deletions(-) diff --git a/grid-agent-gui/frontend/src/components/Settings.svelte b/grid-agent-gui/frontend/src/components/Settings.svelte index 200b563..1901a6b 100644 --- a/grid-agent-gui/frontend/src/components/Settings.svelte +++ b/grid-agent-gui/frontend/src/components/Settings.svelte @@ -173,7 +173,7 @@ if (providerDropdownOpen && !target.closest(".custom-select")) { providerDropdownOpen = false; } - if (modelDropdownOpen && !target.closest(".custom-select") && !target.closest(".model-input-container")) { + if (modelDropdownOpen && !target.closest(".custom-select") && !target.closest(".custom-model-input")) { modelDropdownOpen = false; } if (networkDropdownOpen && !target.closest(".custom-select")) { @@ -775,21 +775,15 @@ {/if}
{:else if advancedProvider === "openrouter"} - -
- -
+ + {#if advancedModel !== "custom"} +
{#if modelDropdownOpen}
{#each availableModels[advancedProvider] || [] as m} {/each} + +
{/if}
-
-

- Choose from suggestions or enter any OpenRouter model name manually (e.g., deepseek/deepseek-r1-0528:free) -

+ {:else} + +
+ { + // Keep "custom" as a special value, don't override with user input + if (advancedModel === "custom") { + advancedModel = ""; + } + }} + /> + +
+

+ Enter any OpenRouter model name (e.g., deepseek/deepseek-r1-0528:free) +

+ {/if} {/if}
@@ -1769,91 +1824,44 @@ background: var(--text-secondary); } - /* Model Input Container for OpenRouter */ - .model-input-container { - position: relative; - } - - .model-input-container input { - padding-right: 120px; /* Space for suggestions button */ - } - - .model-suggestions { - position: absolute; - top: 100%; - right: 0; - z-index: 10; - } - - .suggestions-toggle { - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius-md); - color: var(--text-secondary); - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - gap: 0.25rem; - transition: all 0.2s; - } - .suggestions-toggle:hover { - background: var(--bg-secondary); - border-color: var(--accent); - color: var(--text-primary); - } - .suggestions-arrow { - transition: transform 0.2s; - color: var(--text-secondary); + /* Dropdown Separator */ + .dropdown-separator { + height: 1px; + background: var(--border); + margin: 0.5rem 0; } - .model-suggestions.open .suggestions-arrow.rotated { - transform: rotate(180deg); + /* Custom Option Styling */ + .custom-option { + color: var(--accent) !important; + font-weight: 600; } - .suggestions-dropdown { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-md); - box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); - max-height: 300px; - overflow-y: auto; - z-index: 1000; - min-width: 300px; + .custom-option:hover { + background: var(--accent); + color: white !important; } - .suggestion-option { - width: 100%; - background: transparent; - border: none; - color: var(--text-primary); - padding: 0.75rem 1rem; - font-family: inherit; - font-size: 0.9rem; - text-align: left; - cursor: pointer; - transition: background 0.15s; + .custom-option svg { + margin-right: 0.5rem; } - .suggestion-option:hover { - background: var(--bg-tertiary); + /* Custom Model Input */ + .custom-model-input { + display: flex; + gap: 0.75rem; + align-items: flex-start; } - .suggestion-option:first-child { - border-top-left-radius: var(--radius-md); - border-top-right-radius: var(--radius-md); + .custom-model-input input { + flex: 1; } - .suggestion-option:last-child { - border-bottom-left-radius: var(--radius-md); - border-bottom-right-radius: var(--radius-md); + .back-to-dropdown { + flex-shrink: 0; + white-space: nowrap; } /* Toggle Switch Styles */ From 010b4ff050a6724d5a588657d567f4ef2f53bb76 Mon Sep 17 00:00:00 2001 From: mik-tf Date: Sat, 20 Dec 2025 09:40:35 -0500 Subject: [PATCH 3/5] feat: Store API keys separately to prevent cross-contamination when switching providers --- .../frontend/src/components/Settings.svelte | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/grid-agent-gui/frontend/src/components/Settings.svelte b/grid-agent-gui/frontend/src/components/Settings.svelte index 1901a6b..66dca46 100644 --- a/grid-agent-gui/frontend/src/components/Settings.svelte +++ b/grid-agent-gui/frontend/src/components/Settings.svelte @@ -45,6 +45,10 @@ let originalApiKey = ""; let originalModel = ""; + // Store API keys separately to prevent cross-contamination + let storedGeminiKey = ""; + let storedOpenrouterKey = ""; + // Grid Settings let gridMnemonics = ""; let gridNetwork = ""; @@ -104,8 +108,12 @@ if (show && settingsStore) { resetState(); if ($settingsStore) { + // Load stored API keys + storedGeminiKey = $settingsStore.geminiApiKey || ""; + storedOpenrouterKey = $settingsStore.openrouterApiKey || ""; + advancedProvider = $settingsStore.provider || "gemini"; - advancedApiKey = ($settingsStore.provider === "openrouter" ? $settingsStore.openrouterApiKey : $settingsStore.geminiApiKey) || ""; + advancedApiKey = ($settingsStore.provider === "openrouter" ? storedOpenrouterKey : storedGeminiKey) || ""; advancedModel = $settingsStore.model || "gemini-3-flash-preview"; enableExportSummary = $settingsStore.enableExportSummary || false; // Store original values @@ -127,6 +135,13 @@ 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; From 991432aeb997666a53304b2174273181cdaa5b1e Mon Sep 17 00:00:00 2001 From: mik-tf Date: Sat, 20 Dec 2025 10:41:19 -0500 Subject: [PATCH 4/5] feat: update settings to take different open router models with tool calling support --- .../frontend/src/components/Settings.svelte | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/grid-agent-gui/frontend/src/components/Settings.svelte b/grid-agent-gui/frontend/src/components/Settings.svelte index 66dca46..464163f 100644 --- a/grid-agent-gui/frontend/src/components/Settings.svelte +++ b/grid-agent-gui/frontend/src/components/Settings.svelte @@ -83,12 +83,18 @@ "gemini-robotics-er-1.5-preview", ], openrouter: [ - "anthropic/claude-3.5-sonnet", - "openai/gpt-4o", - "openai/gpt-4o-mini", - "meta-llama/llama-3.1-405b-instruct", - "google/gemini-pro-1.5", - "deepseek/deepseek-r1-0528:free", + "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", ], }; From 19b58fca73dc245f4792bb4e0883d927e0e0e567 Mon Sep 17 00:00:00 2001 From: mik-tf Date: Sat, 20 Dec 2025 10:50:14 -0500 Subject: [PATCH 5/5] feat: enhance OpenRouter JSON parsing with multiple fallback strategies --- agent/pkg/llm/openrouter.go | 185 ++++++++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 10 deletions(-) diff --git a/agent/pkg/llm/openrouter.go b/agent/pkg/llm/openrouter.go index c3641a5..83fc427 100644 --- a/agent/pkg/llm/openrouter.go +++ b/agent/pkg/llm/openrouter.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "regexp" "strings" "github.com/sashabaranov/go-openai" @@ -24,7 +25,7 @@ func NewOpenrouterProvider(apiKey string, modelName string) (*OpenrouterProvider Provider: "openrouter", ModelName: modelName, ResponseMIMEType: "application/json", - SystemPrompt: "You are a helpful AI assistant.", + SystemPrompt: getOpenrouterSystemPrompt(), MaxRetries: 3, MaxJSONRetries: 2, } @@ -36,6 +37,24 @@ func NewOpenrouterProvider(apiKey string, modelName string) (*OpenrouterProvider 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 == "" { @@ -181,24 +200,170 @@ func (p *OpenrouterProvider) Close() error { return nil } -// parseResponse converts Openrouter response to generic Response +// parseResponse converts Openrouter response to generic Response with enhanced flexibility func (p *OpenrouterProvider) parseResponse(text string) (*Response, error) { - // Try to parse JSON using the defined struct + // 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 special error to trigger retry loop in SendMessage - return nil, &JSONParseError{ - OriginalText: text, - Err: err2, - } + return nil, err2 } outerResponses = []LLMOuterResponse{single} } - // Handle all responses in the list for multiple tool calls + 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 @@ -231,7 +396,7 @@ func (p *OpenrouterProvider) parseResponse(text string) (*Response, error) { } genericResp.Text = strings.TrimSpace(finalAnswer.String()) - return genericResp, nil + return genericResp } // friendlyError converts Openrouter API errors to user-friendly messages