summaryrefslogtreecommitdiff
path: root/internal/llm/yousearch.go
blob: 8990c2686e93d90daca05c3cdcb923c414bf53f8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// 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/llm/policy"
	"codeberg.org/snonux/hexai/internal/logging"
)

const youSearchResearchURL = "https://api.you.com/v1/research"

type youSearchClient struct {
	httpClient     *http.Client
	apiKey         string
	baseURL        string // research endpoint URL (overridable for tests)
	researchEffort string // lite|standard|deep|exhaustive
	chatLogger     logging.ChatLogger
}

// Ensure youSearchClient implements Client. It does not implement Streamer
// because the You.com research API returns a single completed result rather
// than a token stream. Methods use value receivers, so the assertion uses a
// zero-value struct (not a pointer).
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 {
		// The Research API runs a long multi-step pipeline, so it uses the
		// larger research timeout from the policy package rather than the
		// default chat timeout.
		timeoutSec = policy.ResearchRequestTimeoutSeconds
	}
	return youSearchClient{
		httpClient:     &http.Client{Timeout: time.Duration(timeoutSec) * time.Second},
		apiKey:         strings.TrimSpace(keys.YouSearchAPIKey),
		baseURL:        youSearchResearchURL,
		researchEffort: strings.TrimSpace(cfg.YouSearchResearchEffort),
		chatLogger:     logging.NewChatLogger("yousearch"),
	}, nil
}

func (c youSearchClient) endpoint() string {
	if c.baseURL != "" {
		return c.baseURL
	}
	return youSearchResearchURL
}

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
	}

	url := c.endpoint()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, 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", url, 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 ""
}