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 ""
}
|