@bolloon/bolloon-agent 0.5.0 → 0.5.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.md +39 -0
- package/dist/agents/agent-lang.js +101 -0
- package/dist/agents/agent-manifest-protocol.js +4 -0
- package/dist/agents/execution-supervisor.js +87 -5
- package/dist/agents/gateway-group.js +44 -3
- package/dist/agents/model-policy.js +651 -0
- package/dist/agents/pi-sdk-tools.js +84 -33
- package/dist/agents/pi-sdk.js +184 -31
- package/dist/agents/run-store.js +53 -0
- package/dist/agents/task/task-runner.js +8 -1
- package/dist/agents/workflow-pivot-loop.js +22 -13
- package/dist/cli/clipboard.js +65 -0
- package/dist/cli/ink-app.js +145 -17
- package/dist/cli/log-gate.js +386 -0
- package/dist/cli/mention-data.js +2 -0
- package/dist/cli/model-selector.js +538 -0
- package/dist/cli/reply-hygiene.js +54 -0
- package/dist/cli/setup-wizard.js +686 -69
- package/dist/cli/startup-notice.js +160 -0
- package/dist/cli/stores.js +22 -1
- package/dist/cli/theme.js +49 -0
- package/dist/cli/tui-select.js +970 -0
- package/dist/cli-entry.js +125 -18
- package/dist/efficode/compress.js +78 -0
- package/dist/efficode/crc32.js +38 -0
- package/dist/efficode/did.js +66 -0
- package/dist/efficode/index.js +24 -0
- package/dist/efficode/lz77.js +170 -0
- package/dist/efficode/negotiate.js +187 -0
- package/dist/efficode/ops.js +87 -0
- package/dist/efficode/packet.js +271 -0
- package/dist/efficode/types.js +45 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js +4 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/execution-supervisor.js +86 -4
- package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- package/dist/electron-build/agents/model-policy.js +706 -0
- package/dist/electron-build/agents/model-policy.js.map +1 -0
- package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +185 -31
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/run-store.js +55 -1
- package/dist/electron-build/agents/run-store.js.map +1 -1
- package/dist/electron-build/agents/task/task-runner.js +8 -1
- package/dist/electron-build/agents/task/task-runner.js.map +1 -1
- package/dist/electron-build/agents/workflow-pivot-loop.js +22 -13
- package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
- package/dist/electron-build/llm/config-store.js +146 -6
- package/dist/electron-build/llm/config-store.js.map +1 -1
- package/dist/electron-build/llm/connection-probe.js +764 -0
- package/dist/electron-build/llm/connection-probe.js.map +1 -0
- package/dist/electron-build/llm/custom-provider-store.js +272 -0
- package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
- package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
- package/dist/electron-build/llm/model-catalog.js +778 -0
- package/dist/electron-build/llm/model-catalog.js.map +1 -0
- package/dist/electron-build/llm/model-discovery.js +1202 -0
- package/dist/electron-build/llm/model-discovery.js.map +1 -0
- package/dist/electron-build/llm/model-selection.js +1315 -0
- package/dist/electron-build/llm/model-selection.js.map +1 -0
- package/dist/electron-build/llm/pi-ai.js +751 -55
- package/dist/electron-build/llm/pi-ai.js.map +1 -1
- package/dist/electron-build/llm/provider-catalog.js +969 -0
- package/dist/electron-build/llm/provider-catalog.js.map +1 -0
- package/dist/electron-build/llm/provider-registry.js +950 -0
- package/dist/electron-build/llm/provider-registry.js.map +1 -0
- package/dist/electron-build/llm/tool-name.js +266 -0
- package/dist/electron-build/llm/tool-name.js.map +1 -0
- package/dist/electron-build/utils/dual-source-facts.js +236 -0
- package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
- package/dist/electron-build/utils/dual-source.js +32 -156
- package/dist/electron-build/utils/dual-source.js.map +1 -1
- package/dist/electron-build/utils/update-manager.js +22 -72
- package/dist/electron-build/utils/update-manager.js.map +1 -1
- package/dist/electron-build/utils/update-state.js +2 -19
- package/dist/electron-build/utils/update-state.js.map +1 -1
- package/dist/electron-build/utils/version-identity.js +193 -0
- package/dist/electron-build/utils/version-identity.js.map +1 -0
- package/dist/electron-build/utils/version-info.js +47 -101
- package/dist/electron-build/utils/version-info.js.map +1 -1
- package/dist/index.js +282 -96
- package/dist/ios/bolloon-web.json +10 -0
- package/dist/ios/mobile-contacts.js +44 -11
- package/dist/ios/mobile-core.js +1723 -84
- package/dist/ios/mobile-tasks.js +339 -0
- package/dist/ios/mobile-update.js +1442 -0
- package/dist/ios/mobile.js +308 -35
- package/dist/ios/routes-llm-config.js +344 -52
- package/dist/ios/routes-mobile-tasks.js +353 -0
- package/dist/ios/server.js +163 -4
- package/dist/ios/sw.js +1 -1
- package/dist/llm/config-store.js +146 -6
- package/dist/llm/connection-probe.js +744 -0
- package/dist/llm/custom-provider-store.js +224 -0
- package/dist/llm/data/provider-catalog-baked.js +19 -0
- package/dist/llm/model-catalog.js +717 -0
- package/dist/llm/model-discovery.js +1137 -0
- package/dist/llm/model-selection.js +1241 -0
- package/dist/llm/pi-ai.js +732 -54
- package/dist/llm/provider-catalog.js +898 -0
- package/dist/llm/provider-registry.js +917 -0
- package/dist/llm/tool-name.js +220 -0
- package/dist/setup/onboard.js +34 -8
- package/dist/utils/dual-source-facts.js +223 -0
- package/dist/utils/dual-source.js +12 -141
- package/dist/utils/update-manager.js +16 -59
- package/dist/utils/update-state.js +6 -18
- package/dist/utils/version-identity.js +176 -0
- package/dist/utils/version-info.js +9 -78
- package/dist/web/bolloon-web.json +10 -0
- package/dist/web/mobile-core.js +1721 -84
- package/dist/web/mobile-update.js +1442 -0
- package/dist/web/mobile.js +372 -35
- package/dist/web/routes-llm-config.js +344 -52
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/scripts/build-web.ts +36 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connection-probe.ts — API URL 完整切换链路的**独立探测原语** (P4, 2026-09-26)
|
|
3
|
+
*
|
|
4
|
+
* ## 修的是什么
|
|
5
|
+
*
|
|
6
|
+
* 换供应商 / 换 API URL 时, 失败一直只有两种说法: 一句"切换成功", 或一句笼统的"连接失败"。
|
|
7
|
+
* 用户分不清到底是
|
|
8
|
+
*
|
|
9
|
+
* URL 写错了 · key 不对 · 服务没起来 · 模型名不存在 · 端点其实是另一个协议 ·
|
|
10
|
+
* 模型不支持工具调用 · 还是网络干脆挂住了
|
|
11
|
+
*
|
|
12
|
+
* 更糟的是有的路径在连不上时**静默退回默认 URL** —— 于是出现"我明明填了内网地址, 请求却打到
|
|
13
|
+
* 了公网"这种**隐藏 URL**。
|
|
14
|
+
*
|
|
15
|
+
* 本模块把整条链路做成**一个可判定、失败必分 7 类的原语**:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* 解析 URL(显式 --base-url > 配置里的 provider baseUrl > provider 默认 URL > 环境变量覆盖)
|
|
19
|
+
* → 规范化 + 合并重复 /v1 → 协议形状校验 → 轻量连接探测
|
|
20
|
+
* → 模型接口可用 → 工具调用能力 → 全通过才判 ok
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* 三条硬规则 (写在实现里, 不是文档里):
|
|
24
|
+
*
|
|
25
|
+
* ① **失败绝不静默退回旧/默认**。选中哪一层来源会被显式记进 `baseUrlSource`; 选中的那一层
|
|
26
|
+
* 若是畸形 URL, 直接 `invalid_url` —— **不会**"悄悄地试下一层"。四层全空也是 `invalid_url`,
|
|
27
|
+
* 不编一个默认出来 (只有"真的没给"(缺失/纯空白)才轮到下一层, 这与"给了但写错了"是两件事)。
|
|
28
|
+
* ② **失败绝不当作成功**。任何一步没过 ⇒ `ok === false` 且 `failureClass` 必非空。
|
|
29
|
+
* ③ **凭证只进请求头, 永不进返回值**。只回凭证**引用名** (`authRef`, 如 `env:DEEPSEEK_API_KEY`),
|
|
30
|
+
* 返回值与错误信息里都没有任何 key 值。
|
|
31
|
+
*
|
|
32
|
+
* ## 为什么是"独立原语"
|
|
33
|
+
*
|
|
34
|
+
* 本文件**不 import** 切换入口 (`model-selection.ts`) 的任何运行时符号 (只用 `import type` 拿
|
|
35
|
+
* 协议类型, 编译期即擦除), 也不碰配置存储 —— 加载它零副作用, 任何一层都能按需调用。
|
|
36
|
+
* URL 规范化与入口里的 `normalizeBaseUrl()` **语义刻意保持一致**, 并有一条单测逐例比对两者:
|
|
37
|
+
* 一旦分叉立刻判红, 这样原语被接进入口时不会出现"两处真相"。
|
|
38
|
+
*/
|
|
39
|
+
// ============================================================
|
|
40
|
+
// 失败分类 (7 类, 冻结)
|
|
41
|
+
// ============================================================
|
|
42
|
+
/**
|
|
43
|
+
* 探测失败的 7 个类别。**只准这 7 个** —— 多一个少一个都算接口变了。
|
|
44
|
+
* 顺序不代表优先级, 只是给门/报告一个稳定的枚举次序。
|
|
45
|
+
*/
|
|
46
|
+
export const PROBE_FAILURE_CLASSES = [
|
|
47
|
+
'invalid_url',
|
|
48
|
+
'auth_failed',
|
|
49
|
+
'provider_unreachable',
|
|
50
|
+
'model_not_found',
|
|
51
|
+
'protocol_mismatch',
|
|
52
|
+
'tool_call_unsupported',
|
|
53
|
+
'timeout',
|
|
54
|
+
];
|
|
55
|
+
/** 每类失败的**人话理由** (界面直接显示; 不改写事实, 只翻译类别) */
|
|
56
|
+
export const PROBE_FAILURE_ZH = {
|
|
57
|
+
invalid_url: 'URL 写错了, 或这个地址上根本没有服务端点',
|
|
58
|
+
auth_failed: '凭证被拒 (401/403), 或本地取不到这个凭证引用指向的密钥',
|
|
59
|
+
provider_unreachable: '连不上供应商 (拒绝连接/网络不可达/上游 5xx/被限流)',
|
|
60
|
+
model_not_found: '端点可达, 但它不认识这个模型名',
|
|
61
|
+
protocol_mismatch: '这个地址上的服务不是该协议 (端点上没有本协议的端点, 或回的是另一种协议的形状)',
|
|
62
|
+
tool_call_unsupported: '模型/端点不接受工具调用声明',
|
|
63
|
+
timeout: '探测超时 (在超时时间内没有任何响应)',
|
|
64
|
+
};
|
|
65
|
+
/** 四层来源的固定优先级 (与计划一致: 显式 > 配置 > 供应商默认 > 环境变量) */
|
|
66
|
+
export const BASE_URL_PRIORITY = ['explicit', 'configured', 'provider', 'env'];
|
|
67
|
+
/** 「给了但写错了」与「没给」是两件事 —— 只有后者才算"这一层没意见" */
|
|
68
|
+
function isGiven(v) {
|
|
69
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 归一化 + **合并重复 `/v1`**。
|
|
73
|
+
*
|
|
74
|
+
* 与切换入口的 `normalizeBaseUrl()` 逐例一致 (单测比对)。重复 `/v1` 只处理**结尾**叠加
|
|
75
|
+
* (`/v1/v1` → `/v1`), 因为 `…/v1/gateway/v1` 这种中间重复可能是服务端真实路径, 合并它就是
|
|
76
|
+
* 改用户的地址。`schema://host` 里的 `//` 不动。
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeChainUrl(raw) {
|
|
79
|
+
let u = String(raw ?? '').trim();
|
|
80
|
+
if (!u)
|
|
81
|
+
return '';
|
|
82
|
+
u = u.replace(/([^:])\/{2,}/g, '$1/'); // 折叠重复斜杠 (保留协议后的 ://)
|
|
83
|
+
u = u.replace(/\/+$/g, ''); // 去尾部斜杠
|
|
84
|
+
u = u.replace(/(\/v\d+)(\/v\d+)+$/i, '$1'); // 合并结尾重复的 /vN
|
|
85
|
+
return u;
|
|
86
|
+
}
|
|
87
|
+
/** URL 形状校验 (只查形状, 不查可达性)。`ok:false` 时 reason 是人话。 */
|
|
88
|
+
export function validateChainUrlShape(raw) {
|
|
89
|
+
const url = normalizeChainUrl(raw);
|
|
90
|
+
if (!url)
|
|
91
|
+
return { ok: false, reason: 'URL 为空' };
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = new URL(url);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { ok: false, reason: `不是合法 URL: ${url}` };
|
|
98
|
+
}
|
|
99
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
100
|
+
return { ok: false, reason: `只支持 http/https, 收到 ${parsed.protocol}` };
|
|
101
|
+
}
|
|
102
|
+
if (!parsed.hostname)
|
|
103
|
+
return { ok: false, reason: '缺少主机名' };
|
|
104
|
+
return { ok: true, url };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 四层优先级解析 —— **纯函数**, 不读盘 (环境那一层的值由调用方给, 或在此从传入的 env 取)。
|
|
108
|
+
*
|
|
109
|
+
* 关键语义: 一旦某一层**真的给了值**, 就它的 (哪怕写错了) —— 不继续往下找。
|
|
110
|
+
* 只有"没给 / 纯空白"才轮到下一层。这样"填错 URL"永远不会被一个默认值悄悄盖掉。
|
|
111
|
+
*/
|
|
112
|
+
export function resolveChainBaseUrl(s, env = process.env) {
|
|
113
|
+
const envFromName = isGiven(s.envVar) ? env[String(s.envVar).trim()] : undefined;
|
|
114
|
+
const layers = [
|
|
115
|
+
{ source: 'explicit', value: s.explicit },
|
|
116
|
+
{ source: 'configured', value: s.configured },
|
|
117
|
+
{ source: 'provider', value: s.providerDefault },
|
|
118
|
+
{ source: 'env', value: isGiven(s.envValue) ? s.envValue : envFromName },
|
|
119
|
+
];
|
|
120
|
+
for (const { source, value } of layers) {
|
|
121
|
+
if (!isGiven(value))
|
|
122
|
+
continue;
|
|
123
|
+
const raw = String(value).trim();
|
|
124
|
+
return { baseUrl: normalizeChainUrl(raw), source, raw };
|
|
125
|
+
}
|
|
126
|
+
return { baseUrl: '', source: null, raw: '' };
|
|
127
|
+
}
|
|
128
|
+
// ============================================================
|
|
129
|
+
// 凭证引用 (只解析, 永不回显)
|
|
130
|
+
// ============================================================
|
|
131
|
+
/** 拆解凭证引用名。**只拆名字, 不取值** */
|
|
132
|
+
export function parseAuthRef(ref) {
|
|
133
|
+
const r = String(ref ?? '').trim();
|
|
134
|
+
if (!r || r === 'none')
|
|
135
|
+
return { kind: 'none', name: '' };
|
|
136
|
+
const i = r.indexOf(':');
|
|
137
|
+
if (i < 0)
|
|
138
|
+
return { kind: 'provider', name: r };
|
|
139
|
+
const kind = r.slice(0, i);
|
|
140
|
+
const name = r.slice(i + 1);
|
|
141
|
+
if (kind === 'env')
|
|
142
|
+
return { kind: 'env', name };
|
|
143
|
+
return { kind: 'provider', name };
|
|
144
|
+
}
|
|
145
|
+
/** 默认凭证解析: 只认 `env:<VAR>`; `provider:<id>` 的值在配置存储里, 由宿主注入 */
|
|
146
|
+
export async function defaultResolveSecret(ref) {
|
|
147
|
+
const { kind, name } = parseAuthRef(ref);
|
|
148
|
+
if (kind === 'env') {
|
|
149
|
+
const v = process.env[name];
|
|
150
|
+
return isGiven(v) ? String(v).trim() : undefined;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
155
|
+
const PROBE_TOOL = {
|
|
156
|
+
name: 'probe_tool',
|
|
157
|
+
description: '连接探测用, 不需要真正执行',
|
|
158
|
+
parameters: { type: 'object', properties: {} },
|
|
159
|
+
};
|
|
160
|
+
function listIds(ids, pick) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const x of ids) {
|
|
163
|
+
const v = pick(x);
|
|
164
|
+
if (typeof v === 'string' && v)
|
|
165
|
+
out.push(v);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
const OPENAI_COMPATIBLE = {
|
|
170
|
+
protocol: 'openai-compatible',
|
|
171
|
+
label: 'OpenAI 兼容',
|
|
172
|
+
catalogPath: '/models',
|
|
173
|
+
headers: (k) => (k ? { ...JSON_HEADERS, Authorization: `Bearer ${k}` } : { ...JSON_HEADERS }),
|
|
174
|
+
parseCatalog: (b) => {
|
|
175
|
+
if (!Array.isArray(b?.data))
|
|
176
|
+
return null;
|
|
177
|
+
const ids = listIds(b.data, (x) => (typeof x === 'string' ? x : x?.id ?? x?.name));
|
|
178
|
+
return ids.length ? ids : null;
|
|
179
|
+
},
|
|
180
|
+
modelRequest: (base, model, k) => ({
|
|
181
|
+
url: `${base}/chat/completions`,
|
|
182
|
+
init: {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: OPENAI_COMPATIBLE.headers(k),
|
|
185
|
+
body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: 'user', content: 'probe' }] }),
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
toolRequest: (base, model, k) => ({
|
|
189
|
+
url: `${base}/chat/completions`,
|
|
190
|
+
init: {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: OPENAI_COMPATIBLE.headers(k),
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
model,
|
|
195
|
+
max_tokens: 8,
|
|
196
|
+
messages: [{ role: 'user', content: 'probe' }],
|
|
197
|
+
tools: [{ type: 'function', function: PROBE_TOOL }],
|
|
198
|
+
tool_choice: 'auto',
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
const ANTHROPIC = {
|
|
204
|
+
protocol: 'anthropic',
|
|
205
|
+
label: 'Anthropic',
|
|
206
|
+
catalogPath: '/models',
|
|
207
|
+
headers: (k) => {
|
|
208
|
+
const h = { ...JSON_HEADERS, 'anthropic-version': '2023-06-01' };
|
|
209
|
+
if (k)
|
|
210
|
+
h['x-api-key'] = k;
|
|
211
|
+
return h;
|
|
212
|
+
},
|
|
213
|
+
parseCatalog: (b) => {
|
|
214
|
+
if (!Array.isArray(b?.data))
|
|
215
|
+
return null;
|
|
216
|
+
const ids = listIds(b.data, (x) => (typeof x === 'string' ? x : x?.id ?? x?.name));
|
|
217
|
+
return ids.length ? ids : null;
|
|
218
|
+
},
|
|
219
|
+
modelRequest: (base, model, k) => ({
|
|
220
|
+
url: `${base}/messages`,
|
|
221
|
+
init: {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: ANTHROPIC.headers(k),
|
|
224
|
+
body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: 'user', content: 'probe' }] }),
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
toolRequest: (base, model, k) => ({
|
|
228
|
+
url: `${base}/messages`,
|
|
229
|
+
init: {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: ANTHROPIC.headers(k),
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
model,
|
|
234
|
+
max_tokens: 8,
|
|
235
|
+
messages: [{ role: 'user', content: 'probe' }],
|
|
236
|
+
tools: [{ name: PROBE_TOOL.name, description: PROBE_TOOL.description, input_schema: PROBE_TOOL.parameters }],
|
|
237
|
+
}),
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
const GEMINI = {
|
|
242
|
+
protocol: 'gemini',
|
|
243
|
+
label: 'Gemini',
|
|
244
|
+
catalogPath: '/models',
|
|
245
|
+
headers: (k) => (k ? { ...JSON_HEADERS, 'x-goog-api-key': k } : { ...JSON_HEADERS }),
|
|
246
|
+
parseCatalog: (b) => {
|
|
247
|
+
if (!Array.isArray(b?.models))
|
|
248
|
+
return null;
|
|
249
|
+
const ids = listIds(b.models, (x) => {
|
|
250
|
+
const n = typeof x === 'string' ? x : x?.name ?? x?.id;
|
|
251
|
+
return typeof n === 'string' ? n.replace(/^models\//, '') : n;
|
|
252
|
+
});
|
|
253
|
+
return ids.length ? ids : null;
|
|
254
|
+
},
|
|
255
|
+
modelRequest: (base, model, k) => ({
|
|
256
|
+
url: `${base}/models/${encodeURIComponent(model)}:generateContent`,
|
|
257
|
+
init: {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: GEMINI.headers(k),
|
|
260
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: 'probe' }] }] }),
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
toolRequest: (base, model, k) => ({
|
|
264
|
+
url: `${base}/models/${encodeURIComponent(model)}:generateContent`,
|
|
265
|
+
init: {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: GEMINI.headers(k),
|
|
268
|
+
body: JSON.stringify({
|
|
269
|
+
contents: [{ parts: [{ text: 'probe' }] }],
|
|
270
|
+
tools: [{
|
|
271
|
+
functionDeclarations: [{
|
|
272
|
+
name: PROBE_TOOL.name,
|
|
273
|
+
description: PROBE_TOOL.description,
|
|
274
|
+
parameters: { type: 'OBJECT', properties: {} },
|
|
275
|
+
}],
|
|
276
|
+
}],
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
}),
|
|
280
|
+
};
|
|
281
|
+
const OLLAMA = {
|
|
282
|
+
protocol: 'ollama',
|
|
283
|
+
label: 'Ollama / 本地',
|
|
284
|
+
catalogPath: '/api/tags',
|
|
285
|
+
headers: (k) => (k ? { ...JSON_HEADERS, Authorization: `Bearer ${k}` } : { ...JSON_HEADERS }),
|
|
286
|
+
parseCatalog: (b) => {
|
|
287
|
+
if (!Array.isArray(b?.models))
|
|
288
|
+
return null;
|
|
289
|
+
const ids = listIds(b.models, (x) => (typeof x === 'string' ? x : x?.name ?? x?.model));
|
|
290
|
+
return ids.length ? ids : null;
|
|
291
|
+
},
|
|
292
|
+
modelRequest: (base, model, k) => ({
|
|
293
|
+
url: `${base}/api/chat`,
|
|
294
|
+
init: {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: OLLAMA.headers(k),
|
|
297
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'probe' }], stream: false }),
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
toolRequest: (base, model, k) => ({
|
|
301
|
+
url: `${base}/api/chat`,
|
|
302
|
+
init: {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: OLLAMA.headers(k),
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
model,
|
|
307
|
+
messages: [{ role: 'user', content: 'probe' }],
|
|
308
|
+
stream: false,
|
|
309
|
+
tools: [{ type: 'function', function: PROBE_TOOL }],
|
|
310
|
+
}),
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
};
|
|
314
|
+
const PROTOCOL_SHAPES = {
|
|
315
|
+
'openai-compatible': OPENAI_COMPATIBLE,
|
|
316
|
+
anthropic: ANTHROPIC,
|
|
317
|
+
gemini: GEMINI,
|
|
318
|
+
ollama: OLLAMA,
|
|
319
|
+
};
|
|
320
|
+
/** 支持的协议名 (报告/错误信息用) */
|
|
321
|
+
export function supportedProtocols() {
|
|
322
|
+
return Object.keys(PROTOCOL_SHAPES);
|
|
323
|
+
}
|
|
324
|
+
// ============================================================
|
|
325
|
+
// 响应信封识别 (判断"这个端点回的是哪种协议的形状")
|
|
326
|
+
// ============================================================
|
|
327
|
+
/** 从一段目录响应体里认出它是**哪种协议**的形状; 认不出 → `null` */
|
|
328
|
+
export function envelopeProtocolOf(body) {
|
|
329
|
+
if (!body || typeof body !== 'object')
|
|
330
|
+
return null;
|
|
331
|
+
if (Array.isArray(body.models)) {
|
|
332
|
+
const m0 = body.models[0];
|
|
333
|
+
if (!m0 || typeof m0 !== 'object')
|
|
334
|
+
return null;
|
|
335
|
+
const name = String(m0.name ?? m0.model ?? '');
|
|
336
|
+
if (name.startsWith('models/'))
|
|
337
|
+
return 'gemini';
|
|
338
|
+
if (m0.digest !== undefined || m0.size !== undefined || m0.modified_at !== undefined)
|
|
339
|
+
return 'ollama';
|
|
340
|
+
if (name)
|
|
341
|
+
return 'ollama';
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(body.data)) {
|
|
345
|
+
const d0 = body.data[0];
|
|
346
|
+
if (d0 && typeof d0 === 'object') {
|
|
347
|
+
if (d0.type === 'model' || d0.display_name !== undefined || body.has_more !== undefined)
|
|
348
|
+
return 'anthropic';
|
|
349
|
+
if (typeof d0.object === 'string' || body.object === 'list')
|
|
350
|
+
return 'openai-compatible';
|
|
351
|
+
}
|
|
352
|
+
if (body.object === 'list')
|
|
353
|
+
return 'openai-compatible';
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
// ============================================================
|
|
359
|
+
// 单个请求: 超时 + 网络错误分类
|
|
360
|
+
// ============================================================
|
|
361
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 8000;
|
|
362
|
+
/**
|
|
363
|
+
* 带超时的 fetch。超时会 `abort()`, 并把调用方给的 `flag.timedOut` 置真 ——
|
|
364
|
+
* 于是**即便这次 fetch 抛错** (abort 会抛), 分类也能确定地判成 `timeout`。
|
|
365
|
+
*/
|
|
366
|
+
export async function fetchWithProbeTimeout(url, init, timeoutMs, fetchImpl, flag = { timedOut: false }) {
|
|
367
|
+
const ctrl = new AbortController();
|
|
368
|
+
const timer = setTimeout(() => { flag.timedOut = true; ctrl.abort(); }, Math.max(1, timeoutMs));
|
|
369
|
+
try {
|
|
370
|
+
return await fetchImpl(url, { ...init, signal: ctrl.signal });
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** 网络层错误 → 类别 + 人话 (超时/主机名解析失败/拒绝连接/不可达) */
|
|
377
|
+
export function classifyNetworkError(err, flag, timeoutMs) {
|
|
378
|
+
const msg = String(err?.message ?? err ?? '未知错误');
|
|
379
|
+
const code = String(err?.code ?? err?.cause?.code ?? '');
|
|
380
|
+
const name = String(err?.name ?? '');
|
|
381
|
+
if (flag.timedOut || name === 'AbortError' || /abort/i.test(msg)) {
|
|
382
|
+
return { failureClass: 'timeout', detail: `超时: ${timeoutMs}ms 内没有任何响应` };
|
|
383
|
+
}
|
|
384
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|ERR_INVALID_URL/i.test(`${code} ${msg}`)) {
|
|
385
|
+
return { failureClass: 'invalid_url', detail: `主机名解析失败 — 检查 URL 主机名 (${code || msg})` };
|
|
386
|
+
}
|
|
387
|
+
if (/ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|EPIPE|ECONNABORTED|UND_ERR/i.test(`${code} ${name} ${msg}`)) {
|
|
388
|
+
return { failureClass: 'provider_unreachable', detail: `拒绝连接/网络不可达 (${code || msg})` };
|
|
389
|
+
}
|
|
390
|
+
return { failureClass: 'provider_unreachable', detail: `连接失败: ${msg}` };
|
|
391
|
+
}
|
|
392
|
+
/** 响应体读成 JSON; 不是 JSON 就给 `null` (不抛) */
|
|
393
|
+
async function readJson(res) {
|
|
394
|
+
const text = await res.text().catch(() => '');
|
|
395
|
+
try {
|
|
396
|
+
return JSON.parse(text);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** 响应片段 (最多 200 字) —— 只用于人话理由; 不含请求头, 因此不会带出凭证 */
|
|
403
|
+
export function snippetOf(body, res) {
|
|
404
|
+
if (body && typeof body === 'object') {
|
|
405
|
+
const m = body?.error?.message ?? body?.message ?? body?.error ?? body?.detail;
|
|
406
|
+
if (typeof m === 'string' && m)
|
|
407
|
+
return m.slice(0, 200);
|
|
408
|
+
}
|
|
409
|
+
return `HTTP ${res.status}`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 非 2xx 状态码 → 类别 (目录/模型调用/工具调用共用的那一半)。
|
|
413
|
+
* 2xx → `null` (交给上层继续解析响应体)。
|
|
414
|
+
*/
|
|
415
|
+
export function classifyStatus(res, body, url, context) {
|
|
416
|
+
const s = res.status;
|
|
417
|
+
if (s >= 200 && s < 300)
|
|
418
|
+
return null;
|
|
419
|
+
const text = JSON.stringify(body ?? '').slice(0, 400);
|
|
420
|
+
if (s === 401 || s === 403)
|
|
421
|
+
return { failureClass: 'auth_failed', detail: `凭证被拒 (HTTP ${s}) — ${snippetOf(body, res)}` };
|
|
422
|
+
if (s === 404 || s === 405) {
|
|
423
|
+
if (context !== 'catalog' && /model/i.test(`${url} ${text}`)) {
|
|
424
|
+
return { failureClass: 'model_not_found', detail: `模型接口不存在 (HTTP ${s}) — ${snippetOf(body, res)}` };
|
|
425
|
+
}
|
|
426
|
+
return { failureClass: 'invalid_url', detail: `端点不存在 (HTTP ${s}) @ ${url} — ${snippetOf(body, res)}` };
|
|
427
|
+
}
|
|
428
|
+
if (s === 429)
|
|
429
|
+
return { failureClass: 'provider_unreachable', detail: `被限流 (HTTP 429) — ${snippetOf(body, res)}` };
|
|
430
|
+
if (s >= 500)
|
|
431
|
+
return { failureClass: 'provider_unreachable', detail: `上游错误 (HTTP ${s}) — ${snippetOf(body, res)}` };
|
|
432
|
+
// 其余 4xx: 端点收到了请求但拒绝了这个形状
|
|
433
|
+
if (context === 'tool' && /tool|function/i.test(text)) {
|
|
434
|
+
return { failureClass: 'tool_call_unsupported', detail: `不接受工具调用声明 (HTTP ${s}) — ${snippetOf(body, res)}` };
|
|
435
|
+
}
|
|
436
|
+
return { failureClass: 'protocol_mismatch', detail: `端点拒绝了本协议的请求 (HTTP ${s}) — ${snippetOf(body, res)}` };
|
|
437
|
+
}
|
|
438
|
+
// ============================================================
|
|
439
|
+
// 交叉判定: "这个地址上的服务其实是另一个协议"
|
|
440
|
+
// ============================================================
|
|
441
|
+
/**
|
|
442
|
+
* 在**服务根**上试几种协议的知名目录端点, 认出端点真实是哪种协议。
|
|
443
|
+
*
|
|
444
|
+
* 只在主目录端点没给出本协议形状时调用 —— 用它把三件事分开:
|
|
445
|
+
* · 协议不对 (另一个协议在这台服务上有端点) → `protocol_mismatch`
|
|
446
|
+
* · 同协议但路径不对 (同协议的端点出现在另一个路径) → `invalid_url` + 线索
|
|
447
|
+
* · 什么都没有 → 按状态码给类别, 不硬说协议不符
|
|
448
|
+
* 认不出 → `null`。
|
|
449
|
+
*/
|
|
450
|
+
export async function detectForeignProtocol(origin, fetchImpl, timeoutMs) {
|
|
451
|
+
const candidates = [`${origin}/api/tags`, `${origin}/v1beta/models`, `${origin}/v1/models`];
|
|
452
|
+
const seen = new Set();
|
|
453
|
+
for (const url of candidates) {
|
|
454
|
+
if (seen.has(url))
|
|
455
|
+
continue;
|
|
456
|
+
seen.add(url);
|
|
457
|
+
try {
|
|
458
|
+
const res = await fetchWithProbeTimeout(url, { method: 'GET' }, Math.min(timeoutMs, 3000), fetchImpl);
|
|
459
|
+
if (!res.ok)
|
|
460
|
+
continue;
|
|
461
|
+
const body = await readJson(res);
|
|
462
|
+
const kind = envelopeProtocolOf(body);
|
|
463
|
+
if (kind)
|
|
464
|
+
return { protocol: kind, url };
|
|
465
|
+
}
|
|
466
|
+
catch { /* 这条试不通 → 试下一条 */ }
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
// ============================================================
|
|
471
|
+
// 主原语
|
|
472
|
+
// ============================================================
|
|
473
|
+
function clampTimeout(v) {
|
|
474
|
+
const n = Number(v);
|
|
475
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
476
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
477
|
+
return Math.max(200, Math.min(60_000, Math.floor(n)));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* 探测一条候选模型配置。**全通过才 `ok:true`**; 任何一步失败都带类别 + 人话理由返回,
|
|
481
|
+
* 且绝不改写调用方给的东西、也绝不偷偷换一个 URL 再试。
|
|
482
|
+
*
|
|
483
|
+
* 步骤 (与计划一致): URL 解析 → 规范化/合并 /v1 → 协议形状 → 凭证 → 轻量连接探测 →
|
|
484
|
+
* 模型接口可用 → 工具调用能力。
|
|
485
|
+
*/
|
|
486
|
+
export async function probe(req) {
|
|
487
|
+
const started = Date.now();
|
|
488
|
+
const checks = [];
|
|
489
|
+
const fetchImpl = req.fetchImpl ?? fetch;
|
|
490
|
+
const timeoutMs = clampTimeout(req.timeoutMs);
|
|
491
|
+
const providerId = String(req.providerId ?? '');
|
|
492
|
+
const model = String(req.model ?? '').trim();
|
|
493
|
+
const authRef = String(req.apiKeyRef ?? 'none');
|
|
494
|
+
const resolveSecret = req.resolveSecret ?? defaultResolveSecret;
|
|
495
|
+
let baseUrl = '';
|
|
496
|
+
let baseUrlSource = null;
|
|
497
|
+
let toolCalling = 'unknown';
|
|
498
|
+
/** 「目录里没有但端点接受」的实测事实 (只在真发过请求且 2xx 时置位) */
|
|
499
|
+
let modelAcceptedOutsideCatalog = false;
|
|
500
|
+
const finish = (r) => ({
|
|
501
|
+
providerId,
|
|
502
|
+
model,
|
|
503
|
+
authRef,
|
|
504
|
+
protocol: req.protocol,
|
|
505
|
+
baseUrl,
|
|
506
|
+
baseUrlSource,
|
|
507
|
+
toolCalling,
|
|
508
|
+
...(modelAcceptedOutsideCatalog ? { modelAcceptedOutsideCatalog: true } : {}),
|
|
509
|
+
checks,
|
|
510
|
+
elapsedMs: Date.now() - started,
|
|
511
|
+
...r,
|
|
512
|
+
});
|
|
513
|
+
const fail = (failureClass, message, extra) => finish({ ok: false, failureClass, message, ...(extra ?? {}) });
|
|
514
|
+
// ── 1) URL 解析 + 规范化 (合并重复 /v1) ──────────────────────
|
|
515
|
+
const chosen = resolveChainBaseUrl({
|
|
516
|
+
explicit: req.baseUrl,
|
|
517
|
+
configured: req.configuredBaseUrl,
|
|
518
|
+
providerDefault: req.providerDefaultUrl,
|
|
519
|
+
envVar: req.envBaseUrlVar,
|
|
520
|
+
envValue: req.envBaseUrlValue,
|
|
521
|
+
});
|
|
522
|
+
if (!chosen.source) {
|
|
523
|
+
checks.push({ step: 'url', ok: false, detail: '四层来源 (显式/配置/供应商默认/环境变量) 全空' });
|
|
524
|
+
return fail('invalid_url', '没有任何一层给出 base URL (显式 --base-url / 配置 / 供应商默认 / 环境变量都为空) — 不退回默认地址');
|
|
525
|
+
}
|
|
526
|
+
baseUrlSource = chosen.source;
|
|
527
|
+
baseUrl = chosen.baseUrl;
|
|
528
|
+
const shapeOk = validateChainUrlShape(chosen.baseUrl);
|
|
529
|
+
if (!shapeOk.ok) {
|
|
530
|
+
checks.push({ step: 'url', ok: false, detail: `来源 ${chosen.source} 给了 '${chosen.raw}', 规范化后 '${chosen.baseUrl}': ${shapeOk.reason}` });
|
|
531
|
+
return fail('invalid_url', `base URL 非法 (来源 ${chosen.source}) — ${shapeOk.reason}`);
|
|
532
|
+
}
|
|
533
|
+
baseUrl = shapeOk.url;
|
|
534
|
+
const changed = shapeOk.url !== chosen.raw;
|
|
535
|
+
checks.push({
|
|
536
|
+
step: 'url',
|
|
537
|
+
ok: true,
|
|
538
|
+
detail: `${shapeOk.url} (来源 ${chosen.source}${changed ? `, 已规范化自 '${chosen.raw}'` : ''})`,
|
|
539
|
+
});
|
|
540
|
+
// ── 2) 协议形状校验 ────────────────────────────────────────
|
|
541
|
+
const proto = PROTOCOL_SHAPES[req.protocol];
|
|
542
|
+
if (!proto) {
|
|
543
|
+
checks.push({ step: 'protocol', ok: false, detail: `不认识的协议 '${String(req.protocol)}'` });
|
|
544
|
+
return fail('protocol_mismatch', `不认识的协议类型 '${String(req.protocol)}' (支持: ${supportedProtocols().join(', ')})`);
|
|
545
|
+
}
|
|
546
|
+
if (!model) {
|
|
547
|
+
checks.push({ step: 'protocol', ok: false, detail: '模型名为空' });
|
|
548
|
+
return fail('protocol_mismatch', '模型名为空 — 无法确认模型接口可用');
|
|
549
|
+
}
|
|
550
|
+
checks.push({ step: 'protocol', ok: true, detail: `${proto.label} (${req.protocol}) @ 目录端点 ${proto.catalogPath}` });
|
|
551
|
+
// ── 3) 凭证 ────────────────────────────────────────────────
|
|
552
|
+
let apiKey;
|
|
553
|
+
if (authRef !== 'none') {
|
|
554
|
+
const ref = parseAuthRef(authRef);
|
|
555
|
+
const value = await Promise.resolve(resolveSecret(authRef)).catch(() => undefined);
|
|
556
|
+
if (!isGiven(value)) {
|
|
557
|
+
checks.push({ step: 'credential', ok: false, detail: `引用 ${authRef} 解析不到密钥${ref.kind === 'provider' ? ' (provider:* 需宿主注入 resolveSecret)' : ''}` });
|
|
558
|
+
return fail('auth_failed', `取不到凭证 — 引用 '${authRef}' 解析为空${ref.kind === 'provider' ? ' (本原语不读配置存储, 需宿主注入解析器)' : ` (环境变量 ${ref.name} 未设置)`}`);
|
|
559
|
+
}
|
|
560
|
+
apiKey = String(value).trim();
|
|
561
|
+
checks.push({ step: 'credential', ok: true, detail: `已解析引用 ${authRef} (值不记录)` });
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
checks.push({ step: 'credential', ok: true, detail: '无凭证 (authRef=none)' });
|
|
565
|
+
}
|
|
566
|
+
// ── 4) 轻量连接探测 (目录端点) ──────────────────────────────
|
|
567
|
+
const origin = new URL(baseUrl).origin;
|
|
568
|
+
const catalogUrl = `${baseUrl}${proto.catalogPath}`;
|
|
569
|
+
let catalog;
|
|
570
|
+
{
|
|
571
|
+
const flag = { timedOut: false };
|
|
572
|
+
let res;
|
|
573
|
+
try {
|
|
574
|
+
res = await fetchWithProbeTimeout(catalogUrl, { method: 'GET', headers: proto.headers(apiKey) }, timeoutMs, fetchImpl, flag);
|
|
575
|
+
}
|
|
576
|
+
catch (e) {
|
|
577
|
+
const cls = classifyNetworkError(e, flag, timeoutMs);
|
|
578
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} → ${cls.detail}` });
|
|
579
|
+
return fail(cls.failureClass, `${catalogUrl} 连不上 — ${cls.detail}`);
|
|
580
|
+
}
|
|
581
|
+
const body = await readJson(res);
|
|
582
|
+
const statusClass = classifyStatus(res, body, catalogUrl, 'catalog');
|
|
583
|
+
const parsed = statusClass || body === null ? null : proto.parseCatalog(body);
|
|
584
|
+
if (parsed) {
|
|
585
|
+
catalog = parsed;
|
|
586
|
+
checks.push({ step: 'connect', ok: true, detail: `${catalogUrl} 可达, 目录 ${catalog.length} 个模型` });
|
|
587
|
+
}
|
|
588
|
+
else if (statusClass?.failureClass === 'auth_failed' || statusClass?.failureClass === 'provider_unreachable') {
|
|
589
|
+
// 这两类无歧义, 不必再交叉试别的协议
|
|
590
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} → ${statusClass.detail}` });
|
|
591
|
+
return fail(statusClass.failureClass, `${catalogUrl} 探测失败 — ${statusClass.detail}`);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// 剩下的都可能是"地址/协议不对" → 到服务根上交叉判定一次
|
|
595
|
+
const foreign = await detectForeignProtocol(origin, fetchImpl, timeoutMs);
|
|
596
|
+
if (foreign && foreign.protocol !== req.protocol) {
|
|
597
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} 探测失败; 但 ${foreign.url} 回 200 且是 ${foreign.protocol} 形状` });
|
|
598
|
+
return fail('protocol_mismatch', `这地址上的服务是 ${foreign.protocol} 协议, 不是 ${req.protocol} (证据: ${foreign.url} 回 200)`);
|
|
599
|
+
}
|
|
600
|
+
const hint = foreign ? `; 同一协议在 ${foreign.url} 上有端点 — 检查 base URL 路径是否少了/多了` : '';
|
|
601
|
+
if (statusClass) {
|
|
602
|
+
const detail = `${statusClass.detail}${hint}`;
|
|
603
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} → ${detail}` });
|
|
604
|
+
const failureClass = foreign && (res.status === 404 || res.status === 405) ? 'invalid_url' : statusClass.failureClass;
|
|
605
|
+
return fail(failureClass, `${catalogUrl} 探测失败 — ${detail}`);
|
|
606
|
+
}
|
|
607
|
+
if (body === null) {
|
|
608
|
+
const ct = res.headers?.get?.('content-type') ?? '未知';
|
|
609
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} 回 HTTP ${res.status} 但不是 JSON (Content-Type: ${ct})${hint}` });
|
|
610
|
+
return fail('protocol_mismatch', `${catalogUrl} 回的不是 JSON (Content-Type: ${ct}) — 这个地址不像 ${proto.label} 端点${hint}`);
|
|
611
|
+
}
|
|
612
|
+
const kind = envelopeProtocolOf(body);
|
|
613
|
+
const detail = kind
|
|
614
|
+
? `回的是 ${kind} 形状的目录, 不是 ${req.protocol}${hint}`
|
|
615
|
+
: `回了无法识别为 ${proto.label} 的目录结构${hint}`;
|
|
616
|
+
checks.push({ step: 'connect', ok: false, detail: `${catalogUrl} → ${detail}` });
|
|
617
|
+
return fail('protocol_mismatch', `${catalogUrl} — ${detail}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// ── 5) 模型接口可用 ────────────────────────────────────────
|
|
621
|
+
//
|
|
622
|
+
// ⚠️ 2026-09-27 实测改判 (真实凭证, 真上游): **上游 `/models` 列出的 ≠ 端点真能用的全集**。
|
|
623
|
+
// 证据: deepseek 的 `/models` 只回 `deepseek-flash` / `deepseek-v4-pro`, 但
|
|
624
|
+
// `deepseek-v4-flash` 发 chat 请求**回 HTTP 200 并给出 choices** (一直有人这么用)。
|
|
625
|
+
// 所以"目录里没有这个名字"**只能是警告, 不能是硬拒** —— 旧实现在这里直接判 `model_not_found`,
|
|
626
|
+
// 把真能用的模型误杀了 (用户先被允许选中, 到探测这一步才被拦)。
|
|
627
|
+
// 真拒的判据只有一个: **发一次请求, 端点自己拒了** (下面 §模型接口那段)。
|
|
628
|
+
const notInCatalog = !catalog.includes(model);
|
|
629
|
+
if (notInCatalog) {
|
|
630
|
+
const sample = catalog.slice(0, 5).join(', ');
|
|
631
|
+
checks.push({
|
|
632
|
+
step: 'model',
|
|
633
|
+
ok: true,
|
|
634
|
+
detail: `目录 (/models) 里没有 '${model}' (目录 ${catalog.length} 个, 例: ${sample})`
|
|
635
|
+
+ ' — /models 不是可用模型的全集, 继续真试一次由端点裁决',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const ping = proto.modelRequest(baseUrl, model, apiKey);
|
|
639
|
+
{
|
|
640
|
+
const flag = { timedOut: false };
|
|
641
|
+
try {
|
|
642
|
+
const res = await fetchWithProbeTimeout(ping.url, ping.init, timeoutMs, fetchImpl, flag);
|
|
643
|
+
const body = await readJson(res);
|
|
644
|
+
const cls = classifyStatus(res, body, ping.url, 'model');
|
|
645
|
+
if (cls) {
|
|
646
|
+
checks.push({ step: 'model', ok: false, detail: `${ping.url} → ${cls.detail}` });
|
|
647
|
+
return fail(cls.failureClass, `模型接口不可用 — ${cls.detail}`, { catalog });
|
|
648
|
+
}
|
|
649
|
+
checks.push({
|
|
650
|
+
step: 'model',
|
|
651
|
+
ok: true,
|
|
652
|
+
detail: `${ping.url} 回 HTTP ${res.status} (模型 '${model}' 可寻址)`
|
|
653
|
+
+ (notInCatalog ? ' — 注: 上游目录未列出, 但端点接受' : ''),
|
|
654
|
+
});
|
|
655
|
+
// 把"目录里没有但端点接受"这个**实测事实**带出去 (界面/报告照实说, 不回头改判成失败)
|
|
656
|
+
if (notInCatalog)
|
|
657
|
+
modelAcceptedOutsideCatalog = true;
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
const cls = classifyNetworkError(e, flag, timeoutMs);
|
|
661
|
+
checks.push({ step: 'model', ok: false, detail: `${ping.url} → ${cls.detail}` });
|
|
662
|
+
return fail(cls.failureClass, `模型接口探测失败 — ${cls.detail}`, { catalog });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// ── 6) 工具调用能力 ───────────────────────────────────────
|
|
666
|
+
if (req.checkToolCalling === false) {
|
|
667
|
+
checks.push({ step: 'tool_call', ok: true, detail: '已跳过 (checkToolCalling=false) — 能力未确认' });
|
|
668
|
+
return finish({ ok: true, message: `探测通过: ${proto.label} @ ${baseUrl}, 模型 '${model}' 可用; 工具调用能力未确认`, catalog });
|
|
669
|
+
}
|
|
670
|
+
const toolReq = proto.toolRequest(baseUrl, model, apiKey);
|
|
671
|
+
{
|
|
672
|
+
const flag = { timedOut: false };
|
|
673
|
+
try {
|
|
674
|
+
const res = await fetchWithProbeTimeout(toolReq.url, toolReq.init, timeoutMs, fetchImpl, flag);
|
|
675
|
+
const body = await readJson(res);
|
|
676
|
+
const cls = classifyStatus(res, body, toolReq.url, 'tool');
|
|
677
|
+
if (cls) {
|
|
678
|
+
checks.push({ step: 'tool_call', ok: false, detail: `${toolReq.url} → ${cls.detail}` });
|
|
679
|
+
return fail(cls.failureClass, `工具调用能力确认失败 — ${cls.detail}`, { catalog });
|
|
680
|
+
}
|
|
681
|
+
toolCalling = hasToolCallEvidence(body) ? 'yes' : 'unknown';
|
|
682
|
+
checks.push({
|
|
683
|
+
step: 'tool_call',
|
|
684
|
+
ok: true,
|
|
685
|
+
detail: toolCalling === 'yes'
|
|
686
|
+
? `${toolReq.url} 回 HTTP ${res.status} 且响应里带工具调用`
|
|
687
|
+
: `${toolReq.url} 回 HTTP ${res.status} (接受了工具声明; 本次未触发工具调用 ⇒ 能力记 unknown)`,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
catch (e) {
|
|
691
|
+
const cls = classifyNetworkError(e, flag, timeoutMs);
|
|
692
|
+
checks.push({ step: 'tool_call', ok: false, detail: `${toolReq.url} → ${cls.detail}` });
|
|
693
|
+
return fail(cls.failureClass, `工具调用能力探测失败 — ${cls.detail}`, { catalog });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return finish({
|
|
697
|
+
ok: true,
|
|
698
|
+
message: `探测通过: ${proto.label} @ ${baseUrl}, 模型 '${model}' 可寻址, 工具调用=${toolCalling === 'yes' ? '已证支持' : '未证 (端点接受工具声明)'}`,
|
|
699
|
+
catalog,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/** 响应里有没有**工具调用**的真证据 (不同协议各查各的字段) */
|
|
703
|
+
export function hasToolCallEvidence(body) {
|
|
704
|
+
if (!body || typeof body !== 'object')
|
|
705
|
+
return false;
|
|
706
|
+
const msg = body?.choices?.[0]?.message;
|
|
707
|
+
// OpenAI 兼容 / Ollama
|
|
708
|
+
if (msg && (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0 || msg.function_call))
|
|
709
|
+
return true;
|
|
710
|
+
if (body?.choices?.[0]?.finish_reason === 'tool_calls')
|
|
711
|
+
return true;
|
|
712
|
+
// Anthropic
|
|
713
|
+
if (Array.isArray(body?.content) && body.content.some((c) => c?.type === 'tool_use'))
|
|
714
|
+
return true;
|
|
715
|
+
if (body?.stop_reason === 'tool_use')
|
|
716
|
+
return true;
|
|
717
|
+
// Gemini
|
|
718
|
+
const parts = body?.candidates?.[0]?.content?.parts;
|
|
719
|
+
if (Array.isArray(parts) && parts.some((p) => p?.functionCall || p?.function_call))
|
|
720
|
+
return true;
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
// ============================================================
|
|
724
|
+
// 展示
|
|
725
|
+
// ============================================================
|
|
726
|
+
/** 一行事实: `✓ 探测通过 · …` 或 `✗ <类别> (人话) · …` */
|
|
727
|
+
export function formatProbeResult(r) {
|
|
728
|
+
const head = `${r.providerId}/${r.model} @ ${r.baseUrl || '(无 URL)'} [来源=${r.baseUrlSource ?? '无'}]`;
|
|
729
|
+
if (r.ok)
|
|
730
|
+
return `✓ 探测通过 · ${head} · 工具调用=${r.toolCalling}`;
|
|
731
|
+
const cls = r.failureClass;
|
|
732
|
+
return `✗ ${cls} (${PROBE_FAILURE_ZH[cls] ?? '未分类'}) · ${head} · ${r.message}`;
|
|
733
|
+
}
|
|
734
|
+
/** 门用: 结果序列化里有没有把凭证值漏出去 (拿真 key 探完后不许出现它) */
|
|
735
|
+
export function resultLeaksSecret(r, secret) {
|
|
736
|
+
if (!secret)
|
|
737
|
+
return false;
|
|
738
|
+
try {
|
|
739
|
+
return JSON.stringify(r).includes(secret);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|