From 83caeb9bcdc4a5eafda937807cd42ea8455948a6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 18:27:35 +0300 Subject: Add YouSearch (You.com Research API) provider integration Amp-Thread-ID: https://ampcode.com/threads/T-019e45ff-4976-750c-b2e6-121d0e5991ef Co-authored-by: Amp --- internal/appconfig/app_sections.go | 2 + internal/appconfig/config_env.go | 2 + internal/appconfig/config_load.go | 12 +++ internal/appconfig/config_merge.go | 3 + internal/appconfig/config_types.go | 5 ++ internal/llm/openai_temp_test.go | 6 +- internal/llm/provider.go | 13 ++- internal/llm/provider_more_test.go | 2 +- internal/llm/provider_test.go | 4 +- internal/llm/yousearch.go | 163 +++++++++++++++++++++++++++++++++++++ internal/llmutils/client.go | 40 +++++---- 11 files changed, 228 insertions(+), 24 deletions(-) create mode 100644 internal/llm/yousearch.go diff --git a/internal/appconfig/app_sections.go b/internal/appconfig/app_sections.go index 6dd60c6..05b3171 100644 --- a/internal/appconfig/app_sections.go +++ b/internal/appconfig/app_sections.go @@ -57,6 +57,8 @@ type ProviderConfig struct { AnthropicModel string `json:"anthropic_model"` // Default temperature for Anthropic requests (nil means use provider default) AnthropicTemperature *float64 `json:"anthropic_temperature"` + // YouSearch options + YouSearchResearchEffort string `json:"yousearch_research_effort"` // lite|standard|deep|exhaustive // Per-surface provider/model configurations (ordered; first entry is primary) CompletionConfigs []SurfaceConfig `json:"-"` CodeActionConfigs []SurfaceConfig `json:"-"` diff --git a/internal/appconfig/config_env.go b/internal/appconfig/config_env.go index 5f576e0..9834183 100644 --- a/internal/appconfig/config_env.go +++ b/internal/appconfig/config_env.go @@ -75,6 +75,8 @@ func applyProviderEnv(out *App, logger *log.Logger) bool { any = true } any = applyEnvFloat(&out.AnthropicTemperature, "HEXAI_ANTHROPIC_TEMPERATURE", logger) || any + + any = applyEnvString(&out.YouSearchResearchEffort, "HEXAI_YOUSEARCH_RESEARCH_EFFORT") || any return any } diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go index aa169bc..3ed1654 100644 --- a/internal/appconfig/config_load.go +++ b/internal/appconfig/config_load.go @@ -205,6 +205,7 @@ func rejectLegacyKeys(raw map[string]any) error { knownTables := map[string]struct{}{ "general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "ollama": {}, "prompts": {}, + "yousearch": {}, } for k := range raw { if _, isTable := knownTables[k]; isTable { @@ -269,6 +270,7 @@ func applyProviderSections(fc *fileConfig, out *App) { applyOpenRouterSection(fc, out) applyOllamaSection(fc, out) applyAnthropicSection(fc, out) + applyYouSearchSection(fc, out) } func applyPromptSections(fc *fileConfig, out *App) { @@ -430,6 +432,16 @@ func applyAnthropicSection(fc *fileConfig, out *App) { out.mergeProviderFields(&tmp) } +func applyYouSearchSection(fc *fileConfig, out *App) { + if fc.YouSearch == (sectionYouSearch{}) { + return + } + tmp := App{ProviderConfig: ProviderConfig{ + YouSearchResearchEffort: fc.YouSearch.ResearchEffort, + }} + out.mergeProviderFields(&tmp) +} + func applyPromptCompletion(fc *fileConfig, out *App) { if fc.Prompts.Completion == (sectionPromptsCompletion{}) { return diff --git a/internal/appconfig/config_merge.go b/internal/appconfig/config_merge.go index 7a99c94..f3557c1 100644 --- a/internal/appconfig/config_merge.go +++ b/internal/appconfig/config_merge.go @@ -141,6 +141,9 @@ func (a *App) mergeProviderFields(other *App) { if other.AnthropicTemperature != nil { // allow explicit 0.0 a.AnthropicTemperature = other.AnthropicTemperature } + if s := strings.TrimSpace(other.YouSearchResearchEffort); s != "" { + a.YouSearchResearchEffort = s + } } // mergeSurfaceModels copies per-surface model and temperature overrides. diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go index 5c6cf0c..8d50f35 100644 --- a/internal/appconfig/config_types.go +++ b/internal/appconfig/config_types.go @@ -163,6 +163,7 @@ type fileConfig struct { OpenRouter sectionOpenRouter `toml:"openrouter"` Ollama sectionOllama `toml:"ollama"` Anthropic sectionAnthropic `toml:"anthropic"` + YouSearch sectionYouSearch `toml:"yousearch"` Prompts sectionPrompts `toml:"prompts"` Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` @@ -305,6 +306,10 @@ type sectionAnthropic struct { Temperature *float64 `toml:"temperature"` } +type sectionYouSearch struct { + ResearchEffort string `toml:"research_effort"` // lite|standard|deep|exhaustive +} + // Prompts sections type sectionPrompts struct { Completion sectionPromptsCompletion `toml:"completion"` diff --git a/internal/llm/openai_temp_test.go b/internal/llm/openai_temp_test.go index 07abbd5..0b1b795 100644 --- a/internal/llm/openai_temp_test.go +++ b/internal/llm/openai_temp_test.go @@ -5,7 +5,7 @@ import "testing" func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) { // OpenAI, gpt-5.* → default temp 1.0 when not provided cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0-preview"} - c, err := NewFromConfig(cfg, "key", "", "", "") + c, err := NewFromConfig(cfg, "key", "", "", "", "") if err != nil { t.Fatalf("new: %v", err) } @@ -18,7 +18,7 @@ func TestNewFromConfig_DefaultTemp_ByModel(t *testing.T) { } // OpenAI, gpt-4.* → default temp 0.2 when not provided cfg2 := Config{Provider: "openai", OpenAIModel: "gpt-4.1"} - c2, err := NewFromConfig(cfg2, "key", "", "", "") + c2, err := NewFromConfig(cfg2, "key", "", "", "", "") if err != nil { t.Fatalf("new2: %v", err) } @@ -32,7 +32,7 @@ func TestNewFromConfig_DefaultTemp_UpgradeWhenGpt5AndDefault02(t *testing.T) { // Simulate app-default of 0.2 while selecting a gpt-5 model: should upgrade to 1.0 v := 0.2 cfg := Config{Provider: "openai", OpenAIModel: "gpt-5.0", OpenAITemperature: &v} - c, err := NewFromConfig(cfg, "key", "", "", "") + c, err := NewFromConfig(cfg, "key", "", "", "", "") if err != nil { t.Fatalf("new: %v", err) } diff --git a/internal/llm/provider.go b/internal/llm/provider.go index d1ff404..f866c83 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -91,6 +91,8 @@ type Config struct { AnthropicBaseURL string AnthropicModel string AnthropicTemperature *float64 + // YouSearch options + YouSearchResearchEffort string // lite|standard|deep|exhaustive } // ProviderKeys contains API credentials used by provider factories. @@ -101,6 +103,7 @@ type ProviderKeys struct { OpenRouterAPIKey string AnthropicAPIKey string OllamaAPIKey string + YouSearchAPIKey string } // ProviderFactory builds an LLM client for a named provider. @@ -134,14 +137,15 @@ func RegisterProvider(name string, factory ProviderFactory) { } // RegisterAllProviders registers all built-in LLM providers (anthropic, openai, -// openrouter, ollama). It is safe to call from multiple entry points because the -// actual registration runs only once via sync.Once. +// openrouter, ollama, yousearch). It is safe to call from multiple entry points +// because the actual registration runs only once via sync.Once. func RegisterAllProviders() { registerProvidersOnce.Do(func() { RegisterProvider("anthropic", anthropicProviderFactory) RegisterProvider("openai", openAIProviderFactory) RegisterProvider("openrouter", openRouterProviderFactory) RegisterProvider("ollama", ollamaProviderFactory) + RegisterProvider("yousearch", youSearchProviderFactory) }) } @@ -149,7 +153,7 @@ func RegisterAllProviders() { // API keys are supplied separately and may be read from the environment by the // caller. ollamaAPIKey is optional and only used when targeting Ollama Cloud; // a local Ollama server works with an empty value. -func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey, ollamaAPIKey string) (Client, error) { +func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey, ollamaAPIKey, youSearchAPIKey string) (Client, error) { provider := normalizeProvider(cfg.Provider) if provider == "" { provider = "ollama" @@ -165,6 +169,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey, OpenRouterAPIKey: openRouterAPIKey, AnthropicAPIKey: anthropicAPIKey, OllamaAPIKey: ollamaAPIKey, + YouSearchAPIKey: youSearchAPIKey, }) } @@ -207,6 +212,8 @@ func providerDisplayName(provider string) string { return "OpenRouter" case "anthropic": return "Anthropic" + case "yousearch": + return "YouSearch" default: return provider } diff --git a/internal/llm/provider_more_test.go b/internal/llm/provider_more_test.go index d3be8ef..18cd49a 100644 --- a/internal/llm/provider_more_test.go +++ b/internal/llm/provider_more_test.go @@ -16,7 +16,7 @@ func TestWithOptions_Apply(t *testing.T) { func TestNewFromConfig_Success_OpenAI(t *testing.T) { // OpenAI success oc := Config{Provider: "openai", OpenAIBaseURL: "http://x", OpenAIModel: "gpt"} - c, err := NewFromConfig(oc, "KEY", "", "", "") + c, err := NewFromConfig(oc, "KEY", "", "", "", "") if err != nil || c == nil || c.Name() != "openai" || c.DefaultModel() == "" { t.Fatalf("openai new: %v %v", c, err) } diff --git a/internal/llm/provider_test.go b/internal/llm/provider_test.go index 2acbc69..4cba691 100644 --- a/internal/llm/provider_test.go +++ b/internal/llm/provider_test.go @@ -7,13 +7,13 @@ import ( func TestNewFromConfig_DefaultsAndErrors(t *testing.T) { // Unknown provider - if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", "", ""); err == nil { + if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", "", "", ""); err == nil { t.Fatalf("expected error for unknown provider") } else if !strings.Contains(err.Error(), "supported providers:") { t.Fatalf("expected supported providers hint, got %q", err.Error()) } // OpenAI missing key - if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", "", ""); err == nil { + if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", "", "", ""); err == nil { t.Fatalf("expected key error") } else if !strings.Contains(err.Error(), "OPENAI_API_KEY") || !strings.Contains(err.Error(), "HEXAI_OPENAI_API_KEY") { t.Fatalf("expected actionable API key hint, got %q", err.Error()) diff --git a/internal/llm/yousearch.go b/internal/llm/yousearch.go new file mode 100644 index 0000000..d38a6e2 --- /dev/null +++ b/internal/llm/yousearch.go @@ -0,0 +1,163 @@ +// You.com Research API provider. Maps Chat() to a single research request using +// the last user message as the query. System messages are ignored — the Research +// API has its own reasoning pipeline. Sources are appended as a markdown section. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +const youSearchResearchURL = "https://api.you.com/v1/research" + +type youSearchClient struct { + httpClient *http.Client + apiKey string + researchEffort string // lite|standard|deep|exhaustive + chatLogger logging.ChatLogger +} + +var _ Client = youSearchClient{} + +type youSearchRequest struct { + Input string `json:"input"` + ResearchEffort string `json:"research_effort,omitempty"` +} + +type youSearchSource struct { + URL string `json:"url"` + Title string `json:"title"` + Snippets []string `json:"snippets"` +} + +type youSearchResponse struct { + Output struct { + Content interface{} `json:"content"` + ContentType string `json:"content_type"` + Sources []youSearchSource `json:"sources"` + } `json:"output"` +} + +func youSearchProviderFactory(cfg Config, keys ProviderKeys) (Client, error) { + if strings.TrimSpace(keys.YouSearchAPIKey) == "" { + return nil, missingAPIKeyError("yousearch", "HEXAI_YOUSEARCH_API_KEY", "YOU_API_KEY") + } + timeoutSec := cfg.RequestTimeout + if timeoutSec <= 0 { + timeoutSec = 120 + } + return youSearchClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + apiKey: strings.TrimSpace(keys.YouSearchAPIKey), + researchEffort: strings.TrimSpace(cfg.YouSearchResearchEffort), + chatLogger: logging.NewChatLogger("yousearch"), + }, nil +} + +func (c youSearchClient) Name() string { return "yousearch" } +func (c youSearchClient) DefaultModel() string { return c.effectiveEffort() } + +func (c youSearchClient) effectiveEffort() string { + if c.researchEffort != "" { + return c.researchEffort + } + return "standard" +} + +// Chat extracts the last user message and sends it as a research query. +func (c youSearchClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { + query := lastUserMessage(messages) + if query == "" { + return "", fmt.Errorf("yousearch: no user message found in conversation") + } + + start := time.Now() + logStartMessages(c.chatLogger, false, Options{Model: c.effectiveEffort()}, messages) + + payload, err := json.Marshal(youSearchRequest{ + Input: query, + ResearchEffort: c.effectiveEffort(), + }) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, youSearchResearchURL, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Content-Type", "application/json") + + logging.Logf("llm/yousearch", "POST %s effort=%s", youSearchResearchURL, c.effectiveEffort()) + resp, err := c.httpClient.Do(req) + if err != nil { + logging.Logf("llm/yousearch", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + logging.Logf("llm/yousearch", "failed to close response body: %v", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK { + logging.Logf("llm/yousearch", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return "", fmt.Errorf("yousearch: API error status %d", resp.StatusCode) + } + + var result youSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("yousearch: decoding response: %w", err) + } + + content := formatYouSearchContent(result) + if content == "" { + return "", fmt.Errorf("yousearch: empty response") + } + + logging.Logf("llm/yousearch", "success size=%d preview=%s%s%s duration=%s", + len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil +} + +func formatYouSearchContent(result youSearchResponse) string { + var sb strings.Builder + + switch v := result.Output.Content.(type) { + case string: + sb.WriteString(strings.TrimSpace(v)) + default: + out, _ := json.MarshalIndent(v, "", " ") + sb.Write(out) + } + + if len(result.Output.Sources) > 0 { + sb.WriteString("\n\n**Sources:**\n") + for i, s := range result.Output.Sources { + title := s.Title + if title == "" { + title = s.URL + } + sb.WriteString(fmt.Sprintf("%d. [%s](%s)\n", i+1, title, s.URL)) + } + } + + return sb.String() +} + +func lastUserMessage(messages []Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if strings.ToLower(messages[i].Role) == "user" { + return strings.TrimSpace(messages[i].Content) + } + } + return "" +} diff --git a/internal/llmutils/client.go b/internal/llmutils/client.go index c626918..a68746e 100644 --- a/internal/llmutils/client.go +++ b/internal/llmutils/client.go @@ -36,6 +36,11 @@ func DefaultModelForProvider(cfg appconfig.App, provider string) string { return model } return "claude-3-5-sonnet-20240620" + case "yousearch": + if effort := strings.TrimSpace(cfg.YouSearchResearchEffort); effort != "" { + return effort + } + return "standard" default: if model := strings.TrimSpace(cfg.OpenAIModel); model != "" { return model @@ -77,20 +82,21 @@ func NewClientFromAppForProvider(cfg appconfig.App, provider, modelOverride stri // NewClientFromApp builds an llm.Client using app config and environment keys. func NewClientFromApp(cfg appconfig.App) (llm.Client, error) { llmCfg := llm.Config{ - Provider: cfg.Provider, - RequestTimeout: cfg.RequestTimeout, - OpenAIBaseURL: cfg.OpenAIBaseURL, - OpenAIModel: cfg.OpenAIModel, - OpenAITemperature: cfg.OpenAITemperature, - OpenRouterBaseURL: cfg.OpenRouterBaseURL, - OpenRouterModel: cfg.OpenRouterModel, - OpenRouterTemperature: cfg.OpenRouterTemperature, - OllamaBaseURL: cfg.OllamaBaseURL, - OllamaModel: cfg.OllamaModel, - OllamaTemperature: cfg.OllamaTemperature, - AnthropicBaseURL: cfg.AnthropicBaseURL, - AnthropicModel: cfg.AnthropicModel, - AnthropicTemperature: cfg.AnthropicTemperature, + Provider: cfg.Provider, + RequestTimeout: cfg.RequestTimeout, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, + YouSearchResearchEffort: cfg.YouSearchResearchEffort, } oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") if strings.TrimSpace(oaKey) == "" { @@ -110,5 +116,9 @@ func NewClientFromApp(cfg appconfig.App) (llm.Client, error) { if strings.TrimSpace(olKey) == "" { olKey = os.Getenv("OLLAMA_API_KEY") } - return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey, olKey) + ysKey := os.Getenv("HEXAI_YOUSEARCH_API_KEY") + if strings.TrimSpace(ysKey) == "" { + ysKey = os.Getenv("YOU_API_KEY") + } + return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey, olKey, ysKey) } -- cgit v1.2.3