zames_pro 2.80.8 → 2.81.1
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/CHANGELOG.md +50 -0
- package/README.md +71 -21
- package/dist/browser-login.js +179 -0
- package/dist/browser-pure.js +112 -8
- package/dist/browser.js +274 -16
- package/dist/commands.js +24 -8
- package/dist/config-command.js +5 -1
- package/dist/config.js +110 -2
- package/dist/glm-browser.js +1770 -0
- package/dist/glm-chats.js +170 -0
- package/dist/glm-flags.js +75 -0
- package/dist/glm-net-capture.js +338 -0
- package/dist/glm-ui.js +171 -0
- package/dist/i18n/common.js +18 -2
- package/dist/i18n/config.js +41 -1
- package/dist/i18n/doctor.js +11 -6
- package/dist/i18n/glm.js +86 -0
- package/dist/i18n/help.js +14 -10
- package/dist/i18n/qwen.js +47 -15
- package/dist/i18n/self-review.js +2 -2
- package/dist/i18n.js +2 -0
- package/dist/index.js +332 -114
- package/dist/provider-handoff.js +11 -0
- package/dist/providers.js +65 -0
- package/dist/qwen-browser.js +274 -30
- package/dist/send-runtime.js +112 -0
- package/dist/send-telemetry.js +99 -0
- package/dist/send-throttle.js +108 -0
- package/dist/system-prompt.js +23 -2
- package/package.json +3 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// PURE scraping/parsing helpers for the GLM provider (chat.z.ai).
|
|
2
|
+
//
|
|
3
|
+
// Mirrors qwen-chats.ts: the chat list and the restored dialogue are read from
|
|
4
|
+
// a page we do not control, so the policy has ONE home that is unit-testable
|
|
5
|
+
// WITHOUT Playwright. The scrapers run INSIDE page.evaluate, so they use only
|
|
6
|
+
// their arguments and DOM globals.
|
|
7
|
+
//
|
|
8
|
+
// Live shapes (see test/fixtures/glm/):
|
|
9
|
+
// GET /api/v1/chats/?page=1 -> a BARE ARRAY of {id,title,updated_at,...}
|
|
10
|
+
// GET /api/v1/chats/<id> -> {chat:{history:{messages:{<id>:{...}}}}}; the
|
|
11
|
+
// assistant node carries NO content (verified),
|
|
12
|
+
// so a restored dialogue is mostly the user's
|
|
13
|
+
// turns and the answers come from the DOM.
|
|
14
|
+
// a chat link -> a[href="/c/<uuid>"]
|
|
15
|
+
const CHAT_HREF_RE = /\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
|
|
16
|
+
/**
|
|
17
|
+
* In-page scraper for the sidebar chat list (fallback for when the API call
|
|
18
|
+
* fails). Returns ids/titles in document order, de-duplicated, capped at
|
|
19
|
+
* `limit`. The title element inside a z.ai row is not class-stable, so the
|
|
20
|
+
* row's textContent (collapsed) is used.
|
|
21
|
+
*/
|
|
22
|
+
export function scrapeGlmChatList(limit) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const anchors = document.querySelectorAll('a[href*="/c/"]');
|
|
26
|
+
for (const a of Array.from(anchors)) {
|
|
27
|
+
const href = a.getAttribute('href') || '';
|
|
28
|
+
const m = href.match(CHAT_HREF_RE);
|
|
29
|
+
if (!m)
|
|
30
|
+
continue;
|
|
31
|
+
if (seen.has(m[1]))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(m[1]);
|
|
34
|
+
const title = (a.textContent || '').trim().replace(/\s+/g, ' ');
|
|
35
|
+
out.push({ id: m[1], title });
|
|
36
|
+
if (out.length >= limit)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* In-page scraper for the WHOLE visible dialogue of the open chat. Nested
|
|
43
|
+
* duplicate containers are collapsed to the OUTERMOST one (the same real bug
|
|
44
|
+
* as Qwen: a broad selector matched both the wrapper and the inner block and
|
|
45
|
+
* every turn printed twice). Reasoning blocks are skipped by class.
|
|
46
|
+
*/
|
|
47
|
+
export function scrapeGlmChatMessages(opts) {
|
|
48
|
+
const thinkRe = new RegExp(opts.thinkRe, 'i');
|
|
49
|
+
const userRe = new RegExp(opts.userRe, 'i');
|
|
50
|
+
const inThink = (e) => {
|
|
51
|
+
let n = e;
|
|
52
|
+
while (n) {
|
|
53
|
+
if (thinkRe.test((n.className || '').toString()))
|
|
54
|
+
return true;
|
|
55
|
+
n = n.parentElement;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
let containers = [];
|
|
60
|
+
for (const sel of opts.containerSels) {
|
|
61
|
+
const found = Array.from(document.querySelectorAll(sel)).filter((e) => !inThink(e) && (e.textContent || '').trim().length > 0);
|
|
62
|
+
if (found.length) {
|
|
63
|
+
containers = found;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const uniq = Array.from(new Set(containers));
|
|
68
|
+
const blocks = uniq.filter((b) => !uniq.some((o) => o !== b && o.contains(b)));
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const c of blocks) {
|
|
71
|
+
const role = userRe.test((c.className || '').toString())
|
|
72
|
+
? 'user'
|
|
73
|
+
: 'assistant';
|
|
74
|
+
const text = (c.textContent || '').trim();
|
|
75
|
+
if (!text)
|
|
76
|
+
continue;
|
|
77
|
+
out.push({ role, text });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
// ---------- API payload parsers (pure, unit-tested) ----------
|
|
82
|
+
/**
|
|
83
|
+
* Parse `GET /api/v1/chats/?page=1`. The live shape is a BARE ARRAY; older /
|
|
84
|
+
* other builds wrap it in `{data:[...]}` or key it by id. Returns [] for
|
|
85
|
+
* anything unrecognizable (never throws).
|
|
86
|
+
*/
|
|
87
|
+
export function parseGlmChatList(json) {
|
|
88
|
+
let arr = json;
|
|
89
|
+
if (arr && typeof arr === 'object' && !Array.isArray(arr)) {
|
|
90
|
+
const o = arr;
|
|
91
|
+
// A wrapper key wins; otherwise the object itself is an id-keyed MAP
|
|
92
|
+
// of chats (a real alternative shape), not an empty list.
|
|
93
|
+
arr = o.data ?? o.chats ?? o.list ?? o.items ?? arr;
|
|
94
|
+
}
|
|
95
|
+
const keyed = Array.isArray(arr)
|
|
96
|
+
? null
|
|
97
|
+
: arr && typeof arr === 'object'
|
|
98
|
+
? Object.entries(arr)
|
|
99
|
+
: null;
|
|
100
|
+
if (!Array.isArray(arr) && !keyed)
|
|
101
|
+
return [];
|
|
102
|
+
const entries = Array.isArray(arr)
|
|
103
|
+
? arr.map((c) => ['', c])
|
|
104
|
+
: keyed;
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const [key, c] of entries) {
|
|
107
|
+
if (!c || typeof c !== 'object')
|
|
108
|
+
continue;
|
|
109
|
+
const m = c;
|
|
110
|
+
const raw = m.id ?? m.chat_id;
|
|
111
|
+
const id = typeof raw === 'string'
|
|
112
|
+
? raw
|
|
113
|
+
: typeof raw === 'number' && Number.isFinite(raw)
|
|
114
|
+
? String(raw)
|
|
115
|
+
: key;
|
|
116
|
+
if (!id)
|
|
117
|
+
continue;
|
|
118
|
+
const title = (typeof m.title === 'string' && m.title) ||
|
|
119
|
+
(typeof m.name === 'string' && m.name) ||
|
|
120
|
+
'';
|
|
121
|
+
out.push({ id, title: title.trim() });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse one chat's history from `GET /api/v1/chats/<id>`. The live shape is
|
|
127
|
+
* `{chat:{history:{messages:{<id>:{role,content,...}}}}}`; an array and a flat
|
|
128
|
+
* `messages` are accepted too. Assistant nodes carry NO content in the live
|
|
129
|
+
* response, so those turns are simply absent (the DOM is the fallback).
|
|
130
|
+
*/
|
|
131
|
+
export function parseGlmHistory(json) {
|
|
132
|
+
const root = json;
|
|
133
|
+
if (!root || typeof root !== 'object')
|
|
134
|
+
return [];
|
|
135
|
+
let messages = root.messages;
|
|
136
|
+
if (!messages && root.chat && typeof root.chat === 'object') {
|
|
137
|
+
const chat = root.chat;
|
|
138
|
+
if (chat.history && typeof chat.history === 'object') {
|
|
139
|
+
messages = chat.history.messages;
|
|
140
|
+
}
|
|
141
|
+
else if (chat.messages) {
|
|
142
|
+
messages = chat.messages;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const arr = Array.isArray(messages)
|
|
146
|
+
? messages
|
|
147
|
+
: messages && typeof messages === 'object'
|
|
148
|
+
? Object.values(messages)
|
|
149
|
+
: null;
|
|
150
|
+
if (!arr)
|
|
151
|
+
return [];
|
|
152
|
+
const sorted = arr
|
|
153
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
154
|
+
.sort((a, b) => (Number(a.timestamp) || 0) - (Number(b.timestamp) || 0));
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const m of sorted) {
|
|
157
|
+
const roleStr = String(m.role ?? '').toLowerCase();
|
|
158
|
+
const isUser = roleStr === 'user' || roleStr === 'human';
|
|
159
|
+
const role = isUser ? 'user' : 'assistant';
|
|
160
|
+
let text = '';
|
|
161
|
+
if (typeof m.content === 'string')
|
|
162
|
+
text = m.content;
|
|
163
|
+
else if (typeof m.text === 'string')
|
|
164
|
+
text = m.text;
|
|
165
|
+
text = text.trim();
|
|
166
|
+
if (text)
|
|
167
|
+
out.push({ role, text });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// PURE inspection of z.ai's completion REQUEST body.
|
|
2
|
+
//
|
|
3
|
+
// WHY: GLM has no stable UI toggle for thinking / web search in the observed
|
|
4
|
+
// build — the flags travel in the request body that the SITE builds:
|
|
5
|
+
// features.enable_thinking, features.web_search, features.auto_web_search,
|
|
6
|
+
// features.preview_mode, features.flags[]
|
|
7
|
+
// (see test/fixtures/glm/completions-request.json — a real capture).
|
|
8
|
+
//
|
|
9
|
+
// Two of them MUST stay off, whatever the operator's thinking/search setting:
|
|
10
|
+
// - preview_mode / the `general_agent` flag turn on z.ai's OWN agent runtime,
|
|
11
|
+
// which emits its own tool calls and changes the answer format — that
|
|
12
|
+
// breaks the zames tool-call protocol;
|
|
13
|
+
// - image_generation likewise.
|
|
14
|
+
// So the provider VERIFIES the body it just sent and warns the operator
|
|
15
|
+
// instead of silently accepting a broken format.
|
|
16
|
+
//
|
|
17
|
+
// This module owns the knowledge; glm-browser.ts only wires it to the page.
|
|
18
|
+
/** Read the features from a parsed completion body (tolerant of shapes). */
|
|
19
|
+
export function readGlmBodyFeatures(body) {
|
|
20
|
+
const out = { flags: [] };
|
|
21
|
+
if (!body || typeof body !== 'object')
|
|
22
|
+
return out;
|
|
23
|
+
const b = body;
|
|
24
|
+
const f = b.features;
|
|
25
|
+
if (!f || typeof f !== 'object')
|
|
26
|
+
return out;
|
|
27
|
+
const feat = f;
|
|
28
|
+
if (typeof feat.enable_thinking === 'boolean') {
|
|
29
|
+
out.enableThinking = feat.enable_thinking;
|
|
30
|
+
}
|
|
31
|
+
if (typeof feat.web_search === 'boolean')
|
|
32
|
+
out.webSearch = feat.web_search;
|
|
33
|
+
if (typeof feat.auto_web_search === 'boolean') {
|
|
34
|
+
out.autoWebSearch = feat.auto_web_search;
|
|
35
|
+
}
|
|
36
|
+
if (typeof feat.preview_mode === 'boolean')
|
|
37
|
+
out.previewMode = feat.preview_mode;
|
|
38
|
+
if (typeof feat.image_generation === 'boolean') {
|
|
39
|
+
out.imageGeneration = feat.image_generation;
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(feat.flags)) {
|
|
42
|
+
out.flags = feat.flags.filter((x) => typeof x === 'string');
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* PURE: the UNSAFE mode names that are ON in this body. An empty array means
|
|
48
|
+
* the body is safe for the zames tool-call protocol.
|
|
49
|
+
*/
|
|
50
|
+
export function unsafeGlmModes(feat) {
|
|
51
|
+
const bad = [];
|
|
52
|
+
if (feat.previewMode)
|
|
53
|
+
bad.push('preview_mode');
|
|
54
|
+
if (feat.imageGeneration)
|
|
55
|
+
bad.push('image_generation');
|
|
56
|
+
if (feat.flags.includes('general_agent'))
|
|
57
|
+
bad.push('general_agent');
|
|
58
|
+
return bad;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* PURE: does the body match the operator's DESIRED thinking/search state?
|
|
62
|
+
* Used to report a mismatch (the site decides the final body, so a UI flip
|
|
63
|
+
* that did not take effect must be visible, not guessed).
|
|
64
|
+
*/
|
|
65
|
+
export function glmFlagsMismatch(feat, want) {
|
|
66
|
+
const out = [];
|
|
67
|
+
if (typeof feat.enableThinking === 'boolean') {
|
|
68
|
+
if (feat.enableThinking !== want.thinking)
|
|
69
|
+
out.push('thinking');
|
|
70
|
+
}
|
|
71
|
+
const ws = feat.webSearch === true || feat.autoWebSearch === true;
|
|
72
|
+
if (want.webSearch !== ws)
|
|
73
|
+
out.push('web_search');
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Extraction of the RAW model answer from z.ai's (GLM) network data.
|
|
2
|
+
//
|
|
3
|
+
// The DOM distorts the answer (markdown/LaTeX/link auto-detection), so —
|
|
4
|
+
// exactly like DeepSeek (net-capture.ts) and Qwen (qwen-net-capture.ts) — the
|
|
5
|
+
// answer the agent consumes is parsed from the intercepted SSE stream.
|
|
6
|
+
//
|
|
7
|
+
// The live shape (verified against a signed-in session, see
|
|
8
|
+
// test/fixtures/glm/answer-simple.sse):
|
|
9
|
+
//
|
|
10
|
+
// data: {"type":"chat:completion","data":{"delta_content":"...","phase":"thinking"}}
|
|
11
|
+
// data: {"type":"chat:completion","data":{"phase":"other","usage":{prompt_tokens,...}}}
|
|
12
|
+
// data: {"type":"chat:completion","data":{"delta_content":"PONG","phase":"answer"}}
|
|
13
|
+
// data: {"type":"chat:completion","data":{"phase":"done","done":true}}
|
|
14
|
+
//
|
|
15
|
+
// Other phases observed in a real stream and what they mean for the agent:
|
|
16
|
+
// thinking - the reasoning chain; NEVER part of the answer
|
|
17
|
+
// tool_call - z.ai's OWN tool call (delta_name/delta_arguments); z.ai
|
|
18
|
+
// runs it in its sandbox and sends back tool_response. This
|
|
19
|
+
// is NOT the zames protocol - the agent must not see it as
|
|
20
|
+
// an answer either, so the provider disables that runtime
|
|
21
|
+
// (preview_mode/agent flags, see glm-ui.ts) and this parser
|
|
22
|
+
// skips the phase.
|
|
23
|
+
// tool_response - the sandbox output of the step above
|
|
24
|
+
// answer - the answer text (concatenated)
|
|
25
|
+
// done - the terminal chunk
|
|
26
|
+
//
|
|
27
|
+
// `usage.prompt_tokens` is the closest thing z.ai exposes to "used context"
|
|
28
|
+
// (there is no accumulated_token_usage like DeepSeek's).
|
|
29
|
+
const NL = String.fromCharCode(10);
|
|
30
|
+
function parseDataLines(body) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const line of String(body || '').split(NL)) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed.startsWith('data:'))
|
|
35
|
+
continue;
|
|
36
|
+
const payload = trimmed.slice(5).trim();
|
|
37
|
+
if (!payload || payload === '[DONE]')
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const obj = JSON.parse(payload);
|
|
41
|
+
if (obj && typeof obj === 'object')
|
|
42
|
+
out.push(obj);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** The `data` object of a `chat:completion` chunk (the live envelope). */
|
|
49
|
+
function completionData(obj) {
|
|
50
|
+
if (obj.type !== 'chat:completion')
|
|
51
|
+
return null;
|
|
52
|
+
const d = obj.data;
|
|
53
|
+
return d && typeof d === 'object' ? d : null;
|
|
54
|
+
}
|
|
55
|
+
/** True when the chunk belongs to a non-answer phase (reasoning/tools). */
|
|
56
|
+
function isNonAnswerPhase(phase) {
|
|
57
|
+
if (typeof phase !== 'string')
|
|
58
|
+
return false;
|
|
59
|
+
return /think|tool|reason/i.test(phase);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Assemble the answer text from the GLM SSE stream. Only chunks whose phase
|
|
63
|
+
* is exactly `answer` contribute: the reasoning (`thinking`) and z.ai's own
|
|
64
|
+
* tool traffic (`tool_call` / `tool_response`) must never leak into the
|
|
65
|
+
* answer, or the agent would treat them as a command.
|
|
66
|
+
*
|
|
67
|
+
* An OpenAI-compatible `choices[].delta.content` shape is also accepted as a
|
|
68
|
+
* safety net (the same fallback Qwen's parser has).
|
|
69
|
+
*/
|
|
70
|
+
export function extractFromSse(body) {
|
|
71
|
+
let out = '';
|
|
72
|
+
for (const obj of parseDataLines(body)) {
|
|
73
|
+
const d = completionData(obj);
|
|
74
|
+
if (d) {
|
|
75
|
+
const phase = d.phase;
|
|
76
|
+
if (typeof phase === 'string' && phase !== 'answer')
|
|
77
|
+
continue;
|
|
78
|
+
const content = d.delta_content;
|
|
79
|
+
if (typeof content === 'string' && content)
|
|
80
|
+
out += content;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// OpenAI-compatible fallback.
|
|
84
|
+
const choices = obj.choices;
|
|
85
|
+
if (!Array.isArray(choices))
|
|
86
|
+
continue;
|
|
87
|
+
for (const ch of choices) {
|
|
88
|
+
if (!ch || typeof ch !== 'object')
|
|
89
|
+
continue;
|
|
90
|
+
const c = ch;
|
|
91
|
+
const delta = c.delta && typeof c.delta === 'object'
|
|
92
|
+
? c.delta
|
|
93
|
+
: c.message && typeof c.message === 'object'
|
|
94
|
+
? c.message
|
|
95
|
+
: null;
|
|
96
|
+
if (!delta)
|
|
97
|
+
continue;
|
|
98
|
+
if (isNonAnswerPhase(delta.phase))
|
|
99
|
+
continue;
|
|
100
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
101
|
+
out += delta.content;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** A plain JSON body (a non-streamed response): pick the answer only. */
|
|
108
|
+
export function extractFromJson(body) {
|
|
109
|
+
let obj;
|
|
110
|
+
try {
|
|
111
|
+
obj = JSON.parse(body);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
return pickAnswer(obj);
|
|
117
|
+
}
|
|
118
|
+
function pickAnswer(node) {
|
|
119
|
+
if (node == null)
|
|
120
|
+
return '';
|
|
121
|
+
if (typeof node === 'string')
|
|
122
|
+
return node;
|
|
123
|
+
if (Array.isArray(node))
|
|
124
|
+
return node.map(pickAnswer).join('');
|
|
125
|
+
if (typeof node !== 'object')
|
|
126
|
+
return '';
|
|
127
|
+
const obj = node;
|
|
128
|
+
if (typeof obj.phase === 'string' && obj.phase !== 'answer') {
|
|
129
|
+
return '';
|
|
130
|
+
}
|
|
131
|
+
if (typeof obj.delta_content === 'string')
|
|
132
|
+
return obj.delta_content;
|
|
133
|
+
if (Array.isArray(obj.choices)) {
|
|
134
|
+
let out = '';
|
|
135
|
+
for (const ch of obj.choices) {
|
|
136
|
+
if (!ch || typeof ch !== 'object')
|
|
137
|
+
continue;
|
|
138
|
+
const c = ch;
|
|
139
|
+
const delta = c.delta && typeof c.delta === 'object'
|
|
140
|
+
? c.delta
|
|
141
|
+
: c.message && typeof c.message === 'object'
|
|
142
|
+
? c.message
|
|
143
|
+
: c;
|
|
144
|
+
if (isNonAnswerPhase(delta.phase))
|
|
145
|
+
continue;
|
|
146
|
+
const content = delta.content;
|
|
147
|
+
if (typeof content === 'string')
|
|
148
|
+
out += content;
|
|
149
|
+
}
|
|
150
|
+
if (out)
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
for (const key of ['content', 'text', 'answer']) {
|
|
154
|
+
const v = obj[key];
|
|
155
|
+
if (typeof v === 'string' && v)
|
|
156
|
+
return v;
|
|
157
|
+
}
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
/** A universal attempt: first SSE, then regular JSON. */
|
|
161
|
+
export function extractAnswer(body) {
|
|
162
|
+
const sse = extractFromSse(body);
|
|
163
|
+
if (sse)
|
|
164
|
+
return sse;
|
|
165
|
+
return extractFromJson(body);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The CONTEXT size (tokens) from `usage.prompt_tokens` - the prompt size for
|
|
169
|
+
* the turn, the same idea as DeepSeek's accumulated_token_usage. Falls back to
|
|
170
|
+
* `total_tokens`. Null when the body carries no usage.
|
|
171
|
+
*/
|
|
172
|
+
export function extractTokenUsage(body) {
|
|
173
|
+
let lastPrompt = null;
|
|
174
|
+
let lastTotal = null;
|
|
175
|
+
for (const obj of parseDataLines(body)) {
|
|
176
|
+
const d = completionData(obj);
|
|
177
|
+
const usage = d ? d.usage : obj.usage;
|
|
178
|
+
if (!usage || typeof usage !== 'object')
|
|
179
|
+
continue;
|
|
180
|
+
const u = usage;
|
|
181
|
+
if (typeof u.prompt_tokens === 'number' && Number.isFinite(u.prompt_tokens))
|
|
182
|
+
lastPrompt = u.prompt_tokens;
|
|
183
|
+
if (typeof u.total_tokens === 'number' && Number.isFinite(u.total_tokens))
|
|
184
|
+
lastTotal = u.total_tokens;
|
|
185
|
+
}
|
|
186
|
+
if (lastPrompt !== null)
|
|
187
|
+
return lastPrompt;
|
|
188
|
+
if (lastTotal !== null)
|
|
189
|
+
return lastTotal;
|
|
190
|
+
try {
|
|
191
|
+
const obj = JSON.parse(body);
|
|
192
|
+
const usage = obj?.usage;
|
|
193
|
+
if (usage) {
|
|
194
|
+
if (typeof usage.prompt_tokens === 'number')
|
|
195
|
+
return usage.prompt_tokens;
|
|
196
|
+
if (typeof usage.total_tokens === 'number')
|
|
197
|
+
return usage.total_tokens;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/** True when a chunk marks the end of the stream (`done: true`). */
|
|
204
|
+
export function isDoneChunk(obj) {
|
|
205
|
+
const d = completionData(obj);
|
|
206
|
+
if (!d)
|
|
207
|
+
return false;
|
|
208
|
+
return d.done === true || d.phase === 'done';
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* True when the stream ENDED with no answer content - the model finished with
|
|
212
|
+
* only reasoning (or only z.ai's own tool traffic). The caller then retries
|
|
213
|
+
* instead of waiting out the whole timeout. Mirrors Qwen's
|
|
214
|
+
* isFinishedWithoutAnswer.
|
|
215
|
+
*/
|
|
216
|
+
export function isFinishedWithoutAnswer(body) {
|
|
217
|
+
const b = String(body || '');
|
|
218
|
+
if (!b)
|
|
219
|
+
return false;
|
|
220
|
+
let finished = false;
|
|
221
|
+
for (const obj of parseDataLines(b)) {
|
|
222
|
+
if (isDoneChunk(obj))
|
|
223
|
+
finished = true;
|
|
224
|
+
}
|
|
225
|
+
if (!finished)
|
|
226
|
+
return false;
|
|
227
|
+
return extractFromSse(b) === '';
|
|
228
|
+
}
|
|
229
|
+
/** True when the body carries actual answer text in a chunk. */
|
|
230
|
+
function hasAnswerContent(body) {
|
|
231
|
+
for (const obj of parseDataLines(body)) {
|
|
232
|
+
const d = completionData(obj);
|
|
233
|
+
if (d && d.phase === 'answer') {
|
|
234
|
+
if (typeof d.delta_content === 'string' && d.delta_content.trim()) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const choices = obj.choices;
|
|
239
|
+
if (!Array.isArray(choices))
|
|
240
|
+
continue;
|
|
241
|
+
for (const ch of choices) {
|
|
242
|
+
if (!ch || typeof ch !== 'object')
|
|
243
|
+
continue;
|
|
244
|
+
const c = ch;
|
|
245
|
+
const delta = c.delta;
|
|
246
|
+
if (!delta || isNonAnswerPhase(delta.phase))
|
|
247
|
+
continue;
|
|
248
|
+
if (typeof delta.content === 'string' && delta.content.trim())
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
// The classifiers below run against the RAW body, so a phrase like `please try
|
|
255
|
+
// again later` would also match a NORMAL answer (the model explaining retry
|
|
256
|
+
// logic) and make ask() back off on a good turn. A real notice arrives in an
|
|
257
|
+
// ERROR payload (`type: "error"` / a code) or as a bare notice with NO answer
|
|
258
|
+
// content; answer text lives in `phase: "answer"` chunks.
|
|
259
|
+
const ERROR_PAYLOAD_RE = /"type"\s*:\s*"error"|"error"\s*:|\{\s*"code"\s*:/i;
|
|
260
|
+
/** True when the RAW body is z.ai's rate-limit notice. */
|
|
261
|
+
/**
|
|
262
|
+
* True when z.ai answers that the ACCOUNT quota is exhausted
|
|
263
|
+
* (`insufficient_quota`, HTTP 429). Distinct from a transient rate limit:
|
|
264
|
+
* it does not clear in minutes, so ask() must fail at once with a clear
|
|
265
|
+
* message instead of retrying for the whole budget.
|
|
266
|
+
*/
|
|
267
|
+
export function isQuotaExhaustedText(body) {
|
|
268
|
+
return /insufficient_quota|quota (?:exceeded|exhausted)|exceeded your current quota/i.test(String(body || ''));
|
|
269
|
+
}
|
|
270
|
+
export function isRateLimitText(body) {
|
|
271
|
+
const b = String(body || '');
|
|
272
|
+
if (!b)
|
|
273
|
+
return false;
|
|
274
|
+
if (/"code"\s*:\s*"(?:rate_limit|quota_limit)"/i.test(b))
|
|
275
|
+
return true;
|
|
276
|
+
if (!ERROR_PAYLOAD_RE.test(b) && hasAnswerContent(b))
|
|
277
|
+
return false;
|
|
278
|
+
// A generic "please try again later" belongs to the BUSY family; a rate
|
|
279
|
+
// limit has its own wording (or a code matched above).
|
|
280
|
+
return /too many requests|rate limit|too frequent|high demand|слишком часто/i.test(b);
|
|
281
|
+
}
|
|
282
|
+
/** True when the RAW body is a transient server-busy notice. */
|
|
283
|
+
export function isServerBusyText(body) {
|
|
284
|
+
const b = String(body || '');
|
|
285
|
+
if (!b)
|
|
286
|
+
return false;
|
|
287
|
+
if (isRateLimitText(b))
|
|
288
|
+
return false;
|
|
289
|
+
if (!ERROR_PAYLOAD_RE.test(b) && hasAnswerContent(b))
|
|
290
|
+
return false;
|
|
291
|
+
return /server is busy|service unavailable|please try again later|high demand/i.test(b);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* True when the site says the GUEST quota is over. It cannot be waited out
|
|
295
|
+
* (it needs a login), so ask() fails fast with a "sign in" message instead of
|
|
296
|
+
* burning the retry budget.
|
|
297
|
+
*/
|
|
298
|
+
export function isGuestLimitText(body) {
|
|
299
|
+
const b = String(body || '');
|
|
300
|
+
if (!b)
|
|
301
|
+
return false;
|
|
302
|
+
// A chat-create response contains the OLD guest email (`guest-123@guest.com`)
|
|
303
|
+
// and the word `guest` in the chat id/title. A SIGNED-IN session therefore
|
|
304
|
+
// looked like a guest limit (a real false positive: the operator was told
|
|
305
|
+
// "working as a guest" while the session was active). The notice must be an
|
|
306
|
+
// ERROR payload or an EXPLICIT limit sentence, never a bare `guest` word.
|
|
307
|
+
const explicit = /guest (?:chat |daily |usage )?limit|reached the guest|log ?in to continue|sign ?in to continue|гостевой лимит/i.test(b);
|
|
308
|
+
if (!explicit)
|
|
309
|
+
return false;
|
|
310
|
+
if (!ERROR_PAYLOAD_RE.test(b) && hasAnswerContent(b))
|
|
311
|
+
return false;
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* True when the raw body is z.ai's Aliyun captcha challenge. The site answers
|
|
316
|
+
* a POST with a "security verification" page instead of a stream, which the
|
|
317
|
+
* agent cannot solve - the operator must sign in in the browser window.
|
|
318
|
+
*/
|
|
319
|
+
export function isCaptchaText(body) {
|
|
320
|
+
const b = String(body || '');
|
|
321
|
+
if (!b)
|
|
322
|
+
return false;
|
|
323
|
+
return /security verification|drag the slider|CertifyId/i.test(b);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Read the chat id off the RAW body (the chat list / history JSON carries it as
|
|
327
|
+
* `id`, and an SSE chunk may echo `chat_id`).
|
|
328
|
+
*/
|
|
329
|
+
export function extractChatId(body) {
|
|
330
|
+
const b = String(body || '');
|
|
331
|
+
if (!b)
|
|
332
|
+
return null;
|
|
333
|
+
const m1 = b.match(/"chat_id"\s*:\s*"([0-9a-f-]{36})"/i);
|
|
334
|
+
if (m1)
|
|
335
|
+
return m1[1];
|
|
336
|
+
const m2 = b.match(/"id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i);
|
|
337
|
+
return m2 ? m2[1] : null;
|
|
338
|
+
}
|