magic-agent 2.4.3 → 2.6.3
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/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +14 -3
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// fallback_test.go - 主备降级(2026-10-24;2026-10-26 按真机报障补口径)。
|
|
4
|
+
//
|
|
5
|
+
// 钉子:同引擎换模型也降级 / 换引擎不把主引擎的模型带过去 / 只有正文算产出 /
|
|
6
|
+
// 降级要发 fallback 帧且不带主引擎残留的终帧 / 降级清会话锚点且只降一级 /
|
|
7
|
+
// 没降级要给得出原因(原因进 FallbackInfo)。
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"context"
|
|
11
|
+
"errors"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type fallbackFakeEngine struct {
|
|
17
|
+
name string
|
|
18
|
+
text string
|
|
19
|
+
events []string
|
|
20
|
+
err error
|
|
21
|
+
// 记录最后一次收到的请求(验降级时清掉了会话锚点 / 模型怎么传的)。
|
|
22
|
+
lastReq Request
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (f *fallbackFakeEngine) Name() string { return f.name }
|
|
26
|
+
func (f *fallbackFakeEngine) Detect() (bool, string) { return true, "fake://" + f.name }
|
|
27
|
+
func (f *fallbackFakeEngine) Complete(_ context.Context, req Request) (Response, error) {
|
|
28
|
+
f.lastReq = req
|
|
29
|
+
return Response{Engine: f.name, Model: req.Model, Text: f.text}, f.err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stream 默认可用 —— 与内置引擎一致(它们都是 Streamer)。
|
|
33
|
+
func (f *fallbackFakeEngine) Stream(_ context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
34
|
+
f.lastReq = req
|
|
35
|
+
// 先以「思考」形态吐一遍(纯过程不拦降级),再把 events 当正文吐。
|
|
36
|
+
if len(f.events) > 0 {
|
|
37
|
+
onEvent(StreamEvent{Kind: KindThinking, Text: "让我想想"})
|
|
38
|
+
}
|
|
39
|
+
for _, s := range f.events {
|
|
40
|
+
onEvent(StreamEvent{Kind: KindText, Text: s})
|
|
41
|
+
}
|
|
42
|
+
return StreamResult{Response: Response{Engine: f.name, Model: req.Model, Text: f.text}}, f.err
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// fallbackFakeNonStream 不支持流式的引擎(只有 Engine 接口,没有 Stream)。
|
|
46
|
+
type fallbackFakeNonStream struct {
|
|
47
|
+
name string
|
|
48
|
+
text string
|
|
49
|
+
req Request
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (f *fallbackFakeNonStream) Name() string { return f.name }
|
|
53
|
+
func (f *fallbackFakeNonStream) Detect() (bool, string) { return true, "fake://" + f.name }
|
|
54
|
+
func (f *fallbackFakeNonStream) Complete(_ context.Context, req Request) (Response, error) {
|
|
55
|
+
f.req = req
|
|
56
|
+
return Response{Engine: f.name, Text: f.text}, nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// withFallbackEngines 临时替换注册表:Lookup 只认这里登记的假引擎,测完还原
|
|
60
|
+
// (别让内置引擎的惰性注册被这个用例打断,见 engine.go 的 registry 注释)。
|
|
61
|
+
func withFallbackEngines(t *testing.T, engines ...Engine) {
|
|
62
|
+
t.Helper()
|
|
63
|
+
old := registry
|
|
64
|
+
registry = append([]Engine(nil), engines...)
|
|
65
|
+
t.Cleanup(func() { registry = old })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// collectFallback 收事件的助手:按 kind 分类存下来。
|
|
69
|
+
type fallbackRecorder struct {
|
|
70
|
+
notices []string
|
|
71
|
+
fallback []FallbackEvent
|
|
72
|
+
texts []string
|
|
73
|
+
turnEnds int
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (r *fallbackRecorder) on(ev StreamEvent) {
|
|
77
|
+
switch ev.Kind {
|
|
78
|
+
case KindNotice:
|
|
79
|
+
r.notices = append(r.notices, ev.Text)
|
|
80
|
+
case KindFallback:
|
|
81
|
+
if ev.Fallback != nil {
|
|
82
|
+
r.fallback = append(r.fallback, *ev.Fallback)
|
|
83
|
+
}
|
|
84
|
+
case KindText:
|
|
85
|
+
r.texts = append(r.texts, ev.Text)
|
|
86
|
+
case KindTurnEnd:
|
|
87
|
+
r.turnEnds++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestStreamWithFallbackOnZeroOutput(t *testing.T) {
|
|
92
|
+
primary := &fallbackFakeEngine{name: "fb-primary", err: errors.New("402 insufficient_balance_error")}
|
|
93
|
+
backup := &fallbackFakeEngine{name: "fb-backup", text: "备用答案"}
|
|
94
|
+
withFallbackEngines(t, primary, backup)
|
|
95
|
+
|
|
96
|
+
rec := &fallbackRecorder{}
|
|
97
|
+
res, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
98
|
+
Model: "m1",
|
|
99
|
+
FallbackEngine: "fb-backup",
|
|
100
|
+
FallbackModel: "m2",
|
|
101
|
+
}, rec.on)
|
|
102
|
+
if err != nil {
|
|
103
|
+
t.Fatalf("主引擎失败、备用成功时不该报错:%v", err)
|
|
104
|
+
}
|
|
105
|
+
if res.Text != "备用答案" {
|
|
106
|
+
t.Errorf("应返回备用引擎的正文,得到 %q", res.Text)
|
|
107
|
+
}
|
|
108
|
+
if len(rec.fallback) != 1 {
|
|
109
|
+
t.Fatalf("应发一条 fallback 帧,得到 %#v", rec.fallback)
|
|
110
|
+
}
|
|
111
|
+
fe := rec.fallback[0]
|
|
112
|
+
if fe.FromEngine != "fb-primary" || fe.ToEngine != "fb-backup" || !fe.Switched || fe.FromModel != "m1" {
|
|
113
|
+
t.Errorf("fallback 帧两端不对:%#v", fe)
|
|
114
|
+
}
|
|
115
|
+
if !strings.Contains(fe.Reason, "402") {
|
|
116
|
+
t.Errorf("fallback 帧应带主引擎失败原因:%q", fe.Reason)
|
|
117
|
+
}
|
|
118
|
+
/* 换引擎:备用模型属于主引擎的清单,不能带过去(z 以前把 codebuddy 的 hy3 喂给 dsh)。 */
|
|
119
|
+
if backup.lastReq.Model != "" || !backup.lastReq.FallbackDown {
|
|
120
|
+
t.Errorf("换引擎时模型应清空并标记 FallbackDown:model=%q down=%v",
|
|
121
|
+
backup.lastReq.Model, backup.lastReq.FallbackDown)
|
|
122
|
+
}
|
|
123
|
+
if backup.lastReq.FallbackEngine != "" {
|
|
124
|
+
t.Error("降级请求里不该再留着备用字段(只降一级)")
|
|
125
|
+
}
|
|
126
|
+
if !info.Used || info.Engine != "fb-backup" || info.SwitchedEngine != true {
|
|
127
|
+
t.Errorf("FallbackInfo 应记录降级已生效:%#v", info)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 同引擎换模型(手机上最常见的配法:dsh 主 modelverse、备 volcengine)——
|
|
132
|
+
// 2026-10-26 真机作业记录里这一路以前被直接判成「没配备用」。
|
|
133
|
+
func TestStreamWithFallbackSameEngineOtherModel(t *testing.T) {
|
|
134
|
+
var calls []string
|
|
135
|
+
primary := &fallbackFakeEngine{name: "fb-same", err: errors.New("402 Token Plan 用量上限")}
|
|
136
|
+
backup := &fallbackFakeEngine{name: "fb-same", text: "备用模型给的答案"}
|
|
137
|
+
withFallbackEngines(t, backup)
|
|
138
|
+
|
|
139
|
+
rec := &fallbackRecorder{}
|
|
140
|
+
res, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
141
|
+
Model: "modelverse/x",
|
|
142
|
+
FallbackEngine: "fb-same",
|
|
143
|
+
FallbackModel: "volcengine/x",
|
|
144
|
+
}, func(ev StreamEvent) {
|
|
145
|
+
if ev.Kind == KindFallback {
|
|
146
|
+
calls = append(calls, "fallback")
|
|
147
|
+
}
|
|
148
|
+
rec.on(ev)
|
|
149
|
+
})
|
|
150
|
+
if err != nil {
|
|
151
|
+
t.Fatalf("同引擎换模型应能降级:%v", err)
|
|
152
|
+
}
|
|
153
|
+
if res.Text != "备用模型给的答案" {
|
|
154
|
+
t.Errorf("应返回备用模型的正文,得到 %q", res.Text)
|
|
155
|
+
}
|
|
156
|
+
if backup.lastReq.Model != "volcengine/x" {
|
|
157
|
+
t.Errorf("同引擎换模型时必须带上备用模型,得到 %q", backup.lastReq.Model)
|
|
158
|
+
}
|
|
159
|
+
if len(rec.fallback) != 1 || rec.fallback[0].Switched || rec.fallback[0].ToModel != "volcengine/x" {
|
|
160
|
+
t.Fatalf("fallback 帧应说明是同引擎换模型:%#v", rec.fallback)
|
|
161
|
+
}
|
|
162
|
+
if !info.Used || info.SwitchedEngine {
|
|
163
|
+
t.Errorf("FallbackInfo:%#v", info)
|
|
164
|
+
}
|
|
165
|
+
// 人话里要能看到模型名 —— 用户配的正是模型。
|
|
166
|
+
if !strings.Contains(FallbackNoticeText(rec.fallback[0]), "volcengine/x") {
|
|
167
|
+
t.Errorf("提示里应带备用模型名:%q", FallbackNoticeText(rec.fallback[0]))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 只有过程没有正文(思考 / 空 delta)也降级:已流出的思考是过程,不是答案。
|
|
172
|
+
func TestStreamWithFallbackIgnoresThinkingOnly(t *testing.T) {
|
|
173
|
+
primary := &fallbackFakeEngine{name: "fb-think", err: errors.New("余额不足")}
|
|
174
|
+
// 主引擎只吐了思考与空白正文,没给答案。
|
|
175
|
+
primary.events = []string{"", " "}
|
|
176
|
+
backup := &fallbackFakeEngine{name: "fb-think-backup", text: "答案"}
|
|
177
|
+
withFallbackEngines(t, primary, backup)
|
|
178
|
+
|
|
179
|
+
rec := &fallbackRecorder{}
|
|
180
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
181
|
+
FallbackEngine: "fb-think-backup",
|
|
182
|
+
}, rec.on)
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatalf("只吐过思考时仍应降级:%v", err)
|
|
185
|
+
}
|
|
186
|
+
if !info.Used {
|
|
187
|
+
t.Errorf("只吐过思考时仍应降级:%#v", info)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 已经吐过正文:不降级,保留原始错误,并给出原因。
|
|
192
|
+
func TestStreamWithFallbackKeepsPartialOutput(t *testing.T) {
|
|
193
|
+
primary := &fallbackFakeEngine{name: "fb-partial", events: []string{"已经写了一"}, err: errors.New("boom")}
|
|
194
|
+
backup := &fallbackFakeEngine{name: "fb-backup2", text: "不该出现"}
|
|
195
|
+
withFallbackEngines(t, primary, backup)
|
|
196
|
+
|
|
197
|
+
rec := &fallbackRecorder{}
|
|
198
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
199
|
+
FallbackEngine: "fb-backup2",
|
|
200
|
+
}, rec.on)
|
|
201
|
+
if err == nil {
|
|
202
|
+
t.Fatal("已经产出正文时应当保留原始错误")
|
|
203
|
+
}
|
|
204
|
+
if len(rec.fallback) != 0 {
|
|
205
|
+
t.Error("已经产出正文时不该降级(会出现两段互相矛盾的答案)")
|
|
206
|
+
}
|
|
207
|
+
if !strings.Contains(info.Reason, "正文") {
|
|
208
|
+
t.Errorf("没降级要给原因:%q", info.Reason)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestStreamWithFallbackClearsSession(t *testing.T) {
|
|
213
|
+
primary := &fallbackFakeEngine{name: "fb-sess", err: errors.New("expired")}
|
|
214
|
+
backup := &fallbackFakeEngine{name: "fb-sess2", text: "ok"}
|
|
215
|
+
withFallbackEngines(t, primary, backup)
|
|
216
|
+
|
|
217
|
+
_, _, err := StreamWithFallback(context.Background(), primary, Request{
|
|
218
|
+
SessionID: "sess-of-primary",
|
|
219
|
+
Continue: true,
|
|
220
|
+
FallbackEngine: "fb-sess2",
|
|
221
|
+
}, func(StreamEvent) {})
|
|
222
|
+
if err != nil {
|
|
223
|
+
t.Fatalf("降级应成功:%v", err)
|
|
224
|
+
}
|
|
225
|
+
if backup.lastReq.SessionID != "" || backup.lastReq.Continue {
|
|
226
|
+
t.Errorf("降级必须清掉会话锚点:session=%q continue=%v", backup.lastReq.SessionID, backup.lastReq.Continue)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 主引擎残留的终帧不能漏给调用方:否则这一轮在备用开口前就被画成「已结束」。
|
|
231
|
+
func TestStreamWithFallbackDropsPrimaryTerminalFrames(t *testing.T) {
|
|
232
|
+
primary := &fallbackFakeEngine{name: "fb-term", err: errors.New("boom")}
|
|
233
|
+
backup := &fallbackFakeEngine{name: "fb-term2", text: "ok"}
|
|
234
|
+
withFallbackEngines(t, primary, backup)
|
|
235
|
+
|
|
236
|
+
rec := &fallbackRecorder{}
|
|
237
|
+
_, _, err := StreamWithFallback(context.Background(), primary, Request{
|
|
238
|
+
FallbackEngine: "fb-term2",
|
|
239
|
+
}, rec.on)
|
|
240
|
+
if err != nil {
|
|
241
|
+
t.Fatalf("应降级成功:%v", err)
|
|
242
|
+
}
|
|
243
|
+
// 假引擎的 Stream 不走 accumulator、不会自己发终帧;这里直接验过滤口径。
|
|
244
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindTurnEnd}); keep {
|
|
245
|
+
t.Error("turn_end 应被吞掉")
|
|
246
|
+
}
|
|
247
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindTurnFailed}); keep {
|
|
248
|
+
t.Error("turn_failed 应被吞掉")
|
|
249
|
+
}
|
|
250
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindThinking, Text: "思考"}); !keep {
|
|
251
|
+
t.Error("思考等过程事件应照常转发")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func TestRunWithFallback(t *testing.T) {
|
|
256
|
+
primary := &fallbackFakeEngine{name: "fb-c1", err: errors.New("quota")}
|
|
257
|
+
backup := &fallbackFakeEngine{name: "fb-c2", text: "完成"}
|
|
258
|
+
withFallbackEngines(t, primary, backup)
|
|
259
|
+
|
|
260
|
+
var note string
|
|
261
|
+
resp, info, err := RunWithFallback(context.Background(), primary, Request{FallbackEngine: "fb-c2"},
|
|
262
|
+
RunOptions{}, func(fe FallbackEvent) { note = FallbackNoticeText(fe) })
|
|
263
|
+
if err != nil {
|
|
264
|
+
t.Fatalf("应降级成功:%v", err)
|
|
265
|
+
}
|
|
266
|
+
if resp.Text != "完成" || !strings.Contains(note, "降级") || !strings.Contains(note, "fb-c2") {
|
|
267
|
+
t.Errorf("resp=%q note=%q", resp.Text, note)
|
|
268
|
+
}
|
|
269
|
+
if !info.Used || info.Engine != "fb-c2" {
|
|
270
|
+
t.Errorf("FallbackInfo:%#v", info)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 同引擎换模型(非流式路径同样要生效)。
|
|
275
|
+
func TestRunWithFallbackSameEngineOtherModel(t *testing.T) {
|
|
276
|
+
primary := &fallbackFakeEngine{name: "fb-rm", err: errors.New("402")}
|
|
277
|
+
backup := &fallbackFakeEngine{name: "fb-rm", text: "备用"}
|
|
278
|
+
withFallbackEngines(t, backup)
|
|
279
|
+
|
|
280
|
+
_, info, err := RunWithFallback(context.Background(), primary, Request{
|
|
281
|
+
Model: "a", FallbackEngine: "fb-rm", FallbackModel: "b",
|
|
282
|
+
}, RunOptions{}, nil)
|
|
283
|
+
if err != nil {
|
|
284
|
+
t.Fatalf("应降级成功:%v", err)
|
|
285
|
+
}
|
|
286
|
+
if backup.lastReq.Model != "b" {
|
|
287
|
+
t.Errorf("同引擎换模型应带上备用模型:%q", backup.lastReq.Model)
|
|
288
|
+
}
|
|
289
|
+
if !info.Used || info.Model != "b" {
|
|
290
|
+
t.Errorf("FallbackInfo.Model 应是备用模型:%#v", info)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 引擎与模型都相同 = 没有可降级的目标:不白跑一次,原因说出来。
|
|
295
|
+
func TestFallbackNoopWhenIdentical(t *testing.T) {
|
|
296
|
+
primary := &fallbackFakeEngine{name: "fb-id", err: errors.New("boom")}
|
|
297
|
+
withFallbackEngines(t, primary)
|
|
298
|
+
|
|
299
|
+
_, info, err := RunWithFallback(context.Background(), primary, Request{
|
|
300
|
+
FallbackEngine: "fb-id", FallbackModel: "",
|
|
301
|
+
}, RunOptions{}, nil)
|
|
302
|
+
if err == nil {
|
|
303
|
+
t.Fatal("引擎与模型都相同时应返回原始错误")
|
|
304
|
+
}
|
|
305
|
+
if !strings.Contains(info.Reason, "没有可降级") {
|
|
306
|
+
t.Errorf("应说明没有降级目标:%q", info.Reason)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func TestFallbackIgnoresUnknownEngine(t *testing.T) {
|
|
311
|
+
primary := &fallbackFakeEngine{name: "fb-only", err: errors.New("boom")}
|
|
312
|
+
withFallbackEngines(t, primary)
|
|
313
|
+
|
|
314
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
315
|
+
FallbackEngine: "不存在的引擎",
|
|
316
|
+
}, func(StreamEvent) {})
|
|
317
|
+
if err == nil {
|
|
318
|
+
t.Fatal("备用引擎不存在时应返回原始错误")
|
|
319
|
+
}
|
|
320
|
+
if !strings.Contains(info.Reason, "不存在") {
|
|
321
|
+
t.Errorf("应说明备用引擎不存在:%q", info.Reason)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 备用没有流式能力:流式路径不降级(原因说出来)。
|
|
326
|
+
func TestFallbackStreamNeedsStreamer(t *testing.T) {
|
|
327
|
+
primary := &fallbackFakeEngine{name: "fb-ns-p", err: errors.New("boom")}
|
|
328
|
+
backup := &fallbackFakeNonStream{name: "fb-ns", text: "非流式答案"}
|
|
329
|
+
withFallbackEngines(t, primary, backup)
|
|
330
|
+
|
|
331
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
332
|
+
FallbackEngine: "fb-ns",
|
|
333
|
+
}, func(StreamEvent) {})
|
|
334
|
+
if err == nil {
|
|
335
|
+
t.Fatal("备用不支持流式时应返回原始错误")
|
|
336
|
+
}
|
|
337
|
+
if !strings.Contains(info.Reason, "流式") {
|
|
338
|
+
t.Errorf("应说明备用不支持流式:%q", info.Reason)
|
|
339
|
+
}
|
|
340
|
+
}
|
package/internal/agent/launch.go
CHANGED
|
@@ -8,7 +8,7 @@ package agent
|
|
|
8
8
|
//
|
|
9
9
|
// npm 全局装的 shim:Windows 上是 claude.cmd / codebuddy.cmd(批处理),
|
|
10
10
|
// macOS/Linux 上是 claude / codebuddy(无扩展名的脚本)
|
|
11
|
-
// 独立安装的原生二进制:
|
|
11
|
+
// 独立安装的原生二进制:traex.exe / dsh 等
|
|
12
12
|
//
|
|
13
13
|
// **批处理不能被直接 exec**。Windows 的 exec.Command 遇到 `.cmd` 会走
|
|
14
14
|
// cmd.exe 解析层,参数里的非 ASCII 字符会被代码页转换吃掉(中文 → 乱码),
|
|
@@ -112,6 +112,48 @@ func launchWrap(bin string, args []string) launchSpec {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/* WrapLaunch 把「可执行文件 + 参数」变成**可直接 exec** 的形式,供本仓之外的
|
|
116
|
+
* 调用方使用(目前是 internal/client 的脚本管理,见 scripts.go)。
|
|
117
|
+
*
|
|
118
|
+
* 与 launchWrap 的区别(三条,都重要):
|
|
119
|
+
*
|
|
120
|
+
* 1. **不做 emptyStringFlags 改写**。那张表登记的是**引擎 prompt flag**
|
|
121
|
+
* (--tools),是引擎语义的一部分;脚本的参数是用户自己填的定长参数,
|
|
122
|
+
* 套上引擎的白名单只会莫名其妙地改写用户的参数。
|
|
123
|
+
* 2. **返回两个值而不是 launchSpec**。launchSpec 是引擎内部的启动决策,
|
|
124
|
+
* 带上 wrapped 诊断位;对外只需要「拿这俩去 exec」。
|
|
125
|
+
* 3. **会传播退出码**(launchWrap 不会)。见下面那段说明。
|
|
126
|
+
*
|
|
127
|
+
* 为什么需要它:Windows 上 `.cmd/.bat/.ps1` **不能直接 exec** ——
|
|
128
|
+
* cmd.exe 解析层会把参数里的中文、& | ^ % 拆坏(见本文件头)。
|
|
129
|
+
* 脚本管理要跑用户登记的脚本文件,Windows 上多半就是 `.cmd` / `.ps1`,
|
|
130
|
+
* 不走这层包装就会静默失败。
|
|
131
|
+
*
|
|
132
|
+
* ── 为什么要传播退出码 ──
|
|
133
|
+
*
|
|
134
|
+
* 实测(2026-10-24):PowerShell 用 `& 'script.cmd'` 调一个 `exit /b 3` 的
|
|
135
|
+
* 批处理时,**PowerShell 自己的退出码是 1,不是 3** —— 脚本的退出码被
|
|
136
|
+
* PowerShell 吞掉了。后果是「脚本失败」在界面上显示成退出码 1,
|
|
137
|
+
* 具体的 3 看不到(用户排查时少一条关键信息)。
|
|
138
|
+
*
|
|
139
|
+
* 补一句 `exit $LASTEXITCODE` 就能把它带出来(实测退出码变回 3)。
|
|
140
|
+
* 引擎路径不需要这个 —— 引擎的成败看的是输出与 `result` 事件,不是退出码。
|
|
141
|
+
*/
|
|
142
|
+
func WrapLaunch(bin string, args []string) (string, []string) {
|
|
143
|
+
if !platformIsWindows() {
|
|
144
|
+
return bin, args
|
|
145
|
+
}
|
|
146
|
+
if !needsPowerShellWrap(bin) {
|
|
147
|
+
return bin, args
|
|
148
|
+
}
|
|
149
|
+
script := powershellInvokeScript(bin, args)
|
|
150
|
+
// 退出码透传:$LASTEXITCODE 是「最近一次原生命令」的退出码。
|
|
151
|
+
// 对 .ps1 它是 $null(PowerShell 脚本没有原生退出码)—— 那时
|
|
152
|
+
// `exit $null` 等于 exit 0,也合理(PowerShell 脚本正常跑完就是成功)。
|
|
153
|
+
script += "; exit $LASTEXITCODE"
|
|
154
|
+
return powershellBin(), []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
// emptyStringFlags 全局登记:本项目里哪些 flag 会以**空串**作为值。
|
|
116
158
|
//
|
|
117
159
|
// 为什么要全局而不是按引擎传:改写发生在 launchWrap 这一层,
|
|
@@ -44,12 +44,17 @@ package agent
|
|
|
44
44
|
import (
|
|
45
45
|
"regexp"
|
|
46
46
|
"strings"
|
|
47
|
+
"time"
|
|
47
48
|
"unicode/utf8"
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
// limitNoticeMaxRunes 通告那一段的长度上限(一句话;长回答不该被整段吞掉)。
|
|
51
52
|
const limitNoticeMaxRunes = 400
|
|
52
53
|
|
|
54
|
+
// limitNoticeWholeMaxRunes 「整段就是通告、但没给恢复时刻」那一档的长度上限。
|
|
55
|
+
// 它没有「时刻」当分水岭,所以更靠长度挡误伤:真通告都短,正经回答很少整段这么短。
|
|
56
|
+
const limitNoticeWholeMaxRunes = 160
|
|
57
|
+
|
|
53
58
|
var (
|
|
54
59
|
// 起点:句子边界(正文开头 / 换行 / 句末标点 / 冒号)+ 已知状态码 + 一个空格。
|
|
55
60
|
// ⚠️ 刻意**不认「普通空格之后」**:模型正经回答里写「HTTP 429 是限流,额度会在 … 重置」
|
|
@@ -64,12 +69,41 @@ var (
|
|
|
64
69
|
// 排障用的请求 id 尾巴(形如 `(0l1a124d…/0l1a123c3-…)`)—— 它属于通告,
|
|
65
70
|
// 判「整段一句话」时要先剥掉。
|
|
66
71
|
limitNoticeIDTailRe = regexp.MustCompile(`\s*\([^()\n]{4,200}\)\s*$`)
|
|
72
|
+
// 请求 id 的另一种写法(实测 2026-10-25 英文额度通告:`Request id: 0217929113…`)——
|
|
73
|
+
// 它既不是给人看的,也不该进卡片的原文行;剥掉括号尾巴之后再剥它一次。
|
|
74
|
+
limitNoticeReqIDRe = regexp.MustCompile(`(?i)\s*request[\s_-]?id\s*[::]\s*[0-9a-z][0-9a-z-]{6,}\s*$`)
|
|
67
75
|
// 末尾那个句号(可有可无,剥掉后再判「段内还有没有句末标点」)。
|
|
68
76
|
limitNoticeEndRe = regexp.MustCompile(`[。.!!??]\s*$`)
|
|
69
77
|
// 段内的句末标点(出现即说明这段不止一句话 → 不是通告)。
|
|
70
78
|
limitNoticeSeptRe = regexp.MustCompile(`[。!?!?]`)
|
|
79
|
+
|
|
80
|
+
/* 「整段就是通告、但没给恢复时刻」那一档(见 wholeNoticeCore)。它的判据不带时刻,
|
|
81
|
+
所以每一条都独立收紧:状态码必须顶在最前面、措辞必须是「用量真的没了」(不是
|
|
82
|
+
「429 表示限流」这种释义句)、并且必须给出「接下来怎么办」。 */
|
|
83
|
+
limitNoticeCodeHeadRe = regexp.MustCompile(`^(?:429|402|401|403|5\d{2})\s`)
|
|
84
|
+
/* 释义句的形状:「429 是限流」「429 表示…」—— 这是模型在**回答**用户的问题,
|
|
85
|
+
不是供应商在报错。只认状态码开头那一种(别的位置出现「表示」不影响)。 */
|
|
86
|
+
limitNoticeExplainRe = regexp.MustCompile(`(?i)^(?:429|402|401|403|5\d{2})\s*(?:是|就是|表示|指|means|refers)`)
|
|
87
|
+
noticeExhaustRe = regexp.MustCompile(`(?i)用量上限|用量已|额度|余额|欠费|用尽|用完|quota|insufficient|exceeded|balance`)
|
|
88
|
+
noticeExhaustHintRe = regexp.MustCompile(`(?i)升级|购买|充值|续费|补充|开通|重置|切换|稍后|再试|upgrade|purchase|top ?up|recharge|reset|retry|switch`)
|
|
71
89
|
)
|
|
72
90
|
|
|
91
|
+
// stripNoticeTails 剥掉通告末尾的排障尾巴(括号里的请求 id、以及 `Request id: …` 那种写法)
|
|
92
|
+
// 与末尾标点。三条路径(末尾夹通告 / 整段就是通告)都要剥同一套,所以收在一处。
|
|
93
|
+
func stripNoticeTails(s string) string {
|
|
94
|
+
out := strings.TrimSpace(s)
|
|
95
|
+
// 可能连着几条尾巴(实测 402:「…消耗。 (2067) (01a1…/01a1…)」),循环剥干净。
|
|
96
|
+
for i := 0; i < 3; i++ {
|
|
97
|
+
next := strings.TrimSpace(limitNoticeIDTailRe.ReplaceAllString(out, ""))
|
|
98
|
+
next = strings.TrimSpace(limitNoticeReqIDRe.ReplaceAllString(next, ""))
|
|
99
|
+
if next == out {
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
out = next
|
|
103
|
+
}
|
|
104
|
+
return strings.TrimSpace(limitNoticeEndRe.ReplaceAllString(out, ""))
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
// splitLimitNotice 判 text 的**末尾**是不是夹着一句供应商的限流/额度通告。
|
|
74
108
|
//
|
|
75
109
|
// 命中返回 (通告原文, 通告之前真正的正文, true);不命中返回 ("", "", false)。
|
|
@@ -88,7 +122,7 @@ func splitLimitNotice(text string) (notice, body string, ok bool) {
|
|
|
88
122
|
if seg == "" || utf8.RuneCountInString(seg) > limitNoticeMaxRunes {
|
|
89
123
|
continue
|
|
90
124
|
}
|
|
91
|
-
core :=
|
|
125
|
+
core := stripNoticeTails(seg)
|
|
92
126
|
if limitNoticeSeptRe.MatchString(core) {
|
|
93
127
|
continue
|
|
94
128
|
}
|
|
@@ -99,5 +133,177 @@ func splitLimitNotice(text string) (notice, body string, ok bool) {
|
|
|
99
133
|
}
|
|
100
134
|
return strings.Join(strings.Fields(core), " "), strings.TrimRight(s[:at], " \t\r\n\u3000"), true
|
|
101
135
|
}
|
|
136
|
+
// 整段就是通告、但**没给恢复时刻**那一档(2026-10-25,实测 402 原文):
|
|
137
|
+
// 402 当前已达到 Token Plan 用量上限。为避免调用中断,请升级 Token Plan 套餐,或购买积分…
|
|
138
|
+
// 上面那条判据把「有恢复时刻」当分水岭,这类通告因此漏了;而它整轮只有这一段正文,
|
|
139
|
+
// 落到画面上就是「一大段供应商原文 + 已完成」。这里补一条**更严**的窄路(四条同时成立):
|
|
140
|
+
// ① 整段就是它(body 必须为空)—— 不是「末尾夹着」,那就没有「哪句是通告」的歧义;
|
|
141
|
+
// ② 开头就是状态码(前面连正文都没有);
|
|
142
|
+
// ③ 有「用量真的没了」的措辞(noticeExhaustRe)—— 不是「429 是什么意思」那种释义句;
|
|
143
|
+
// ④ 短(≤160 字)。
|
|
144
|
+
if core, hit := wholeNoticeCore(s); hit {
|
|
145
|
+
return core, "", true
|
|
146
|
+
}
|
|
102
147
|
return "", "", false
|
|
103
148
|
}
|
|
149
|
+
|
|
150
|
+
// wholeNoticeCore 上面那条窄路的本体(整段就是一个不带时刻的额度通告)。
|
|
151
|
+
//
|
|
152
|
+
// ⚠️ 这条路**不要求「一句话」**:实测那条 402 原文本身就有两个句号(「…用量上限。为避免
|
|
153
|
+
// 调用中断,请升级…」)。换成四条更硬的约束来挡误伤 —— 它们任何一条都比「句数」更难蒙对:
|
|
154
|
+
// 整段只有它(没有「哪句是通告」的歧义)、开头就是状态码、措辞是「用量真的没了」而不是
|
|
155
|
+
// 「429 表示…」这种释义句、并且短。
|
|
156
|
+
func wholeNoticeCore(s string) (string, bool) {
|
|
157
|
+
core := stripNoticeTails(s)
|
|
158
|
+
if core == "" || utf8.RuneCountInString(core) > limitNoticeWholeMaxRunes {
|
|
159
|
+
return "", false
|
|
160
|
+
}
|
|
161
|
+
if strings.ContainsAny(core, "\n\r") { // 通告只有一段;换行 = 这是个正经回答
|
|
162
|
+
return "", false
|
|
163
|
+
}
|
|
164
|
+
if !limitNoticeCodeHeadRe.MatchString(core) { // 状态码必须在最前面
|
|
165
|
+
return "", false
|
|
166
|
+
}
|
|
167
|
+
if limitNoticeExplainRe.MatchString(core) { // 「429 是限流…」= 在回答,不是通告
|
|
168
|
+
return "", false
|
|
169
|
+
}
|
|
170
|
+
if !noticeExhaustRe.MatchString(core) || !noticeExhaustHintRe.MatchString(core) {
|
|
171
|
+
return "", false
|
|
172
|
+
}
|
|
173
|
+
return strings.Join(strings.Fields(core), " "), true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ── 通告的结构化字段(2026-10-25)────────────────────────────────
|
|
177
|
+
*
|
|
178
|
+
* 由来:用户报障「没额度了,设计一个提示卡」。界面要画一张**额度卡**
|
|
179
|
+
* (什么时候恢复 / 该怎么办),可事件上原来只有 Error / Reason 两段人话原文,
|
|
180
|
+
* 客户端只能靠正则从原文里反推 —— 反推逻辑一旦写错就是错卡,且三端会各写一套。
|
|
181
|
+
* 所以分类与时刻解析收在这里(core 一处),事件带上 error_kind / error_code /
|
|
182
|
+
* reset_at,客户端只做展示。
|
|
183
|
+
*
|
|
184
|
+
* 两档(客户端据此决定标题与建议,不能只有一个「出了错」):
|
|
185
|
+
*
|
|
186
|
+
* quota 额度用尽 / 余额不足 / 套餐用量上限 —— 等恢复或换模型,与代码无关;
|
|
187
|
+
* rate_limit 频率限制(短时窗口)—— 隔一会儿再试即可。
|
|
188
|
+
*
|
|
189
|
+
* 判据刻意保守:认不出来就返回空 kind(客户端退回普通「调用失败」卡),
|
|
190
|
+
* 不硬套标签 —— 把「工具报错」画成「额度不足」比不画更糟。
|
|
191
|
+
*/
|
|
192
|
+
const (
|
|
193
|
+
// ErrorKindQuota 额度用尽(余额 / 套餐用量上限 / quota exhausted)。
|
|
194
|
+
ErrorKindQuota = "quota"
|
|
195
|
+
// ErrorKindRateLimit 频率限制(短时窗口,等一会儿就能再试)。
|
|
196
|
+
ErrorKindRateLimit = "rate_limit"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
var (
|
|
200
|
+
// 额度类措辞(比 limitNoticeNounRe 更宽:通告已经被认出来了,这里只分档)。
|
|
201
|
+
noticeQuotaRe = regexp.MustCompile(`(?i)额度|余额|欠费|quota|insufficient|balance|credit|用量上限|token plan|plan limit|套餐`)
|
|
202
|
+
noticeRateRe = regexp.MustCompile(`(?i)频率限制|限流|rate[\s_-]?limit|frequency limit|too many requests|用量过多`)
|
|
203
|
+
// 状态码(通告开头那几个,与 splitLimitNotice 的起点判据同一套)。
|
|
204
|
+
noticeCodeRe = regexp.MustCompile(`^\s*(\d{3})\b`)
|
|
205
|
+
// 恢复时刻:`2026-10-25 15:07:27` 主体(两种实测格式共用)。
|
|
206
|
+
noticeStampRe = regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?`)
|
|
207
|
+
// 时区偏移:`+0800` / `+08:00` / `UTC+8` 三种拼法(实测都出现过)。
|
|
208
|
+
noticeZoneRe = regexp.MustCompile(`(?i)(?:UTC)?\s*([+-])(\d{1,2}):?(\d{2})?`)
|
|
209
|
+
// ISO 的 Z(= UTC)。必须单独判:`noticeZoneRe` 认的是带符号的偏移,Z 没有符号。
|
|
210
|
+
noticeZuluRe = regexp.MustCompile(`(?i)^\s*Z\b`)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// classifyNotice 把一条已认定的额度/限流通告分成两档,并取出状态码。
|
|
214
|
+
//
|
|
215
|
+
// kind 为空 = 是限流类通告但分不清是哪一档(客户端按普通失败卡处理)。
|
|
216
|
+
// code 为空 = 通告里没有状态码。
|
|
217
|
+
func classifyNotice(text string) (kind, code string) {
|
|
218
|
+
s := strings.TrimSpace(text)
|
|
219
|
+
if s == "" {
|
|
220
|
+
return "", ""
|
|
221
|
+
}
|
|
222
|
+
if m := noticeCodeRe.FindStringSubmatch(s); m != nil {
|
|
223
|
+
code = m[1]
|
|
224
|
+
}
|
|
225
|
+
switch {
|
|
226
|
+
case noticeQuotaRe.MatchString(s):
|
|
227
|
+
kind = ErrorKindQuota
|
|
228
|
+
case noticeRateRe.MatchString(s):
|
|
229
|
+
kind = ErrorKindRateLimit
|
|
230
|
+
}
|
|
231
|
+
/* 402 是「需要付费」的语义码,原文措辞可能只有一句「Payment Required」——
|
|
232
|
+
这种也要算额度,否则用户看到的是一张普通错误卡。 */
|
|
233
|
+
if kind == "" && code == "402" {
|
|
234
|
+
kind = ErrorKindQuota
|
|
235
|
+
}
|
|
236
|
+
return kind, code
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// parseNoticeReset 从通告里取「什么时候恢复」,取不到返回零值时间。
|
|
240
|
+
//
|
|
241
|
+
// ⚠️ 三种拼法都要认(都是本仓库日志里的**实测原文**):
|
|
242
|
+
//
|
|
243
|
+
// 2026-10-25 15:07:27 +0800 CST ← 英文版;偏移无冒号,尾部还有一个**无意义**的 CST
|
|
244
|
+
// 2026-10-25 23:45:51 UTC+8 ← 中文版
|
|
245
|
+
// 2026-10-25T15:07:27Z ← 少数供应商给 ISO 形状
|
|
246
|
+
//
|
|
247
|
+
// ⚠️ **绝不用 Date.parse 那套宽松解析**:V8 会把 `CST` 当美国中部时间,
|
|
248
|
+
// 实测偏 14 小时 —— 界面上就成了「半夜三点恢复」。这里只认显式偏移,
|
|
249
|
+
// 没有偏移就按**本地时区**理解(通告是给本机用户看的,供应商给的钟点也是本地的)。
|
|
250
|
+
func parseNoticeReset(text string) time.Time {
|
|
251
|
+
s := strings.TrimSpace(text)
|
|
252
|
+
m := noticeStampRe.FindStringSubmatch(s)
|
|
253
|
+
if m == nil {
|
|
254
|
+
return time.Time{}
|
|
255
|
+
}
|
|
256
|
+
atoi := func(v string) int {
|
|
257
|
+
n := 0
|
|
258
|
+
for _, c := range v {
|
|
259
|
+
if c < '0' || c > '9' {
|
|
260
|
+
return n
|
|
261
|
+
}
|
|
262
|
+
n = n*10 + int(c-'0')
|
|
263
|
+
}
|
|
264
|
+
return n
|
|
265
|
+
}
|
|
266
|
+
sec := 0
|
|
267
|
+
if m[6] != "" {
|
|
268
|
+
sec = atoi(m[6])
|
|
269
|
+
}
|
|
270
|
+
t := time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
271
|
+
atoi(m[4]), atoi(m[5]), sec, 0, time.Local)
|
|
272
|
+
/* 时区:只在时间戳**紧跟其后**的那一小段里找偏移 —— 全串搜会把
|
|
273
|
+
「Request id: +86…」这类内容当成时区。 */
|
|
274
|
+
rest := strings.TrimSpace(s[strings.Index(s, m[0])+len(m[0]):])
|
|
275
|
+
if rest != "" {
|
|
276
|
+
zonePart := rest
|
|
277
|
+
if i := strings.IndexAny(zonePart, "\n"); i >= 0 {
|
|
278
|
+
zonePart = zonePart[:i]
|
|
279
|
+
}
|
|
280
|
+
if len(zonePart) > 16 {
|
|
281
|
+
zonePart = zonePart[:16]
|
|
282
|
+
}
|
|
283
|
+
if z := noticeZoneRe.FindStringSubmatch(zonePart); z != nil {
|
|
284
|
+
h, min := atoi(z[2]), atoi(z[3])
|
|
285
|
+
offset := h*3600 + min*60
|
|
286
|
+
if z[1] == "-" {
|
|
287
|
+
offset = -offset
|
|
288
|
+
}
|
|
289
|
+
loc := time.FixedZone("", offset)
|
|
290
|
+
t = time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
291
|
+
atoi(m[4]), atoi(m[5]), sec, 0, loc)
|
|
292
|
+
} else if noticeZuluRe.MatchString(zonePart) {
|
|
293
|
+
/* `…T15:07:27Z`(ISO 的 Z = UTC)。别漏了这一支:漏了就会按**本地时区**理解,
|
|
294
|
+
本机在 +08 时整体偏 8 小时(测试就是在这里抓到它的)。 */
|
|
295
|
+
t = time.Date(atoi(m[1]), time.Month(atoi(m[2])), atoi(m[3]),
|
|
296
|
+
atoi(m[4]), atoi(m[5]), sec, 0, time.UTC)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return t
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// noticeResetRFC3339 通告里的恢复时刻(RFC3339);取不到回空串(wire 上是 omitempty)。
|
|
303
|
+
func noticeResetRFC3339(text string) string {
|
|
304
|
+
t := parseNoticeReset(text)
|
|
305
|
+
if t.IsZero() {
|
|
306
|
+
return ""
|
|
307
|
+
}
|
|
308
|
+
return t.Format(time.RFC3339)
|
|
309
|
+
}
|