magic-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// llmhttp.go - llm 引擎「配置文件模型」的直连执行(models.json 条目 → OpenAI 兼容端点)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它:见 llmmodels.go 的文件头 —— 配置里的 id 是唯一标识,真实模型名会重复,
|
|
6
|
+
// 变体差异(extraBody)只有直连才表达得出来。llm CLI 那条路只服务「llm 自己注册的
|
|
7
|
+
// model_id」,两者并存:命中配置文件 → 直连;否则原样委托 llm CLI。
|
|
8
|
+
//
|
|
9
|
+
// 与 llm CLI 路径的差异(都是刻意的):
|
|
10
|
+
//
|
|
11
|
+
// 会话续接:直连没有会话库 → 不支持 --session / -c。显式要求续接时报错而不是静默丢上下文。
|
|
12
|
+
// 思考过程:非流式走 splitThinkingTags、流式走 thinkSplitter(与 CLI 路径同一套剥离逻辑);
|
|
13
|
+
// MiniMax 这类把思维链以标签混进 content 的模型,两端都能分流。
|
|
14
|
+
// 参数透传:max_tokens / temperature 优先用调用方(req)给的值,其次用条目自带的默认;
|
|
15
|
+
// extraBody 整体 merge 到请求体顶层,但 model / messages / stream 由本文件最终设定,
|
|
16
|
+
// 避免配置写错把这三个关键键覆盖掉。
|
|
17
|
+
// 错误:统一 `HTTP <code>: <片段>`(runner.isRetryable 靠 "429/503/timeout" 这类子串判重试)。
|
|
18
|
+
// Token 计数:端点给了 usage 就回填(llm CLI 路径靠 --json,直连靠响应体)。
|
|
19
|
+
|
|
20
|
+
import (
|
|
21
|
+
"bufio"
|
|
22
|
+
"bytes"
|
|
23
|
+
"context"
|
|
24
|
+
"encoding/json"
|
|
25
|
+
"fmt"
|
|
26
|
+
"io"
|
|
27
|
+
"net/http"
|
|
28
|
+
"strings"
|
|
29
|
+
"time"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// llmConfiguredClient 直连使用的 HTTP 客户端。超时按条目/调用方取,这里只兜底。
|
|
33
|
+
func llmConfiguredClient() *http.Client {
|
|
34
|
+
return &http.Client{Timeout: 0} // 超时统一走 ctx(条目 timeout / CLI -t),避免两套超时打架
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// llmConfiguredTimeout 解析本次调用的超时:调用方 req.Timeout > 条目 timeout(秒)> 引擎默认。
|
|
38
|
+
func llmConfiguredTimeout(m LLMConfigModel, req Request) time.Duration {
|
|
39
|
+
if req.Timeout > 0 {
|
|
40
|
+
return req.Timeout
|
|
41
|
+
}
|
|
42
|
+
if m.Timeout > 0 {
|
|
43
|
+
return time.Duration(m.Timeout) * time.Second
|
|
44
|
+
}
|
|
45
|
+
return DefaultLLMTimeout
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// buildConfiguredBody 组装请求体。stream 由调用方决定。
|
|
49
|
+
func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string]any, error) {
|
|
50
|
+
msgs := make([]map[string]any, 0, len(req.Messages)+1)
|
|
51
|
+
if s := strings.TrimSpace(req.SystemPrompt); s != "" {
|
|
52
|
+
msgs = append(msgs, map[string]any{"role": "system", "content": s})
|
|
53
|
+
}
|
|
54
|
+
for _, msg := range req.Messages {
|
|
55
|
+
if strings.TrimSpace(msg.Content) == "" {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
role := strings.TrimSpace(msg.Role)
|
|
59
|
+
if role == "" {
|
|
60
|
+
role = "user"
|
|
61
|
+
}
|
|
62
|
+
msgs = append(msgs, map[string]any{"role": role, "content": msg.Content})
|
|
63
|
+
}
|
|
64
|
+
if len(msgs) == 0 {
|
|
65
|
+
return nil, fmt.Errorf("llm: empty prompt")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 附件(截图)→ OpenAI 兼容的多模态 content parts,挂到**最后一条 user 消息**上
|
|
69
|
+
//(就是本次提问)。实测:MiniMax-M3 直连收 image_url(data URL) 能正确识图
|
|
70
|
+
//(2026-09-17:纯红 64x64 PNG → 答「红色」)。
|
|
71
|
+
if len(req.Attachments) > 0 {
|
|
72
|
+
if err := attachToLastUserMessage(msgs, req.Attachments); err != nil {
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
body := map[string]any{}
|
|
78
|
+
for k, v := range m.ExtraBody { // extraBody 先铺(变体差异常在这)
|
|
79
|
+
body[k] = v
|
|
80
|
+
}
|
|
81
|
+
body["model"] = m.WireModel()
|
|
82
|
+
body["messages"] = msgs
|
|
83
|
+
body["stream"] = stream
|
|
84
|
+
|
|
85
|
+
if req.MaxTokens > 0 {
|
|
86
|
+
body["max_tokens"] = req.MaxTokens
|
|
87
|
+
} else if m.MaxOutputTokens > 0 {
|
|
88
|
+
body["max_tokens"] = m.MaxOutputTokens
|
|
89
|
+
}
|
|
90
|
+
if req.Temperature != nil {
|
|
91
|
+
body["temperature"] = *req.Temperature
|
|
92
|
+
} else if m.Temperature != nil {
|
|
93
|
+
body["temperature"] = *m.Temperature
|
|
94
|
+
}
|
|
95
|
+
return body, nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// attachToLastUserMessage 把附件塞进最后一条 user 消息的 content:
|
|
99
|
+
//
|
|
100
|
+
// 图片 → {"type":"image_url","image_url":{"url":"data:<mime>;base64,<...>"}}(在前)
|
|
101
|
+
// 其他 → 路径清单并入文本(协议里没有通用文件类型)
|
|
102
|
+
//
|
|
103
|
+
// 找不到 user 消息(理论上不会:CLI 层总要给提示词)时返回错误,不静默丢附件。
|
|
104
|
+
func attachToLastUserMessage(msgs []map[string]any, atts []Attachment) error {
|
|
105
|
+
idx := -1
|
|
106
|
+
for i := len(msgs) - 1; i >= 0; i-- {
|
|
107
|
+
if msgs[i]["role"] == "user" {
|
|
108
|
+
idx = i
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if idx < 0 {
|
|
113
|
+
return fmt.Errorf("llm: 附件需要一条 user 消息承载,本次没有")
|
|
114
|
+
}
|
|
115
|
+
text, _ := msgs[idx]["content"].(string)
|
|
116
|
+
|
|
117
|
+
parts := make([]map[string]any, 0, len(atts)+1)
|
|
118
|
+
var rest []Attachment
|
|
119
|
+
for _, a := range atts {
|
|
120
|
+
if !a.IsImage() {
|
|
121
|
+
rest = append(rest, a)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
data, err := readAttachmentBase64(a)
|
|
125
|
+
if err != nil {
|
|
126
|
+
return err
|
|
127
|
+
}
|
|
128
|
+
mime := a.MIME
|
|
129
|
+
if mime == "" {
|
|
130
|
+
mime = "image/png"
|
|
131
|
+
}
|
|
132
|
+
parts = append(parts, map[string]any{
|
|
133
|
+
"type": "image_url",
|
|
134
|
+
"image_url": map[string]any{"url": "data:" + mime + ";base64," + data},
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
parts = append(parts, map[string]any{"type": "text", "text": appendAttachmentSection(text, rest)})
|
|
138
|
+
msgs[idx]["content"] = parts
|
|
139
|
+
return nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// doConfiguredRequest 发一次请求;非 2xx 统一报 `HTTP <code>: <片段>`。
|
|
143
|
+
func doConfiguredRequest(ctx context.Context, m LLMConfigModel, req Request, body map[string]any) (*http.Response, error) {
|
|
144
|
+
ep := m.Endpoint()
|
|
145
|
+
if ep == "" {
|
|
146
|
+
return nil, fmt.Errorf("llm: model %q has no url in models.json", m.ID)
|
|
147
|
+
}
|
|
148
|
+
raw, err := json.Marshal(body)
|
|
149
|
+
if err != nil {
|
|
150
|
+
return nil, err
|
|
151
|
+
}
|
|
152
|
+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, ep, bytes.NewReader(raw))
|
|
153
|
+
if err != nil {
|
|
154
|
+
return nil, err
|
|
155
|
+
}
|
|
156
|
+
httpReq.Header.Set("Content-Type", "application/json")
|
|
157
|
+
if m.APIKey != "" {
|
|
158
|
+
httpReq.Header.Set("Authorization", "Bearer "+m.APIKey)
|
|
159
|
+
}
|
|
160
|
+
if stream, _ := body["stream"].(bool); stream {
|
|
161
|
+
httpReq.Header.Set("Accept", "text/event-stream")
|
|
162
|
+
}
|
|
163
|
+
resp, err := llmConfiguredClient().Do(httpReq)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return nil, err // 网络类错误原样返回:isRetryable 靠 "connection refused" 等子串判定
|
|
166
|
+
}
|
|
167
|
+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
168
|
+
defer resp.Body.Close()
|
|
169
|
+
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
|
170
|
+
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, compactSnippet(string(snippet)))
|
|
171
|
+
}
|
|
172
|
+
return resp, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// compactSnippet 把错误正文压成单行短串(日志/envelope 里可读)。
|
|
176
|
+
func compactSnippet(s string) string {
|
|
177
|
+
s = strings.Join(strings.Fields(s), " ")
|
|
178
|
+
if len(s) > 300 {
|
|
179
|
+
s = s[:300] + "…"
|
|
180
|
+
}
|
|
181
|
+
return s
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// checkConfiguredSession 直连没有会话库:显式要求续接时明确报错,不静默丢上下文。
|
|
185
|
+
func checkConfiguredSession(m LLMConfigModel, req Request) error {
|
|
186
|
+
if strings.TrimSpace(req.SessionID) != "" || req.Continue {
|
|
187
|
+
return fmt.Errorf("llm 配置文件模型 %q 不支持会话续接(直连无会话库):请去掉 --session/-c,或改用 llm CLI 注册的模型", m.ID)
|
|
188
|
+
}
|
|
189
|
+
return nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// completeConfigured 非流式直连。返回体与 llm CLI 路径同形(含 token 计数)。
|
|
193
|
+
func (e *LLMEngine) completeConfigured(ctx context.Context, m LLMConfigModel, req Request) (Response, error) {
|
|
194
|
+
start := time.Now()
|
|
195
|
+
if err := checkConfiguredSession(m, req); err != nil {
|
|
196
|
+
return Response{}, err
|
|
197
|
+
}
|
|
198
|
+
body, err := buildConfiguredBody(m, req, false)
|
|
199
|
+
if err != nil {
|
|
200
|
+
return Response{}, err
|
|
201
|
+
}
|
|
202
|
+
cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
|
|
203
|
+
defer cancel()
|
|
204
|
+
resp, err := doConfiguredRequest(cctx, m, req, body)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return Response{}, err
|
|
207
|
+
}
|
|
208
|
+
defer resp.Body.Close()
|
|
209
|
+
raw, err := io.ReadAll(resp.Body)
|
|
210
|
+
if err != nil {
|
|
211
|
+
return Response{}, err
|
|
212
|
+
}
|
|
213
|
+
out, err := parseConfiguredChat(raw)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return Response{}, err
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
text, thinking := splitThinkingTags(out.Content)
|
|
219
|
+
if strings.TrimSpace(text) == "" {
|
|
220
|
+
// 全是思维链(模型把答案写进了 reasoning)→ 原样返回,别丢内容
|
|
221
|
+
text = out.Content
|
|
222
|
+
}
|
|
223
|
+
if strings.TrimSpace(text) == "" && strings.TrimSpace(out.Reasoning) != "" {
|
|
224
|
+
text = out.Reasoning
|
|
225
|
+
}
|
|
226
|
+
if req.JSONSchema != nil {
|
|
227
|
+
if extracted, ok := extractJSONObjectStrict(text, req.JSONSchema); ok {
|
|
228
|
+
text = extracted
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
_ = thinking // 非流式路径不单独回传思考(与 llm CLI 路径一致)
|
|
232
|
+
return Response{
|
|
233
|
+
Engine: e.Name(),
|
|
234
|
+
Text: strings.TrimSpace(text),
|
|
235
|
+
// Model 回填 **配置里的 id**(调用标识),不是线上真实名:
|
|
236
|
+
// 真实模型名会重复(MiniMax-M3 与 minimax-nothink 同名),只有 id 能唯一指认
|
|
237
|
+
// 「这一轮到底用的哪条配置」;真实名按 id 查配置文件即可。
|
|
238
|
+
Model: m.ID,
|
|
239
|
+
Latency: time.Since(start),
|
|
240
|
+
InputTokens: out.PromptTokens,
|
|
241
|
+
OutputTokens: out.CompletionTokens,
|
|
242
|
+
TotalTokens: out.PromptTokens + out.CompletionTokens,
|
|
243
|
+
}, nil
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// configuredChat 非流式响应里我们需要的部分。
|
|
247
|
+
type configuredChat struct {
|
|
248
|
+
Content string
|
|
249
|
+
Reasoning string
|
|
250
|
+
PromptTokens int
|
|
251
|
+
CompletionTokens int
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func parseConfiguredChat(raw []byte) (configuredChat, error) {
|
|
255
|
+
var doc struct {
|
|
256
|
+
Choices []struct {
|
|
257
|
+
Message struct {
|
|
258
|
+
Content string `json:"content"`
|
|
259
|
+
ReasoningContent string `json:"reasoning_content"`
|
|
260
|
+
} `json:"message"`
|
|
261
|
+
Text string `json:"text"`
|
|
262
|
+
} `json:"choices"`
|
|
263
|
+
Usage struct {
|
|
264
|
+
PromptTokens int `json:"prompt_tokens"`
|
|
265
|
+
CompletionTokens int `json:"completion_tokens"`
|
|
266
|
+
} `json:"usage"`
|
|
267
|
+
Error *struct {
|
|
268
|
+
Message string `json:"message"`
|
|
269
|
+
} `json:"error"`
|
|
270
|
+
}
|
|
271
|
+
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
272
|
+
return configuredChat{}, fmt.Errorf("llm: 响应不是 JSON(%w): %s", err, compactSnippet(string(raw)))
|
|
273
|
+
}
|
|
274
|
+
if doc.Error != nil && doc.Error.Message != "" {
|
|
275
|
+
return configuredChat{}, fmt.Errorf("llm: %s", doc.Error.Message)
|
|
276
|
+
}
|
|
277
|
+
if len(doc.Choices) == 0 {
|
|
278
|
+
return configuredChat{}, fmt.Errorf("llm: 响应没有 choices: %s", compactSnippet(string(raw)))
|
|
279
|
+
}
|
|
280
|
+
c := doc.Choices[0]
|
|
281
|
+
content := c.Message.Content
|
|
282
|
+
if content == "" {
|
|
283
|
+
content = c.Text // 少数端点(completion 风格)用 text
|
|
284
|
+
}
|
|
285
|
+
return configuredChat{
|
|
286
|
+
Content: content,
|
|
287
|
+
Reasoning: c.Message.ReasoningContent,
|
|
288
|
+
PromptTokens: doc.Usage.PromptTokens,
|
|
289
|
+
CompletionTokens: doc.Usage.CompletionTokens,
|
|
290
|
+
}, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// streamConfigured 流式直连(SSE)。逐块 delta 经 tagGuard 防标签被切块后,
|
|
294
|
+
// 交给与 CLI 路径同一个 thinkSplitter 分流 thinking/text。
|
|
295
|
+
func (e *LLMEngine) streamConfigured(ctx context.Context, m LLMConfigModel, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
296
|
+
start := time.Now()
|
|
297
|
+
if err := checkConfiguredSession(m, req); err != nil {
|
|
298
|
+
return StreamResult{}, err
|
|
299
|
+
}
|
|
300
|
+
body, err := buildConfiguredBody(m, req, true)
|
|
301
|
+
if err != nil {
|
|
302
|
+
return StreamResult{}, err
|
|
303
|
+
}
|
|
304
|
+
cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
|
|
305
|
+
defer cancel()
|
|
306
|
+
resp, err := doConfiguredRequest(cctx, m, req, body)
|
|
307
|
+
if err != nil {
|
|
308
|
+
return StreamResult{}, err
|
|
309
|
+
}
|
|
310
|
+
defer resp.Body.Close()
|
|
311
|
+
|
|
312
|
+
sp := &thinkSplitter{OnEvent: onEvent}
|
|
313
|
+
guard := &tagGuard{}
|
|
314
|
+
var usage struct {
|
|
315
|
+
PromptTokens int
|
|
316
|
+
CompletionTokens int
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
sc := bufio.NewScanner(resp.Body)
|
|
320
|
+
sc.Buffer(make([]byte, 0, 64<<10), 16<<20) // 单行上限 16MB(与 runStreamCLI 同口径)
|
|
321
|
+
for sc.Scan() {
|
|
322
|
+
line := strings.TrimSpace(sc.Text())
|
|
323
|
+
if line == "" || strings.HasPrefix(line, ":") {
|
|
324
|
+
continue // 心跳/注释行
|
|
325
|
+
}
|
|
326
|
+
if !strings.HasPrefix(line, "data:") {
|
|
327
|
+
continue // event:/id: 等其它 SSE 字段
|
|
328
|
+
}
|
|
329
|
+
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
330
|
+
if payload == "[DONE]" {
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
var chunk struct {
|
|
334
|
+
Choices []struct {
|
|
335
|
+
Delta struct {
|
|
336
|
+
Content string `json:"content"`
|
|
337
|
+
ReasoningContent string `json:"reasoning_content"`
|
|
338
|
+
} `json:"delta"`
|
|
339
|
+
} `json:"choices"`
|
|
340
|
+
Usage *struct {
|
|
341
|
+
PromptTokens int `json:"prompt_tokens"`
|
|
342
|
+
CompletionTokens int `json:"completion_tokens"`
|
|
343
|
+
} `json:"usage"`
|
|
344
|
+
}
|
|
345
|
+
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
|
346
|
+
continue // 非 JSON 片段(有些网关会插日志行)→ 跳过,不打断整条流
|
|
347
|
+
}
|
|
348
|
+
if chunk.Usage != nil {
|
|
349
|
+
usage.PromptTokens = chunk.Usage.PromptTokens
|
|
350
|
+
usage.CompletionTokens = chunk.Usage.CompletionTokens
|
|
351
|
+
}
|
|
352
|
+
if len(chunk.Choices) == 0 {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
d := chunk.Choices[0].Delta
|
|
356
|
+
if d.ReasoningContent != "" {
|
|
357
|
+
sp.feedThinking(d.ReasoningContent) // 独立 reasoning 通道:无标签,直接进 thinking
|
|
358
|
+
}
|
|
359
|
+
if d.Content != "" {
|
|
360
|
+
sp.feed(guard.push(d.Content))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if err := sc.Err(); err != nil {
|
|
364
|
+
return StreamResult{}, err
|
|
365
|
+
}
|
|
366
|
+
sp.feed(guard.flush())
|
|
367
|
+
|
|
368
|
+
text, thinking := sp.finish()
|
|
369
|
+
if text == "" {
|
|
370
|
+
return StreamResult{}, fmt.Errorf("llm: 流式响应没有正文(配置条目 %s → %s)", m.ID, m.WireModel())
|
|
371
|
+
}
|
|
372
|
+
return StreamResult{
|
|
373
|
+
Response: Response{
|
|
374
|
+
Engine: e.Name(),
|
|
375
|
+
Text: text,
|
|
376
|
+
Model: m.ID, // id(调用标识)—— 见 completeConfigured 里的说明
|
|
377
|
+
Latency: time.Since(start),
|
|
378
|
+
InputTokens: usage.PromptTokens,
|
|
379
|
+
OutputTokens: usage.CompletionTokens,
|
|
380
|
+
TotalTokens: usage.PromptTokens + usage.CompletionTokens,
|
|
381
|
+
},
|
|
382
|
+
Thinking: thinking,
|
|
383
|
+
}, nil
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// tagGuard 防「思维链标签被 SSE 分块切开」:把尾部可能是 `<think>` / `</think>`
|
|
387
|
+
// 前缀的那几个字符暂扣,等下一块补齐再一起投喂状态机。
|
|
388
|
+
// (llm CLI 路径靠「Scanner 按行交付、标签不含换行」天然免疫,直连没有这层保证。)
|
|
389
|
+
type tagGuard struct {
|
|
390
|
+
buf string
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// feedThinking 把一段**已知属于思维链**的文本送进 thinking 通道。
|
|
394
|
+
// 独立 reasoning 通道(如 MiniMax 的 delta.reasoning_content)没有标签,
|
|
395
|
+
// 不能走 feed(那会被当正文)—— 见 streamConfigured。
|
|
396
|
+
func (sp *thinkSplitter) feedThinking(s string) {
|
|
397
|
+
sp.emitThinking(s)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func (g *tagGuard) push(s string) string {
|
|
401
|
+
if s == "" {
|
|
402
|
+
return ""
|
|
403
|
+
}
|
|
404
|
+
g.buf += s
|
|
405
|
+
keep := prefixKeep(g.buf, thinkOpenTag)
|
|
406
|
+
if k := prefixKeep(g.buf, thinkCloseTag); k > keep {
|
|
407
|
+
keep = k
|
|
408
|
+
}
|
|
409
|
+
if keep == 0 {
|
|
410
|
+
out := g.buf
|
|
411
|
+
g.buf = ""
|
|
412
|
+
return out
|
|
413
|
+
}
|
|
414
|
+
out := g.buf[:len(g.buf)-keep]
|
|
415
|
+
g.buf = g.buf[len(g.buf)-keep:]
|
|
416
|
+
return out
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func (g *tagGuard) flush() string {
|
|
420
|
+
out := g.buf
|
|
421
|
+
g.buf = ""
|
|
422
|
+
return out
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// prefixKeep 返回 buf 尾部最多保留多少个字符才「可能是 tag 的前缀」。
|
|
426
|
+
func prefixKeep(buf, tag string) int {
|
|
427
|
+
max := len(tag) - 1
|
|
428
|
+
if max > len(buf) {
|
|
429
|
+
max = len(buf)
|
|
430
|
+
}
|
|
431
|
+
for n := max; n > 0; n-- {
|
|
432
|
+
if strings.HasPrefix(tag, buf[len(buf)-n:]) {
|
|
433
|
+
return n
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return 0
|
|
437
|
+
}
|