@mokuyoaxis/dsh-iris 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+ /** Text/Vision v0 纯契约;不读取配置、宿主或存储,不拥有网络与 timer。 */
3
+
4
+ export const MODEL_PORT_CONTRACT_VERSION = 0;
5
+ export const MODEL_IMAGE_MEDIA_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
6
+
7
+ const MESSAGES = Object.freeze({
8
+ IRIS_MODEL_INPUT_INVALID: '模型请求或预算不合法',
9
+ IRIS_MODEL_IMAGE_TOO_LARGE: '图片超过模型输入预算',
10
+ IRIS_MODEL_UNAVAILABLE: '模型能力不可用',
11
+ IRIS_MODEL_INCOMPATIBLE: '模型端口或协议不兼容',
12
+ IRIS_MODEL_UNSUPPORTED: '模型不支持所请求的能力或参数',
13
+ IRIS_MODEL_AUTH_FAILED: '模型请求被认证或权限检查拒绝',
14
+ IRIS_MODEL_RATE_LIMITED: '模型请求受到速率或额度限制',
15
+ IRIS_MODEL_REQUEST_FAILED: '模型请求失败,未自动重试',
16
+ IRIS_MODEL_PROTOCOL_INVALID: '模型响应不符合协议',
17
+ IRIS_MODEL_INCOMPLETE: '模型结果缺少正常结束信号',
18
+ IRIS_MODEL_OUTPUT_LIMIT: '模型结果超过输出预算或被截断',
19
+ IRIS_MODEL_CALL_LIMIT: '模型操作的调用次数已用尽',
20
+ IRIS_MODEL_EMPTY_RESULT: '模型正常结束但没有正文',
21
+ IRIS_MODEL_UNEXPECTED_TOOL: '模型意外请求了工具',
22
+ IRIS_MODEL_CONTENT_BLOCKED: '模型结果被内容策略阻断',
23
+ IRIS_MODEL_ABORTED: '模型操作已在本地取消',
24
+ IRIS_MODEL_TIMEOUT: '模型操作超过时间预算'
25
+ });
26
+ export const MODEL_ERROR_CODES = Object.freeze(Object.keys(MESSAGES));
27
+ const ERROR_RECORDS = new WeakMap();
28
+ const STAGES = new Set(['validate', 'prepare', 'invoke', 'read', 'normalize']);
29
+ const INVOCATIONS = new Set(['not_invoked', 'rejected', 'responded', 'unknown']);
30
+ const SUPPORT = new Set(['supported', 'unsupported', 'unknown']);
31
+ const BACKEND_ID = /^[a-z][a-z0-9-]*:[A-Za-z0-9._~%:-]+$/;
32
+ const encoder = new TextEncoder();
33
+
34
+ function backendIdValid(value) {
35
+ return typeof value === 'string' && value.length <= 512 && BACKEND_ID.test(value);
36
+ }
37
+
38
+ /** 不接受供应商原文;私有记录确保篡改 Error.message/code 不影响安全序列化。 */
39
+ export class ModelPortError extends Error {
40
+ constructor(code, details = {}) {
41
+ if (typeof code !== 'string' || !Object.hasOwn(MESSAGES, code)) throw new TypeError('未知模型错误码');
42
+ super(MESSAGES[code]);
43
+ this.name = 'ModelPortError';
44
+ const { stage = 'validate', invocation = 'not_invoked', backendId, status, imageBytes, imageMaxBytes } = details;
45
+ if (!STAGES.has(stage) || !INVOCATIONS.has(invocation)
46
+ || (backendId !== undefined && !backendIdValid(backendId))
47
+ || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599))
48
+ || [imageBytes, imageMaxBytes].some(value => value !== undefined && (!Number.isSafeInteger(value) || value <= 0))) {
49
+ throw new TypeError('模型错误上下文不合法');
50
+ }
51
+ const message = code === 'IRIS_MODEL_IMAGE_TOO_LARGE' && imageBytes
52
+ ? `图片大小 ${imageBytes} 字节${imageMaxBytes ? `,输入上限 ${imageMaxBytes} 字节` : ',超过模型输入上限'};请调整账号或模型的看图输入预算`
53
+ : MESSAGES[code];
54
+ const record = Object.freeze({ code, message, stage, invocation,
55
+ ...(backendId !== undefined ? { backendId } : {}), ...(status !== undefined ? { status } : {}),
56
+ ...(imageBytes !== undefined ? { imageBytes } : {}), ...(imageMaxBytes !== undefined ? { imageMaxBytes } : {}) });
57
+ ERROR_RECORDS.set(this, record);
58
+ Object.assign(this, record);
59
+ }
60
+ toJSON() { return modelErrorRecord(this); }
61
+ }
62
+
63
+ /** 任意原始错误默认按未知调用失败处理,不信任其 code/status/message/cause。 */
64
+ export function modelErrorRecord(error) {
65
+ return Object.freeze({ ...(ERROR_RECORDS.get(error) || {
66
+ code: 'IRIS_MODEL_REQUEST_FAILED', message: MESSAGES.IRIS_MODEL_REQUEST_FAILED,
67
+ stage: 'invoke', invocation: 'unknown'
68
+ }) });
69
+ }
70
+
71
+ export function normalizeModelError(error, context = {}) {
72
+ const record = modelErrorRecord(error);
73
+ return new ModelPortError(record.code, {
74
+ stage: record.stage, invocation: record.invocation,
75
+ ...(record.backendId !== undefined ? { backendId: record.backendId } : {}),
76
+ ...(record.status !== undefined ? { status: record.status } : {}), ...context,
77
+ ...(record.imageBytes !== undefined ? { imageBytes: record.imageBytes } : {}),
78
+ ...(record.imageMaxBytes !== undefined ? { imageMaxBytes: record.imageMaxBytes } : {})
79
+ });
80
+ }
81
+
82
+ function fail(code, context) { throw new ModelPortError(code, context); }
83
+
84
+ // DTO 必须为自有数据字段;不执行 getter、不接受原型 live object 或 symbol 字段。
85
+ function dto(value, allowed, code, context) {
86
+ if (value === null || typeof value !== 'object'
87
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(code, context);
88
+ const fields = Object.getOwnPropertyDescriptors(value);
89
+ for (const key of Reflect.ownKeys(fields)) {
90
+ if (typeof key !== 'string' || !allowed.includes(key) || !Object.hasOwn(fields[key], 'value')) fail(code, context);
91
+ }
92
+ }
93
+
94
+ function positiveInteger(value) { return Number.isSafeInteger(value) && value > 0; }
95
+ function stableId(value) {
96
+ return typeof value === 'string' && value.length > 0 && value.length <= 256
97
+ && value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value)
98
+ && !value.startsWith('/') && !value.includes('://');
99
+ }
100
+
101
+ function stringList(value, accepts, code) {
102
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail(code);
103
+ const fields = Object.getOwnPropertyDescriptors(value);
104
+ const length = fields.length.value;
105
+ // 不读取元素 getter,也不调用自定义 iterator;只接受密集的自有数据元素。
106
+ if (Reflect.ownKeys(fields).length !== length + 1) fail(code);
107
+ const out = [];
108
+ for (let index = 0; index < length; index++) {
109
+ const field = fields[index];
110
+ if (!field || !Object.hasOwn(field, 'value') || !accepts(field.value)) fail(code);
111
+ out.push(field.value);
112
+ }
113
+ if (new Set(out).size !== length) fail(code);
114
+ return Object.freeze(out);
115
+ }
116
+
117
+ function identity(value, code, context) {
118
+ dto(value, ['origin', 'backendId', 'providerId', 'modelId'], code, context);
119
+ if (!['provider', 'host'].includes(value.origin) || !backendIdValid(value.backendId)) fail(code, context);
120
+ const out = { origin: value.origin, backendId: value.backendId };
121
+ for (const key of ['providerId', 'modelId']) {
122
+ if (value[key] !== undefined) {
123
+ if (!stableId(value[key])) fail(code, context);
124
+ out[key] = value[key];
125
+ } else if (value.origin === 'provider') fail(code, context);
126
+ }
127
+ return Object.freeze(out);
128
+ }
129
+
130
+ /** 验证并复制描述;unknown 保持 unknown,缺省 Host 身份与限制不编造。 */
131
+ export function normalizeModelDescriptor(value) {
132
+ const code = 'IRIS_MODEL_INCOMPATIBLE';
133
+ dto(value, ['contractVersion', 'kind', 'identity', 'availability', 'reasonCode', 'features', 'reasoning', 'image'], code);
134
+ if (value.contractVersion !== MODEL_PORT_CONTRACT_VERSION || !['text', 'vision'].includes(value.kind)
135
+ || !['available', 'unavailable', 'incompatible'].includes(value.availability)) fail(code);
136
+ const validReason = typeof value.reasonCode === 'string' && Object.hasOwn(MESSAGES, value.reasonCode);
137
+ if ((value.availability !== 'available' && !validReason)
138
+ || (value.reasonCode !== undefined && !validReason)) fail(code);
139
+ const bound = identity(value.identity, code);
140
+ dto(value.features, ['system', 'temperature', 'maxOutputTokens', 'reasoning'], code);
141
+ const features = {};
142
+ for (const name of ['system', 'temperature', 'maxOutputTokens', 'reasoning']) {
143
+ if (!SUPPORT.has(value.features[name])) fail(code);
144
+ features[name] = value.features[name];
145
+ }
146
+ const out = { contractVersion: MODEL_PORT_CONTRACT_VERSION, kind: value.kind, identity: bound,
147
+ availability: value.availability, ...(value.reasonCode !== undefined ? { reasonCode: value.reasonCode } : {}),
148
+ features: Object.freeze(features) };
149
+ if (value.reasoning !== undefined) {
150
+ dto(value.reasoning, ['off', 'effortIds'], code);
151
+ if (!SUPPORT.has(value.reasoning.off)) fail(code);
152
+ const reasoning = { off: value.reasoning.off };
153
+ if (value.reasoning.effortIds !== undefined) {
154
+ reasoning.effortIds = stringList(value.reasoning.effortIds, stableId, code);
155
+ }
156
+ if (features.reasoning !== 'supported' && (reasoning.off === 'supported' || reasoning.effortIds?.length)) fail(code);
157
+ out.reasoning = Object.freeze(reasoning);
158
+ }
159
+ if (value.image !== undefined) {
160
+ if (value.kind !== 'vision') fail(code);
161
+ dto(value.image, ['mediaTypes', 'maxBytes'], code);
162
+ const image = {};
163
+ if (value.image.mediaTypes !== undefined) {
164
+ image.mediaTypes = stringList(value.image.mediaTypes, type => MODEL_IMAGE_MEDIA_TYPES.includes(type), code);
165
+ }
166
+ if (value.image.maxBytes !== undefined) {
167
+ if (!positiveInteger(value.image.maxBytes)) fail(code);
168
+ image.maxBytes = value.image.maxBytes;
169
+ }
170
+ out.image = Object.freeze(image);
171
+ }
172
+ return Object.freeze(out);
173
+ }
174
+
175
+ export function modelPortSnapshot(port) {
176
+ try {
177
+ dto(port, ['describe', 'complete'], 'IRIS_MODEL_INCOMPATIBLE');
178
+ if (typeof port.describe !== 'function' || typeof port.complete !== 'function') fail('IRIS_MODEL_INCOMPATIBLE');
179
+ return normalizeModelDescriptor(port.describe());
180
+ } catch (_) { fail('IRIS_MODEL_INCOMPATIBLE'); }
181
+ }
182
+
183
+ export function normalizeModelCallOptions(descriptor, options) {
184
+ const code = 'IRIS_MODEL_INPUT_INVALID';
185
+ const context = { backendId: descriptor.identity.backendId };
186
+ dto(options, ['signal', 'budget'], code, context);
187
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) fail(code, context);
188
+ dto(options.budget, ['timeoutMs', 'maxInputTextBytes', 'maxOutputChars', ...(descriptor.kind === 'vision' ? ['maxImageBytes'] : [])], code, context);
189
+ const budget = {};
190
+ for (const key of ['timeoutMs', 'maxInputTextBytes', 'maxOutputChars', ...(descriptor.kind === 'vision' ? ['maxImageBytes'] : [])]) {
191
+ if (!positiveInteger(options.budget[key])) fail(code, context);
192
+ budget[key] = options.budget[key];
193
+ }
194
+ return Object.freeze({ budget: Object.freeze(budget), ...(options.signal !== undefined ? { signal: options.signal } : {}) });
195
+ }
196
+
197
+ export function normalizeModelOperationOptions(value) {
198
+ const code = 'IRIS_MODEL_INPUT_INVALID';
199
+ dto(value, ['signal', 'budget'], code);
200
+ if (value.signal !== undefined && !(value.signal instanceof AbortSignal)) fail(code);
201
+ dto(value.budget, ['timeoutMs', 'maxInvocations'], code);
202
+ const maxInvocations = value.budget.maxInvocations === undefined ? 1 : value.budget.maxInvocations;
203
+ if (!positiveInteger(value.budget.timeoutMs) || !positiveInteger(maxInvocations)) fail(code);
204
+ return Object.freeze({ budget: Object.freeze({ timeoutMs: value.budget.timeoutMs, maxInvocations }),
205
+ ...(value.signal !== undefined ? { signal: value.signal } : {}) });
206
+ }
207
+
208
+ /** 只做纯校验,不启动 deadline;Vision 字节在验证大小后复制。 */
209
+ export function normalizeModelCall(descriptor, request, options) {
210
+ const code = 'IRIS_MODEL_INPUT_INVALID';
211
+ const context = { backendId: descriptor.identity.backendId };
212
+ const normalizedOptions = normalizeModelCallOptions(descriptor, options);
213
+ const budget = normalizedOptions.budget;
214
+ dto(request, ['prompt', 'system', 'generation', ...(descriptor.kind === 'vision' ? ['image'] : [])], code, context);
215
+ if (typeof request.prompt !== 'string' || !request.prompt.trim()) fail(code, context);
216
+ if (request.system !== undefined && (typeof request.system !== 'string' || !request.system.trim())) fail(code, context);
217
+ if (encoder.encode(request.prompt).byteLength + encoder.encode(request.system || '').byteLength > budget.maxInputTextBytes) fail(code, context);
218
+ const out = { prompt: request.prompt, ...(request.system !== undefined ? { system: request.system } : {}) };
219
+ if (request.system !== undefined && descriptor.features.system !== 'supported') fail('IRIS_MODEL_UNSUPPORTED', context);
220
+ if (request.generation !== undefined) {
221
+ dto(request.generation, ['temperature', 'maxOutputTokens', 'reasoning'], code, context);
222
+ const generation = {};
223
+ for (const key of ['temperature', 'maxOutputTokens']) {
224
+ const value = request.generation[key];
225
+ if (value !== undefined) {
226
+ if (key === 'temperature' ? !Number.isFinite(value) || value < 0 : !positiveInteger(value)) fail(code, context);
227
+ if (descriptor.features[key] !== 'supported') fail('IRIS_MODEL_UNSUPPORTED', context);
228
+ generation[key] = value;
229
+ }
230
+ }
231
+ if (request.generation.reasoning !== undefined) {
232
+ const value = request.generation.reasoning;
233
+ dto(value, ['mode', 'effortId'], code, context);
234
+ if (!['provider-default', 'off', 'effort'].includes(value.mode)) fail(code, context);
235
+ if (value.mode !== 'effort' && Object.hasOwn(value, 'effortId')) fail(code, context);
236
+ if (value.mode === 'effort' && !stableId(value.effortId)) fail(code, context);
237
+ if (value.mode !== 'provider-default' && (descriptor.features.reasoning !== 'supported'
238
+ || (value.mode === 'off' ? descriptor.reasoning?.off !== 'supported' : !descriptor.reasoning?.effortIds?.includes(value.effortId)))) {
239
+ fail('IRIS_MODEL_UNSUPPORTED', context);
240
+ }
241
+ generation.reasoning = Object.freeze({ mode: value.mode, ...(value.mode === 'effort' ? { effortId: value.effortId } : {}) });
242
+ }
243
+ out.generation = Object.freeze(generation);
244
+ }
245
+ if (descriptor.kind === 'vision') {
246
+ dto(request.image, ['bytes', 'mediaType'], code, context);
247
+ const { bytes, mediaType } = request.image;
248
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || !MODEL_IMAGE_MEDIA_TYPES.includes(mediaType)) fail(code, context);
249
+ if (bytes.byteLength > budget.maxImageBytes || (descriptor.image?.maxBytes !== undefined && bytes.byteLength > descriptor.image.maxBytes)) fail(code, context);
250
+ if (descriptor.image?.mediaTypes && !descriptor.image.mediaTypes.includes(mediaType)) fail('IRIS_MODEL_UNSUPPORTED', context);
251
+ out.image = Object.freeze({ bytes: new Uint8Array(bytes), mediaType });
252
+ }
253
+ return Object.freeze({ request: Object.freeze(out), options: normalizedOptions });
254
+ }
255
+
256
+ function requireStop(reason, context) {
257
+ if (reason === 'stop') return;
258
+ const codes = { length: 'IRIS_MODEL_OUTPUT_LIMIT', 'max-tokens': 'IRIS_MODEL_OUTPUT_LIMIT',
259
+ 'tool-calls': 'IRIS_MODEL_UNEXPECTED_TOOL', 'content-blocked': 'IRIS_MODEL_CONTENT_BLOCKED',
260
+ error: 'IRIS_MODEL_REQUEST_FAILED', aborted: 'IRIS_MODEL_ABORTED' };
261
+ fail(reason === undefined || reason === null ? 'IRIS_MODEL_INCOMPLETE'
262
+ : typeof reason === 'string' && Object.hasOwn(codes, reason) ? codes[reason] : 'IRIS_MODEL_PROTOCOL_INVALID', context);
263
+ }
264
+
265
+ export function normalizeModelCompletion(descriptor, value, budget) {
266
+ const context = { stage: 'normalize', invocation: 'unknown', backendId: descriptor.identity.backendId };
267
+ dto(value, ['contractVersion', 'text', 'finishReason', 'identity', 'usage'], 'IRIS_MODEL_PROTOCOL_INVALID', context);
268
+ if (typeof value.text === 'string' && value.text.length || value.finishReason === 'stop') context.invocation = 'responded';
269
+ if (value.contractVersion !== MODEL_PORT_CONTRACT_VERSION || typeof value.text !== 'string') fail('IRIS_MODEL_PROTOCOL_INVALID', context);
270
+ requireStop(value.finishReason, context);
271
+ if (!value.text.trim()) fail('IRIS_MODEL_EMPTY_RESULT', context);
272
+ if (value.text.length > budget.maxOutputChars) fail('IRIS_MODEL_OUTPUT_LIMIT', context);
273
+ const actual = identity(value.identity, 'IRIS_MODEL_PROTOCOL_INVALID', context);
274
+ for (const key of ['origin', 'backendId', 'providerId', 'modelId']) {
275
+ if (descriptor.identity[key] !== undefined && actual[key] !== descriptor.identity[key]) fail('IRIS_MODEL_PROTOCOL_INVALID', context);
276
+ }
277
+ const out = { contractVersion: MODEL_PORT_CONTRACT_VERSION, text: value.text, finishReason: 'stop', identity: actual };
278
+ if (value.usage !== undefined) {
279
+ dto(value.usage, ['inputTokens', 'outputTokens', 'totalTokens', 'reasoningTokens'], 'IRIS_MODEL_PROTOCOL_INVALID', context);
280
+ const usage = {};
281
+ for (const [key, count] of Object.entries(value.usage)) {
282
+ if (!Number.isSafeInteger(count) || count < 0) fail('IRIS_MODEL_PROTOCOL_INVALID', context);
283
+ usage[key] = count;
284
+ }
285
+ out.usage = Object.freeze(usage);
286
+ }
287
+ return Object.freeze(out);
288
+ }
289
+
290
+ /** 协议无关的正文收集器;适配器先映射事件,思考/工具事件不得作为 text 传入。 */
291
+ export function createModelTextCollector({ maxOutputChars, signal, backendId } = {}) {
292
+ if (!positiveInteger(maxOutputChars) || (signal !== undefined && !(signal instanceof AbortSignal))) fail('IRIS_MODEL_INPUT_INVALID');
293
+ if (backendId !== undefined && !backendIdValid(backendId)) fail('IRIS_MODEL_INPUT_INVALID');
294
+ const blocks = new Map();
295
+ let length = 0;
296
+ let responded = false;
297
+ let stopped = false;
298
+ let failure;
299
+ const context = () => ({ stage: 'read', invocation: responded ? 'responded' : 'unknown', ...(backendId ? { backendId } : {}) });
300
+ const reject = (code) => { failure = new ModelPortError(code, context()); throw failure; };
301
+ const check = () => {
302
+ if (failure) throw failure;
303
+ if (signal?.aborted) reject('IRIS_MODEL_ABORTED');
304
+ };
305
+ const update = (text, id, replace) => {
306
+ check();
307
+ if (stopped || typeof text !== 'string' || !stableId(id)) reject('IRIS_MODEL_PROTOCOL_INVALID');
308
+ if (text.length) responded = true;
309
+ const previous = blocks.get(id) || '';
310
+ const nextLength = length + text.length - (replace ? previous.length : 0);
311
+ if (nextLength > maxOutputChars) {
312
+ failure = new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { ...context(), invocation: 'responded' });
313
+ throw failure;
314
+ }
315
+ blocks.set(id, replace ? text : previous + text);
316
+ length = nextLength;
317
+ };
318
+ return Object.freeze({
319
+ append: (text, id = 'default') => update(text, id, false),
320
+ replace: (text, id = 'default') => update(text, id, true),
321
+ fail(error) {
322
+ check();
323
+ failure = normalizeModelError(error, context());
324
+ throw failure;
325
+ },
326
+ finish(reason) {
327
+ check();
328
+ if (stopped) reject('IRIS_MODEL_PROTOCOL_INVALID');
329
+ if (['stop', 'length', 'max-tokens', 'tool-calls', 'content-blocked'].includes(reason)) responded = true;
330
+ try { requireStop(reason, context()); } catch (error) { failure = error; throw error; }
331
+ stopped = true;
332
+ },
333
+ complete() {
334
+ check();
335
+ if (!stopped) reject('IRIS_MODEL_INCOMPLETE');
336
+ const text = [...blocks.values()].join('');
337
+ if (!text.trim()) reject('IRIS_MODEL_EMPTY_RESULT');
338
+ return text;
339
+ }
340
+ });
341
+ }
package/lib/models.js CHANGED
@@ -9,9 +9,11 @@
9
9
  * ① provider 显式 `models` 数组(未来配置,可带 capabilities 覆盖)
10
10
  * ② 旧模型字段迁移:imageModel→image-gen、videoModel→video-gen、ttsModel→tts、
11
11
  * transcribeModel→transcribe、visionModel→vision
12
- * ③ 裸 DashScope 账号:已知模型清单(wan 系列 / qwen-vl / qwen-tts)作为池
12
+ * 旧裸账号的已知目录仅由配置写入边界物化,不参与运行时回退。
13
13
  */
14
14
  import { CAPABILITIES } from './capability.js';
15
+ import { isDashScopeBaseUrl, selectMediaProtocol } from './provider-protocol.js';
16
+ import { modelRateLimit } from './provider-health.js';
15
17
 
16
18
  /** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
17
19
  export const CAP_FIELD = {
@@ -62,8 +64,8 @@ const MODEL_CAP_RULES = [
62
64
  [/^gemini/i, [CAPABILITIES.VISION]] // Gemini 默认按视觉能力纳入(可被显式配置覆盖)
63
65
  ];
64
66
 
65
- /** 裸 DashScope 已知模型清单(无显式模型字段时作为池) */
66
- const DASHSCOPE_KNOWN = [
67
+ /** 仅供配置写入时接回旧裸账号,不参与运行时选型。 */
68
+ const KNOWN_MODELS_BY_PROTOCOL = { dashscope: [
67
69
  { id: 'wan2.2-t2i-flash', caps: [CAPABILITIES.IMAGE] },
68
70
  { id: 'wan2.2-t2v-flash', caps: [CAPABILITIES.VIDEO] },
69
71
  { id: 'wan2.2-s2v-flash', caps: [CAPABILITIES.VIDEO] },
@@ -72,7 +74,17 @@ const DASHSCOPE_KNOWN = [
72
74
  { id: 'qwen3-vl-235b-a22b-thinking', caps: [CAPABILITIES.VISION] },
73
75
  { id: 'qwen-tts-latest', caps: [CAPABILITIES.TTS] },
74
76
  { id: 'qwen-audio-3.0-asr-flash-filetrans', caps: [CAPABILITIES.TRANSCRIBE] }
75
- ];
77
+ ] };
78
+
79
+ export function migrateProviderModels(provider) {
80
+ if (Array.isArray(provider.models) || Object.values(CAP_FIELD).some((field) => provider[field])) return false;
81
+ const protocol = selectMediaProtocol(provider).mediaProtocol;
82
+ if (!isDashScopeBaseUrl(provider.baseUrl) || !Object.hasOwn(KNOWN_MODELS_BY_PROTOCOL, protocol)) return false;
83
+ provider.models = KNOWN_MODELS_BY_PROTOCOL[protocol].map((m) => ({
84
+ id: m.id, capabilities: [...m.caps], source: 'migration'
85
+ }));
86
+ return true;
87
+ }
76
88
 
77
89
  /** 按模型名规则推断能力 */
78
90
  export function capabilitiesOfModel(modelName) {
@@ -83,6 +95,24 @@ export function capabilitiesOfModel(modelName) {
83
95
  return [];
84
96
  }
85
97
 
98
+ /** 官方模型目录能力代码 → Iris 能力。未识别代码保守忽略。 */
99
+ const DISCOVERY_CAPABILITIES = Object.freeze({
100
+ IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
101
+ VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
102
+ TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
103
+ TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
104
+ });
105
+
106
+ /** 发现元数据优先,名称规则兜底;用户保存后的显式 capabilities 仍是最终覆盖。 */
107
+ export function capabilitiesOfDiscoveredModel(model) {
108
+ const id = String(typeof model === 'string' ? model : model?.id || '').trim();
109
+ const declared = Array.isArray(model?.capabilities)
110
+ ? [...new Set(model.capabilities.map((value) => DISCOVERY_CAPABILITIES[String(value).toUpperCase()]).filter(Boolean))]
111
+ : [];
112
+ if (declared.length) return declared;
113
+ return capabilitiesOfModel(id);
114
+ }
115
+
86
116
  /** 一个 provider 的模型池条目列表:[{ id, providerId, capabilities:[], verified?, source? }] */
87
117
  export function providerModels(p) {
88
118
  if (!p) return [];
@@ -90,15 +120,21 @@ export function providerModels(p) {
90
120
  const push = (id, caps, extra) => {
91
121
  const name = String(id || '').trim();
92
122
  if (!name || out.some((m) => m.id === name)) return;
93
- out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}) });
123
+ out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}),
124
+ ...(modelRateLimit(p, name) ? { rateLimited: true } : {}) });
94
125
  };
95
126
  // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
96
- if (Array.isArray(p.models) && p.models.length) {
127
+ // 该字段一旦存在(含空数组)即为权威声明:用户可明确标记「此供应商没有可用模型」,
128
+ // 不得再落入 ② 旧字段迁移或 ③ 裸账号已知池。空数组曾因 `.length` 判定被穿透,
129
+ // 导致用户声明无模型却仍被注入 7 个厂商默认模型(见审计 B-5)。
130
+ if (Array.isArray(p.models)) {
97
131
  for (const m of p.models) {
98
132
  const id = typeof m === 'string' ? m : m && m.id;
99
- // 显式空数组也是权威覆盖:用户可明确标记“此模型无能力”。
133
+ // 元素级空能力数组同样是权威覆盖:用户可明确标记“此模型无能力”。
100
134
  const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
101
- const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source } : {};
135
+ const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
136
+ ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
137
+ ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}) } : {};
102
138
  push(id, caps, extra);
103
139
  }
104
140
  return out;
@@ -107,14 +143,69 @@ export function providerModels(p) {
107
143
  for (const [cap, field] of Object.entries(CAP_FIELD)) {
108
144
  if (p[field]) push(p[field], [cap]);
109
145
  }
110
- if (out.length) return out;
111
- // ③ 裸 DashScope 账号 → 已知模型池
112
- if (/dashscope/i.test(String(p.baseUrl || ''))) {
113
- for (const m of DASHSCOPE_KNOWN) push(m.id, m.caps);
114
- }
115
146
  return out;
116
147
  }
117
148
 
149
+ /** 只解析用户配置中的模型;裸 ID 与复合引用共享能力校验。 */
150
+ export function resolveModelRef(provider, capability, requested = '') {
151
+ const pool = providerModels(provider).filter((m) => m.capabilities.includes(capability));
152
+ const raw = String(requested || '').trim();
153
+ const selected = raw ? resolvePoolModel(pool, raw, capability)
154
+ : pool.find((m) => m.id === provider?.[CAP_FIELD[capability]]) || pool[0];
155
+ return selected?.ref || null;
156
+ }
157
+
158
+ /** 共享旧分配对象、裸名称和复合引用的解析,池顺序决定同名匹配。 */
159
+ export function resolvePoolModel(pool, raw, capability) {
160
+ let providerId = '';
161
+ let modelId = '';
162
+ if (raw && typeof raw === 'object') {
163
+ providerId = String(raw.providerId || '');
164
+ modelId = String(raw.modelId || raw.id || '');
165
+ } else if (typeof raw === 'string') {
166
+ const parsed = parseModelRef(raw);
167
+ if (parsed) ({ providerId, modelId } = parsed);
168
+ else if (!raw.includes(MODEL_REF_SEP)) modelId = raw;
169
+ }
170
+ return pool.find((m) => m.id === modelId && (!providerId || m.providerId === providerId)
171
+ && m.capabilities.includes(capability)) || null;
172
+ }
173
+
174
+ /** DSH 与 CLI 共用的候选顺序:分配优先、按池顺序补齐并去重。 */
175
+ export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
176
+ const ordered = [];
177
+ const seen = new Set();
178
+ const add = (model) => {
179
+ if (model && (includeRateLimited || !model.rateLimited)
180
+ && model.capabilities.includes(capability) && !seen.has(model.ref)) {
181
+ seen.add(model.ref);
182
+ ordered.push(model);
183
+ }
184
+ };
185
+ for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
186
+ add(resolvePoolModel(pool, raw, capability));
187
+ }
188
+ for (const model of pool) add(model);
189
+ return ordered;
190
+ }
191
+
192
+ /**
193
+ * 返回候选在当前候选链构造时的配置来源。调用方显式 model_ref 由入口直接标为
194
+ * explicit;本函数只区分 assignment 与池序补齐,结果必须随 Attempt 写前落盘,
195
+ * 不能在事后用可变配置重算。
196
+ */
197
+ export function selectionReasonForModel(pool, capability, assignments, modelOrRef) {
198
+ const selected = resolvePoolModel(pool,
199
+ modelOrRef && typeof modelOrRef === 'object' ? modelOrRef.ref : modelOrRef,
200
+ capability);
201
+ if (!selected) return null;
202
+ for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
203
+ const assigned = resolvePoolModel(pool, raw, capability);
204
+ if (assigned?.ref === selected.ref) return 'assignment';
205
+ }
206
+ return 'pool';
207
+ }
208
+
118
209
  /** 全局模型池:合并所有 provider 的模型 */
119
210
  export function modelPool(providers) {
120
211
  const pool = [];
@@ -124,5 +215,5 @@ export function modelPool(providers) {
124
215
 
125
216
  /** 从池里挑有某能力的模型(按池顺序) */
126
217
  export function pickModel(pool, capability) {
127
- return (pool || []).find((m) => m.capabilities.includes(capability)) || null;
218
+ return (pool || []).find((m) => !m.rateLimited && m.capabilities.includes(capability)) || null;
128
219
  }
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ /** OCR Host 边界:配置显式传入;同一 operation 从解引用开始覆盖整张长图。 */
3
+ import { buildVisionModelCandidates } from './vision-model-routing.js';
4
+ import { longOcr, normalizeOcrSettings, OCR_BUDGET } from './ocr.js';
5
+ import { createModelOperation } from './model-invoker.js';
6
+ import { ModelPortError, modelPortSnapshot, normalizeModelCall } from './model-port-contract.js';
7
+ import { waitForModelWork } from './model-call-runtime.js';
8
+ import { createVisionInputObserver } from './vision-image-input.js';
9
+
10
+ export async function runOcrRequest(host, { providers = [], model, image, prepareImage, signal,
11
+ chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit }) {
12
+ const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
13
+ const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
14
+ try {
15
+ const inputs = new Map();
16
+ const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
17
+ prepareImageRequest: createVisionInputObserver(inputs) });
18
+ normalizeModelCall(modelPortSnapshot(candidates[0].port), { prompt: 'OCR',
19
+ image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
20
+ const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
21
+ return await longOcr({ image: resolved, ports: candidates.map(candidate => candidate.port), ...settings, operation, budget, inputs });
22
+ } catch (error) {
23
+ if (error instanceof ModelPortError) throw error;
24
+ throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
25
+ } finally { operation.dispose(); }
26
+ }