@gehennawu/dsh-service 2.0.7 → 2.0.8
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 +58 -10
- package/README.md +58 -10
- package/client.js +1 -1
- package/index.js +127 -7
- package/model-config-routes.js +633 -0
- package/model-config-schema.js +132 -0
- package/model-smart-fill.js +2206 -0
- package/model-workbench-routes.js +1740 -0
- package/model-workbench-settings.js +389 -0
- package/model-workbench.js +1618 -0
- package/package.json +10 -2
- package/provider-availability.js +84 -0
- package/subagent-routes.js +7 -0
|
@@ -0,0 +1,2206 @@
|
|
|
1
|
+
// Host deep module:智能填充来源与证据模型(P2,规格 docs/planning/model-config-smart-fill.md §4/§5)。
|
|
2
|
+
//
|
|
3
|
+
// 定位:这一层只做「证据 → 候选 → 分组建议」的纯逻辑与受控取数,不写配置、不碰密钥、不探测渠道。
|
|
4
|
+
// 宿主路由拿它回答两个问题:
|
|
5
|
+
// 1) 某渠道/协议/模型在**固定来源**里到底有什么可引用的资料;
|
|
6
|
+
// 2) 开关 OFF 时零网络、ON 时按固定白名单做条件请求并给出分组建议与来源状态。
|
|
7
|
+
//
|
|
8
|
+
// 与 model-workbench.js 的关系:复用其中的**纯函数**(stableStringify / piAiReasoningEfforts /
|
|
9
|
+
// THINKING_LEVELS_SET / MODEL_ID_RE / MAX_MODEL_ID_LENGTH)避免两处漂移;网络原语**不复用**
|
|
10
|
+
// `fetchDocText`——它只校验 HTTPS 与 host 非空、且把 304 当重定向拒绝(research §8 明确列为
|
|
11
|
+
// 本次必须修正的缺口),而固定来源白名单与条件请求 304 都是本层的硬要求,因此在本模块内
|
|
12
|
+
// 结构化强制(`assertSourceAllowed` + `createSourceFetcher` + `createMetadataCache`)。
|
|
13
|
+
//
|
|
14
|
+
// 安全不变量(每个导出都要过一遍):
|
|
15
|
+
// - 固定来源白名单写死在 SOURCE_WHITELIST:origin + 路径(或路径前缀)+ 允许的查询键三重匹配,
|
|
16
|
+
// 任何其它 URL 一律拒绝(拒绝码不含输入回显)。查询键必须单值有界;值不得含 `/` `\` 或 `..`
|
|
17
|
+
// (含百分号编码形态,路径按解码后判定)。模型 ID 只作为查询参数值经 encodeURIComponent,
|
|
18
|
+
// 绝不进 shell/路径/命令。
|
|
19
|
+
// - 取数有界:HTTPS-only、端口空或 443、禁 URL 凭据、禁重定向、**整次请求墙钟上限**、传输与
|
|
20
|
+
// 解压后双重体积上限(解压走 zlib `maxOutputLength`,引擎侧截断而非先分配后检查)、
|
|
21
|
+
// 失败/过期可见(staleError),不宣称最新。
|
|
22
|
+
// - 元数据来源**永不应收到凭据**:请求头走固定闭集 METADATA_HEADER_ALLOWLIST,Authorization /
|
|
23
|
+
// Cookie / X-Api-Key 等敏感头(含大小写变体)与集合外的新头一律在发请求前拒绝。
|
|
24
|
+
// - 不做任何渠道探测、不读取真实配置/密钥、不发推理请求。测试注入 transport 即可离线运行。
|
|
25
|
+
//
|
|
26
|
+
// 语义红线(规格 §5 表格,逐条对应实现):
|
|
27
|
+
// - pi-ai 索引保留 **渠道 + api + 模型 ID** 三元身份;同渠道同 ID 跨 api 是不同记录,绝不合并。
|
|
28
|
+
// - reasoning 三态:true / false / **未声明(undefined)**,未声明绝不塌成 false。
|
|
29
|
+
// - 模态按集合比较,数组顺序差异不是冲突。
|
|
30
|
+
// - wire(reasoningEfforts)与 compat 只有「当前渠道 + 当前协议」的精确目录条目可建议;
|
|
31
|
+
// 同渠道跨 api / 跨渠道 / 外部档案一律只读参考,需手填。
|
|
32
|
+
// - name / contextWindow / maxTokens / input 跨渠道只作参考,可逐组**明确**采纳。
|
|
33
|
+
// - modelparams 记录保留 provider/authType/apiSurface/model/wireId 独立身份,不做跨记录并集;
|
|
34
|
+
// 其 effort values/default 是「来源声明」,不是当前渠道 wire/默认;range.max 只读,绝不填 maxTokens。
|
|
35
|
+
// 逐条语义漂移(非法 ID / 缺 path / range 非整数边界…)只跳该条并计入 skipped/skippedDetail,
|
|
36
|
+
// 绝不抛出去打崩整条来源,也绝不静默当成「无资料」。
|
|
37
|
+
// - 运行时 reasoning.efforts 只返回 id/name/description,没有 wire value,**不能按 ID 捏造 wire**;
|
|
38
|
+
// defaultMaxTokens/defaultEffort 是 adapter 调用默认,不是模型能力上限或远端默认。
|
|
39
|
+
// - OpenRouter 同名多作者必须保留全部匹配,绝不默认取首条;单模型详情端点的 `data` 是**对象**
|
|
40
|
+
// (`/api/v1/model/{author}/{slug}`),解析与匹配两层同形处理,不进「取首条」逻辑。
|
|
41
|
+
// - 来源绑定(`resolveSourceBinding`)只登记「哪条候选对应 route+api+model+地址」的身份指纹;
|
|
42
|
+
// 不携带 wire/compat 值,地址或身份变化即待复核/失效,绝不按关联跨来源采纳。
|
|
43
|
+
// - 资料缺席不是「不支持」,不生成否定结论。
|
|
44
|
+
|
|
45
|
+
import { createHash } from 'node:crypto'
|
|
46
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
47
|
+
import { join } from 'node:path'
|
|
48
|
+
import https from 'node:https'
|
|
49
|
+
import { brotliDecompress, gunzip, inflate } from 'node:zlib'
|
|
50
|
+
|
|
51
|
+
import {
|
|
52
|
+
MAX_MODEL_ID_LENGTH,
|
|
53
|
+
MODEL_ID_RE,
|
|
54
|
+
THINKING_LEVELS_SET,
|
|
55
|
+
piAiReasoningEfforts,
|
|
56
|
+
stableStringify,
|
|
57
|
+
} from './model-workbench.js'
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// 常量:模式、来源白名单、上限
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 智能填充开关档位:每次进页默认 OFF,OFF 绝不发起任何外部请求。 */
|
|
64
|
+
export const SMART_FILL_MODES = Object.freeze({ OFF: 'off', ON: 'on' })
|
|
65
|
+
|
|
66
|
+
/** 用户在 UI 上看到的固定来源集合(可闭集:不含 models.dev、不含任意 URL、不含网页搜索)。 */
|
|
67
|
+
export const SMART_FILL_SOURCE_IDS = Object.freeze(['pi-ai', 'modelparams', 'openrouter', 'runtime'])
|
|
68
|
+
|
|
69
|
+
/** 元数据请求统一 UA(固定字符串,不含任何用户/渠道信息)。 */
|
|
70
|
+
export const SMART_FILL_USER_AGENT = 'dsh-service-smart-fill'
|
|
71
|
+
/** **整次请求的墙钟上限**(不是 socket 空闲超时;慢速持续吐字节也会被它截断)。 */
|
|
72
|
+
export const METADATA_TIMEOUT_MS = 10000
|
|
73
|
+
/** 传输体上限(解压前)。 */
|
|
74
|
+
export const MAX_METADATA_BYTES = 4 * 1024 * 1024
|
|
75
|
+
/** 解压后上限:与传输上限分开,防压缩炸弹。传给 zlib 的 maxOutputLength,引擎侧截断。 */
|
|
76
|
+
export const MAX_METADATA_INFLATED_BYTES = 8 * 1024 * 1024
|
|
77
|
+
/** OpenRouter 搜索 limit 的硬上界(`/api/v1/models` 只接受有界十进制)。 */
|
|
78
|
+
export const MAX_OPENROUTER_SEARCH_LIMIT = 50
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 元数据请求允许的头部闭集(小写)。元数据来源**永远不能收到凭据**:Authorization /
|
|
82
|
+
* Cookie / X-Api-Key / Proxy-Authorization 等一律不在集合内;集合外的新键直接拒绝,
|
|
83
|
+
* 因此调用方无法把任意头(含敏感头)塞进对第三方资料站的请求。
|
|
84
|
+
*/
|
|
85
|
+
export const METADATA_HEADER_ALLOWLIST = Object.freeze(['accept', 'accept-encoding', 'user-agent', 'if-none-match'])
|
|
86
|
+
|
|
87
|
+
/** 敏感头名(小写,含常见变体);命中即拒,错误码 forbidden-header 且不回显键名。 */
|
|
88
|
+
const SENSITIVE_HEADER_NAMES = Object.freeze([
|
|
89
|
+
'authorization', 'proxy-authorization', 'cookie', 'cookie2', 'set-cookie',
|
|
90
|
+
'x-api-key', 'api-key', 'apikey', 'x-auth-token', 'x-access-token', 'x-api-token',
|
|
91
|
+
'authentication', 'www-authenticate', 'proxy-authenticate',
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
/** 查询值里不允许出现的路径/控制形状(模型 ID 只作数据参数,绝不表达路径)。 */
|
|
95
|
+
const QUERY_VALUE_FORBIDDEN_RE = /[\u0000-\u001f\u007f/\\]/
|
|
96
|
+
/** 单次扫描/索引的记录上限(防御性,不静默截断语义)。 */
|
|
97
|
+
export const MAX_PI_RECORDS = 50000
|
|
98
|
+
export const MAX_MODELPARAMS_RECORDS = 200000
|
|
99
|
+
/** 单模型保留的候选上限(超出保留前 N 并记录 overflow,不假装完整)。 */
|
|
100
|
+
export const MAX_OPTIONS_PER_GROUP = 24
|
|
101
|
+
export const MAX_OPENROUTER_MATCHES = 20
|
|
102
|
+
|
|
103
|
+
/** modelparams.dev 中表达「档位式思考」的参数路径;其余 thinking.* 是 toggle/预算制,不表态。 */
|
|
104
|
+
export const MODELPARAMS_EFFORT_PATHS = Object.freeze(['reasoning_effort', 'output_config.effort', 'reasoning.effort'])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 固定来源白名单。三重匹配:origin host + 路径(精确或前缀)+ 允许的查询键。
|
|
108
|
+
* `paths` 与 `pathPrefixes` 互斥使用;前缀必须在 `length > prefix.length` 时才算命中
|
|
109
|
+
* (`/api/v1/model/` 后面必须还有段)。
|
|
110
|
+
*/
|
|
111
|
+
export const SOURCE_WHITELIST = Object.freeze([
|
|
112
|
+
Object.freeze({
|
|
113
|
+
id: 'modelparams',
|
|
114
|
+
origin: 'https://modelparams.dev',
|
|
115
|
+
paths: Object.freeze(['/api/v1/models.json']),
|
|
116
|
+
queryKeys: Object.freeze([]),
|
|
117
|
+
}),
|
|
118
|
+
Object.freeze({
|
|
119
|
+
id: 'openrouter-model',
|
|
120
|
+
origin: 'https://openrouter.ai',
|
|
121
|
+
pathPrefixes: Object.freeze(['/api/v1/model/']),
|
|
122
|
+
queryKeys: Object.freeze([]),
|
|
123
|
+
}),
|
|
124
|
+
Object.freeze({
|
|
125
|
+
id: 'openrouter-search',
|
|
126
|
+
origin: 'https://openrouter.ai',
|
|
127
|
+
paths: Object.freeze(['/api/v1/models']),
|
|
128
|
+
queryKeys: Object.freeze(['q', 'limit']),
|
|
129
|
+
}),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
/** OpenRouter 单模型/搜索两条**固定** URL(浏览器永远不参与拼装,模型 ID 只作查询值编码)。 */
|
|
133
|
+
export function modelParamsRequest() {
|
|
134
|
+
return { sourceId: 'modelparams', url: 'https://modelparams.dev/api/v1/models.json' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 查询值编码:整个值经 encodeURIComponent(查询里 `/` 无结构含义,安全且最保守)。 */
|
|
138
|
+
function encodeQueryValue(value) {
|
|
139
|
+
return encodeURIComponent(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 路径段编码:只转义**会改变路径结构或控制语义**的字符,其余按 RFC 3986 pchar 原样保留
|
|
144
|
+
* (保留 `@`、`:`、`.`、`~`、子分隔符 `!$&'()*+,;=`)。理由:`%40`/`%2F` 这类多余转义对
|
|
145
|
+
* 不解码百分号的源站就是另一个字符串——`%2F` 实测直接 404;上游模型 ID 本身含 `@`(如
|
|
146
|
+
* `@cf/...`)与 `.`,过度编码会把合法 ID 变成查不到的 ID。
|
|
147
|
+
* 转义集:控制字符/DEL/空格、`"` `<` `>` `\` `^` `` ` `` `{` `|` `}`、以及 `?` `#` `%`
|
|
148
|
+
* (前两者会截断路径进入 query/fragment,后者会开启百分号解码歧义)。`/` 不经过本函数
|
|
149
|
+
* (调用方按 `/` 切段),因此路径分隔语义恒定。
|
|
150
|
+
*/
|
|
151
|
+
function encodePathSegment(segment) {
|
|
152
|
+
const text = typeof segment === 'string' ? segment : ''
|
|
153
|
+
let out = ''
|
|
154
|
+
for (const char of text) {
|
|
155
|
+
const code = char.codePointAt(0)
|
|
156
|
+
if (code <= 0x20 || code === 0x7f || '?<>\\^`{|}%"#'.includes(char)) {
|
|
157
|
+
out += encodeURIComponent(char)
|
|
158
|
+
} else {
|
|
159
|
+
out += char
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* OpenRouter 两条固定 URL(浏览器永远不参与拼装,模型 ID 只作数据)。
|
|
167
|
+
*
|
|
168
|
+
* **路径段逐段编码**(实测 2026-10-12,代理匿名):
|
|
169
|
+
* `/api/v1/model/{author}/{slug}` 对**字面**斜杠返回 200,对 `%2F` 一律 404
|
|
170
|
+
* (`/api/v1/model/anthropic/claude-haiku-5.5` → 200;同 URL 的 `%2F` 形态 → 404)。
|
|
171
|
+
* 因此模型 ID 必须按 `/` 切成段、逐段做结构安全编码再拼回:`@cf/meta/llama` 这类多段 ID
|
|
172
|
+
* 保留路径分隔语义,段内的空白/`?`/`#`/`%` 仍被转义,不可能改写路径结构或注入查询。
|
|
173
|
+
* 查询形态(无 `/` 的裸 ID)只在 `q` 值里编码。
|
|
174
|
+
*/
|
|
175
|
+
export function openRouterRequest(modelId) {
|
|
176
|
+
const id = typeof modelId === 'string' ? modelId : ''
|
|
177
|
+
if (id.includes('/')) {
|
|
178
|
+
const encodedPath = id.split('/').map(encodePathSegment).join('/')
|
|
179
|
+
return {
|
|
180
|
+
sourceId: 'openrouter-model',
|
|
181
|
+
url: `https://openrouter.ai/api/v1/model/${encodedPath}`,
|
|
182
|
+
kind: 'model',
|
|
183
|
+
href: `https://openrouter.ai/models/${encodedPath}`,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
sourceId: 'openrouter-search',
|
|
188
|
+
kind: 'search',
|
|
189
|
+
url: `https://openrouter.ai/api/v1/models?q=${encodeQueryValue(id)}&limit=${MAX_OPENROUTER_SEARCH_LIMIT}`,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
// 基础工具(本地实现,避免与既有模块耦合到非导出行为)
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
198
|
+
|
|
199
|
+
const str = (value) => (typeof value === 'string' ? value : '')
|
|
200
|
+
|
|
201
|
+
const positiveInt = (value) => (Number.isInteger(value) && value > 0 ? value : undefined)
|
|
202
|
+
|
|
203
|
+
const finiteInt = (value) => (Number.isInteger(value) ? value : undefined)
|
|
204
|
+
|
|
205
|
+
const clone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)))
|
|
206
|
+
|
|
207
|
+
/** 元数据错误统一带稳定 code,错误信息只含来源自己的码,不回显 URL/输入。 */
|
|
208
|
+
function metadataError(code) {
|
|
209
|
+
const error = new Error(code)
|
|
210
|
+
error.code = code
|
|
211
|
+
return error
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 稳定候选 ID:值变则 ID 变,宿主可重算复现;客户端只能回传服务端签发的 ID。 */
|
|
215
|
+
function candidateIdFor(parts) {
|
|
216
|
+
return createHash('sha256').update(stableStringify(parts)).digest('hex').slice(0, 16)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
// 白名单与受控取数
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 固定来源白名单校验。返回 `{ok:true, sourceId, url}` 或 `{ok:false, code}`。
|
|
225
|
+
* 拒绝码:bad-url / insecure / credentials-in-url / bad-port / bad-host / bad-path /
|
|
226
|
+
* bad-query / source-not-whitelisted。
|
|
227
|
+
*
|
|
228
|
+
* 查询面加固(三道):① 键必须在来源声明的集合内且**不重复**(重复键的取值歧义在宿主侧
|
|
229
|
+
* 无法表达,直接拒);② 值是单个、有界的字符串(拒控制字符,长度不超过模型 ID 上限);
|
|
230
|
+
* ③ 值不得含 `/` `\`(含 `%2f` 解码后)或 `..`(含 `%2e%2e` 解码后),杜绝编码型路径穿越。
|
|
231
|
+
* 模型 ID 只经 encodeURIComponent 变成查询值,绝不进 path / shell / 命令。
|
|
232
|
+
*/
|
|
233
|
+
export function assertSourceAllowed(rawUrl) {
|
|
234
|
+
let parsed
|
|
235
|
+
try {
|
|
236
|
+
parsed = new URL(String(rawUrl ?? ''))
|
|
237
|
+
} catch (_) {
|
|
238
|
+
return { ok: false, code: 'bad-url' }
|
|
239
|
+
}
|
|
240
|
+
if (parsed.protocol !== 'https:') return { ok: false, code: 'insecure' }
|
|
241
|
+
if (parsed.username !== '' || parsed.password !== '') return { ok: false, code: 'credentials-in-url' }
|
|
242
|
+
if (parsed.port !== '' && parsed.port !== '443') return { ok: false, code: 'bad-port' }
|
|
243
|
+
const host = parsed.hostname.toLowerCase().replace(/\.$/, '')
|
|
244
|
+
if (host === '') return { ok: false, code: 'bad-host' }
|
|
245
|
+
// 路径按**解码后**判定:`%2e%2e%2f` 这类编码穿越在 URL 层不会被归一化,必须显式解码。
|
|
246
|
+
// 解码失败的畸形百分号序列本身也不该进入白名单匹配,直接拒。
|
|
247
|
+
let path
|
|
248
|
+
try {
|
|
249
|
+
path = decodeURIComponent(parsed.pathname)
|
|
250
|
+
} catch (_) {
|
|
251
|
+
return { ok: false, code: 'bad-path' }
|
|
252
|
+
}
|
|
253
|
+
if (path === '' || path.includes('..') || path.includes('\\') || /[\u0000-\u001f]/.test(path)) return { ok: false, code: 'bad-path' }
|
|
254
|
+
for (const source of SOURCE_WHITELIST) {
|
|
255
|
+
let originHost = ''
|
|
256
|
+
try {
|
|
257
|
+
originHost = new URL(source.origin).hostname.toLowerCase()
|
|
258
|
+
} catch (_) {
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
if (originHost !== host) continue
|
|
262
|
+
const exact = (source.paths ?? []).includes(path)
|
|
263
|
+
const prefix = (source.pathPrefixes ?? []).some((candidate) => path.startsWith(candidate) && path.length > candidate.length)
|
|
264
|
+
if (!exact && !prefix) continue
|
|
265
|
+
const allowedKeys = source.queryKeys ?? []
|
|
266
|
+
const seenKeys = new Set()
|
|
267
|
+
for (const [key, value] of parsed.searchParams) {
|
|
268
|
+
if (!allowedKeys.includes(key)) return { ok: false, code: 'bad-query' }
|
|
269
|
+
// 重复键:同一参数出现两次(?limit=50&limit=50、?q=a&q=b)一律拒绝。
|
|
270
|
+
if (seenKeys.has(key)) return { ok: false, code: 'bad-query' }
|
|
271
|
+
seenKeys.add(key)
|
|
272
|
+
if (value === '' || value.length > MAX_MODEL_ID_LENGTH) return { ok: false, code: 'bad-query' }
|
|
273
|
+
if (QUERY_VALUE_FORBIDDEN_RE.test(value) || value.includes('..')) return { ok: false, code: 'bad-query' }
|
|
274
|
+
}
|
|
275
|
+
if (source.id === 'openrouter-search' && parsed.searchParams.has('limit')) {
|
|
276
|
+
// limit 必须是**数值**且有界:只接受 1..50 的十进制整数(手拼的 051、99999 全拒)。
|
|
277
|
+
const rawLimit = parsed.searchParams.get('limit')
|
|
278
|
+
if (!/^\d{1,2}$/.test(rawLimit)) return { ok: false, code: 'bad-query' }
|
|
279
|
+
if (Number(rawLimit) > MAX_OPENROUTER_SEARCH_LIMIT) return { ok: false, code: 'bad-query' }
|
|
280
|
+
}
|
|
281
|
+
return { ok: true, sourceId: source.id, url: parsed.toString() }
|
|
282
|
+
}
|
|
283
|
+
return { ok: false, code: 'source-not-whitelisted' }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 默认传输:HTTPS 直连、禁重定向(3xx 由调用方判定)、有界超时、传输与解压双上限。
|
|
288
|
+
*
|
|
289
|
+
* 有界性的两点硬要求(旧实现都在检查之后才分配,等于没限住):
|
|
290
|
+
* - **解压带 `maxOutputLength`**:zlib 在引擎侧边解边截断,不会先解出整个 bomb 再判大小。
|
|
291
|
+
* 解压结果仍做一次字节复核,覆盖「zlib 截断行为与版本差异」。
|
|
292
|
+
* - **整次请求的墙钟上限**:`timeout` 选项只统计 socket 空闲,攻击者持续慢速吐字节即可
|
|
293
|
+
* 无限期占用。这里用 `Date.now()` 死线 + `AbortSignal` + 保险计时器,超时一律 abort。
|
|
294
|
+
* - 三个上限(timeoutMs / maxBytes / maxInflatedBytes)缺省都回落到模块常量,绝不出现
|
|
295
|
+
* `NaN` 死线或 `undefined` 上限。
|
|
296
|
+
*
|
|
297
|
+
* 网络面为什么**不做 IP 钉住**(与 model-workbench 的在线发现有意不同):
|
|
298
|
+
* - 本层没有「用户输入的 host」这条攻击面:URL 只能来自 SOURCE_WHITELIST 的三个固定
|
|
299
|
+
* origin,用户可控的只有模型 ID,且它只作为查询值/路径段经结构安全编码。
|
|
300
|
+
* DNS rebinding 需要攻击者控制域名或解析,这里两者都不存在。
|
|
301
|
+
* - 本机出网由 `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY` 走 DSH 的进程级 http 代理(实测
|
|
302
|
+
* `https.request` 因此命中代理;loopback 被 NO_PROXY 强制直连)。若把连接钉到解析出的
|
|
303
|
+
* 公网 IP,HTTPS 的 CONNECT/SNI 目标会变成裸 IP,代理侧虚拟主机路由与 TLS 身份都会
|
|
304
|
+
* 失配——即「为堵一个不存在的洞而破坏现有可用来源」。
|
|
305
|
+
* - 因此网络面加固落在别处:单来源固定 origin、HTTPS-only、禁重定向、禁凭据、固定头部
|
|
306
|
+
* 闭集、双重体积上限与整次墙钟上限。
|
|
307
|
+
*
|
|
308
|
+
* 导出说明:`defaultTransport` 是**不校验白名单的传输原语**(调用方默认是
|
|
309
|
+
* `createSourceFetcher`,白名单在那层强制)。导出它是为了让回归测试能把原语指向本地
|
|
310
|
+
* HTTPS 测试服务器,验证真实 socket 路径的墙钟死线/解压上限/错误码分类;**它不是来源
|
|
311
|
+
* 边界**,业务代码不要直接调用。
|
|
312
|
+
*/
|
|
313
|
+
export function defaultTransport(rawUrl, { headers, timeoutMs, maxBytes, maxInflatedBytes, signal }) {
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
// 缺省兜底:`timeoutMs` 未给时必须回落模块常量。否则 deadline 会是 NaN,
|
|
316
|
+
// `failIfExpired()` 在首块数据到达时就把正常响应误判成超时。
|
|
317
|
+
const limitMs = positiveInt(timeoutMs) ?? METADATA_TIMEOUT_MS
|
|
318
|
+
const inflatedLimit = positiveInt(maxInflatedBytes) ?? MAX_METADATA_INFLATED_BYTES
|
|
319
|
+
const byteLimit = positiveInt(maxBytes) ?? MAX_METADATA_BYTES
|
|
320
|
+
const deadline = Date.now() + limitMs
|
|
321
|
+
let finished = false
|
|
322
|
+
let timer
|
|
323
|
+
let aborted = false
|
|
324
|
+
// 「是否因墙钟死线而失败」必须显式记账:用 Date.now() 反推会把死线之后才发现的
|
|
325
|
+
// 坏压缩流误报成 timeout。
|
|
326
|
+
let timedOut = false
|
|
327
|
+
const abortController = new AbortController()
|
|
328
|
+
const abortOnce = () => {
|
|
329
|
+
if (aborted) return
|
|
330
|
+
aborted = true
|
|
331
|
+
abortController.abort()
|
|
332
|
+
}
|
|
333
|
+
if (signal !== undefined && signal !== null) {
|
|
334
|
+
if (signal.aborted) abortOnce()
|
|
335
|
+
else signal.addEventListener('abort', abortOnce, { once: true })
|
|
336
|
+
}
|
|
337
|
+
const settle = (fn, value) => {
|
|
338
|
+
if (finished) return
|
|
339
|
+
finished = true
|
|
340
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
341
|
+
fn(value)
|
|
342
|
+
}
|
|
343
|
+
// abort/超限都先**落定**再断连接:否则紧随其后的 request 'error' 会把错误归类成
|
|
344
|
+
// network,把有界性失败伪装成普通网络故障。
|
|
345
|
+
const fail = (code) => {
|
|
346
|
+
settle(reject, metadataError(code))
|
|
347
|
+
abortOnce()
|
|
348
|
+
}
|
|
349
|
+
const expired = () => Date.now() >= deadline
|
|
350
|
+
const failIfExpired = () => {
|
|
351
|
+
if (!expired()) return false
|
|
352
|
+
timedOut = true
|
|
353
|
+
fail('timeout')
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
let request
|
|
357
|
+
try {
|
|
358
|
+
request = https.request(rawUrl, { method: 'GET', headers, timeout: limitMs, signal: abortController.signal }, (response) => {
|
|
359
|
+
const status = response.statusCode ?? 0
|
|
360
|
+
const chunks = []
|
|
361
|
+
let received = 0
|
|
362
|
+
response.on('data', (chunk) => {
|
|
363
|
+
// 已落定(超限/超时)后不再累积:abort 关连接前可能还有几块数据到达。
|
|
364
|
+
if (finished) return
|
|
365
|
+
if (failIfExpired()) return
|
|
366
|
+
received += chunk.length
|
|
367
|
+
if (received > byteLimit) {
|
|
368
|
+
fail('too-large')
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
chunks.push(chunk)
|
|
372
|
+
})
|
|
373
|
+
response.on('error', (error) => {
|
|
374
|
+
if (finished) return
|
|
375
|
+
settle(reject, metadataError(error?.code === 'ABORT_ERR' || aborted ? 'timeout' : 'network'))
|
|
376
|
+
})
|
|
377
|
+
response.on('end', () => {
|
|
378
|
+
if (finished) return
|
|
379
|
+
if (failIfExpired()) return
|
|
380
|
+
const encoding = String(response.headers['content-encoding'] || '').toLowerCase()
|
|
381
|
+
const body = Buffer.concat(chunks)
|
|
382
|
+
// 未压缩正文的最终判据在这里;压缩正文由 zlib maxOutputLength 引擎侧截断后再复核。
|
|
383
|
+
if (received > byteLimit || body.length > byteLimit) {
|
|
384
|
+
fail('too-large')
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const finish = (text) => {
|
|
388
|
+
if (finished) return
|
|
389
|
+
if (failIfExpired()) return
|
|
390
|
+
if (Buffer.byteLength(text, 'utf8') > Math.min(byteLimit, inflatedLimit)) fail('too-large')
|
|
391
|
+
else settle(resolve, { status, headers: response.headers, body: text })
|
|
392
|
+
}
|
|
393
|
+
// zlib 的 maxOutputLength 截断报 ERR_BUFFER_TOO_LARGE:这是**有界性**命中,
|
|
394
|
+
// 不是坏压缩流;真实坏流才是 bad-<encoding>。
|
|
395
|
+
const onDecoded = (kind) => (error, decoded) => {
|
|
396
|
+
if (finished) return
|
|
397
|
+
if (error) fail(error?.code === 'ERR_BUFFER_TOO_LARGE' ? 'too-large' : (timedOut ? 'timeout' : `bad-${kind}`))
|
|
398
|
+
else finish(decoded.toString('utf8'))
|
|
399
|
+
}
|
|
400
|
+
if (status === 304) {
|
|
401
|
+
settle(resolve, { status, headers: response.headers, body: '' })
|
|
402
|
+
} else if (encoding === 'gzip' || encoding === 'x-gzip') {
|
|
403
|
+
gunzip(body, { maxOutputLength: inflatedLimit }, onDecoded('gzip'))
|
|
404
|
+
} else if (encoding === 'br') {
|
|
405
|
+
brotliDecompress(body, { maxOutputLength: inflatedLimit }, onDecoded('br'))
|
|
406
|
+
} else if (encoding === 'deflate') {
|
|
407
|
+
inflate(body, { maxOutputLength: inflatedLimit }, onDecoded('deflate'))
|
|
408
|
+
} else {
|
|
409
|
+
finish(body.toString('utf8'))
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
} catch (_) {
|
|
414
|
+
settle(reject, metadataError('network'))
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
timer = setTimeout(() => {
|
|
418
|
+
timedOut = true
|
|
419
|
+
fail('timeout')
|
|
420
|
+
}, limitMs)
|
|
421
|
+
// abort 后 socket 错误事件仍可能到达:此时已完成落定,绝不让它升级成 unhandled error。
|
|
422
|
+
request.on('error', (error) => {
|
|
423
|
+
if (finished) return
|
|
424
|
+
settle(reject, metadataError(error?.code === 'ABORT_ERR' || aborted ? 'timeout' : 'network'))
|
|
425
|
+
})
|
|
426
|
+
timer.unref?.()
|
|
427
|
+
// socket 空闲超时是墙钟死线之外的补充,不是替代:它抓不到「一直有字节但不结束」。
|
|
428
|
+
request.on('timeout', () => {
|
|
429
|
+
fail('timeout')
|
|
430
|
+
})
|
|
431
|
+
request.end()
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 元数据请求头白名单校验。元数据来源是**外部第三方**,永不应收到本机凭据:
|
|
437
|
+
* - 任何敏感头(Authorization / Proxy-Authorization / Cookie / X-Api-Key …,含常见变体)→
|
|
438
|
+
* `forbidden-header`(先于白名单判定,错误信息不回显键名/值);
|
|
439
|
+
* - 白名单外的任意头 → `unknown-header`(默认拒绝,新增元数据头必须显式进闭集)。
|
|
440
|
+
* 名字大小写不敏感,全部归一为小写(http 层同名同义,杜绝大小写变体绕过)。
|
|
441
|
+
*/
|
|
442
|
+
function normalizeMetadataHeaders(headers) {
|
|
443
|
+
const normalized = {}
|
|
444
|
+
if (headers === undefined || headers === null) return normalized
|
|
445
|
+
if (!isPlainObject(headers)) throw metadataError('bad-headers')
|
|
446
|
+
for (const key of Object.keys(headers)) {
|
|
447
|
+
const name = key.toLowerCase()
|
|
448
|
+
if (SENSITIVE_HEADER_NAMES.includes(name)) throw metadataError('forbidden-header')
|
|
449
|
+
if (!METADATA_HEADER_ALLOWLIST.includes(name)) throw metadataError('unknown-header')
|
|
450
|
+
const value = headers[key]
|
|
451
|
+
if (typeof value !== 'string' && typeof value !== 'number') throw metadataError('bad-header-value')
|
|
452
|
+
const text = String(value)
|
|
453
|
+
// CR/LF 注入与超长值在进入 http 层之前就拒(http 层虽也拒,但这里要给稳定码)。
|
|
454
|
+
if (/[\r\n\u0000]/.test(text) || text.length > 1024) throw metadataError('bad-header-value')
|
|
455
|
+
normalized[name] = text
|
|
456
|
+
}
|
|
457
|
+
return normalized
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 受控取数器:先过白名单,再发条件请求。
|
|
462
|
+
* `transport` 可注入(测试完全离线);默认实现见 defaultTransport。
|
|
463
|
+
* 头部走固定闭集:元数据来源**永远收不到凭据**(`forbidden-header` / `unknown-header`)。
|
|
464
|
+
* 注入 transport 也套**整次请求墙钟上限**:注入实现不尊重 timeout 时不会无限挂住。
|
|
465
|
+
* 返回 `{status:200, body, etag, sourceId}` 或 `{status:304, etag, body:'', sourceId}`;
|
|
466
|
+
* 失败抛带 code 的 Error(redirect / http-status:N / too-large / timeout / network / bad-*)。
|
|
467
|
+
*/
|
|
468
|
+
export function createSourceFetcher({ transport, limits = {} } = {}) {
|
|
469
|
+
const impl = typeof transport === 'function' ? transport : defaultTransport
|
|
470
|
+
const maxBytes = positiveInt(limits.maxBytes) ?? MAX_METADATA_BYTES
|
|
471
|
+
const maxInflatedBytes = positiveInt(limits.maxInflatedBytes) ?? MAX_METADATA_INFLATED_BYTES
|
|
472
|
+
const timeoutMs = positiveInt(limits.timeoutMs) ?? METADATA_TIMEOUT_MS
|
|
473
|
+
const callTransport = (url, options) => {
|
|
474
|
+
// 注入实现与默认实现一视同仁:整次请求有墙钟死线,不是只有 socket 空闲超时。
|
|
475
|
+
// 手写 race(不用 Promise.race):超时后到达的 resolve/reject 都被吞掉,绝不产生
|
|
476
|
+
// unhandledRejection;abort 信号同时兜住支持 signal 的实现。
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
let settled = false
|
|
479
|
+
let timer
|
|
480
|
+
const controller = new AbortController()
|
|
481
|
+
const settle = (fn, value) => {
|
|
482
|
+
if (settled) return
|
|
483
|
+
settled = true
|
|
484
|
+
clearTimeout(timer)
|
|
485
|
+
fn(value)
|
|
486
|
+
}
|
|
487
|
+
timer = setTimeout(() => {
|
|
488
|
+
controller.abort()
|
|
489
|
+
settle(reject, metadataError('timeout'))
|
|
490
|
+
}, timeoutMs)
|
|
491
|
+
timer.unref?.()
|
|
492
|
+
Promise.resolve()
|
|
493
|
+
.then(() => impl(url, { ...options, signal: controller.signal }))
|
|
494
|
+
.then(
|
|
495
|
+
(response) => settle(resolve, response),
|
|
496
|
+
(error) => settle(reject, error?.code !== undefined ? error : metadataError('network')),
|
|
497
|
+
)
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
return async function fetchSource(rawUrl, { etag = '', headers = {} } = {}) {
|
|
501
|
+
const allowed = assertSourceAllowed(rawUrl)
|
|
502
|
+
if (!allowed.ok) throw metadataError(allowed.code)
|
|
503
|
+
const requestHeaders = {
|
|
504
|
+
accept: 'application/json',
|
|
505
|
+
'accept-encoding': 'gzip, br',
|
|
506
|
+
'user-agent': SMART_FILL_USER_AGENT,
|
|
507
|
+
...normalizeMetadataHeaders(headers),
|
|
508
|
+
}
|
|
509
|
+
if (typeof etag === 'string' && etag !== '') {
|
|
510
|
+
if (/[\r\n\u0000]/.test(etag) || etag.length > 1024) throw metadataError('bad-etag')
|
|
511
|
+
requestHeaders['if-none-match'] = etag
|
|
512
|
+
}
|
|
513
|
+
const response = await callTransport(allowed.url, { method: 'GET', headers: requestHeaders, timeoutMs, maxBytes, maxInflatedBytes })
|
|
514
|
+
const status = Number(response?.status ?? 0)
|
|
515
|
+
// 304 必须先于重定向判定(条件请求命中就在 3xx 区间)。
|
|
516
|
+
if (status === 304) {
|
|
517
|
+
const nextEtag = str(response?.headers?.etag) || etag
|
|
518
|
+
return { status: 304, etag: nextEtag, body: '', sourceId: allowed.sourceId, url: allowed.url }
|
|
519
|
+
}
|
|
520
|
+
if (status >= 300 && status < 400) throw metadataError('redirect')
|
|
521
|
+
if (status < 200 || status >= 300) throw metadataError(`http-status:${status}`)
|
|
522
|
+
const body = typeof response?.body === 'string' ? response.body : ''
|
|
523
|
+
// 注入 transport 的字符串正文:传输上限与解压后上限取更严者(默认实现里解压分支
|
|
524
|
+
// 已由 zlib maxOutputLength 引擎侧截断,这里覆盖注入实现与默认实现两条路径)。
|
|
525
|
+
if (Buffer.byteLength(body, 'utf8') > Math.min(maxBytes, maxInflatedBytes)) throw metadataError('too-large')
|
|
526
|
+
return { status: 200, etag: str(response?.headers?.etag), body, sourceId: allowed.sourceId, url: allowed.url }
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** 从来源载荷里取上游时间(modelparams 的 generatedAt 存在时);无法解析返回 undefined。 */
|
|
531
|
+
export function upstreamAtOf(sourceId, payload) {
|
|
532
|
+
if (sourceId !== 'modelparams' || !isPlainObject(payload)) return undefined
|
|
533
|
+
const raw = payload.generatedAt
|
|
534
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw > 1e12 ? raw : raw * 1000
|
|
535
|
+
if (typeof raw === 'string' && raw !== '') {
|
|
536
|
+
const parsed = Date.parse(raw)
|
|
537
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
538
|
+
}
|
|
539
|
+
return undefined
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 元数据缓存 + 单飞 + 条件请求。
|
|
544
|
+
* - 同 key 的并发 `get` 合并成一次请求(含 force);
|
|
545
|
+
* - 已有缓存且未 force → 直接返回 `status:'cached'`(先展示,宿主可再 force 更新);
|
|
546
|
+
* - force 时带 etag 条件请求,304 只续期 fetchedAt(零等待);
|
|
547
|
+
* - 失败**不 reject**:有旧载荷返回 `status:'stale'` 并带 staleError;无载荷返回 `status:'error'`。
|
|
548
|
+
* 失败/过期是可见状态,绝不宣称最新。
|
|
549
|
+
*/
|
|
550
|
+
export function createMetadataCache({ fetcher, now = Date.now, maxEntries = 16 } = {}) {
|
|
551
|
+
if (typeof fetcher !== 'function') throw new Error('smart-fill: fetcher required')
|
|
552
|
+
const entries = new Map()
|
|
553
|
+
const inFlight = new Map()
|
|
554
|
+
const keyOf = (sourceId, url) => `${sourceId}\u0000${url}`
|
|
555
|
+
const remember = (key, entry) => {
|
|
556
|
+
entries.set(key, entry)
|
|
557
|
+
while (entries.size > maxEntries) entries.delete(entries.keys().next().value)
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
async get({ sourceId, url, force = false, parse } = {}) {
|
|
561
|
+
const key = keyOf(String(sourceId ?? ''), String(url ?? ''))
|
|
562
|
+
const pending = inFlight.get(key)
|
|
563
|
+
if (pending !== undefined) return pending
|
|
564
|
+
const existing = entries.get(key)
|
|
565
|
+
if (!force && existing !== undefined) {
|
|
566
|
+
return { ...existing, status: existing.status === 'ok' ? 'cached' : existing.status, fromCache: true }
|
|
567
|
+
}
|
|
568
|
+
const flight = (async () => {
|
|
569
|
+
try {
|
|
570
|
+
const response = await fetcher(url, { etag: existing?.etag ?? '' })
|
|
571
|
+
if (response.status === 304) {
|
|
572
|
+
const next = {
|
|
573
|
+
sourceId,
|
|
574
|
+
url,
|
|
575
|
+
status: 'ok',
|
|
576
|
+
fetchedAt: now(),
|
|
577
|
+
upstreamAt: existing?.upstreamAt,
|
|
578
|
+
etag: response.etag ?? existing?.etag ?? '',
|
|
579
|
+
payload: existing?.payload,
|
|
580
|
+
conditional: true,
|
|
581
|
+
}
|
|
582
|
+
remember(key, next)
|
|
583
|
+
return next
|
|
584
|
+
}
|
|
585
|
+
const payload = typeof parse === 'function' ? parse(response.body) : response.body
|
|
586
|
+
const next = {
|
|
587
|
+
sourceId,
|
|
588
|
+
url,
|
|
589
|
+
status: 'ok',
|
|
590
|
+
fetchedAt: now(),
|
|
591
|
+
upstreamAt: upstreamAtOf(sourceId, payload),
|
|
592
|
+
etag: response.etag ?? '',
|
|
593
|
+
payload,
|
|
594
|
+
conditional: false,
|
|
595
|
+
}
|
|
596
|
+
remember(key, next)
|
|
597
|
+
return next
|
|
598
|
+
} catch (error) {
|
|
599
|
+
const message = error?.code || error?.message || 'network'
|
|
600
|
+
const stale = {
|
|
601
|
+
sourceId,
|
|
602
|
+
url,
|
|
603
|
+
status: existing?.payload !== undefined ? 'stale' : 'error',
|
|
604
|
+
fetchedAt: existing?.fetchedAt,
|
|
605
|
+
upstreamAt: existing?.upstreamAt,
|
|
606
|
+
etag: existing?.etag ?? '',
|
|
607
|
+
payload: existing?.payload,
|
|
608
|
+
staleError: message,
|
|
609
|
+
lastErrorAt: now(),
|
|
610
|
+
}
|
|
611
|
+
remember(key, stale)
|
|
612
|
+
return stale
|
|
613
|
+
}
|
|
614
|
+
})()
|
|
615
|
+
inFlight.set(key, flight)
|
|
616
|
+
flight.then(() => inFlight.delete(key), () => inFlight.delete(key))
|
|
617
|
+
return flight
|
|
618
|
+
},
|
|
619
|
+
drop(sourceId, url) {
|
|
620
|
+
const key = keyOf(String(sourceId ?? ''), String(url ?? ''))
|
|
621
|
+
entries.delete(key)
|
|
622
|
+
inFlight.delete(key)
|
|
623
|
+
},
|
|
624
|
+
peek(sourceId, url) {
|
|
625
|
+
return entries.get(keyOf(String(sourceId ?? ''), String(url ?? '')))
|
|
626
|
+
},
|
|
627
|
+
size() {
|
|
628
|
+
return entries.size
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* 载荷解析:形状不合就抛带 code 的错(缓存会记录为可见失败,而不是当成「无建议」)。
|
|
635
|
+
* 解析零条数据也算失败——来源 schema 漂移必须显示失败,不能静默声称「无建议」。
|
|
636
|
+
*
|
|
637
|
+
* 零条判据:`models` 必须是**非空**数组。空数组只有两种解释(来源整体下线 / 字段被改名),
|
|
638
|
+
* 都不是「该模型无资料」;若放行,选择器会展示「无建议」,把 schema 漂移伪装成正常结果。
|
|
639
|
+
*/
|
|
640
|
+
export function parseModelParamsPayload(text) {
|
|
641
|
+
let data
|
|
642
|
+
try {
|
|
643
|
+
data = JSON.parse(text)
|
|
644
|
+
} catch (_) {
|
|
645
|
+
throw metadataError('bad-json')
|
|
646
|
+
}
|
|
647
|
+
if (!isPlainObject(data) || !Array.isArray(data.models) || data.models.length === 0) throw metadataError('bad-shape')
|
|
648
|
+
return data
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* OpenRouter 载荷解析。两条真实形状:
|
|
653
|
+
* - `/api/v1/models`(目录/搜索)→ `{data: [model, ...]}`(本模块只接受这一种批量形状);
|
|
654
|
+
* - `/api/v1/model/{author}/{slug}`(单模型详情)→ **`{data: model}` 对象**,且部分镜像/版本
|
|
655
|
+
* 直接返回裸 model 对象(顶层即 `{id, name, context_length, ...}`)。
|
|
656
|
+
*
|
|
657
|
+
* 这里不做任何「取第一条」的语义选择:单模型对象原样包成单元素数组返回,歧义由
|
|
658
|
+
* `openRouterMatches` 保留全部候选交用户选择。非对象元素(字符串/数字等)按坏形状拒绝,
|
|
659
|
+
* 避免下游把垃圾当模型行。
|
|
660
|
+
*/
|
|
661
|
+
export function parseOpenRouterPayload(text) {
|
|
662
|
+
let data
|
|
663
|
+
try {
|
|
664
|
+
data = JSON.parse(text)
|
|
665
|
+
} catch (_) {
|
|
666
|
+
throw metadataError('bad-json')
|
|
667
|
+
}
|
|
668
|
+
// 目录/搜索:只有数组形态才是行集合(`{data:[]}` 合法空集,代表该查询无结果)。
|
|
669
|
+
if (isPlainObject(data) && Array.isArray(data.data)) return { data: data.data }
|
|
670
|
+
if (Array.isArray(data)) return { data }
|
|
671
|
+
// 单模型详情:`{data:{...}}` 或裸 `{...}`;以字符串 id 作为「这是模型行」的最小身份判据。
|
|
672
|
+
const wrapped = isPlainObject(data) && isPlainObject(data.data) ? data.data : data
|
|
673
|
+
if (isPlainObject(wrapped) && typeof wrapped.id === 'string' && wrapped.id !== '') return { data: [wrapped] }
|
|
674
|
+
throw metadataError('bad-shape')
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
678
|
+
// 模态集合比较
|
|
679
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 归一为模态集合(只收宿主可表达的 text/image),按固定顺序返回。
|
|
683
|
+
* 空数组/非数组/只有未知模态 → undefined(= 未声明,不是「不支持」)。
|
|
684
|
+
*/
|
|
685
|
+
export function modalitySet(value) {
|
|
686
|
+
if (!Array.isArray(value)) return undefined
|
|
687
|
+
const set = new Set()
|
|
688
|
+
for (const mode of value) {
|
|
689
|
+
if (mode === 'text' || mode === 'image') set.add(mode)
|
|
690
|
+
}
|
|
691
|
+
if (set.size === 0) return undefined
|
|
692
|
+
return [...set].sort()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** 模态集合比较:'equal' / 'different' / 'undeclared'。数组顺序差异不算冲突。 */
|
|
696
|
+
export function compareModalitySets(a, b) {
|
|
697
|
+
const left = modalitySet(a)
|
|
698
|
+
const right = modalitySet(b)
|
|
699
|
+
if (left === undefined || right === undefined) return 'undeclared'
|
|
700
|
+
if (left.length !== right.length) return 'different'
|
|
701
|
+
return left.every((mode, index) => mode === right[index]) ? 'equal' : 'different'
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
705
|
+
// pi-ai 证据索引:保留渠道 + api + 模型 ID 三元身份
|
|
706
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
const exactKeyOf = (channel, api, modelId) => `${channel}\u0000${api}\u0000${modelId}`
|
|
709
|
+
const channelModelKeyOf = (channel, modelId) => `${channel}\u0000${modelId}`
|
|
710
|
+
|
|
711
|
+
function baseUrlKindOf(baseUrl) {
|
|
712
|
+
if (typeof baseUrl !== 'string' || baseUrl.trim() === '') return 'empty'
|
|
713
|
+
return baseUrl.includes('{') ? 'template' : 'exact'
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* pi-ai 内置目录(data/*.json 文本 → {渠道文件名: 文本})→ 证据索引。
|
|
718
|
+
*
|
|
719
|
+
* **关键差异(相对 model-workbench.buildPiAiIndex)**:记录保留 `api`,并按
|
|
720
|
+
* `channel + api + modelId` 建 byExactKey。同渠道同 ID 跨 api 是**不同记录**(研究 §4:5 对
|
|
721
|
+
* 且能力值不同),旧索引丢 api 会合并不该合并的候选。这里绝不合并,碰撞原样保留并计入 audit。
|
|
722
|
+
*/
|
|
723
|
+
export function buildPiEvidence(texts, { maxRecords = MAX_PI_RECORDS } = {}) {
|
|
724
|
+
const records = []
|
|
725
|
+
const byExactKey = new Map()
|
|
726
|
+
const byChannelModel = new Map()
|
|
727
|
+
const byModelId = new Map()
|
|
728
|
+
const channelIds = new Set()
|
|
729
|
+
const channels = new Map()
|
|
730
|
+
let skipped = 0
|
|
731
|
+
let truncated = false
|
|
732
|
+
|
|
733
|
+
const push = (record) => {
|
|
734
|
+
records.push(record)
|
|
735
|
+
const exact = exactKeyOf(record.channel, record.api, record.modelId)
|
|
736
|
+
if (!byExactKey.has(exact)) byExactKey.set(exact, [])
|
|
737
|
+
byExactKey.get(exact).push(record)
|
|
738
|
+
const pair = channelModelKeyOf(record.channel, record.modelId)
|
|
739
|
+
if (!byChannelModel.has(pair)) byChannelModel.set(pair, [])
|
|
740
|
+
byChannelModel.get(pair).push(record)
|
|
741
|
+
if (!byModelId.has(record.modelId)) byModelId.set(record.modelId, [])
|
|
742
|
+
byModelId.get(record.modelId).push(record)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
for (const [channel, text] of Object.entries(texts ?? {})) {
|
|
746
|
+
if (channel === '' || channel.startsWith('.')) continue
|
|
747
|
+
let data
|
|
748
|
+
try {
|
|
749
|
+
data = JSON.parse(text)
|
|
750
|
+
} catch (_) {
|
|
751
|
+
skipped += 1
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
754
|
+
if (!isPlainObject(data)) {
|
|
755
|
+
skipped += 1
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
let channelHasEntry = false
|
|
759
|
+
for (const [api, group] of Object.entries(data)) {
|
|
760
|
+
if (!isPlainObject(group)) continue
|
|
761
|
+
const apiId = api === '' ? 'unknown' : api
|
|
762
|
+
for (const entry of Object.values(group)) {
|
|
763
|
+
if (!isPlainObject(entry)) continue
|
|
764
|
+
const modelId = typeof entry.id === 'string' && entry.id !== '' ? entry.id : ''
|
|
765
|
+
if (modelId === '' || modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) {
|
|
766
|
+
skipped += 1
|
|
767
|
+
continue
|
|
768
|
+
}
|
|
769
|
+
if (records.length >= maxRecords) {
|
|
770
|
+
truncated = true
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
channelHasEntry = true
|
|
774
|
+
const thinkingLevelMap = isPlainObject(entry.thinkingLevelMap) ? entry.thinkingLevelMap : undefined
|
|
775
|
+
const reasoningDeclared = entry.reasoning === true ? true : entry.reasoning === false ? false : undefined
|
|
776
|
+
// reasoningEfforts 只在目录**明确声明 reasoning:true** 且有可表达档位时才产出 wire;
|
|
777
|
+
// 缺席不等于 false,所以 reasoningDeclared===undefined 时既不产出 wire 也不产出 false。
|
|
778
|
+
const reasoningEfforts = reasoningDeclared === true ? piAiReasoningEfforts(thinkingLevelMap, true) : undefined
|
|
779
|
+
push({
|
|
780
|
+
sourceId: 'pi-ai',
|
|
781
|
+
channel,
|
|
782
|
+
api: apiId,
|
|
783
|
+
modelId,
|
|
784
|
+
name: typeof entry.name === 'string' && entry.name !== '' ? entry.name : undefined,
|
|
785
|
+
provider: typeof entry.provider === 'string' && entry.provider !== '' ? entry.provider : channel,
|
|
786
|
+
baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : '',
|
|
787
|
+
baseUrlKind: baseUrlKindOf(entry.baseUrl),
|
|
788
|
+
contextWindow: positiveInt(entry.contextWindow),
|
|
789
|
+
maxTokens: positiveInt(entry.maxTokens),
|
|
790
|
+
input: modalitySet(entry.input),
|
|
791
|
+
inputDeclared: modalitySet(entry.input) !== undefined,
|
|
792
|
+
// 三态:true / false / undefined(未声明)。未声明绝不塌成 false。
|
|
793
|
+
reasoningDeclared,
|
|
794
|
+
thinkingLevelMapDeclared: thinkingLevelMap !== undefined,
|
|
795
|
+
reasoningEfforts,
|
|
796
|
+
// compat 原样保留(写入前还要过协议门),不在这里做可写性判断。
|
|
797
|
+
compat: isPlainObject(entry.compat) ? clone(entry.compat) : undefined,
|
|
798
|
+
compatDeclared: isPlainObject(entry.compat),
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (channelHasEntry) {
|
|
803
|
+
channelIds.add(channel)
|
|
804
|
+
if (!channels.has(channel)) channels.set(channel, new Set())
|
|
805
|
+
for (const api of Object.keys(data)) channels.get(channel).add(api)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return { records, byExactKey, byChannelModel, byModelId, channels, channelIds, skipped, truncated }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** 读取一个 pi-ai data 目录(*.json)→ 证据索引;目录缺失返回空索引(降级,不判失败)。 */
|
|
812
|
+
export async function loadPiEvidenceFromDir(dir) {
|
|
813
|
+
const texts = {}
|
|
814
|
+
try {
|
|
815
|
+
const files = await readdir(dir)
|
|
816
|
+
for (const file of files) {
|
|
817
|
+
if (!file.endsWith('.json') || file.startsWith('.') || file.includes('.bak')) continue
|
|
818
|
+
try {
|
|
819
|
+
texts[file.slice(0, -5)] = await readFile(join(dir, file), 'utf8')
|
|
820
|
+
} catch (_) {
|
|
821
|
+
// 单文件失败跳过,其余照常(部分可用优于整体失败)
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
} catch (_) {
|
|
825
|
+
return buildPiEvidence({})
|
|
826
|
+
}
|
|
827
|
+
return buildPiEvidence(texts)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 按目标身份取证据,三类分开、绝不合并:
|
|
832
|
+
* - exact:渠道 + api + 模型 ID 全等(唯一可作 wire/compat 建议的来源);
|
|
833
|
+
* - sameChannelOtherApi:同渠道同 ID 不同 api(协议不同,wire 不代填);
|
|
834
|
+
* - crossChannel:其它渠道的同名模型(能力/输入/名称仅参考)。
|
|
835
|
+
*/
|
|
836
|
+
export function piEvidenceFor(index, { channel, api, modelId } = {}) {
|
|
837
|
+
const exact = index?.byExactKey?.get(exactKeyOf(str(channel), str(api), str(modelId))) ?? []
|
|
838
|
+
const sameChannelOtherApi = (index?.byChannelModel?.get(channelModelKeyOf(str(channel), str(modelId))) ?? [])
|
|
839
|
+
.filter((record) => record.api !== api)
|
|
840
|
+
const crossChannel = (index?.byModelId?.get(str(modelId)) ?? []).filter((record) => record.channel !== channel)
|
|
841
|
+
return { exact, sameChannelOtherApi, crossChannel }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* 索引自检(扫描脚本与测试共用):不依赖任何硬编码规模。
|
|
846
|
+
* 校验身份完整、exact 键可回查、reasoning 三态合法、wire 只在声明 true 时出现、模态值合法;
|
|
847
|
+
* 并输出跨 api / 重复 exact 的碰撞清单(碰撞是保留事实,不是错误)。
|
|
848
|
+
*/
|
|
849
|
+
export function auditPiEvidence(index) {
|
|
850
|
+
const problems = []
|
|
851
|
+
const collisions = []
|
|
852
|
+
for (const [pair, records] of index?.byChannelModel ?? []) {
|
|
853
|
+
if (records.length <= 1) continue
|
|
854
|
+
const apis = [...new Set(records.map((record) => record.api))].sort()
|
|
855
|
+
collisions.push({
|
|
856
|
+
channel: records[0].channel,
|
|
857
|
+
modelId: records[0].modelId,
|
|
858
|
+
pair,
|
|
859
|
+
apis,
|
|
860
|
+
count: records.length,
|
|
861
|
+
kind: apis.length > 1 ? 'same-channel-cross-api' : 'duplicate-exact-key',
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
const counts = { records: index?.records?.length ?? 0, channels: index?.channelIds?.size ?? 0, models: index?.byModelId?.size ?? 0, reasoningTrue: 0, reasoningFalse: 0, reasoningUndeclared: 0 }
|
|
865
|
+
for (const record of index?.records ?? []) {
|
|
866
|
+
if (record.channel === '' || record.api === '' || record.modelId === '') problems.push('identity-missing')
|
|
867
|
+
if (!index?.byExactKey?.has(exactKeyOf(record.channel, record.api, record.modelId))) problems.push('exact-key-missing')
|
|
868
|
+
if (record.reasoningDeclared === true) counts.reasoningTrue += 1
|
|
869
|
+
else if (record.reasoningDeclared === false) counts.reasoningFalse += 1
|
|
870
|
+
else counts.reasoningUndeclared += 1
|
|
871
|
+
if (record.reasoningEfforts !== undefined && record.reasoningDeclared !== true) problems.push('wire-without-declared-true')
|
|
872
|
+
if (record.input !== undefined) {
|
|
873
|
+
for (const mode of record.input) {
|
|
874
|
+
if (mode !== 'text' && mode !== 'image') problems.push('bad-modality')
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (record.baseUrlKind === 'exact' && (record.baseUrl === '' || record.baseUrl.includes('{'))) problems.push('base-url-kind-mismatch')
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
ok: problems.length === 0,
|
|
881
|
+
problems: [...new Set(problems)],
|
|
882
|
+
collisions,
|
|
883
|
+
counts: { ...counts, collisions: collisions.length, skipped: index?.skipped ?? 0, truncated: index?.truncated === true },
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
888
|
+
// modelparams.dev:独立记录身份,不做并集
|
|
889
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* modelparams 目录 → 独立记录数组。
|
|
893
|
+
* 每条记录的**身份是 provider + authType + apiSurface + model + wireId**;同一裸 ID 不同
|
|
894
|
+
* authType/apiSurface 是不同记录(研究 §5:59 组 default 可能不同)。这里绝不求并集、
|
|
895
|
+
* 绝不贴第一条 provider 标签。
|
|
896
|
+
*
|
|
897
|
+
* 逐条容错 + 漂移留证(本函数**永不抛**):
|
|
898
|
+
* - 单条 entry 里的任何异常都只让该条被跳过(`continue`),不中断整个来源,也不冒泡成
|
|
899
|
+
* 「整个来源失败」——调用方(`querySmartFill`) 的 per-source 兜底只负责网络/解析失败;
|
|
900
|
+
* - 语义漂移(model/id 形状非法、params 非数组、path 缺失、range 声明了却不是整数边界)
|
|
901
|
+
* 全部计入 `skipped` 与 `skippedDetail`(截断后的 model 标识 + 稳定 reason),
|
|
902
|
+
* 供扫描/接口显示,绝不静默当成「无资料」。
|
|
903
|
+
*/
|
|
904
|
+
export function indexModelParamsRecords(catalog, { maxRecords = MAX_MODELPARAMS_RECORDS } = {}) {
|
|
905
|
+
const records = []
|
|
906
|
+
const byModelId = new Map()
|
|
907
|
+
const skippedDetail = []
|
|
908
|
+
const bump = (value, reason) => {
|
|
909
|
+
if (skippedDetail.length < 200) skippedDetail.push({ model: String(value).slice(0, MAX_MODEL_ID_LENGTH), reason })
|
|
910
|
+
}
|
|
911
|
+
const models = Array.isArray(catalog?.models) ? catalog.models : []
|
|
912
|
+
let truncated = false
|
|
913
|
+
outer: for (const entry of models) {
|
|
914
|
+
try {
|
|
915
|
+
if (!isPlainObject(entry)) {
|
|
916
|
+
bump(entry === null ? null : typeof entry, 'entry-not-object')
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
const modelId = typeof entry.model === 'string' ? entry.model.trim() : ''
|
|
920
|
+
if (modelId === '') {
|
|
921
|
+
bump(entry.model ?? '', 'model-missing')
|
|
922
|
+
continue
|
|
923
|
+
}
|
|
924
|
+
if (modelId.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_RE.test(modelId)) {
|
|
925
|
+
bump(modelId, 'model-id-invalid')
|
|
926
|
+
continue
|
|
927
|
+
}
|
|
928
|
+
const identity = {
|
|
929
|
+
provider: str(entry.provider),
|
|
930
|
+
authType: str(entry.authType) || 'unknown',
|
|
931
|
+
apiSurface: str(entry.apiSurface),
|
|
932
|
+
model: modelId,
|
|
933
|
+
wireId: str(entry.wireId),
|
|
934
|
+
}
|
|
935
|
+
const shared = {
|
|
936
|
+
status: str(entry.status),
|
|
937
|
+
replacement: str(entry.replacement) || undefined,
|
|
938
|
+
shutdownOn: str(entry.shutdownOn) || undefined,
|
|
939
|
+
}
|
|
940
|
+
const params = Array.isArray(entry.params) ? entry.params : []
|
|
941
|
+
if (params.length === 0) {
|
|
942
|
+
if (entry.params !== undefined && !Array.isArray(entry.params)) bump(modelId, 'params-not-array')
|
|
943
|
+
const record = { sourceId: 'modelparams', index: records.length, ...identity, paramPath: '', values: [], ...shared }
|
|
944
|
+
records.push(record)
|
|
945
|
+
if (!byModelId.has(modelId)) byModelId.set(modelId, [])
|
|
946
|
+
byModelId.get(modelId).push(record)
|
|
947
|
+
continue
|
|
948
|
+
}
|
|
949
|
+
for (const param of params) {
|
|
950
|
+
if (!isPlainObject(param)) {
|
|
951
|
+
bump(modelId, 'param-not-object')
|
|
952
|
+
continue
|
|
953
|
+
}
|
|
954
|
+
const paramPath = typeof param.path === 'string' ? param.path : ''
|
|
955
|
+
if (paramPath === '') {
|
|
956
|
+
bump(modelId, 'param-path-missing')
|
|
957
|
+
continue
|
|
958
|
+
}
|
|
959
|
+
// range 语义校验:声明了 range 就要求对象 + 整数边界(true/1.5/"8192" 都是漂移)。
|
|
960
|
+
const declaredRange = param.range !== undefined && param.range !== null
|
|
961
|
+
if (declaredRange && !isPlainObject(param.range)) bump(modelId, 'range-not-object')
|
|
962
|
+
else if (isPlainObject(param.range)) {
|
|
963
|
+
if (param.range.min !== undefined && finiteInt(param.range.min) === undefined) bump(modelId, 'range-min-not-integer')
|
|
964
|
+
if (param.range.max !== undefined && finiteInt(param.range.max) === undefined) bump(modelId, 'range-max-not-integer')
|
|
965
|
+
}
|
|
966
|
+
if (param.values !== undefined && !Array.isArray(param.values)) bump(modelId, 'values-not-array')
|
|
967
|
+
const values = Array.isArray(param.values)
|
|
968
|
+
? param.values.filter((value) => typeof value === 'string' && value !== '').slice(0, 64)
|
|
969
|
+
: []
|
|
970
|
+
const record = {
|
|
971
|
+
sourceId: 'modelparams',
|
|
972
|
+
index: records.length,
|
|
973
|
+
...identity,
|
|
974
|
+
paramPath,
|
|
975
|
+
values,
|
|
976
|
+
defaultValue: param.default !== undefined && ['string', 'number', 'boolean'].includes(typeof param.default) ? param.default : undefined,
|
|
977
|
+
rangeMin: finiteInt(param?.range?.min),
|
|
978
|
+
rangeMax: finiteInt(param?.range?.max),
|
|
979
|
+
...shared,
|
|
980
|
+
}
|
|
981
|
+
records.push(record)
|
|
982
|
+
if (!byModelId.has(modelId)) byModelId.set(modelId, [])
|
|
983
|
+
byModelId.get(modelId).push(record)
|
|
984
|
+
if (records.length >= maxRecords) {
|
|
985
|
+
truncated = true
|
|
986
|
+
break outer
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
} catch (_) {
|
|
990
|
+
// 兜底:即便上面的字段访问出现意外(代理对象、getter 抛错),也只跳该条。
|
|
991
|
+
bump(entry?.model ?? '', 'entry-invalid')
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return { records, byModelId, skipped: skippedDetail.length, skippedDetail, truncated }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* 某模型的 modelparams 参考面:保持每条记录独立,不聚合、不并集、不推断 wire。
|
|
999
|
+
* - requestRanges:`range.max` 是 **API 参数取值上限**,只读,映射不到任何模型字段;
|
|
1000
|
+
* - effortValues:来源声明的档位名(`wire:false`),不是当前渠道 wire;
|
|
1001
|
+
* - defaults:来源默认,不是当前远端默认。
|
|
1002
|
+
*/
|
|
1003
|
+
export function modelParamsReference(index, modelId) {
|
|
1004
|
+
const records = index?.byModelId?.get(str(modelId)) ?? []
|
|
1005
|
+
const requestRanges = []
|
|
1006
|
+
const effortValues = []
|
|
1007
|
+
const defaults = []
|
|
1008
|
+
for (const record of records) {
|
|
1009
|
+
const identity = {
|
|
1010
|
+
provider: record.provider,
|
|
1011
|
+
authType: record.authType,
|
|
1012
|
+
apiSurface: record.apiSurface,
|
|
1013
|
+
model: record.model,
|
|
1014
|
+
wireId: record.wireId,
|
|
1015
|
+
}
|
|
1016
|
+
if (record.rangeMax !== undefined) {
|
|
1017
|
+
requestRanges.push({
|
|
1018
|
+
paramPath: record.paramPath,
|
|
1019
|
+
rangeMax: record.rangeMax,
|
|
1020
|
+
rangeMin: record.rangeMin,
|
|
1021
|
+
identity,
|
|
1022
|
+
readOnly: true,
|
|
1023
|
+
kind: 'request-parameter-ceiling',
|
|
1024
|
+
note: 'API 参数取值上限,不是模型最大输出能力;不生成 maxTokens 建议',
|
|
1025
|
+
})
|
|
1026
|
+
}
|
|
1027
|
+
if (MODELPARAMS_EFFORT_PATHS.includes(record.paramPath) && record.values.length > 0) {
|
|
1028
|
+
effortValues.push({
|
|
1029
|
+
paramPath: record.paramPath,
|
|
1030
|
+
values: [...record.values],
|
|
1031
|
+
identity,
|
|
1032
|
+
wire: false,
|
|
1033
|
+
kind: 'source-declared-effort-values',
|
|
1034
|
+
note: '来源声明的档位名,不是当前渠道 wire,不能据此生成 wire 映射',
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
if (record.defaultValue !== undefined) {
|
|
1038
|
+
defaults.push({
|
|
1039
|
+
paramPath: record.paramPath,
|
|
1040
|
+
value: record.defaultValue,
|
|
1041
|
+
identity,
|
|
1042
|
+
kind: 'source-declared-default',
|
|
1043
|
+
note: '来源默认,不是当前远端默认值',
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
sourceId: 'modelparams',
|
|
1049
|
+
modelId: str(modelId),
|
|
1050
|
+
records,
|
|
1051
|
+
// 来源漂移留证:被跳过的条目与原因随参考面一起回传,调用方才能区分
|
|
1052
|
+
// 「该模型无资料」与「来源 schema 漂移」。
|
|
1053
|
+
skipped: index?.skipped ?? 0,
|
|
1054
|
+
skippedDetail: index?.skippedDetail ?? [],
|
|
1055
|
+
identities: [...new Set(records.map((record) => `${record.provider}\u0000${record.authType}\u0000${record.apiSurface}\u0000${record.wireId}`))]
|
|
1056
|
+
.map((key) => {
|
|
1057
|
+
const [provider, authType, apiSurface, wireId] = key.split('\u0000')
|
|
1058
|
+
return { provider, authType, apiSurface, wireId, model: str(modelId) }
|
|
1059
|
+
}),
|
|
1060
|
+
requestRanges,
|
|
1061
|
+
effortValues,
|
|
1062
|
+
defaults,
|
|
1063
|
+
note: 'provider 名称文本相同不等于 DSH route 相同;每条记录保留独立身份',
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1068
|
+
// OpenRouter:保留全部匹配,绝不取首条
|
|
1069
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* 载荷 → 行数组。与 `parseOpenRouterPayload` 同一套形状判据(两处必须同步,否则解析通过、
|
|
1073
|
+
* 匹配为空,会把「单模型详情」静默变成「零命中」):
|
|
1074
|
+
* 数组 / `{data:[...]}` / `{models:[...]}` → 批量;`{data:{...}}` 或裸 `{id,...}` → 单模型一行。
|
|
1075
|
+
* 无身份的对象(没有字符串 id)不算行,返回空数组。
|
|
1076
|
+
*/
|
|
1077
|
+
function normalizeOpenRouterRows(payload) {
|
|
1078
|
+
if (Array.isArray(payload)) return payload
|
|
1079
|
+
if (isPlainObject(payload) && Array.isArray(payload.data)) return payload.data
|
|
1080
|
+
if (isPlainObject(payload) && Array.isArray(payload.models)) return payload.models
|
|
1081
|
+
const wrapped = isPlainObject(payload) && isPlainObject(payload.data) ? payload.data : payload
|
|
1082
|
+
if (isPlainObject(wrapped) && typeof wrapped.id === 'string' && wrapped.id !== '') return [wrapped]
|
|
1083
|
+
return []
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* 从 OpenRouter 返回里挑出与输入 ID 相关的**全部**匹配(按匹配强度排序,但都保留)。
|
|
1088
|
+
* 绝不返回「唯一一条真值」:同名多作者必须由用户选择。
|
|
1089
|
+
* 匹配规则(与既有 pickOpenRouterMatch 同向,但保留全部):
|
|
1090
|
+
* exact id / canonical_slug / hugging_face_id → id 后缀(排除 :variant 变体与其它作者同名)
|
|
1091
|
+
* → canonical_slug 后缀 → hugging_face_id 大小写不敏感。
|
|
1092
|
+
*/
|
|
1093
|
+
export function openRouterMatches(modelId, payload, { maxMatches = MAX_OPENROUTER_MATCHES } = {}) {
|
|
1094
|
+
const id = str(modelId)
|
|
1095
|
+
const rows = normalizeOpenRouterRows(payload)
|
|
1096
|
+
const matches = []
|
|
1097
|
+
const push = (row, matchKind) => {
|
|
1098
|
+
if (!isPlainObject(row) || matches.some((match) => match.row === row)) return
|
|
1099
|
+
matches.push({ row, matchKind })
|
|
1100
|
+
}
|
|
1101
|
+
// 只对**对象行**做属性访问:批量载荷可能混入 null/字符串(上游轮换字段时出现过),
|
|
1102
|
+
// 直接读 row.id 会对 null 抛类型错误,把整条来源变成不可用。
|
|
1103
|
+
const bounded = rows.slice(0, 500).filter((row) => isPlainObject(row))
|
|
1104
|
+
for (const row of bounded) {
|
|
1105
|
+
if (row.id === id || row.canonical_slug === id || row.hugging_face_id === id) push(row, 'exact')
|
|
1106
|
+
}
|
|
1107
|
+
for (const row of bounded) {
|
|
1108
|
+
if (typeof row.id === 'string' && id !== '' && row.id.endsWith('/' + id)) push(row, 'id-suffix')
|
|
1109
|
+
}
|
|
1110
|
+
for (const row of bounded) {
|
|
1111
|
+
if (typeof row.canonical_slug === 'string' && id !== '' && row.canonical_slug.endsWith('/' + id)) push(row, 'canonical-suffix')
|
|
1112
|
+
}
|
|
1113
|
+
for (const row of bounded) {
|
|
1114
|
+
if (typeof row.hugging_face_id === 'string' && id !== '' && row.hugging_face_id.toLowerCase() === id.toLowerCase()) push(row, 'hf-id')
|
|
1115
|
+
}
|
|
1116
|
+
const sliced = matches.slice(0, maxMatches)
|
|
1117
|
+
return { matches: sliced, ambiguous: sliced.length > 1, searched: rows.length, total: matches.length, truncated: matches.length > sliced.length }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* OpenRouter 命中 → 参考事实。**只确认模型档案身份**,不确认自定义中转渠道保留 OpenRouter
|
|
1122
|
+
* 的 wire/compat;支持的档位名与默认档位只是来源声明,不是当前渠道 wire/默认。
|
|
1123
|
+
*/
|
|
1124
|
+
export function openRouterReference(match) {
|
|
1125
|
+
const row = isPlainObject(match) && isPlainObject(match.row) ? match.row : match
|
|
1126
|
+
if (!isPlainObject(row)) return undefined
|
|
1127
|
+
const id = str(row.id)
|
|
1128
|
+
const parts = id.includes('/') ? id.split('/') : []
|
|
1129
|
+
const rawInput = Array.isArray(row?.architecture?.input_modalities) ? row.architecture.input_modalities : []
|
|
1130
|
+
const input = modalitySet(rawInput)
|
|
1131
|
+
const inexpressible = [...new Set(rawInput.filter((mode) => typeof mode === 'string' && mode !== 'text' && mode !== 'image'))].slice(0, 8)
|
|
1132
|
+
const efforts = Array.isArray(row?.reasoning?.supported_efforts)
|
|
1133
|
+
? row.reasoning.supported_efforts.filter((value) => typeof value === 'string' && value !== '').slice(0, 32)
|
|
1134
|
+
: []
|
|
1135
|
+
return {
|
|
1136
|
+
sourceId: 'openrouter',
|
|
1137
|
+
identity: {
|
|
1138
|
+
id,
|
|
1139
|
+
canonicalSlug: str(row.canonical_slug),
|
|
1140
|
+
author: parts.length === 2 ? parts[0] : '',
|
|
1141
|
+
slug: parts.length === 2 ? parts[1] : '',
|
|
1142
|
+
huggingFaceId: str(row.hugging_face_id),
|
|
1143
|
+
},
|
|
1144
|
+
matchKind: isPlainObject(match) && typeof match.matchKind === 'string' ? match.matchKind : 'provided',
|
|
1145
|
+
name: str(row.name) || undefined,
|
|
1146
|
+
contextWindow: positiveInt(row.context_length),
|
|
1147
|
+
maxTokens: positiveInt(row?.top_provider?.max_completion_tokens),
|
|
1148
|
+
input,
|
|
1149
|
+
inputInexpressible: inexpressible,
|
|
1150
|
+
supportedEfforts: efforts,
|
|
1151
|
+
defaultEffort: str(row?.reasoning?.default_effort) || undefined,
|
|
1152
|
+
supportedParameters: Array.isArray(row.supported_parameters)
|
|
1153
|
+
? row.supported_parameters.filter((value) => typeof value === 'string').slice(0, 64)
|
|
1154
|
+
: [],
|
|
1155
|
+
evidence: 'external-reference',
|
|
1156
|
+
note: '外部档案只确认模型身份;不确认当前渠道 wire/compat,也不代表匿名访问长期可用',
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1161
|
+
// 运行时:只解释生效投影,不从 effort ID 捏造 wire
|
|
1162
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* `llm.resolveModelInfo` 返回 → 运行时参考。**没有 wire value**:
|
|
1166
|
+
* `reasoningWireAvailable:false`、不产出 `{effortId: wire}` 映射(旧 simpleModelObserved 的
|
|
1167
|
+
* `wire = value || id` fallback 被明确禁止)。defaultMaxTokens/defaultEffort 标为 adapter
|
|
1168
|
+
* 调用默认/预算,不是模型能力上限或远端默认。
|
|
1169
|
+
*/
|
|
1170
|
+
export function runtimeReference(info) {
|
|
1171
|
+
const input = modalitySet(info?.input ?? info?.modalities?.input)
|
|
1172
|
+
const effortIds = []
|
|
1173
|
+
const effortNames = []
|
|
1174
|
+
const efforts = info?.reasoning?.efforts
|
|
1175
|
+
if (Array.isArray(efforts)) {
|
|
1176
|
+
for (const effort of efforts) {
|
|
1177
|
+
const id = typeof effort?.id === 'string' ? effort.id : ''
|
|
1178
|
+
if (id === '') continue
|
|
1179
|
+
effortIds.push(id)
|
|
1180
|
+
if (typeof effort?.name === 'string' && effort.name !== '') effortNames.push(effort.name)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
sourceId: 'runtime',
|
|
1185
|
+
evidence: 'runtime-effective',
|
|
1186
|
+
input: input ?? undefined,
|
|
1187
|
+
inputDeclared: input !== undefined,
|
|
1188
|
+
contextWindow: positiveInt(info?.contextWindow),
|
|
1189
|
+
reasoningEffortIds: [...new Set(effortIds)],
|
|
1190
|
+
reasoningEffortNames: [...new Set(effortNames)],
|
|
1191
|
+
// 关键:即使 ID 恰好等于 DSH 档位名,也不推断 wire。
|
|
1192
|
+
reasoningWire: null,
|
|
1193
|
+
reasoningWireAvailable: false,
|
|
1194
|
+
defaultMaxTokens: positiveInt(info?.defaultMaxTokens),
|
|
1195
|
+
defaultMaxTokensKind: 'adapter-budget',
|
|
1196
|
+
defaultEffort: str(info?.defaultEffort) || undefined,
|
|
1197
|
+
declared: {
|
|
1198
|
+
input: input !== undefined,
|
|
1199
|
+
reasoning: Array.isArray(efforts) && efforts.length > 0,
|
|
1200
|
+
contextWindow: positiveInt(info?.contextWindow) !== undefined,
|
|
1201
|
+
},
|
|
1202
|
+
note: '当前 adapter 投影,不是独立测量;未返回不等于不支持',
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1207
|
+
// compat 协议门(写死当前 dsh-llm-pi-ai 的 disposition;offer 才可写)
|
|
1208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1209
|
+
|
|
1210
|
+
const gate = (entries) => Object.freeze(entries)
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* 每个协议的 compat 键门。`offer` = 宿主当前协议可写(可建议);`withhold` = 目录可能含但
|
|
1214
|
+
* 宿主明确不可写(只读解释,绝不暗自写入)。与装机 dsh-llm-pi-ai COMPAT_GATES 对照。
|
|
1215
|
+
*/
|
|
1216
|
+
export const COMPAT_PROTOCOL_GATE = Object.freeze({
|
|
1217
|
+
'openai-completions': gate({
|
|
1218
|
+
supportsStore: 'offer',
|
|
1219
|
+
supportsDeveloperRole: 'offer',
|
|
1220
|
+
supportsReasoningEffort: 'offer',
|
|
1221
|
+
supportsUsageInStreaming: 'offer',
|
|
1222
|
+
supportsFinishReason: 'offer',
|
|
1223
|
+
maxTokensField: 'offer',
|
|
1224
|
+
requiresToolResultName: 'offer',
|
|
1225
|
+
requiresAssistantAfterToolResult: 'offer',
|
|
1226
|
+
requiresThinkingAsText: 'offer',
|
|
1227
|
+
requiresReasoningContentOnAssistantMessages: 'offer',
|
|
1228
|
+
thinkingFormat: 'offer',
|
|
1229
|
+
chatTemplateKwargs: 'offer',
|
|
1230
|
+
chatTemplateArgs: 'offer',
|
|
1231
|
+
supportsThinkingTokenBudget: 'offer',
|
|
1232
|
+
thinkingTokenBudgetField: 'offer',
|
|
1233
|
+
vllmPriority: 'offer',
|
|
1234
|
+
supportsStrictMode: 'offer',
|
|
1235
|
+
cacheControlFormat: 'offer',
|
|
1236
|
+
supportsLongCacheRetention: 'offer',
|
|
1237
|
+
openRouterRouting: 'withhold',
|
|
1238
|
+
vercelGatewayRouting: 'withhold',
|
|
1239
|
+
zaiToolStream: 'withhold',
|
|
1240
|
+
supportsOpenAIGrammarTools: 'withhold',
|
|
1241
|
+
sendSessionAffinityHeaders: 'withhold',
|
|
1242
|
+
supportsMidConvoSystemMessages: 'withhold',
|
|
1243
|
+
supportsMidConvoToolAdditions: 'withhold',
|
|
1244
|
+
sessionAffinityFormat: 'withhold',
|
|
1245
|
+
}),
|
|
1246
|
+
'openai-responses': gate({
|
|
1247
|
+
supportsDeveloperRole: 'offer',
|
|
1248
|
+
supportsMaxOutputTokens: 'offer',
|
|
1249
|
+
supportsStrictMode: 'offer',
|
|
1250
|
+
supportsLongCacheRetention: 'offer',
|
|
1251
|
+
sessionAffinityFormat: 'withhold',
|
|
1252
|
+
supportsOpenAIGrammarTools: 'withhold',
|
|
1253
|
+
supportsAdditionalTools: 'withhold',
|
|
1254
|
+
supportsToolSearch: 'withhold',
|
|
1255
|
+
supportsExplicitPromptCacheMode: 'withhold',
|
|
1256
|
+
supportsMidConvoSystemMessages: 'withhold',
|
|
1257
|
+
}),
|
|
1258
|
+
'anthropic-messages': gate({
|
|
1259
|
+
supportsEagerToolInputStreaming: 'offer',
|
|
1260
|
+
supportsLongCacheRetention: 'offer',
|
|
1261
|
+
supportsCacheControlOnTools: 'offer',
|
|
1262
|
+
supportsTemperature: 'offer',
|
|
1263
|
+
forceAdaptiveThinking: 'offer',
|
|
1264
|
+
allowEmptySignature: 'offer',
|
|
1265
|
+
supportsStrictTools: 'offer',
|
|
1266
|
+
sendSessionAffinityHeaders: 'withhold',
|
|
1267
|
+
sessionAffinityFormat: 'withhold',
|
|
1268
|
+
supportsMidConvoSystemMessages: 'withhold',
|
|
1269
|
+
supportsMidConvoToolChanges: 'withhold',
|
|
1270
|
+
supportsMidConvoEffort: 'withhold',
|
|
1271
|
+
allowedFallbackModels: 'withhold',
|
|
1272
|
+
}),
|
|
1273
|
+
'mistral-conversations': gate({ supportsMidConvoSystemMessages: 'withhold' }),
|
|
1274
|
+
'bedrock-converse-stream': gate({ supportsStrictMode: 'offer' }),
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
const WRITABLE_PROTOCOLS = Object.freeze(['openai-completions', 'openai-responses', 'anthropic-messages'])
|
|
1278
|
+
|
|
1279
|
+
/** 协议是否属于当前宿主可配置写面(与 llm-pi-ai supportedProtocols 同向)。 */
|
|
1280
|
+
export function isWritableProtocol(api) {
|
|
1281
|
+
return WRITABLE_PROTOCOLS.includes(str(api))
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1285
|
+
// 分组建议:字段级选项,逐组明确采纳,缺席有理由
|
|
1286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1287
|
+
|
|
1288
|
+
const CAPABILITY_FIELDS = Object.freeze(['name', 'contextWindow', 'maxTokens', 'input'])
|
|
1289
|
+
|
|
1290
|
+
const SOURCE_LABELS = Object.freeze({
|
|
1291
|
+
'pi-ai': '本机 pi-ai 目录',
|
|
1292
|
+
modelparams: 'modelparams.dev',
|
|
1293
|
+
openrouter: 'OpenRouter',
|
|
1294
|
+
runtime: '运行时 adapter',
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
function normalizeTarget(target) {
|
|
1298
|
+
const raw = isPlainObject(target) ? target : {}
|
|
1299
|
+
return {
|
|
1300
|
+
channel: str(raw.channel),
|
|
1301
|
+
api: str(raw.api),
|
|
1302
|
+
modelId: str(raw.modelId),
|
|
1303
|
+
modelOnly: raw.modelOnly === true,
|
|
1304
|
+
current: isPlainObject(raw.current) ? raw.current : {},
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function piRef(record) {
|
|
1309
|
+
return {
|
|
1310
|
+
source: 'pi-ai',
|
|
1311
|
+
channel: record.channel,
|
|
1312
|
+
api: record.api,
|
|
1313
|
+
modelId: record.modelId,
|
|
1314
|
+
baseUrl: record.baseUrl,
|
|
1315
|
+
baseUrlKind: record.baseUrlKind,
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function fieldValueFromPi(record, field) {
|
|
1320
|
+
if (field === 'name') return record.name
|
|
1321
|
+
if (field === 'contextWindow') return record.contextWindow
|
|
1322
|
+
if (field === 'maxTokens') return record.maxTokens
|
|
1323
|
+
if (field === 'input') return record.input
|
|
1324
|
+
return undefined
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function fieldValueFromOpenRouter(reference, field) {
|
|
1328
|
+
if (field === 'name') return reference.name
|
|
1329
|
+
if (field === 'contextWindow') return reference.contextWindow
|
|
1330
|
+
if (field === 'maxTokens') return reference.maxTokens
|
|
1331
|
+
if (field === 'input') return reference.input
|
|
1332
|
+
return undefined
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function makeOption(field, spec) {
|
|
1336
|
+
const hasValue = spec.hasValue !== false && spec.value !== undefined
|
|
1337
|
+
const candidateId = candidateIdFor([
|
|
1338
|
+
field,
|
|
1339
|
+
spec.source,
|
|
1340
|
+
spec.evidence,
|
|
1341
|
+
spec.key ?? null,
|
|
1342
|
+
spec.kind ?? null,
|
|
1343
|
+
hasValue ? spec.value : null,
|
|
1344
|
+
spec.sourceRef ?? null,
|
|
1345
|
+
])
|
|
1346
|
+
return {
|
|
1347
|
+
candidateId,
|
|
1348
|
+
field,
|
|
1349
|
+
...(spec.key !== undefined ? { key: spec.key } : {}),
|
|
1350
|
+
...(hasValue ? { value: spec.value } : {}),
|
|
1351
|
+
source: spec.source,
|
|
1352
|
+
sourceLabel: SOURCE_LABELS[spec.source] ?? spec.source,
|
|
1353
|
+
evidence: spec.evidence,
|
|
1354
|
+
confidence: spec.confidence,
|
|
1355
|
+
adoptable: spec.adoptable === true,
|
|
1356
|
+
readOnly: spec.readOnly === true,
|
|
1357
|
+
// 任何选项都只能被显式勾选写入,没有自动勾选。
|
|
1358
|
+
requiresExplicitAdoption: true,
|
|
1359
|
+
...(spec.kind !== undefined ? { kind: spec.kind } : {}),
|
|
1360
|
+
...(spec.sourceRef !== undefined ? { sourceRef: spec.sourceRef } : {}),
|
|
1361
|
+
...(spec.note !== undefined ? { note: spec.note } : {}),
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function currentOf(target, field) {
|
|
1366
|
+
const value = target.current?.[field]
|
|
1367
|
+
return { declared: value !== undefined, value: value === undefined ? undefined : clone(value) }
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function comparisonFor(field, current, options) {
|
|
1371
|
+
if (current === undefined) return { state: 'empty', equal: false, note: '空字段=未声明/继承,不是 false/null/空列表' }
|
|
1372
|
+
if (field === 'input') {
|
|
1373
|
+
if (modalitySet(current) === undefined) return { state: 'undeclared', equal: false, note: '空模态数组=未声明' }
|
|
1374
|
+
for (const option of options) {
|
|
1375
|
+
if (compareModalitySets(current, option.value) === 'equal') return { state: 'match', equal: true, note: '模态按集合比较,顺序差异不算冲突' }
|
|
1376
|
+
}
|
|
1377
|
+
return { state: 'differs', equal: false, note: '模态按集合比较,顺序差异不算冲突' }
|
|
1378
|
+
}
|
|
1379
|
+
for (const option of options) {
|
|
1380
|
+
const candidate = option.key !== undefined ? { [option.key]: option.value } : option.value
|
|
1381
|
+
if (stableStringify(current) === stableStringify(candidate)) return { state: 'match', equal: true }
|
|
1382
|
+
}
|
|
1383
|
+
return { state: 'differs', equal: false }
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function optionOverflow(options) {
|
|
1387
|
+
if (options.length <= MAX_OPTIONS_PER_GROUP) return { options, overflow: 0 }
|
|
1388
|
+
return { options: options.slice(0, MAX_OPTIONS_PER_GROUP), overflow: options.length - MAX_OPTIONS_PER_GROUP }
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function capabilityGroup(field, target, pi, openRouter) {
|
|
1392
|
+
const collected = []
|
|
1393
|
+
const seen = new Set()
|
|
1394
|
+
const add = (option) => {
|
|
1395
|
+
if (seen.has(option.candidateId)) return
|
|
1396
|
+
seen.add(option.candidateId)
|
|
1397
|
+
collected.push(option)
|
|
1398
|
+
}
|
|
1399
|
+
for (const record of pi.exact) {
|
|
1400
|
+
const value = fieldValueFromPi(record, field)
|
|
1401
|
+
if (value === undefined) continue
|
|
1402
|
+
add(makeOption(field, {
|
|
1403
|
+
value,
|
|
1404
|
+
source: 'pi-ai',
|
|
1405
|
+
evidence: 'exact-channel-protocol',
|
|
1406
|
+
confidence: 'high',
|
|
1407
|
+
adoptable: true,
|
|
1408
|
+
sourceRef: piRef(record),
|
|
1409
|
+
note: '当前渠道与协议精确匹配',
|
|
1410
|
+
}))
|
|
1411
|
+
}
|
|
1412
|
+
for (const record of pi.sameChannelOtherApi) {
|
|
1413
|
+
const value = fieldValueFromPi(record, field)
|
|
1414
|
+
if (value === undefined) continue
|
|
1415
|
+
add(makeOption(field, {
|
|
1416
|
+
value,
|
|
1417
|
+
source: 'pi-ai',
|
|
1418
|
+
evidence: 'same-channel-other-protocol',
|
|
1419
|
+
confidence: 'reference',
|
|
1420
|
+
adoptable: true,
|
|
1421
|
+
sourceRef: piRef(record),
|
|
1422
|
+
note: '同渠道不同协议的目录条目,按组明确采纳后才写入',
|
|
1423
|
+
}))
|
|
1424
|
+
}
|
|
1425
|
+
for (const record of pi.crossChannel) {
|
|
1426
|
+
const value = fieldValueFromPi(record, field)
|
|
1427
|
+
if (value === undefined) continue
|
|
1428
|
+
add(makeOption(field, {
|
|
1429
|
+
value,
|
|
1430
|
+
source: 'pi-ai',
|
|
1431
|
+
evidence: 'cross-channel-reference',
|
|
1432
|
+
confidence: 'reference',
|
|
1433
|
+
adoptable: true,
|
|
1434
|
+
sourceRef: piRef(record),
|
|
1435
|
+
note: '跨渠道资料仅参考,按组明确采纳后才写入',
|
|
1436
|
+
}))
|
|
1437
|
+
}
|
|
1438
|
+
for (const reference of openRouter?.references ?? []) {
|
|
1439
|
+
const value = fieldValueFromOpenRouter(reference, field)
|
|
1440
|
+
if (value === undefined) continue
|
|
1441
|
+
add(makeOption(field, {
|
|
1442
|
+
value,
|
|
1443
|
+
source: 'openrouter',
|
|
1444
|
+
evidence: 'external-reference',
|
|
1445
|
+
confidence: 'reference',
|
|
1446
|
+
adoptable: true,
|
|
1447
|
+
sourceRef: { source: 'openrouter', ...reference.identity },
|
|
1448
|
+
note: '外部档案只确认模型身份,跨渠道仅参考',
|
|
1449
|
+
}))
|
|
1450
|
+
}
|
|
1451
|
+
const { options, overflow } = optionOverflow(collected)
|
|
1452
|
+
const anyEvidence = pi.exact.length + pi.sameChannelOtherApi.length + pi.crossChannel.length > 0
|
|
1453
|
+
return {
|
|
1454
|
+
field,
|
|
1455
|
+
scope: 'model',
|
|
1456
|
+
current: currentOf(target, field),
|
|
1457
|
+
comparison: comparisonFor(field, target.current?.[field], options),
|
|
1458
|
+
options,
|
|
1459
|
+
overflow,
|
|
1460
|
+
readOnly: false,
|
|
1461
|
+
adoptable: options.some((option) => option.adoptable),
|
|
1462
|
+
// 缺席理由:没有资料 vs 资料未表态。两者都不是「不支持」。
|
|
1463
|
+
absentReason: options.length === 0 ? (anyEvidence ? 'source-undeclared' : 'no-local-evidence') : undefined,
|
|
1464
|
+
unsupported: false,
|
|
1465
|
+
notes: options.length === 0
|
|
1466
|
+
? ['资料缺席不是「不支持」,可合法手填']
|
|
1467
|
+
: ['无自动勾选;✓ 才写入草稿'],
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function reasoningGroup(target, pi, runtime) {
|
|
1472
|
+
const collected = []
|
|
1473
|
+
const seen = new Set()
|
|
1474
|
+
const add = (option) => {
|
|
1475
|
+
if (seen.has(option.candidateId)) return
|
|
1476
|
+
seen.add(option.candidateId)
|
|
1477
|
+
collected.push(option)
|
|
1478
|
+
}
|
|
1479
|
+
const records = target.modelOnly ? [...pi.exact].sort((a, b) => Number(b.reasoningEfforts !== undefined) - Number(a.reasoningEfforts !== undefined)) : pi.exact
|
|
1480
|
+
for (const record of records) {
|
|
1481
|
+
if (record.reasoningEfforts !== undefined) {
|
|
1482
|
+
add(makeOption('reasoningEfforts', {
|
|
1483
|
+
value: record.reasoningEfforts,
|
|
1484
|
+
source: 'pi-ai',
|
|
1485
|
+
evidence: 'exact-channel-protocol',
|
|
1486
|
+
confidence: 'high',
|
|
1487
|
+
adoptable: true,
|
|
1488
|
+
sourceRef: piRef(record),
|
|
1489
|
+
note: '当前渠道与协议精确匹配的 wire 映射',
|
|
1490
|
+
}))
|
|
1491
|
+
} else if (record.reasoningDeclared === false) {
|
|
1492
|
+
add(makeOption('reasoningEfforts', {
|
|
1493
|
+
value: false,
|
|
1494
|
+
source: 'pi-ai',
|
|
1495
|
+
evidence: 'exact-channel-protocol',
|
|
1496
|
+
confidence: 'high',
|
|
1497
|
+
adoptable: true,
|
|
1498
|
+
sourceRef: piRef(record),
|
|
1499
|
+
note: '目录**明确声明**不支持推理(未声明不会到这里)',
|
|
1500
|
+
}))
|
|
1501
|
+
}
|
|
1502
|
+
// reasoningDeclared === undefined:既不产出 wire,也不产出 false。
|
|
1503
|
+
}
|
|
1504
|
+
// 同渠道跨 api / 跨渠道的 wire 只读参考:协议不同,wire 不可代填。
|
|
1505
|
+
for (const record of [...pi.sameChannelOtherApi, ...pi.crossChannel]) {
|
|
1506
|
+
if (record.reasoningEfforts === undefined) continue
|
|
1507
|
+
add(makeOption('reasoningEfforts', {
|
|
1508
|
+
value: record.reasoningEfforts,
|
|
1509
|
+
source: 'pi-ai',
|
|
1510
|
+
evidence: record.channel === target.channel ? 'same-channel-other-protocol' : 'cross-channel-reference',
|
|
1511
|
+
confidence: 'read-only',
|
|
1512
|
+
adoptable: false,
|
|
1513
|
+
readOnly: true,
|
|
1514
|
+
kind: 'foreign-wire',
|
|
1515
|
+
sourceRef: piRef(record),
|
|
1516
|
+
note: '协议/渠道不同,wire 映射不可代填,需手填',
|
|
1517
|
+
}))
|
|
1518
|
+
}
|
|
1519
|
+
const { options, overflow } = optionOverflow(collected)
|
|
1520
|
+
const anyEvidence = pi.exact.length + pi.sameChannelOtherApi.length + pi.crossChannel.length > 0
|
|
1521
|
+
let absentReason
|
|
1522
|
+
if (options.length === 0) absentReason = anyEvidence ? 'source-undeclared' : 'no-local-evidence'
|
|
1523
|
+
return {
|
|
1524
|
+
field: 'reasoningEfforts',
|
|
1525
|
+
scope: 'model',
|
|
1526
|
+
supported: [...pi.exact, ...pi.sameChannelOtherApi, ...pi.crossChannel].some((record) => record.reasoningDeclared === true),
|
|
1527
|
+
current: currentOf(target, 'reasoningEfforts'),
|
|
1528
|
+
comparison: comparisonFor('reasoningEfforts', target.current?.reasoningEfforts, options),
|
|
1529
|
+
options,
|
|
1530
|
+
overflow,
|
|
1531
|
+
readOnly: false,
|
|
1532
|
+
adoptable: options.some((option) => option.adoptable),
|
|
1533
|
+
absentReason,
|
|
1534
|
+
unsupported: false,
|
|
1535
|
+
notes: [
|
|
1536
|
+
'wire 只来自当前渠道+协议;运行时档位 ID 不能捏造 wire',
|
|
1537
|
+
'reasoning 未声明与明确 false 是两回事,不合并展示',
|
|
1538
|
+
],
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const SDK_WITHHELD_NOTE = 'withhold 键在当前协议不可写,只读解释,不丢弃也不暗自写入'
|
|
1543
|
+
|
|
1544
|
+
function compatGroup(target, pi) {
|
|
1545
|
+
const collected = []
|
|
1546
|
+
const seen = new Set()
|
|
1547
|
+
const inexpressible = []
|
|
1548
|
+
const add = (option) => {
|
|
1549
|
+
if (seen.has(option.candidateId)) return
|
|
1550
|
+
seen.add(option.candidateId)
|
|
1551
|
+
collected.push(option)
|
|
1552
|
+
}
|
|
1553
|
+
const protocolGate = COMPAT_PROTOCOL_GATE[target.api]
|
|
1554
|
+
for (const record of pi.exact) {
|
|
1555
|
+
if (!isPlainObject(record.compat)) continue
|
|
1556
|
+
for (const [key, value] of Object.entries(record.compat)) {
|
|
1557
|
+
const disposition = protocolGate?.[key]
|
|
1558
|
+
if (disposition === 'offer') {
|
|
1559
|
+
add(makeOption('compat', {
|
|
1560
|
+
key,
|
|
1561
|
+
value,
|
|
1562
|
+
source: 'pi-ai',
|
|
1563
|
+
evidence: 'exact-channel-protocol',
|
|
1564
|
+
confidence: 'high',
|
|
1565
|
+
adoptable: true,
|
|
1566
|
+
sourceRef: piRef(record),
|
|
1567
|
+
note: `协议 ${target.api} 的可写键`,
|
|
1568
|
+
}))
|
|
1569
|
+
} else {
|
|
1570
|
+
inexpressible.push({
|
|
1571
|
+
key,
|
|
1572
|
+
value: clone(value),
|
|
1573
|
+
reason: disposition === 'withhold' ? 'protocol-withholds-key' : isWritableProtocol(target.api) ? 'unknown-key' : 'unknown-protocol',
|
|
1574
|
+
note: '目录含该键但当前协议不可写,只读解释,不暗自写入',
|
|
1575
|
+
})
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
for (const record of pi.sameChannelOtherApi) {
|
|
1580
|
+
if (!isPlainObject(record.compat)) continue
|
|
1581
|
+
for (const [key, value] of Object.entries(record.compat)) {
|
|
1582
|
+
add(makeOption('compat', {
|
|
1583
|
+
key,
|
|
1584
|
+
value,
|
|
1585
|
+
source: 'pi-ai',
|
|
1586
|
+
evidence: 'same-channel-other-protocol',
|
|
1587
|
+
confidence: 'read-only',
|
|
1588
|
+
adoptable: false,
|
|
1589
|
+
readOnly: true,
|
|
1590
|
+
kind: 'foreign-compat',
|
|
1591
|
+
sourceRef: piRef(record),
|
|
1592
|
+
note: '同渠道不同协议的 compat,跨协议只参考/手填',
|
|
1593
|
+
}))
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
for (const record of pi.crossChannel) {
|
|
1597
|
+
if (!isPlainObject(record.compat)) continue
|
|
1598
|
+
for (const [key, value] of Object.entries(record.compat)) {
|
|
1599
|
+
add(makeOption('compat', {
|
|
1600
|
+
key,
|
|
1601
|
+
value,
|
|
1602
|
+
source: 'pi-ai',
|
|
1603
|
+
evidence: 'cross-channel-reference',
|
|
1604
|
+
confidence: 'read-only',
|
|
1605
|
+
adoptable: false,
|
|
1606
|
+
readOnly: true,
|
|
1607
|
+
kind: 'foreign-compat',
|
|
1608
|
+
sourceRef: piRef(record),
|
|
1609
|
+
note: '跨渠道 compat 只参考/手填',
|
|
1610
|
+
}))
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
const { options, overflow } = optionOverflow(collected)
|
|
1614
|
+
const anyEvidence = pi.exact.length + pi.sameChannelOtherApi.length + pi.crossChannel.length > 0
|
|
1615
|
+
return {
|
|
1616
|
+
field: 'compat',
|
|
1617
|
+
scope: 'model',
|
|
1618
|
+
keyed: true,
|
|
1619
|
+
current: currentOf(target, 'compat'),
|
|
1620
|
+
// compat 是按 key 逐键确认,不存在单一「整对象比较」的 match/differs。
|
|
1621
|
+
comparison: { state: 'keyed', equal: false },
|
|
1622
|
+
options,
|
|
1623
|
+
overflow,
|
|
1624
|
+
inexpressible,
|
|
1625
|
+
readOnly: false,
|
|
1626
|
+
adoptable: options.some((option) => option.adoptable),
|
|
1627
|
+
// 有目录键但当前协议全部不可写时,理由是「协议不可写」而不是「无资料」。
|
|
1628
|
+
absentReason: options.length === 0
|
|
1629
|
+
? (inexpressible.length > 0 ? 'not-writable-under-protocol' : anyEvidence ? 'source-undeclared' : 'no-local-evidence')
|
|
1630
|
+
: undefined,
|
|
1631
|
+
unsupported: false,
|
|
1632
|
+
notes: [
|
|
1633
|
+
'compat 按具体可写键逐键确认;目录全部键 ≠ 宿主全部可写键',
|
|
1634
|
+
SDK_WITHHELD_NOTE,
|
|
1635
|
+
],
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function modelParamsGroups(target, modelParams) {
|
|
1640
|
+
if (modelParams === undefined) return []
|
|
1641
|
+
const groups = []
|
|
1642
|
+
if (modelParams.requestRanges.length > 0) {
|
|
1643
|
+
const options = modelParams.requestRanges.map((range) => makeOption('paramRangeMax', {
|
|
1644
|
+
value: range.rangeMax,
|
|
1645
|
+
source: 'modelparams',
|
|
1646
|
+
evidence: 'source-declaration',
|
|
1647
|
+
confidence: 'read-only',
|
|
1648
|
+
adoptable: false,
|
|
1649
|
+
readOnly: true,
|
|
1650
|
+
kind: 'request-parameter-ceiling',
|
|
1651
|
+
sourceRef: { source: 'modelparams', ...range.identity, paramPath: range.paramPath },
|
|
1652
|
+
note: range.note,
|
|
1653
|
+
}))
|
|
1654
|
+
groups.push({
|
|
1655
|
+
field: 'paramRangeMax',
|
|
1656
|
+
scope: 'request-parameter',
|
|
1657
|
+
// 明确映射不到模型字段:不给 maxTokens 生成建议。
|
|
1658
|
+
mapsToModelField: null,
|
|
1659
|
+
current: currentOf(target, 'maxTokens'),
|
|
1660
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1661
|
+
options,
|
|
1662
|
+
readOnly: true,
|
|
1663
|
+
adoptable: false,
|
|
1664
|
+
absentReason: undefined,
|
|
1665
|
+
unsupported: false,
|
|
1666
|
+
notes: ['range.max 是 API 参数取值上限,只读;不填模型 maxTokens'],
|
|
1667
|
+
})
|
|
1668
|
+
}
|
|
1669
|
+
if (modelParams.effortValues.length > 0) {
|
|
1670
|
+
const options = modelParams.effortValues.map((entry) => makeOption('sourceEffortValues', {
|
|
1671
|
+
value: entry.values,
|
|
1672
|
+
source: 'modelparams',
|
|
1673
|
+
evidence: 'source-declaration',
|
|
1674
|
+
confidence: 'read-only',
|
|
1675
|
+
adoptable: false,
|
|
1676
|
+
readOnly: true,
|
|
1677
|
+
kind: 'source-effort-names',
|
|
1678
|
+
sourceRef: { source: 'modelparams', ...entry.identity, paramPath: entry.paramPath },
|
|
1679
|
+
note: entry.note,
|
|
1680
|
+
}))
|
|
1681
|
+
groups.push({
|
|
1682
|
+
field: 'sourceEffortValues',
|
|
1683
|
+
scope: 'source-reference',
|
|
1684
|
+
current: { declared: false, value: undefined },
|
|
1685
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1686
|
+
options,
|
|
1687
|
+
readOnly: true,
|
|
1688
|
+
adoptable: false,
|
|
1689
|
+
unsupported: false,
|
|
1690
|
+
notes: ['来源档位名不等于当前渠道 wire;每条记录独立,不做并集'],
|
|
1691
|
+
})
|
|
1692
|
+
}
|
|
1693
|
+
if (modelParams.defaults.length > 0) {
|
|
1694
|
+
const options = modelParams.defaults.map((entry) => makeOption('sourceDefaults', {
|
|
1695
|
+
value: entry.value,
|
|
1696
|
+
source: 'modelparams',
|
|
1697
|
+
evidence: 'source-declaration',
|
|
1698
|
+
confidence: 'read-only',
|
|
1699
|
+
adoptable: false,
|
|
1700
|
+
readOnly: true,
|
|
1701
|
+
kind: 'source-declared-default',
|
|
1702
|
+
sourceRef: { source: 'modelparams', ...entry.identity, paramPath: entry.paramPath },
|
|
1703
|
+
note: entry.note,
|
|
1704
|
+
}))
|
|
1705
|
+
groups.push({
|
|
1706
|
+
field: 'sourceDefaults',
|
|
1707
|
+
scope: 'source-reference',
|
|
1708
|
+
current: { declared: false, value: undefined },
|
|
1709
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1710
|
+
options,
|
|
1711
|
+
readOnly: true,
|
|
1712
|
+
adoptable: false,
|
|
1713
|
+
unsupported: false,
|
|
1714
|
+
notes: ['来源默认不是当前远端默认值'],
|
|
1715
|
+
})
|
|
1716
|
+
}
|
|
1717
|
+
return groups
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function runtimeGroups(runtime) {
|
|
1721
|
+
if (runtime === undefined) return []
|
|
1722
|
+
const groups = []
|
|
1723
|
+
const defaults = []
|
|
1724
|
+
if (runtime.defaultMaxTokens !== undefined) {
|
|
1725
|
+
defaults.push(makeOption('adapterDefaults', {
|
|
1726
|
+
value: runtime.defaultMaxTokens,
|
|
1727
|
+
source: 'runtime',
|
|
1728
|
+
evidence: 'runtime-effective',
|
|
1729
|
+
confidence: 'read-only',
|
|
1730
|
+
adoptable: false,
|
|
1731
|
+
readOnly: true,
|
|
1732
|
+
kind: 'adapter-budget',
|
|
1733
|
+
sourceRef: { source: 'runtime' },
|
|
1734
|
+
note: 'adapter 调用默认/预算,不是模型能力上限',
|
|
1735
|
+
}))
|
|
1736
|
+
}
|
|
1737
|
+
if (runtime.defaultEffort !== undefined) {
|
|
1738
|
+
defaults.push(makeOption('adapterDefaults', {
|
|
1739
|
+
value: runtime.defaultEffort,
|
|
1740
|
+
source: 'runtime',
|
|
1741
|
+
evidence: 'runtime-effective',
|
|
1742
|
+
confidence: 'read-only',
|
|
1743
|
+
adoptable: false,
|
|
1744
|
+
readOnly: true,
|
|
1745
|
+
kind: 'adapter-default-effort',
|
|
1746
|
+
sourceRef: { source: 'runtime' },
|
|
1747
|
+
note: 'adapter 默认档位,不是远端默认',
|
|
1748
|
+
}))
|
|
1749
|
+
}
|
|
1750
|
+
if (defaults.length > 0) {
|
|
1751
|
+
groups.push({
|
|
1752
|
+
field: 'adapterDefaults',
|
|
1753
|
+
scope: 'adapter',
|
|
1754
|
+
current: { declared: false, value: undefined },
|
|
1755
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1756
|
+
options: defaults,
|
|
1757
|
+
readOnly: true,
|
|
1758
|
+
adoptable: false,
|
|
1759
|
+
unsupported: false,
|
|
1760
|
+
notes: ['adapter 调用默认/预算只读展示,不当模型能力上限或远端默认'],
|
|
1761
|
+
})
|
|
1762
|
+
}
|
|
1763
|
+
if (runtime.reasoningEffortIds.length > 0) {
|
|
1764
|
+
groups.push({
|
|
1765
|
+
field: 'runtimeEfforts',
|
|
1766
|
+
scope: 'runtime',
|
|
1767
|
+
wireAvailable: false,
|
|
1768
|
+
// 这里是运行时**生效投影**,不是模型的显式声明值;比较状态固定 reference-only。
|
|
1769
|
+
current: { declared: true, value: [...runtime.reasoningEffortIds] },
|
|
1770
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1771
|
+
options: [makeOption('runtimeEfforts', {
|
|
1772
|
+
value: { ids: runtime.reasoningEffortIds, names: runtime.reasoningEffortNames },
|
|
1773
|
+
source: 'runtime',
|
|
1774
|
+
evidence: 'runtime-effective',
|
|
1775
|
+
confidence: 'read-only',
|
|
1776
|
+
adoptable: false,
|
|
1777
|
+
readOnly: true,
|
|
1778
|
+
kind: 'effort-ids-no-wire',
|
|
1779
|
+
sourceRef: { source: 'runtime' },
|
|
1780
|
+
note: runtime.note,
|
|
1781
|
+
})],
|
|
1782
|
+
readOnly: true,
|
|
1783
|
+
adoptable: false,
|
|
1784
|
+
unsupported: false,
|
|
1785
|
+
notes: ['运行时只返回 id/name/description,没有 wire value;不能按 ID 捏造同名 wire'],
|
|
1786
|
+
})
|
|
1787
|
+
}
|
|
1788
|
+
if (runtime.input !== undefined) {
|
|
1789
|
+
groups.push({
|
|
1790
|
+
field: 'runtimeInput',
|
|
1791
|
+
scope: 'runtime',
|
|
1792
|
+
current: { declared: runtime.inputDeclared === true, value: clone(runtime.input) },
|
|
1793
|
+
comparison: { state: 'reference-only', equal: false },
|
|
1794
|
+
options: [makeOption('runtimeInput', {
|
|
1795
|
+
value: runtime.input,
|
|
1796
|
+
source: 'runtime',
|
|
1797
|
+
evidence: 'runtime-effective',
|
|
1798
|
+
confidence: 'read-only',
|
|
1799
|
+
adoptable: false,
|
|
1800
|
+
readOnly: true,
|
|
1801
|
+
kind: 'effective-input',
|
|
1802
|
+
sourceRef: { source: 'runtime' },
|
|
1803
|
+
note: '运行时生效投影,不是独立佐证',
|
|
1804
|
+
})],
|
|
1805
|
+
readOnly: true,
|
|
1806
|
+
adoptable: false,
|
|
1807
|
+
unsupported: false,
|
|
1808
|
+
notes: ['生效投影不等于来源证据,不整份写回显式配置'],
|
|
1809
|
+
})
|
|
1810
|
+
}
|
|
1811
|
+
return groups
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* 证据 → 分组建议。纯函数,不触网。
|
|
1816
|
+
* `piIndex`、`modelParams`、`openRouter`({matches, ambiguous, references})、`runtime` 都可缺席。
|
|
1817
|
+
* 所有选项 `requiresExplicitAdoption:true`;readOnly 组永不 adoptable;缺席带 absentReason 且
|
|
1818
|
+
* `unsupported:false`(资料缺席不是不支持)。
|
|
1819
|
+
*/
|
|
1820
|
+
export function buildSmartFillGroups({ target, piIndex, modelParams, openRouter, runtime } = {}) {
|
|
1821
|
+
const normalized = normalizeTarget(target)
|
|
1822
|
+
const pi = piIndex !== undefined && piIndex !== null
|
|
1823
|
+
? piEvidenceFor(piIndex, normalized)
|
|
1824
|
+
: { exact: [], sameChannelOtherApi: [], crossChannel: [] }
|
|
1825
|
+
if (normalized.modelOnly) {
|
|
1826
|
+
pi.exact = [...pi.exact, ...pi.sameChannelOtherApi, ...pi.crossChannel]
|
|
1827
|
+
.sort((a, b) => [a.channel, a.api, a.modelId].join('/').localeCompare([b.channel, b.api, b.modelId].join('/')))
|
|
1828
|
+
pi.sameChannelOtherApi = []
|
|
1829
|
+
pi.crossChannel = []
|
|
1830
|
+
}
|
|
1831
|
+
const groups = []
|
|
1832
|
+
for (const field of CAPABILITY_FIELDS) groups.push(capabilityGroup(field, normalized, pi, openRouter))
|
|
1833
|
+
groups.push(reasoningGroup(normalized, pi, runtime))
|
|
1834
|
+
groups.push(compatGroup(normalized, pi))
|
|
1835
|
+
groups.push(...modelParamsGroups(normalized, modelParams))
|
|
1836
|
+
groups.push(...runtimeGroups(runtime))
|
|
1837
|
+
if (normalized.modelOnly) {
|
|
1838
|
+
for (const group of groups) {
|
|
1839
|
+
for (const option of group.options ?? []) {
|
|
1840
|
+
if (option.evidence === 'exact-channel-protocol') {
|
|
1841
|
+
option.evidence = 'model-identity'
|
|
1842
|
+
option.confidence = 'reference'
|
|
1843
|
+
option.note = '按模型身份匹配的资料建议,不代表当前渠道实测能力'
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if (group.field === 'reasoningEfforts') group.notes = ['思考映射按模型身份匹配;资料未声明时不猜测协议值']
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const notes = []
|
|
1851
|
+
if (pi.exact.length === 0 && pi.sameChannelOtherApi.length === 0 && pi.crossChannel.length === 0) {
|
|
1852
|
+
notes.push('本机 pi-ai 目录没有该模型记录:缺席不是「不支持」')
|
|
1853
|
+
}
|
|
1854
|
+
if (openRouter?.ambiguous === true) {
|
|
1855
|
+
notes.push(normalized.modelOnly ? 'OpenRouter 命中多个同名条目:保留候选,模型页按稳定来源顺序展示建议,填入需统一确认' : 'OpenRouter 命中多个同名条目:保留全部候选,由用户选择,不默认取首条')
|
|
1856
|
+
}
|
|
1857
|
+
return { groups, notes }
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1861
|
+
// 来源候选绑定:身份指纹 + 关联复核(只登记关联,绝不把来源 wire 带过渠道)
|
|
1862
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1863
|
+
|
|
1864
|
+
/** 绑定指纹版本:判据变化时递增,旧绑定自然失效而不是被误判为同一身份。 */
|
|
1865
|
+
export const SOURCE_BINDING_VERSION = 1
|
|
1866
|
+
|
|
1867
|
+
function normalizeAddress(baseUrl, kind) {
|
|
1868
|
+
if (kind === 'empty' || kind === 'template') return ''
|
|
1869
|
+
if (typeof baseUrl !== 'string') return ''
|
|
1870
|
+
return baseUrl.trim().toLowerCase().replace(/\/+$/, '').replace(/:(443)(?=\/|$)/, '')
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* 路由身份指纹。返回两个摘要:
|
|
1875
|
+
* - `fingerprint`:**不含地址**的稳定身份(channel + api + modelId),是绑定的主键——
|
|
1876
|
+
* 地址变化时它不变,绑定因此能被识别为「同一条 binding 待复核」而不是凭空消失;
|
|
1877
|
+
* - `identityFingerprint`:**含地址**的完整身份,用来判定地址是否漂移(地址变化按规格是
|
|
1878
|
+
* `address-changed` 待复核,不是自动沿用)。
|
|
1879
|
+
* `includeAddress` 只影响 `fingerprint`(true 时等价于 `identityFingerprint`),
|
|
1880
|
+
* 供调用方需要「换地址即整条失效」语义时使用。
|
|
1881
|
+
*/
|
|
1882
|
+
export function routeIdentityFingerprint(target, { includeAddress = false } = {}) {
|
|
1883
|
+
const normalized = normalizeTarget(target)
|
|
1884
|
+
const kind = typeof target?.baseUrlKind === 'string' ? target.baseUrlKind : baseUrlKindOf(target?.baseUrl)
|
|
1885
|
+
const address = normalizeAddress(target?.baseUrl, kind)
|
|
1886
|
+
const baseParts = [SOURCE_BINDING_VERSION, normalized.channel, normalized.api, normalized.modelId]
|
|
1887
|
+
const addressParts = [kind, address]
|
|
1888
|
+
const identityFingerprint = createHash('sha256').update(stableStringify([...baseParts, ...addressParts])).digest('hex').slice(0, 16)
|
|
1889
|
+
return {
|
|
1890
|
+
channel: normalized.channel,
|
|
1891
|
+
api: normalized.api,
|
|
1892
|
+
modelId: normalized.modelId,
|
|
1893
|
+
address,
|
|
1894
|
+
addressKind: kind,
|
|
1895
|
+
fingerprint: includeAddress ? identityFingerprint : createHash('sha256').update(stableStringify(baseParts)).digest('hex').slice(0, 16),
|
|
1896
|
+
identityFingerprint,
|
|
1897
|
+
addressBound: address !== '',
|
|
1898
|
+
addressFingerprint: address !== ''
|
|
1899
|
+
? createHash('sha256').update(stableStringify([SOURCE_BINDING_VERSION, normalized.channel, address])).digest('hex').slice(0, 16)
|
|
1900
|
+
: '',
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/** 候选来源侧的同一套身份指纹(`baseUrlKind` 可选,缺省由 baseUrl 推断)。 */
|
|
1905
|
+
export function sourceCandidateFingerprint(candidate) {
|
|
1906
|
+
const raw = isPlainObject(candidate) ? candidate : {}
|
|
1907
|
+
return routeIdentityFingerprint({
|
|
1908
|
+
channel: raw.channel,
|
|
1909
|
+
api: raw.api,
|
|
1910
|
+
modelId: raw.modelId,
|
|
1911
|
+
baseUrl: raw.baseUrl,
|
|
1912
|
+
baseUrlKind: raw.baseUrlKind,
|
|
1913
|
+
})
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* 来源绑定解析:把**当前路由身份**与候选来源逐条对齐,并说明旧关联是否需要复核。
|
|
1918
|
+
*
|
|
1919
|
+
* 语义边界(规格 §5/§6):
|
|
1920
|
+
* - 只登记「哪条目录记录对应这条 route + api + model」。**不携带任何 wire/compat 值**,
|
|
1921
|
+
* 也不产出凭关联就能采纳的候选;关联不等于能力实测,也不等于确定中转透传。
|
|
1922
|
+
* - `previous` 是上次登记的绑定(本函数上一次的产物)。指纹不同 → `invalid` 并给
|
|
1923
|
+
* `fingerprint-changed`;只有地址部分变化 → `address-changed`(待复核)。
|
|
1924
|
+
* - 候选的 `state`:`match`(route 身份全等)/ `address-unresolved`(来源地址为空或模板,
|
|
1925
|
+
* 不能当当前渠道事实)/ `address-mismatch`(同 route 身份但地址不同)/
|
|
1926
|
+
* `identity-mismatch`(渠道、api 或模型 ID 不同)。
|
|
1927
|
+
* - 绝不做「跨来源求并集」或「按关联改写值」:每个候选保持自己的身份与地址。
|
|
1928
|
+
*
|
|
1929
|
+
* 返回值可安全序列化给宿主路由;`linked` 只是**候选索引**,不是采纳动作。
|
|
1930
|
+
*/
|
|
1931
|
+
export function resolveSourceBinding({ target, candidateSources = [], previous } = {}) {
|
|
1932
|
+
const route = routeIdentityFingerprint(target)
|
|
1933
|
+
const candidates = []
|
|
1934
|
+
let matchIndex = -1
|
|
1935
|
+
for (const [index, raw] of (Array.isArray(candidateSources) ? candidateSources : []).entries()) {
|
|
1936
|
+
const candidate = isPlainObject(raw) ? raw : {}
|
|
1937
|
+
const fingerprint = sourceCandidateFingerprint(candidate)
|
|
1938
|
+
let state
|
|
1939
|
+
let reason
|
|
1940
|
+
if (fingerprint.channel !== route.channel || fingerprint.api !== route.api || fingerprint.modelId !== route.modelId) {
|
|
1941
|
+
state = 'identity-mismatch'
|
|
1942
|
+
reason = 'route-identity-differs'
|
|
1943
|
+
} else if (!fingerprint.addressBound) {
|
|
1944
|
+
state = 'address-unresolved'
|
|
1945
|
+
reason = fingerprint.addressKind === 'template' ? 'template-address' : 'empty-address'
|
|
1946
|
+
} else if (!route.addressBound || fingerprint.addressFingerprint !== route.addressFingerprint) {
|
|
1947
|
+
state = 'address-mismatch'
|
|
1948
|
+
reason = 'address-differs'
|
|
1949
|
+
} else {
|
|
1950
|
+
state = 'match'
|
|
1951
|
+
reason = undefined
|
|
1952
|
+
if (matchIndex === -1) matchIndex = index
|
|
1953
|
+
}
|
|
1954
|
+
candidates.push({
|
|
1955
|
+
index,
|
|
1956
|
+
sourceId: str(candidate.sourceId),
|
|
1957
|
+
channel: fingerprint.channel,
|
|
1958
|
+
api: fingerprint.api,
|
|
1959
|
+
modelId: fingerprint.modelId,
|
|
1960
|
+
address: fingerprint.address,
|
|
1961
|
+
addressKind: fingerprint.addressKind,
|
|
1962
|
+
fingerprint: fingerprint.fingerprint,
|
|
1963
|
+
addressFingerprint: fingerprint.addressFingerprint,
|
|
1964
|
+
addressBound: fingerprint.addressBound,
|
|
1965
|
+
state,
|
|
1966
|
+
reason,
|
|
1967
|
+
})
|
|
1968
|
+
}
|
|
1969
|
+
const previousRoute = previous !== undefined && previous !== null && isPlainObject(previous.route) ? previous.route : undefined
|
|
1970
|
+
let linked = matchIndex === -1 ? null : { index: matchIndex, sourceId: candidates[matchIndex].sourceId }
|
|
1971
|
+
let state
|
|
1972
|
+
let reason
|
|
1973
|
+
let rereviewRequired = false
|
|
1974
|
+
const sameBaseIdentity = previousRoute !== undefined
|
|
1975
|
+
&& previousRoute.fingerprint !== undefined
|
|
1976
|
+
&& previousRoute.fingerprint === route.fingerprint
|
|
1977
|
+
if (previousRoute === undefined) {
|
|
1978
|
+
state = 'new'
|
|
1979
|
+
reason = linked === null ? 'no-matching-source' : 'matched'
|
|
1980
|
+
} else if (sameBaseIdentity && previousRoute.identityFingerprint === route.identityFingerprint) {
|
|
1981
|
+
state = 'stable'
|
|
1982
|
+
reason = linked === null ? 'no-matching-source' : 'matched'
|
|
1983
|
+
// 上一条绑定指向的候选在这次列表里已不存在(目录改版/条目消失):保留旧索引但标待复核。
|
|
1984
|
+
const previousLinkedIndex = finiteInt(previous?.linked?.index)
|
|
1985
|
+
if (previousLinkedIndex !== undefined && previousLinkedIndex !== matchIndex) {
|
|
1986
|
+
rereviewRequired = true
|
|
1987
|
+
reason = 'linked-source-moved'
|
|
1988
|
+
linked = linked === null ? null : { ...linked, previousIndex: previousLinkedIndex }
|
|
1989
|
+
}
|
|
1990
|
+
} else if (sameBaseIdentity) {
|
|
1991
|
+
// 只有地址变:规格要求「地址变化使来源关联待复核」,而不是悄悄沿用旧关联。
|
|
1992
|
+
state = 'review'
|
|
1993
|
+
reason = 'address-changed'
|
|
1994
|
+
rereviewRequired = true
|
|
1995
|
+
} else {
|
|
1996
|
+
state = 'invalid'
|
|
1997
|
+
reason = 'fingerprint-changed'
|
|
1998
|
+
rereviewRequired = true
|
|
1999
|
+
}
|
|
2000
|
+
return {
|
|
2001
|
+
version: SOURCE_BINDING_VERSION,
|
|
2002
|
+
route,
|
|
2003
|
+
previousRoute: previousRoute ?? null,
|
|
2004
|
+
candidates,
|
|
2005
|
+
links: candidates.filter((candidate) => candidate.state === 'match').map((candidate) => candidate.index),
|
|
2006
|
+
...(linked === null ? {} : { linked }),
|
|
2007
|
+
...(previous?.linked !== undefined && previous?.linked !== null && state !== 'invalid' && state !== 'review'
|
|
2008
|
+
? { previousLinked: previous.linked }
|
|
2009
|
+
: {}),
|
|
2010
|
+
state,
|
|
2011
|
+
reason: reason ?? 'no-matching-source',
|
|
2012
|
+
// 关联待复核时必须由用户重新确认,绝不自动沿用旧关联。
|
|
2013
|
+
rereviewRequired,
|
|
2014
|
+
// 关联只表示「这条记录对应这条 route」;不携带 wire/compat,也绝不跨来源/渠道采纳。
|
|
2015
|
+
adoptionScope: 'candidate-identity-only',
|
|
2016
|
+
wireCarryover: false,
|
|
2017
|
+
note: '关联只登记身份,不代表能力实测或确定透传;wire/compat 只在当前渠道+协议精确匹配时才可建议',
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* 采纳单个候选:只有显式给出 candidateId 且该选项 `adoptable && !readOnly` 才通过。
|
|
2023
|
+
* 返回 `{ok:true, field, key, value}` 或 `{ok:false, error}`;绝不自动选择任何候选。
|
|
2024
|
+
*/
|
|
2025
|
+
export function adoptCandidate(groups, candidateId) {
|
|
2026
|
+
const id = String(candidateId ?? '')
|
|
2027
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
2028
|
+
for (const option of group?.options ?? []) {
|
|
2029
|
+
if (option.candidateId !== id) continue
|
|
2030
|
+
if (option.adoptable !== true || option.readOnly === true) return { ok: false, error: 'not-adoptable', candidateId: id }
|
|
2031
|
+
return {
|
|
2032
|
+
ok: true,
|
|
2033
|
+
candidateId: id,
|
|
2034
|
+
field: group.field,
|
|
2035
|
+
...(option.key !== undefined ? { key: option.key } : {}),
|
|
2036
|
+
value: clone(option.value),
|
|
2037
|
+
source: option.source,
|
|
2038
|
+
evidence: option.evidence,
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
return { ok: false, error: 'unknown-candidate', candidateId: id }
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* 把一组**显式选定**的候选写入草稿副本:只提交手改 + 已采纳,未决建议不写入。
|
|
2047
|
+
* 任何未知/只读 candidateId → 整体拒绝(防伪造,且不产生半套状态)。
|
|
2048
|
+
* compat 按 key 合并进草稿 compat 对象。
|
|
2049
|
+
*/
|
|
2050
|
+
export function applyAdoption(draft, groups, candidateIds) {
|
|
2051
|
+
const next = isPlainObject(draft) ? clone(draft) : {}
|
|
2052
|
+
const applied = []
|
|
2053
|
+
for (const candidateId of Array.isArray(candidateIds) ? candidateIds : []) {
|
|
2054
|
+
const adopted = adoptCandidate(groups, candidateId)
|
|
2055
|
+
if (!adopted.ok) return { ok: false, error: adopted.error, candidateId: adopted.candidateId }
|
|
2056
|
+
if (adopted.key !== undefined) {
|
|
2057
|
+
next.compat = isPlainObject(next.compat) ? next.compat : {}
|
|
2058
|
+
next.compat[adopted.key] = adopted.value
|
|
2059
|
+
} else {
|
|
2060
|
+
next[adopted.field] = adopted.value
|
|
2061
|
+
}
|
|
2062
|
+
applied.push(adopted.candidateId)
|
|
2063
|
+
}
|
|
2064
|
+
return { ok: true, draft: next, applied }
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2068
|
+
// 查询编排:OFF 零网络 / ON 固定来源 + 运行时
|
|
2069
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2070
|
+
|
|
2071
|
+
async function readViaCache(cache, { sourceId, url, force, parse, sources, label }) {
|
|
2072
|
+
const entry = await cache.get({ sourceId, url, force, parse })
|
|
2073
|
+
const status = entry.status === 'ok' ? 'ok' : entry.status === 'cached' ? 'cached' : entry.status
|
|
2074
|
+
sources.push({
|
|
2075
|
+
sourceId: label ?? sourceId,
|
|
2076
|
+
status,
|
|
2077
|
+
url,
|
|
2078
|
+
network: true,
|
|
2079
|
+
fetchedAt: entry.fetchedAt ?? null,
|
|
2080
|
+
upstreamAt: entry.upstreamAt ?? null,
|
|
2081
|
+
etag: entry.etag || '',
|
|
2082
|
+
// 失败/过期可见,不宣称最新。
|
|
2083
|
+
error: entry.staleError ?? null,
|
|
2084
|
+
})
|
|
2085
|
+
return entry.payload ?? null
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* 路由查询入口。**OFF 分支在取任何来源之前返回**:不读缓存、不触网、不发推理请求,
|
|
2090
|
+
* 返回值里 `network:false`、`groups:[]`。
|
|
2091
|
+
*
|
|
2092
|
+
* ON 分支:本地 pi-ai 目录总是可用(无网);modelparams / OpenRouter 走注入的
|
|
2093
|
+
* `cache`(条件请求 + 单飞)或直接给的载荷;runtime 走 `runtimeInfo`。任何来源失败都在
|
|
2094
|
+
* `sources[].error` 可见,且不阻断其它来源。
|
|
2095
|
+
*
|
|
2096
|
+
* @param {object} options
|
|
2097
|
+
* @param {'off'|'on'} options.mode
|
|
2098
|
+
* @param {{channel:string, api:string, modelId:string, current?:object}} options.target
|
|
2099
|
+
* @param {object} [options.piIndex] buildPiEvidence 产物
|
|
2100
|
+
* @param {object} [options.modelParamsIndex] indexModelParamsRecords 产物
|
|
2101
|
+
* @param {object} [options.modelParamsCatalog] 直接给目录(会内部建索引)
|
|
2102
|
+
* @param {object} [options.openRouterPayload] 直接给 OpenRouter 载荷(测试/离线)
|
|
2103
|
+
* @param {object} [options.runtimeInfo] llm.resolveModelInfo 返回
|
|
2104
|
+
* @param {object} [options.cache] createMetadataCache 实例
|
|
2105
|
+
* @param {boolean} [options.forceRefresh] true=带 etag 条件刷新
|
|
2106
|
+
*/
|
|
2107
|
+
export async function querySmartFill(options = {}) {
|
|
2108
|
+
const mode = options.mode === SMART_FILL_MODES.ON ? SMART_FILL_MODES.ON : SMART_FILL_MODES.OFF
|
|
2109
|
+
if (mode === SMART_FILL_MODES.OFF) {
|
|
2110
|
+
// OFF:先于一切来源返回。这里绝不调用 cache/fetch/网络。
|
|
2111
|
+
return {
|
|
2112
|
+
mode: 'off',
|
|
2113
|
+
network: false,
|
|
2114
|
+
groups: [],
|
|
2115
|
+
runtime: null,
|
|
2116
|
+
sources: [{ sourceId: 'smart-fill', status: 'off', network: false, error: null }],
|
|
2117
|
+
notes: ['开关关闭:不访问外部资料、不探测渠道、不发推理请求'],
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const target = normalizeTarget(options.target)
|
|
2122
|
+
const sources = []
|
|
2123
|
+
const notes = []
|
|
2124
|
+
let attemptedNetwork = false
|
|
2125
|
+
|
|
2126
|
+
// 1) 本机 pi-ai 目录(本地,不触网)
|
|
2127
|
+
const piIndex = options.piIndex ?? null
|
|
2128
|
+
sources.push({ sourceId: 'pi-ai', status: piIndex === null ? 'unavailable' : 'ok', network: false, error: null })
|
|
2129
|
+
|
|
2130
|
+
// 2) modelparams:优先用直接给的索引/目录,否则走缓存条件请求
|
|
2131
|
+
let modelParamsIndex = options.modelParamsIndex ?? null
|
|
2132
|
+
if (modelParamsIndex !== null) {
|
|
2133
|
+
sources.push({ sourceId: 'modelparams', status: 'provided', network: false, error: null })
|
|
2134
|
+
} else if (isPlainObject(options.modelParamsCatalog)) {
|
|
2135
|
+
modelParamsIndex = indexModelParamsRecords(options.modelParamsCatalog)
|
|
2136
|
+
sources.push({ sourceId: 'modelparams', status: 'provided', network: false, error: null })
|
|
2137
|
+
} else if (options.cache !== undefined) {
|
|
2138
|
+
attemptedNetwork = true
|
|
2139
|
+
const request = modelParamsRequest()
|
|
2140
|
+
const payload = await readViaCache(options.cache, {
|
|
2141
|
+
sourceId: request.sourceId,
|
|
2142
|
+
url: request.url,
|
|
2143
|
+
force: Boolean(options.forceRefresh),
|
|
2144
|
+
parse: parseModelParamsPayload,
|
|
2145
|
+
sources,
|
|
2146
|
+
})
|
|
2147
|
+
if (payload !== null) modelParamsIndex = indexModelParamsRecords(payload)
|
|
2148
|
+
} else if (modelParamsIndex === null) {
|
|
2149
|
+
sources.push({ sourceId: 'modelparams', status: 'skipped', network: false, error: 'no-source' })
|
|
2150
|
+
}
|
|
2151
|
+
// 来源语义漂移留证:被跳过的条目数随来源状态回传(绝不静默当成「无资料」)。
|
|
2152
|
+
if (modelParamsIndex !== null && modelParamsIndex.skipped > 0) {
|
|
2153
|
+
const entry = sources.find((source) => source.sourceId === 'modelparams')
|
|
2154
|
+
if (entry !== undefined) {
|
|
2155
|
+
entry.skipped = modelParamsIndex.skipped
|
|
2156
|
+
entry.skippedDetail = modelParamsIndex.skippedDetail.slice(0, 20)
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// 3) OpenRouter:直接载荷优先,否则走缓存条件请求
|
|
2161
|
+
let openRouterPayload = null
|
|
2162
|
+
if (options.openRouterPayload !== undefined) {
|
|
2163
|
+
openRouterPayload = options.openRouterPayload
|
|
2164
|
+
sources.push({ sourceId: 'openrouter', status: 'provided', network: false, error: null })
|
|
2165
|
+
} else if (options.cache !== undefined && target.modelId !== '') {
|
|
2166
|
+
attemptedNetwork = true
|
|
2167
|
+
const request = openRouterRequest(target.modelId)
|
|
2168
|
+
openRouterPayload = await readViaCache(options.cache, {
|
|
2169
|
+
sourceId: request.sourceId,
|
|
2170
|
+
url: request.url,
|
|
2171
|
+
force: Boolean(options.forceRefresh),
|
|
2172
|
+
parse: parseOpenRouterPayload,
|
|
2173
|
+
sources,
|
|
2174
|
+
label: 'openrouter',
|
|
2175
|
+
})
|
|
2176
|
+
} else {
|
|
2177
|
+
sources.push({ sourceId: 'openrouter', status: 'skipped', network: false, error: 'no-source' })
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// 4) 运行时(本地投影,不触网、不发推理)
|
|
2181
|
+
const runtime = options.runtimeInfo !== undefined ? runtimeReference(options.runtimeInfo) : undefined
|
|
2182
|
+
sources.push({ sourceId: 'runtime', status: runtime === undefined ? 'unavailable' : 'ok', network: false, error: null })
|
|
2183
|
+
|
|
2184
|
+
const modelParams = modelParamsIndex !== null ? modelParamsReference(modelParamsIndex, target.modelId) : undefined
|
|
2185
|
+
const matches = openRouterPayload !== null ? openRouterMatches(target.modelId, openRouterPayload) : undefined
|
|
2186
|
+
const references = matches !== undefined
|
|
2187
|
+
? matches.matches.map((match) => openRouterReference(match)).filter((reference) => reference !== undefined)
|
|
2188
|
+
: []
|
|
2189
|
+
const built = buildSmartFillGroups({
|
|
2190
|
+
target,
|
|
2191
|
+
piIndex,
|
|
2192
|
+
modelParams,
|
|
2193
|
+
openRouter: matches !== undefined ? { matches: matches.matches, ambiguous: matches.ambiguous, references } : undefined,
|
|
2194
|
+
runtime,
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
if (matches !== undefined && matches.ambiguous) notes.push('OpenRouter 命中多个同名条目:保留全部候选,由用户选择')
|
|
2198
|
+
return {
|
|
2199
|
+
mode: 'on',
|
|
2200
|
+
network: attemptedNetwork,
|
|
2201
|
+
groups: built.groups,
|
|
2202
|
+
sources,
|
|
2203
|
+
runtime: runtime ?? null,
|
|
2204
|
+
notes: [...notes, ...built.notes],
|
|
2205
|
+
}
|
|
2206
|
+
}
|