Repository navigation
Expand file tree
/
Copy pathdeepseek.go
More file actions
219 lines (206 loc) · 6.98 KB
/
Copy pathdeepseek.go
File metadata and controls
219 lines (206 loc) · 6.98 KB
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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
// Minimal OpenAI-compatible chat client — DeepSeek speaks this dialect,
// including function calling on deepseek-chat.
type Msg struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// Response metadata is useful for diagnosing empty/truncated generations,
// but must never be echoed back as part of the chat transcript.
FinishReason string `json:"-"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type ToolDef struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []Msg `json:"messages"`
Tools []ToolDef `json:"tools,omitempty"`
MaxTok int `json:"max_tokens,omitempty"`
// Reasoning caps hidden thinking tokens (OpenRouter-style; providers that
// don't support it ignore the field). Set only on length-empty re-rolls.
Reasoning *reasoningCfg `json:"reasoning,omitempty"`
}
// Multimodal request shapes (OpenAI vision format): the user message content
// is an array of text/image parts instead of a plain string.
type visionImgURL struct {
URL string `json:"url"`
}
type visionPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *visionImgURL `json:"image_url,omitempty"`
}
type visionMsg struct {
Role string `json:"role"`
Content []visionPart `json:"content"`
}
type reasoningCfg struct {
MaxTokens int `json:"max_tokens,omitempty"`
}
type visionRequest struct {
Model string `json:"model"`
Messages []visionMsg `json:"messages"`
MaxTok int `json:"max_tokens,omitempty"`
Reasoning *reasoningCfg `json:"reasoning,omitempty"`
}
type chatResponse struct {
Choices []struct {
Message Msg `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
type LLM struct {
baseURL string
key string
model string
client *http.Client
}
func NewLLM(cfg *Config) *LLM {
return &LLM{
baseURL: cfg.DeepseekURL,
key: cfg.DeepseekKey,
model: cfg.Model,
client: &http.Client{Timeout: 180 * time.Second},
}
}
func (l *LLM) Chat(ctx context.Context, messages []Msg, tools []ToolDef) (*Msg, error) {
// 8192, not 4096: a full self-contained HTML mockup's content easily
// exceeds 4k output tokens, and when it does the save_artifact tool-call
// arguments get truncated to invalid JSON — the artifact never saves and
// the model burns its iter budget retrying. Bigger ceiling fits the file.
body, err := json.Marshal(chatRequest{Model: l.model, Messages: messages, Tools: tools, MaxTok: 8192})
if err != nil {
return nil, err
}
msg, err := l.post(ctx, body)
if err != nil {
return nil, err
}
// finish=length with NOTHING visible means the whole output budget went
// to hidden reasoning — it happens on long build-turn contexts, and three
// in a row killed a live build on 2026-08-18. Re-roll once with double
// the ceiling and an explicit reasoning cap before handing the agent an
// empty message; the agent's own triple-empty stop stays as the backstop.
if msg.FinishReason == "length" && strings.TrimSpace(msg.Content) == "" && len(msg.ToolCalls) == 0 {
log.Printf("llm length-empty — re-rolling with a doubled output budget")
retry, rerr := json.Marshal(chatRequest{
Model: l.model, Messages: messages, Tools: tools, MaxTok: 16384,
Reasoning: &reasoningCfg{MaxTokens: 4000},
})
if rerr == nil {
if second, serr := l.post(ctx, retry); serr == nil {
return second, nil
}
}
}
return msg, nil
}
// post sends a pre-marshalled chat/completions body and retries transient
// failures. The per-turn ctx bounds total time so a hung upstream can't hold
// the turn (and its concurrency slot) indefinitely. Shared by Chat and Vision.
func (l *LLM) post(ctx context.Context, body []byte) (*Msg, error) {
// Inference providers 429/503 under load routinely — retry with backoff
// instead of surfacing a transient blip to the channel.
var lastErr error
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
select { // don't burn the backoff sleep past the turn deadline
case <-ctx.Done():
return nil, fmt.Errorf("turn deadline reached: %w", ctx.Err())
case <-time.After(time.Duration(attempt*attempt) * 2 * time.Second): // 2s, 8s, 18s
}
}
if ctx.Err() != nil {
return nil, fmt.Errorf("turn deadline reached: %w", ctx.Err())
}
req, err := http.NewRequestWithContext(ctx, "POST", l.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+l.key)
resp, err := l.client.Do(req)
if err != nil {
lastErr = err
continue // network blip
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
lastErr = fmt.Errorf("provider %d: %.200s", resp.StatusCode, raw)
continue
}
var out chatResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("bad response (%d): %.200s", resp.StatusCode, raw)
}
if out.Error != nil {
return nil, fmt.Errorf("api: %s", out.Error.Message)
}
if len(out.Choices) == 0 {
return nil, fmt.Errorf("empty response (%d)", resp.StatusCode)
}
choice := &out.Choices[0]
choice.Message.FinishReason = choice.FinishReason
return &choice.Message, nil
}
return nil, fmt.Errorf("provider unavailable after retries: %w", lastErr)
}
// Vision runs one multimodal turn on the vision model and returns its text.
// Images are data-URI or https strings in OpenAI's image_url format. The vision
// model is a reasoner that can think past the token budget and return EMPTY
// content (finish_reason "length"); we cap its thinking so the answer always
// lands, and still give the overall response generous room.
func (l *LLM) Vision(ctx context.Context, model, prompt string, imageURLs []string) (string, error) {
parts := []visionPart{{Type: "text", Text: prompt}}
for _, u := range imageURLs {
parts = append(parts, visionPart{Type: "image_url", ImageURL: &visionImgURL{URL: u}})
}
body, err := json.Marshal(visionRequest{
Model: model,
Messages: []visionMsg{{Role: "user", Content: parts}},
MaxTok: 6000,
Reasoning: &reasoningCfg{MaxTokens: 2000},
})
if err != nil {
return "", err
}
msg, err := l.post(ctx, body)
if err != nil {
return "", err
}
return msg.Content, nil
}