diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 80702bfb..c4e365a9 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -41,7 +41,9 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { } if cfg.Providers.OpenRouter.APIKey != "" { - registry.Register(providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929")) + orProv := providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929") + orProv.WithSiteInfo("https://goclaw.sh", "GoClaw") + registry.Register(orProv) slog.Info("registered provider", "name", "openrouter") } @@ -404,6 +406,9 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } + if p.ProviderType == store.ProviderOpenRouter { + prov.WithSiteInfo("https://goclaw.sh", "GoClaw") + } registry.RegisterForTenant(p.TenantID, prov) } slog.Info("registered provider from DB", "name", p.Name) diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 42ae9d8b..d59e881e 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -55,7 +55,14 @@ func buildMCPToolsInlineSection(descs map[string]string) []string { mcpOptionalParamInstruction, "", } - for name, desc := range descs { + // Sort MCP tool names for deterministic ordering — critical for prompt caching. + sortedNames := make([]string, 0, len(descs)) + for name := range descs { + sortedNames = append(sortedNames, name) + } + slices.Sort(sortedNames) + for _, name := range sortedNames { + desc := descs[name] if len(desc) > mcpToolDescMaxLen { desc = desc[:mcpToolDescMaxLen] + "…" } diff --git a/internal/providers/openai.go b/internal/providers/openai.go index fcd057f4..8f4f4a56 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -27,6 +27,8 @@ type OpenAIProvider struct { authPrefix string // auth header prefix, defaults to "Bearer " if empty defaultModel string providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native") + siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer) + siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title) client *http.Client retryConfig RetryConfig } @@ -114,6 +116,14 @@ func (p *OpenAIProvider) WithAuthPrefix(prefix string) *OpenAIProvider { return p } +// WithSiteInfo sets site identification headers sent with API requests. +// Used by OpenRouter for rankings (HTTP-Referer, X-Title). +func (p *OpenAIProvider) WithSiteInfo(url, title string) *OpenAIProvider { + p.siteURL = url + p.siteTitle = title + return p +} + func (p *OpenAIProvider) Name() string { return p.name } func (p *OpenAIProvider) DefaultModel() string { return p.defaultModel } func (p *OpenAIProvider) SupportsThinking() bool { return true } @@ -576,6 +586,13 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body any) (io.ReadCloser } httpReq.Header.Set("Authorization", prefix+p.apiKey) } + // OpenRouter identification headers for rankings/analytics + if p.siteURL != "" { + httpReq.Header.Set("HTTP-Referer", p.siteURL) + } + if p.siteTitle != "" { + httpReq.Header.Set("X-Title", p.siteTitle) + } resp, err := p.client.Do(httpReq) if err != nil { diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index b1763fc7..7684d3bd 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -25,7 +25,7 @@ func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UU return nil, err } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause, + "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause+" ORDER BY file_name", append([]any{agentID}, tArgs...)...) if err != nil { return nil, err @@ -87,7 +87,7 @@ func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUI return nil, err } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause, + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause+" ORDER BY file_name", append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err diff --git a/internal/store/sqlitestore/agents_context.go b/internal/store/sqlitestore/agents_context.go index a3dc2418..cded9c0c 100644 --- a/internal/store/sqlitestore/agents_context.go +++ b/internal/store/sqlitestore/agents_context.go @@ -27,7 +27,7 @@ func (s *SQLiteAgentStore) GetAgentContextFiles(ctx context.Context, agentID uui return nil, err } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause, + "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = ?"+tClause+" ORDER BY file_name", append([]any{agentID}, tArgs...)...) if err != nil { return nil, err @@ -97,7 +97,7 @@ func (s *SQLiteAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid return nil, err } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause, + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = ? AND user_id = ?"+tClause+" ORDER BY file_name", append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err diff --git a/internal/tools/policy.go b/internal/tools/policy.go index e7f70a0e..57df0a84 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -130,8 +130,16 @@ func (pe *PolicyEngine) FilterTools( } } - // Add registry aliases for allowed canonical tools - for alias, canonical := range registry.Aliases() { + // Add registry aliases for allowed canonical tools. + // Sort alias names for deterministic ordering (prompt caching). + aliasMap := registry.Aliases() + aliasList := make([]string, 0, len(aliasMap)) + for alias := range aliasMap { + aliasList = append(aliasList, alias) + } + slices.Sort(aliasList) + for _, alias := range aliasList { + canonical := aliasMap[alias] if !allowedSet[canonical] { continue } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 0325a464..490841f8 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "maps" + "slices" "strings" "sync" "time" @@ -222,18 +223,34 @@ func safeExecute(tool Tool, ctx context.Context, args map[string]any) (result *R // ProviderDefs returns tool definitions for LLM provider APIs. // Includes alias definitions (same params/description, alias name). +// Results are sorted by tool name for deterministic ordering (prompt caching). func (r *Registry) ProviderDefs() []providers.ToolDefinition { r.mu.RLock() defer r.mu.RUnlock() - defs := make([]providers.ToolDefinition, 0, len(r.tools)+len(r.aliases)) - for name, tool := range r.tools { - if r.disabled[name] { - continue + // Sort canonical tool names for deterministic ordering. + sortedNames := make([]string, 0, len(r.tools)) + for name := range r.tools { + if !r.disabled[name] { + sortedNames = append(sortedNames, name) } - defs = append(defs, ToProviderDef(tool)) } - for alias, canonical := range r.aliases { + slices.Sort(sortedNames) + + defs := make([]providers.ToolDefinition, 0, len(sortedNames)+len(r.aliases)) + for _, name := range sortedNames { + defs = append(defs, ToProviderDef(r.tools[name])) + } + + // Sort alias names for deterministic ordering. + sortedAliases := make([]string, 0, len(r.aliases)) + for alias := range r.aliases { + sortedAliases = append(sortedAliases, alias) + } + slices.Sort(sortedAliases) + + for _, alias := range sortedAliases { + canonical := r.aliases[alias] if r.disabled[canonical] { continue } @@ -254,6 +271,8 @@ func (r *Registry) ProviderDefs() []providers.ToolDefinition { } // List returns all registered canonical tool names (excludes aliases). +// Results are sorted lexicographically for deterministic ordering — critical +// for LLM prompt caching (Anthropic/OpenAI cache by exact prefix match). func (r *Registry) List() []string { r.mu.RLock() defer r.mu.RUnlock() @@ -263,6 +282,7 @@ func (r *Registry) List() []string { names = append(names, name) } } + slices.Sort(names) return names }