magpie-ima-local-auth 0.2.0 → 0.3.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/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/docs/evaluation.md +20 -2
- package/docs/internals.md +8 -0
- package/index.mjs +42 -0
- package/package.json +1 -1
- package/src/context.mjs +1 -1
- package/src/models.mjs +1 -1
- package/src/tools.mjs +325 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
- **工具调用**:新增 `src/tools.mjs` 文本工具协议。把 OpenAI tools 写成提示,解析模型输出
|
|
6
|
+
`{"tool_calls":[...]}`,转成 OpenAI `tool_calls`;支持 `role:"tool"` 结果回灌、流式/非流式;
|
|
7
|
+
模型条目声明 `toolcall: true`。实测 `task_plan` / `subagent_spawn` 5/5,DSH `write` 端到端成功。
|
|
8
|
+
- 工具模式每个请求用全新 ima session + 完整历史,避免单问 session 丢工具上下文。
|
|
9
|
+
- README / docs 修正:ima 不是裸 LLM,但正常工具契约可用;sovereign 破甲会触发注入检测,不要用。
|
|
10
|
+
|
|
3
11
|
## 0.2.0 — 2026-10-28
|
|
4
12
|
|
|
5
13
|
- **修复客户端指纹**:`x-ima-cookie` 的 `CLIENT-TYPE` 改为 ima 2.6.15 实际值 `256021`。没有它,服务端静默退回旧目录(DeepSeek V3.2 / HY 2.0,Copilot 只有默认模型);有了它,免费目录变为 **DeepSeek-V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash(各带 Think 变体)**,Copilot `scene:1` 也能读到完整积分目录。
|
package/README.md
CHANGED
|
@@ -96,10 +96,10 @@ magpie plugin list
|
|
|
96
96
|
不用。登录导入之后,magpie 用 refresh token 续期;客户端只在导入那一下需要存在。
|
|
97
97
|
|
|
98
98
|
**Q:为什么没有 Copilot 的模型?**
|
|
99
|
-
Copilot 是 ima 的云端 agent(`robot_type=15
|
|
99
|
+
Copilot 是 ima 的云端 agent(`robot_type=15`),不是模型通道。它的 V4.1 / Hy3 / Hy4 / GLM / MiMo / MiniMax / Kimi 目录确实存在、也支持流式思考;插件按“模型供应商”定位,不把 agent 包装成模型。
|
|
100
100
|
|
|
101
101
|
**Q:支持工具调用 / 图片吗?**
|
|
102
|
-
|
|
102
|
+
**工具调用支持**:ima 的问答接口没有原生 tools 字段,插件用文本工具协议模拟——把调用方的工具表写进问题,模型输出 `{"tool_calls":[...]}`,插件再转成 OpenAI 的 `tool_calls`;工具结果会作为 `role:"tool"` 回灌。已用 `task_plan` / `subagent_spawn` 和 DSH 的 `todo_write` / `write` 实测。图片**不支持**(`attachment: false`)。
|
|
103
103
|
|
|
104
104
|
**Q:上下文窗口是多少?**
|
|
105
105
|
ima 的模型目录不下发窗口大小。插件把 `limit.context` 留 0,让 magpie 用它自己的模型库;不写一个猜的数字。
|
package/docs/evaluation.md
CHANGED
|
@@ -106,6 +106,23 @@ V3.2;把客户端真实的 `CLIENT-TYPE=256021` 带上后,同一 `official_3
|
|
|
106
106
|
服务端降级,不是 UI 造假。
|
|
107
107
|
- Copilot 的 V4.1(`official_paid_ep-i2azx8ko`)也是真 V4.1;两条通道不同,但都真实存在。
|
|
108
108
|
|
|
109
|
+
## 工具协议与系统提示词测试(2026-10-28)
|
|
110
|
+
|
|
111
|
+
关键修正:ima 的模型不是裸 LLM(有自己的身份/安全包装),但**正常工具契约可以工作**;
|
|
112
|
+
sovereign 破甲反而触发它的注入检测,不要用。
|
|
113
|
+
|
|
114
|
+
| 测试 | 结果 |
|
|
115
|
+
|---|---|
|
|
116
|
+
| 免费 V4.1 + `task_plan` / `subagent_spawn` 工具表 | **5/5 输出合法裸 JSON `tool_calls`**(非流式);流式同样返回 tool_calls 帧 |
|
|
117
|
+
| Copilot V4.1 + 同一工具表 | 同样输出合法 `tool_calls`(`task_plan` 参数补全了 goal/steps) |
|
|
118
|
+
| DSH headless + `write` 工具 | 模型实际调用 DSH 的 `write`,文件被创建并回复 `DSH-BASH-OK` |
|
|
119
|
+
| 工具结果回灌 | assistant tool_calls + role=tool 的消息被拼回提示,模型输出最终正文 |
|
|
120
|
+
| sovereign 破甲 + 强制改身份 | 免费/Copilot 都识别为注入并拒绝,**不要用破甲** |
|
|
121
|
+
|
|
122
|
+
实现:`src/tools.mjs` 把 OpenAI tools 写成提示,解析 `{"tool_calls":[...]}` 转成
|
|
123
|
+
OpenAI tool_calls;`toolcall: true`;流式工具请求先攒后发(工具 JSON 必须完整)。
|
|
124
|
+
工具调用不是 ima 原生能力,是文本协议模拟,但已达到正常 API 的可用形态。
|
|
125
|
+
|
|
109
126
|
## 与 CodeBuddy / 其它免费源的对比
|
|
110
127
|
|
|
111
128
|
| 免费源 | 档位 | 接入成本 | 结论 |
|
|
@@ -126,7 +143,8 @@ V3.2;把客户端真实的 `CLIENT-TYPE=256021` 带上后,同一 `official_3
|
|
|
126
143
|
|
|
127
144
|
- **单问 session**:`assistant/qa` 一次只收一个问题,没有 messages 数组;插件用 session
|
|
128
145
|
保持多轮,但 system prompt 只在 session 第一问里发。
|
|
129
|
-
-
|
|
146
|
+
- **工具调用是文本协议模拟**:ima 没有原生 tools 字段;插件把工具表写进提示,模型输出
|
|
147
|
+
`{"tool_calls":[...]}` 后转成 OpenAI `tool_calls`,工具由 DSH 执行。平台升级后需重新验证。
|
|
130
148
|
- **没有 token 计数**:ima 不返回 usage;插件不按字符数编,返回 0。
|
|
131
149
|
- **没有上下文窗口**:模型目录不下发;`limit.context` 留 0,交给 magpie 的模型库。
|
|
132
150
|
- **私有接口 + 指纹**:`CLIENT-TYPE=256021` 是抓包值,客户端升级可能变;可用
|
|
@@ -137,7 +155,7 @@ V3.2;把客户端真实的 `CLIENT-TYPE=256021` 带上后,同一 `official_3
|
|
|
137
155
|
## 已完成的验收
|
|
138
156
|
|
|
139
157
|
- `node scripts/check.mjs`:通过 29 · 警告 0 · 失败 0
|
|
140
|
-
- `node --test`:
|
|
158
|
+
- `node --test`:17/17
|
|
141
159
|
- magpie 0.1.1814 安装后 `ima (ima, 8 models) signed in as 001ab…`
|
|
142
160
|
- 网关:`POST /v1/chat/completions`(`ima/deepseek-v4.1-flash`)返回 `GATEWAY-V41`;
|
|
143
161
|
流式返回 role/content/`finish_reason:"stop"`;思考模型返回 `reasoning_content`。
|
package/docs/internals.md
CHANGED
|
@@ -97,6 +97,14 @@ HEARTBEAT
|
|
|
97
97
|
V4.1-Flash `official_paid_ep-i2azx8ko`);但 Copilot 走 `robot_type=15`,是 ima 的云端
|
|
98
98
|
agent,所以默认不列。
|
|
99
99
|
|
|
100
|
+
### 5.1 工具调用协议
|
|
101
|
+
|
|
102
|
+
ima 没有 OpenAI 的 `tools` 字段。`src/tools.mjs` 把工具表写进问题,要求模型输出
|
|
103
|
+
`{"tool_calls":[{"name":"...","arguments":{...}}]}`;插件解析后转成 OpenAI `tool_calls`。
|
|
104
|
+
工具结果(`role:"tool"`)会被拼回下一次问题。已实测 `task_plan` / `subagent_spawn` 5/5、
|
|
105
|
+
DSH 的 `write` 端到端成功。sovereign 破甲不要用——它反而触发 ima 的注入检测,正常工具契约
|
|
106
|
+
就够了。工具模式每个请求开新 ima session,避免单问 session 丢工具上下文。
|
|
107
|
+
|
|
100
108
|
## 6. 被否掉的方案
|
|
101
109
|
|
|
102
110
|
- **云端 cookie 直连**:magpie-ima-auth 走的就是它;它把 ima 的 agent 会话包装成 chat,
|
package/index.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
transcript,
|
|
34
34
|
} from "./src/chat.mjs"
|
|
35
35
|
import { buildModels } from "./src/models.mjs"
|
|
36
|
+
import { aggregateToolResponse, buildToolPrompt, streamToolResponse } from "./src/tools.mjs"
|
|
36
37
|
import { importLocalLogin, saveSeed } from "./src/login.mjs"
|
|
37
38
|
|
|
38
39
|
const SESSION_TTL_MS = 30 * 60 * 1000
|
|
@@ -114,6 +115,25 @@ function authError(e, message) {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/** 选一条 session 路径,失败时按 session/auth 各重试一次 */
|
|
118
|
+
async function sendToolTurn(auth, { model, question, save }) {
|
|
119
|
+
try {
|
|
120
|
+
const id = await initSession(auth, question)
|
|
121
|
+
return await ask(auth, { sessionId: id, question, model })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (!(e instanceof ImaError)) throw e
|
|
124
|
+
if (e.auth) {
|
|
125
|
+
const next = await refreshToken(auth)
|
|
126
|
+
save?.({ ...auth, access: next.access, refresh: next.refresh, expires: next.expires })
|
|
127
|
+
return await sendToolTurn({ ...auth, ...next }, { model, question, save })
|
|
128
|
+
}
|
|
129
|
+
if (e.session) {
|
|
130
|
+
const id = await initSession(auth, question)
|
|
131
|
+
return await ask(auth, { sessionId: id, question, model })
|
|
132
|
+
}
|
|
133
|
+
throw e
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
async function sendTurn(auth, { model, question, conv, instructions, carried, save }) {
|
|
118
138
|
const key = auth.uid + "|" + (conv || "anon")
|
|
119
139
|
let entry = getSession(key)
|
|
@@ -226,6 +246,28 @@ export const ImaLocalAuthPlugin = async ({ client } = {}) => {
|
|
|
226
246
|
if (!model) {
|
|
227
247
|
return errorResponse(404, `ima: 没有模型 ${String(chat.model ?? "")}`, {})
|
|
228
248
|
}
|
|
249
|
+
|
|
250
|
+
const tools = Array.isArray(chat.tools) ? chat.tools : []
|
|
251
|
+
const hasToolHistory = Array.isArray(chat.messages) && chat.messages.some(
|
|
252
|
+
(m) => m?.role === "tool" || (m?.role === "assistant" && Array.isArray(m?.tool_calls) && m.tool_calls.length > 0),
|
|
253
|
+
)
|
|
254
|
+
const toolMode = tools.length > 0 && (chat.tool_choice !== "none" || hasToolHistory)
|
|
255
|
+
if (toolMode) {
|
|
256
|
+
const { question } = buildToolPrompt(chat.messages, tools, chat.tool_choice)
|
|
257
|
+
try {
|
|
258
|
+
const res = await sendToolTurn(auth, { model, question, save: saveAuth })
|
|
259
|
+
if (chat.stream === false) return await aggregateToolResponse(res, model.id, tools)
|
|
260
|
+
return streamToolResponse(res, model.id, tools, {
|
|
261
|
+
includeUsage: chat.stream_options?.include_usage === true,
|
|
262
|
+
})
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e instanceof ImaError && e.auth) {
|
|
265
|
+
return errorResponse(401, `ima 登录被拒:${e.message}`, { signIn: "expired" })
|
|
266
|
+
}
|
|
267
|
+
return errorResponse(502, `ima: ${e?.message ?? e}`, {})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
229
271
|
const { text, index } = lastUser(chat.messages)
|
|
230
272
|
if (!text) return errorResponse(400, "ima: 这轮没有 user 消息", {})
|
|
231
273
|
const instructions = systemText(chat.messages)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-ima-local-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "magpie / OpenCode provider plugin for the ima.copilot desktop app: its free DeepSeek and Tencent HY models, imported from the client already signed in on this machine, without pasting cookies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/src/context.mjs
CHANGED
package/src/models.mjs
CHANGED
|
@@ -47,7 +47,7 @@ function entry(id, name, thinking, { free = true, rate = null } = {}) {
|
|
|
47
47
|
capabilities: {
|
|
48
48
|
temperature: false,
|
|
49
49
|
reasoning: !!thinking,
|
|
50
|
-
toolcall:
|
|
50
|
+
toolcall: true,
|
|
51
51
|
attachment: false,
|
|
52
52
|
input: { text: true, image: false },
|
|
53
53
|
output: { text: true },
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// src/tools.mjs —— 文本工具协议
|
|
2
|
+
//
|
|
3
|
+
// ima 的 /assistant/qa 没有 OpenAI 的 tools 字段,但实测(2026-10-28):
|
|
4
|
+
// 把 DSH 的工具表写进问题,要求模型输出
|
|
5
|
+
// {"tool_calls":[{"name":"task_plan","arguments":{...}}]}
|
|
6
|
+
// 免费 V4.1 和 Copilot V4.1 都能稳定输出裸 JSON(不要加 sovereign 破甲;
|
|
7
|
+
// 破甲反而触发 ima 的注入检测,正常工具契约就够了)。
|
|
8
|
+
//
|
|
9
|
+
// 这里负责:
|
|
10
|
+
// 1. 把 OpenAI tools 变成提示词里的工具表 + 输出契约
|
|
11
|
+
// 2. 把 messages(含 assistant.tool_calls 和 role=tool 的结果)拼成一次问题
|
|
12
|
+
// 3. 从模型正文里解析 tool_calls
|
|
13
|
+
// 4. 把 ima 的 SSE 包成 OpenAI 的 chat.completion(.chunk)
|
|
14
|
+
|
|
15
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
16
|
+
import {
|
|
17
|
+
eventError,
|
|
18
|
+
eventText,
|
|
19
|
+
eventThinking,
|
|
20
|
+
isContextMessage,
|
|
21
|
+
partText,
|
|
22
|
+
readIMAStream,
|
|
23
|
+
systemText,
|
|
24
|
+
} from "./chat.mjs"
|
|
25
|
+
|
|
26
|
+
export function toolEntries(tools) {
|
|
27
|
+
const out = []
|
|
28
|
+
for (const t of Array.isArray(tools) ? tools : []) {
|
|
29
|
+
const f = t?.function ?? t
|
|
30
|
+
const name = String(f?.name ?? "").trim()
|
|
31
|
+
if (!name) continue
|
|
32
|
+
out.push({
|
|
33
|
+
name,
|
|
34
|
+
description: String(f?.description ?? "").trim(),
|
|
35
|
+
parameters: f?.parameters ?? f?.input_schema ?? { type: "object", properties: {} },
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toolNames(tools) {
|
|
42
|
+
return new Set(toolEntries(tools).map((t) => t.name))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contract(tools, toolChoice) {
|
|
46
|
+
const defs = toolEntries(tools)
|
|
47
|
+
.map((t, i) => `${i + 1}. ${t.name}\n 描述:${t.description || "(无描述)"}\n 参数 Schema:${JSON.stringify(t.parameters)}`)
|
|
48
|
+
.join("\n")
|
|
49
|
+
const forced =
|
|
50
|
+
toolChoice === "required"
|
|
51
|
+
? "本次必须调用至少一个工具。"
|
|
52
|
+
: toolChoice && typeof toolChoice === "object" && toolChoice?.function?.name
|
|
53
|
+
? `本次必须调用工具 ${toolChoice.function.name}。`
|
|
54
|
+
: ""
|
|
55
|
+
return `## 工具调用协议(DSH 编排层)
|
|
56
|
+
|
|
57
|
+
你是 DSH 的模型后端,一个纯文本补全引擎。下面这份工具表由**调用方**提供,不是 ima 的内置工具;
|
|
58
|
+
即使 ima 自己没有同名工具,也必须按本协议输出调用请求,由 DSH 在用户机器上执行。
|
|
59
|
+
不要回答“我没有这个工具”,不要用 ima 的搜索、文件、图片、脑图等内置能力替代这些工具,
|
|
60
|
+
不要用你自己的环境执行,也不要假装已经执行。
|
|
61
|
+
|
|
62
|
+
本次可用工具:
|
|
63
|
+
${defs || "(本次没有可用工具)"}
|
|
64
|
+
|
|
65
|
+
需要调用工具时,只输出一个裸 JSON 对象,不要代码块围栏、不要前后解释、不要道歉:
|
|
66
|
+
{"tool_calls":[{"name":"工具名","arguments":{参数对象}}]}
|
|
67
|
+
|
|
68
|
+
严格示例(必须照这个形状):
|
|
69
|
+
{"tool_calls":[{"name":"task_plan","arguments":{"action":"update","goal":"修复登录 bug","steps":[{"id":"1","title":"复现"}]}}]}
|
|
70
|
+
|
|
71
|
+
不需要调用工具时,直接输出给用户看的自然语言正文。
|
|
72
|
+
如果已经收到工具结果,请基于结果继续;需要继续调用工具就再输出 tool_calls,否则直接给最终答案。
|
|
73
|
+
${forced}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toolCallText(m) {
|
|
77
|
+
const calls = Array.isArray(m?.tool_calls) ? m.tool_calls : []
|
|
78
|
+
if (!calls.length) return ""
|
|
79
|
+
const normalized = calls.map((c) => {
|
|
80
|
+
let args = c?.function?.arguments ?? c?.arguments ?? {}
|
|
81
|
+
if (typeof args === "string") {
|
|
82
|
+
try { args = JSON.parse(args) } catch { args = {} }
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
name: c?.function?.name ?? c?.name ?? "",
|
|
86
|
+
arguments: args && typeof args === "object" ? args : {},
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
return JSON.stringify({ tool_calls: normalized })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 把 OpenAI messages 拼成 ima 的一次问题。
|
|
94
|
+
* 返回 { question, current, hasToolResult }。
|
|
95
|
+
*/
|
|
96
|
+
export function buildToolPrompt(messages, tools, toolChoice) {
|
|
97
|
+
const list = Array.isArray(messages) ? messages : []
|
|
98
|
+
const instructions = systemText(list)
|
|
99
|
+
const turns = []
|
|
100
|
+
let lastRole = ""
|
|
101
|
+
let lastUserText = ""
|
|
102
|
+
|
|
103
|
+
for (const m of list) {
|
|
104
|
+
if (!m || m.role === "system" || m.role === "developer") continue
|
|
105
|
+
if (isContextMessage(m)) continue
|
|
106
|
+
if (m.role === "user") {
|
|
107
|
+
const text = partText(m.content).trim()
|
|
108
|
+
if (!text) continue
|
|
109
|
+
turns.push(`User: ${text.slice(0, 6000)}`)
|
|
110
|
+
lastRole = "user"
|
|
111
|
+
lastUserText = text
|
|
112
|
+
} else if (m.role === "assistant") {
|
|
113
|
+
const calls = toolCallText(m)
|
|
114
|
+
const text = partText(m.content).trim()
|
|
115
|
+
if (calls) {
|
|
116
|
+
turns.push(`Assistant(工具调用): ${calls.slice(0, 4000)}`)
|
|
117
|
+
lastRole = "assistant"
|
|
118
|
+
} else if (text) {
|
|
119
|
+
turns.push(`Assistant: ${text.slice(0, 4000)}`)
|
|
120
|
+
lastRole = "assistant"
|
|
121
|
+
}
|
|
122
|
+
} else if (m.role === "tool") {
|
|
123
|
+
const text = partText(m.content).trim()
|
|
124
|
+
const name = m.name || m.tool_call_id || "工具"
|
|
125
|
+
if (text) {
|
|
126
|
+
turns.push(`Tool result(${name}): ${text.slice(0, 6000)}`)
|
|
127
|
+
lastRole = "tool"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let current = ""
|
|
133
|
+
let history = turns
|
|
134
|
+
if (lastRole === "user" && lastUserText) {
|
|
135
|
+
current = lastUserText
|
|
136
|
+
history = turns.slice(0, -1)
|
|
137
|
+
} else {
|
|
138
|
+
current = "工具结果已经返回。请根据上面的工具结果继续;需要继续调用工具就再输出 tool_calls,否则直接给最终答案。"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parts = []
|
|
142
|
+
if (instructions) parts.push(`## 调用方系统指令\n${instructions.slice(0, 12000)}`)
|
|
143
|
+
if (history.length) parts.push(`## 对话历史\n${history.join("\n\n")}`)
|
|
144
|
+
parts.push(contract(tools, toolChoice))
|
|
145
|
+
parts.push(`## 当前请求\n${current}`)
|
|
146
|
+
return {
|
|
147
|
+
question: parts.join("\n\n").slice(0, 60000),
|
|
148
|
+
current,
|
|
149
|
+
hasToolResult: lastRole === "tool" || list.some((m) => m?.role === "tool"),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tryJson(s) {
|
|
154
|
+
const attempts = [s]
|
|
155
|
+
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
156
|
+
if (fenced) attempts.unshift(fenced[1].trim())
|
|
157
|
+
const i = s.indexOf("{")
|
|
158
|
+
const k = s.lastIndexOf("}")
|
|
159
|
+
if (i >= 0 && k > i) attempts.push(s.slice(i, k + 1))
|
|
160
|
+
for (const a of attempts) {
|
|
161
|
+
try {
|
|
162
|
+
const v = JSON.parse(a)
|
|
163
|
+
if (v && typeof v === "object") return v
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeCalls(obj, known) {
|
|
170
|
+
let listed = null
|
|
171
|
+
if (Array.isArray(obj?.tool_calls)) listed = obj.tool_calls
|
|
172
|
+
else if (Array.isArray(obj?.toolCalls)) listed = obj.toolCalls
|
|
173
|
+
else if (obj?.name || obj?.function?.name) listed = [obj]
|
|
174
|
+
if (!listed) return null
|
|
175
|
+
const out = []
|
|
176
|
+
for (const c of listed) {
|
|
177
|
+
const name = String(c?.function?.name ?? c?.name ?? "").trim()
|
|
178
|
+
if (!name) continue
|
|
179
|
+
if (known?.size && !known.has(name)) continue
|
|
180
|
+
let args = c?.function?.arguments ?? c?.arguments ?? {}
|
|
181
|
+
if (typeof args === "string") {
|
|
182
|
+
try { args = JSON.parse(args) } catch { args = {} }
|
|
183
|
+
}
|
|
184
|
+
out.push({ name, arguments: args && typeof args === "object" ? args : {} })
|
|
185
|
+
}
|
|
186
|
+
return out.length ? out : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 从模型正文里解析 tool_calls;解析失败返回 null,由调用方当正文。 */
|
|
190
|
+
export function parseToolCalls(text, knownNames) {
|
|
191
|
+
const raw = typeof text === "string" ? text.trim() : ""
|
|
192
|
+
if (!raw) return null
|
|
193
|
+
const obj = tryJson(raw)
|
|
194
|
+
if (!obj) return null
|
|
195
|
+
const calls = normalizeCalls(obj, knownNames instanceof Set ? knownNames : new Set(knownNames ?? []))
|
|
196
|
+
return calls ? { calls } : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function argumentsString(args) {
|
|
200
|
+
try {
|
|
201
|
+
return JSON.stringify(args && typeof args === "object" ? args : {})
|
|
202
|
+
} catch {
|
|
203
|
+
return "{}"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function toOpenAIToolCalls(calls) {
|
|
208
|
+
return (calls ?? []).map((c) => ({
|
|
209
|
+
id: "call_" + randomUUID().replace(/-/g, "").slice(0, 24),
|
|
210
|
+
type: "function",
|
|
211
|
+
function: { name: c.name, arguments: argumentsString(c.arguments) },
|
|
212
|
+
}))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function collect(res) {
|
|
216
|
+
let text = ""
|
|
217
|
+
let thinking = ""
|
|
218
|
+
let failed = null
|
|
219
|
+
await readIMAStream(res, (name, raw) => {
|
|
220
|
+
if (failed) return
|
|
221
|
+
const bad = eventError(name, raw)
|
|
222
|
+
if (bad) { failed = new Error(bad.message); return }
|
|
223
|
+
if (name === "THINKING" || name === "STRUCTURED_BLOCK") thinking += eventThinking(raw)
|
|
224
|
+
if (name === "MESSAGE" || name === "STRUCTURED_BLOCK") text += eventText(raw)
|
|
225
|
+
})
|
|
226
|
+
if (failed) throw failed
|
|
227
|
+
return { text, thinking }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function id() {
|
|
231
|
+
return "chatcmpl-" + randomBytes(12).toString("hex")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const USAGE = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
235
|
+
|
|
236
|
+
/** 非流式:工具请求的 chat.completion */
|
|
237
|
+
export async function aggregateToolResponse(res, modelId, tools) {
|
|
238
|
+
const { text, thinking } = await collect(res)
|
|
239
|
+
const parsed = parseToolCalls(text, toolNames(tools))
|
|
240
|
+
const calls = parsed?.calls ?? null
|
|
241
|
+
const openaiCalls = calls ? toOpenAIToolCalls(calls) : null
|
|
242
|
+
return new Response(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
id: id(),
|
|
245
|
+
object: "chat.completion",
|
|
246
|
+
created: Math.floor(Date.now() / 1000),
|
|
247
|
+
model: modelId,
|
|
248
|
+
choices: [
|
|
249
|
+
{
|
|
250
|
+
index: 0,
|
|
251
|
+
message: {
|
|
252
|
+
role: "assistant",
|
|
253
|
+
content: calls ? null : text,
|
|
254
|
+
...(thinking ? { reasoning_content: thinking } : {}),
|
|
255
|
+
...(openaiCalls ? { tool_calls: openaiCalls } : {}),
|
|
256
|
+
},
|
|
257
|
+
finish_reason: calls ? "tool_calls" : "stop",
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
usage: USAGE,
|
|
261
|
+
}),
|
|
262
|
+
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 流式:工具请求先攒后发(工具 JSON 必须完整才能判定),再按 OpenAI chunk 发。 */
|
|
267
|
+
export function streamToolResponse(res, modelId, tools, { includeUsage = false } = {}) {
|
|
268
|
+
const responseId = id()
|
|
269
|
+
const created = Math.floor(Date.now() / 1000)
|
|
270
|
+
const enc = new TextEncoder()
|
|
271
|
+
return new Response(
|
|
272
|
+
new ReadableStream({
|
|
273
|
+
async start(controller) {
|
|
274
|
+
const send = (obj) => controller.enqueue(enc.encode("data: " + JSON.stringify(obj) + "\n\n"))
|
|
275
|
+
const frame = (delta, finish = null) =>
|
|
276
|
+
send({
|
|
277
|
+
id: responseId,
|
|
278
|
+
object: "chat.completion.chunk",
|
|
279
|
+
created,
|
|
280
|
+
model: modelId,
|
|
281
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
282
|
+
})
|
|
283
|
+
try {
|
|
284
|
+
const { text, thinking } = await collect(res)
|
|
285
|
+
const parsed = parseToolCalls(text, toolNames(tools))
|
|
286
|
+
const calls = parsed?.calls ?? null
|
|
287
|
+
frame({ role: "assistant" })
|
|
288
|
+
if (thinking) frame({ reasoning_content: thinking })
|
|
289
|
+
if (calls) {
|
|
290
|
+
const openaiCalls = toOpenAIToolCalls(calls)
|
|
291
|
+
frame({
|
|
292
|
+
tool_calls: openaiCalls.map((tc, i) => ({
|
|
293
|
+
index: i,
|
|
294
|
+
id: tc.id,
|
|
295
|
+
type: "function",
|
|
296
|
+
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
297
|
+
})),
|
|
298
|
+
})
|
|
299
|
+
frame({}, "tool_calls")
|
|
300
|
+
} else {
|
|
301
|
+
if (text) frame({ content: text })
|
|
302
|
+
frame({}, "stop")
|
|
303
|
+
}
|
|
304
|
+
if (includeUsage) {
|
|
305
|
+
send({ id: responseId, object: "chat.completion.chunk", created, model: modelId, choices: [], usage: USAGE })
|
|
306
|
+
}
|
|
307
|
+
controller.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
308
|
+
} catch (e) {
|
|
309
|
+
send({ error: { type: "api_error", message: String(e?.message ?? e) } })
|
|
310
|
+
} finally {
|
|
311
|
+
controller.close()
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
{
|
|
316
|
+
headers: {
|
|
317
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
318
|
+
"cache-control": "no-cache, no-transform",
|
|
319
|
+
"x-accel-buffering": "no",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export const _toolsInternal = { contract, tryJson, normalizeCalls, USAGE }
|