magpie-gemini-web 0.3.0 → 0.3.2
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/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/backend.mjs +26 -4
- package/lib/cite.mjs +166 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -37,8 +37,9 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
|
|
|
37
37
|
| `allowTools` | `false` | Allows tool calls (experimental). When off, requests with tools get a 400 |
|
|
38
38
|
| `allowImages` | `false` | Allows image input |
|
|
39
39
|
| `baseURL` | `http://127.0.0.1:8083/v1` | Default backend URL |
|
|
40
|
-
| `contextLimit` / `outputLimit` | `
|
|
40
|
+
| `contextLimit` / `outputLimit` | `1000000` / `8192` | Context and output limits reported to clients |
|
|
41
41
|
| `requestTimeoutMs` / `modelTimeoutMs` | `240000` / `10000` | Timeouts in ms for requests and for fetching the model list |
|
|
42
|
+
| `stripCitations` | `true` | Removes the citation markers Gemini adds to replies, `[cite: 1]` and `[cite_start]`, tool call arguments included. Set it to `false` while Gemini edits code that contains such text itself |
|
|
42
43
|
|
|
43
44
|
## Thinking effort
|
|
44
45
|
|
package/README.md
CHANGED
|
@@ -37,8 +37,9 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
|
|
|
37
37
|
| `allowTools` | `false` | 允许工具调用(实验性)。关闭时带工具的请求会返回 400 |
|
|
38
38
|
| `allowImages` | `false` | 允许图片输入 |
|
|
39
39
|
| `baseURL` | `http://127.0.0.1:8083/v1` | 默认后端地址 |
|
|
40
|
-
| `contextLimit` / `outputLimit` | `
|
|
40
|
+
| `contextLimit` / `outputLimit` | `1000000` / `8192` | 向客户端声明的上下文 / 输出上限 |
|
|
41
41
|
| `requestTimeoutMs` / `modelTimeoutMs` | `240000` / `10000` | 请求 / 拉取模型列表的超时(毫秒) |
|
|
42
|
+
| `stripCitations` | `true` | 去掉 Gemini 在回复里加的引用标记 `[cite: 1]`、`[cite_start]`(包括工具调用参数)。让 Gemini 编辑本身含有这类文本的代码时,可以设为 `false` |
|
|
42
43
|
|
|
43
44
|
## 思考档位
|
|
44
45
|
|
package/lib/backend.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { citeStream, citeJSON } from './cite.mjs';
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_BASE = 'http://127.0.0.1:8083/v1';
|
|
2
4
|
|
|
3
5
|
const MEDIA_SPECIAL = /(?:^|[-_])(image|music|canvas|video|research)(?:[-_]|$)/i;
|
|
@@ -23,7 +25,8 @@ const KNOWN_OPTIONS = new Set([
|
|
|
23
25
|
'contextLimit',
|
|
24
26
|
'outputLimit',
|
|
25
27
|
'requestTimeoutMs',
|
|
26
|
-
'modelTimeoutMs'
|
|
28
|
+
'modelTimeoutMs',
|
|
29
|
+
'stripCitations'
|
|
27
30
|
]);
|
|
28
31
|
|
|
29
32
|
function makeOpenAIError(status, message, extraHeaders = {}) {
|
|
@@ -127,7 +130,7 @@ export function settings(options = {}) {
|
|
|
127
130
|
allowImages = options.allowImages;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
let contextLimit =
|
|
133
|
+
let contextLimit = 1000000;
|
|
131
134
|
if (options.contextLimit !== undefined) {
|
|
132
135
|
if (!Number.isInteger(options.contextLimit) || options.contextLimit <= 0) {
|
|
133
136
|
throw new Error('Invalid option contextLimit: must be a positive integer');
|
|
@@ -163,6 +166,16 @@ export function settings(options = {}) {
|
|
|
163
166
|
modelTimeoutMs = options.modelTimeoutMs;
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
// Gemini's citation markers come out of replies unless this is false: text that means them,
|
|
170
|
+
// as code about them does, would lose them too.
|
|
171
|
+
let stripCitations = true;
|
|
172
|
+
if (options.stripCitations !== undefined) {
|
|
173
|
+
if (typeof options.stripCitations !== 'boolean') {
|
|
174
|
+
throw new Error('Invalid option stripCitations: must be a boolean');
|
|
175
|
+
}
|
|
176
|
+
stripCitations = options.stripCitations;
|
|
177
|
+
}
|
|
178
|
+
|
|
166
179
|
return {
|
|
167
180
|
baseURL,
|
|
168
181
|
allowTools,
|
|
@@ -170,7 +183,8 @@ export function settings(options = {}) {
|
|
|
170
183
|
contextLimit,
|
|
171
184
|
outputLimit,
|
|
172
185
|
requestTimeoutMs,
|
|
173
|
-
modelTimeoutMs
|
|
186
|
+
modelTimeoutMs,
|
|
187
|
+
stripCitations
|
|
174
188
|
};
|
|
175
189
|
}
|
|
176
190
|
|
|
@@ -500,7 +514,15 @@ export async function forward(input, init, getAuth, opts) {
|
|
|
500
514
|
respHeaders.set('X-Magpie-Sign-In', 'expired');
|
|
501
515
|
}
|
|
502
516
|
|
|
503
|
-
|
|
517
|
+
// Gemini's citation markers come out of a chat reply, streamed or whole (see cite.mjs).
|
|
518
|
+
let respBody = upstreamRes.body;
|
|
519
|
+
if (opts.stripCitations && incomingUrl === chatUrl && upstreamRes.ok && respBody) {
|
|
520
|
+
const type = upstreamRes.headers.get('content-type') || '';
|
|
521
|
+
if (type.includes('text/event-stream')) respBody = citeStream(respBody);
|
|
522
|
+
else if (type.includes('json')) respBody = citeJSON(await upstreamRes.text());
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return new Response(respBody, {
|
|
504
526
|
status: upstreamRes.status,
|
|
505
527
|
statusText: upstreamRes.statusText,
|
|
506
528
|
headers: respHeaders
|
package/lib/cite.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Gemini's web app marks the sentences it grounds in a source with [cite_start] … [cite: 1, 2],
|
|
2
|
+
// which its page turns into citation chips. The backend hands the raw text on, so an agent's
|
|
3
|
+
// replies, and the files its tools write, fill with them; the context an agent sends counts as
|
|
4
|
+
// the source. They are taken out of every chat completion here, streamed or whole.
|
|
5
|
+
|
|
6
|
+
const MARKER = /[ \t]*\[cite:\s*\d+(?:\s*,\s*\d+)*\s*\]|\[cite_(?:start|end)\]/g;
|
|
7
|
+
// The end of a piece that may be the start of a marker the next piece finishes, with the blanks
|
|
8
|
+
// before it, which go with a [cite: n].
|
|
9
|
+
const PARTIAL = /[ \t]*(?:\[(?:c(?:i(?:t(?:e(?:_(?:s(?:t(?:a(?:r(?:t)?)?)?)?|e(?:n(?:d)?)?)?|:[\d,\s]*)?)?)?)?)?)?$/;
|
|
10
|
+
|
|
11
|
+
export function stripCites(text) {
|
|
12
|
+
return typeof text === 'string' ? text.replace(MARKER, '') : text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// CiteFilter strips one streamed text: push gives what can go now, holding back an end that may
|
|
16
|
+
// be a marker's start; flush gives what it held.
|
|
17
|
+
export class CiteFilter {
|
|
18
|
+
held = '';
|
|
19
|
+
|
|
20
|
+
push(piece) {
|
|
21
|
+
const text = stripCites(this.held + piece);
|
|
22
|
+
const tail = PARTIAL.exec(text)[0];
|
|
23
|
+
this.held = tail;
|
|
24
|
+
return text.slice(0, text.length - tail.length);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
flush() {
|
|
28
|
+
const rest = this.held;
|
|
29
|
+
this.held = '';
|
|
30
|
+
return rest;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The streamed texts of a chat completion chunk: each choice's content and reasoning, and
|
|
35
|
+
// each tool call's arguments.
|
|
36
|
+
function* texts(chunk) {
|
|
37
|
+
for (const choice of Array.isArray(chunk?.choices) ? chunk.choices : []) {
|
|
38
|
+
const d = choice?.delta;
|
|
39
|
+
if (!d || typeof d !== 'object') continue;
|
|
40
|
+
const at = choice.index ?? 0;
|
|
41
|
+
for (const field of ['content', 'reasoning_content', 'reasoning']) {
|
|
42
|
+
if (typeof d[field] === 'string') yield { key: `${at}:${field}`, get: () => d[field], set: v => { d[field] = v; } };
|
|
43
|
+
}
|
|
44
|
+
for (const call of Array.isArray(d.tool_calls) ? d.tool_calls : []) {
|
|
45
|
+
const fn = call?.function;
|
|
46
|
+
if (typeof fn?.arguments === 'string') {
|
|
47
|
+
yield { key: `${at}:tool:${call.index ?? 0}`, get: () => fn.arguments, set: v => { fn.arguments = v; } };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// heldChunk is a chunk carrying what the filters still hold, for the end of a stream, made
|
|
54
|
+
// after last, the stream's last chunk.
|
|
55
|
+
function heldChunk(filters, last) {
|
|
56
|
+
const choices = new Map();
|
|
57
|
+
for (const [key, f] of filters) {
|
|
58
|
+
const rest = f.flush();
|
|
59
|
+
if (!rest) continue;
|
|
60
|
+
const [at, kind, call] = key.split(':');
|
|
61
|
+
const index = Number(at);
|
|
62
|
+
if (!choices.has(index)) choices.set(index, { index, delta: {} });
|
|
63
|
+
const delta = choices.get(index).delta;
|
|
64
|
+
if (kind === 'tool') (delta.tool_calls ??= []).push({ index: Number(call), function: { arguments: rest } });
|
|
65
|
+
else delta[kind] = rest;
|
|
66
|
+
}
|
|
67
|
+
if (!choices.size) return null;
|
|
68
|
+
const { id, object, created, model } = last || {};
|
|
69
|
+
return { id, object, created, model, choices: [...choices.values()] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// mergeHeld adds what the filters hold to chunk, in the choices it names (others get their own).
|
|
73
|
+
function mergeHeld(chunk, filters) {
|
|
74
|
+
const held = heldChunk(filters, chunk);
|
|
75
|
+
for (const h of held?.choices ?? []) {
|
|
76
|
+
const c = chunk.choices.find(x => (x?.index ?? 0) === h.index);
|
|
77
|
+
if (!c) {
|
|
78
|
+
chunk.choices.push(h);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const d = (c.delta ??= {});
|
|
82
|
+
for (const [field, rest] of Object.entries(h.delta)) {
|
|
83
|
+
if (field !== 'tool_calls') {
|
|
84
|
+
d[field] = (typeof d[field] === 'string' ? d[field] : '') + rest;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const call of rest) {
|
|
88
|
+
const same = (d.tool_calls ??= []).find(x => (x?.index ?? 0) === call.index);
|
|
89
|
+
if (same) (same.function ??= {}).arguments = (same.function.arguments ?? '') + call.function.arguments;
|
|
90
|
+
else d.tool_calls.push(call);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// citeStream strips the markers from an SSE stream of chat completion chunks. An event that
|
|
97
|
+
// isn't a chunk goes on as it came; what the filters hold goes out before the stream ends.
|
|
98
|
+
export function citeStream(body) {
|
|
99
|
+
const filters = new Map();
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
const encoder = new TextEncoder();
|
|
102
|
+
let buffer = '';
|
|
103
|
+
let last = null;
|
|
104
|
+
|
|
105
|
+
const event = data => encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
|
106
|
+
const flushAll = controller => {
|
|
107
|
+
const chunk = heldChunk(filters, last);
|
|
108
|
+
if (chunk) controller.enqueue(event(chunk));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handle = (raw, controller) => {
|
|
112
|
+
const data = /^data: ?(.*)$/s.exec(raw)?.[1];
|
|
113
|
+
if (data === undefined) return controller.enqueue(encoder.encode(raw + '\n\n'));
|
|
114
|
+
if (data.trim() === '[DONE]') {
|
|
115
|
+
flushAll(controller);
|
|
116
|
+
return controller.enqueue(encoder.encode(raw + '\n\n'));
|
|
117
|
+
}
|
|
118
|
+
let chunk;
|
|
119
|
+
try {
|
|
120
|
+
chunk = JSON.parse(data);
|
|
121
|
+
} catch {
|
|
122
|
+
return controller.enqueue(encoder.encode(raw + '\n\n'));
|
|
123
|
+
}
|
|
124
|
+
last = chunk;
|
|
125
|
+
for (const t of texts(chunk)) {
|
|
126
|
+
if (!filters.has(t.key)) filters.set(t.key, new CiteFilter());
|
|
127
|
+
t.set(filters.get(t.key).push(t.get()));
|
|
128
|
+
}
|
|
129
|
+
// a client may take the chunk that finishes a choice for its end: what is held goes in it
|
|
130
|
+
if (Array.isArray(chunk.choices) && chunk.choices.some(c => c?.finish_reason)) mergeHeld(chunk, filters);
|
|
131
|
+
controller.enqueue(event(chunk));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return body.pipeThrough(new TransformStream({
|
|
135
|
+
transform(piece, controller) {
|
|
136
|
+
buffer += decoder.decode(piece, { stream: true });
|
|
137
|
+
const events = buffer.split(/\r?\n\r?\n/);
|
|
138
|
+
buffer = events.pop();
|
|
139
|
+
for (const raw of events) if (raw) handle(raw, controller);
|
|
140
|
+
},
|
|
141
|
+
flush(controller) {
|
|
142
|
+
buffer += decoder.decode();
|
|
143
|
+
if (buffer.trim()) handle(buffer.replace(/\s+$/, ''), controller);
|
|
144
|
+
flushAll(controller);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// citeJSON strips the markers from a whole chat completion.
|
|
150
|
+
export function citeJSON(text) {
|
|
151
|
+
let body;
|
|
152
|
+
try {
|
|
153
|
+
body = JSON.parse(text);
|
|
154
|
+
} catch {
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
|
|
158
|
+
const m = choice?.message;
|
|
159
|
+
if (!m || typeof m !== 'object') continue;
|
|
160
|
+
for (const field of ['content', 'reasoning_content', 'reasoning']) m[field] = stripCites(m[field]);
|
|
161
|
+
for (const call of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
|
|
162
|
+
if (call?.function) call.function.arguments = stripCites(call.function.arguments);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return JSON.stringify(body);
|
|
166
|
+
}
|