@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.en.md +72 -15
  3. package/README.md +72 -15
  4. package/bin/dsh-iris.js +392 -13
  5. package/docs/AGENT_QUICKSTART.md +35 -0
  6. package/docs/ARCHITECTURE.md +80 -64
  7. package/docs/ARTIFACT_MANIFEST.md +60 -0
  8. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  9. package/docs/DSH_CORE_MIGRATION.md +106 -0
  10. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  11. package/docs/HEADLESS_CLI.md +217 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
  13. package/docs/HOST_DOCTOR.md +2 -0
  14. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  15. package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
  16. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  17. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
  18. package/docs/ROADMAP.md +42 -86
  19. package/docs/SECURITY.md +4 -3
  20. package/docs/assets/logo/README.md +55 -0
  21. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  22. package/docs/assets/logo/iris-bubble.svg +14 -0
  23. package/docs/assets/logo/iris-interim-flower.png +0 -0
  24. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  25. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  26. package/docs/assets/logo/iris-logo.svg +78 -0
  27. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  28. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  29. package/docs/assets/logo/iris-mark.svg +42 -0
  30. package/docs/releases/0.2.0.md +48 -0
  31. package/lib/actions.js +374 -362
  32. package/lib/adapters.js +52 -15
  33. package/lib/api.js +152 -5
  34. package/lib/client.js +763 -48
  35. package/lib/command-service.js +420 -0
  36. package/lib/config.js +56 -43
  37. package/lib/core-artifact-store.js +598 -0
  38. package/lib/core-artifacts.js +4 -0
  39. package/lib/core-attention.js +103 -0
  40. package/lib/core-contract.js +105 -0
  41. package/lib/core-doctor.js +41 -0
  42. package/lib/core-lease-recovery.js +170 -0
  43. package/lib/core-runtime.js +232 -0
  44. package/lib/core-tasks.js +560 -0
  45. package/lib/core-user-projection.js +192 -0
  46. package/lib/doctor.js +92 -33
  47. package/lib/dsh-core-adapter.js +599 -0
  48. package/lib/dsh-host-adapter.js +58 -7
  49. package/lib/generation-input.js +95 -0
  50. package/lib/guard.js +3 -3
  51. package/lib/index.js +159 -54
  52. package/lib/model-invoker.js +218 -0
  53. package/lib/model-port-contract.js +333 -0
  54. package/lib/models.js +97 -11
  55. package/lib/provider-adapter.js +49 -23
  56. package/lib/provider-adapters.js +84 -21
  57. package/lib/provider-catalog.js +171 -0
  58. package/lib/provider-contract.js +72 -3
  59. package/lib/provider-protocol.js +51 -2
  60. package/lib/provider-task-runner.js +328 -0
  61. package/lib/tasks.js +16 -4
  62. package/lib/vision.js +28 -7
  63. package/package.json +8 -8
  64. package/user_guide.md +27 -12
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+ /** 有限操作内调用控制。只有 deadline timer;无后台任务、网络、配置或存储。 */
3
+ import {
4
+ ModelPortError, modelErrorRecord, normalizeModelError, modelPortSnapshot,
5
+ normalizeModelCallOptions, normalizeModelCall, normalizeModelCompletion, normalizeModelOperationOptions
6
+ } from './model-port-contract.js';
7
+
8
+ const TIMER_MAX_MS = 2147483647;
9
+ const now = () => performance.now();
10
+
11
+ function scope({ parents = [], timeoutMs, startedAt = now() }) {
12
+ const controller = new AbortController();
13
+ const deadline = startedAt + timeoutMs;
14
+ const listeners = [];
15
+ let timer;
16
+ let failureCode;
17
+ let disposed = false;
18
+ const abort = (code) => {
19
+ if (controller.signal.aborted) return;
20
+ failureCode = code;
21
+ clearTimeout(timer);
22
+ controller.abort(new ModelPortError(code, { stage: 'invoke', invocation: 'unknown' }));
23
+ };
24
+ const schedule = () => {
25
+ if (disposed || controller.signal.aborted) return;
26
+ const remaining = deadline - now();
27
+ if (remaining <= 0) abort('IRIS_MODEL_TIMEOUT');
28
+ else timer = setTimeout(schedule, Math.min(TIMER_MAX_MS, Math.ceil(remaining)));
29
+ };
30
+ const seen = new Set();
31
+ for (const parent of parents) {
32
+ if (!parent.signal || seen.has(parent.signal)) continue;
33
+ seen.add(parent.signal);
34
+ const listener = () => abort(parent.code());
35
+ if (parent.signal.aborted) listener();
36
+ else {
37
+ parent.signal.addEventListener('abort', listener, { once: true });
38
+ listeners.push([parent.signal, listener]);
39
+ }
40
+ }
41
+ schedule();
42
+ return {
43
+ signal: controller.signal,
44
+ get failureCode() { return failureCode; },
45
+ // 超大 deadline 的浮点舍入不能让端口收到超过原始输入的预算。
46
+ remaining() { return Math.max(0, Math.min(timeoutMs, deadline - now())); },
47
+ check(context = {}) {
48
+ if (!controller.signal.aborted && now() >= deadline) abort('IRIS_MODEL_TIMEOUT');
49
+ if (controller.signal.aborted) throw new ModelPortError(failureCode, context);
50
+ },
51
+ abort,
52
+ dispose() {
53
+ disposed = true;
54
+ clearTimeout(timer);
55
+ for (const [signal, listener] of listeners) signal.removeEventListener('abort', listener);
56
+ }
57
+ };
58
+ }
59
+
60
+ /** abort 必须已传播到底层;race 只用于不合作的端口有界退出并处理晚到拒绝。 */
61
+ async function waitForCompletion(work, signal) {
62
+ let listener;
63
+ const interrupted = new Promise((_, reject) => {
64
+ listener = () => reject(signal.reason);
65
+ if (signal.aborted) listener();
66
+ else signal.addEventListener('abort', listener, { once: true });
67
+ });
68
+ try { return await Promise.race([work, interrupted]); }
69
+ finally { signal.removeEventListener('abort', listener); }
70
+ }
71
+
72
+ function candidatePolicy(value) {
73
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
74
+ const out = { skipUnavailable: false, allowRejected: false, allowEmptyResult: false };
75
+ for (const [key, field] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
76
+ if (!Object.hasOwn(out, key) || !Object.hasOwn(field, 'value') || typeof field.value !== 'boolean') {
77
+ throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
78
+ }
79
+ out[key] = field.value;
80
+ }
81
+ if (Object.getOwnPropertySymbols(value).length) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
82
+ return out;
83
+ }
84
+
85
+ function mayContinue(error, policy) {
86
+ const { code, invocation } = modelErrorRecord(error);
87
+ if (invocation === 'not_invoked' && policy.skipUnavailable) {
88
+ return ['IRIS_MODEL_UNAVAILABLE', 'IRIS_MODEL_INCOMPATIBLE', 'IRIS_MODEL_UNSUPPORTED'].includes(code);
89
+ }
90
+ if (invocation === 'rejected' && policy.allowRejected) {
91
+ return ['IRIS_MODEL_AUTH_FAILED', 'IRIS_MODEL_RATE_LIMITED'].includes(code);
92
+ }
93
+ return policy.allowEmptyResult && invocation === 'responded' && code === 'IRIS_MODEL_EMPTY_RESULT';
94
+ }
95
+
96
+ function operation(options, startedAt) {
97
+ const total = scope({ timeoutMs: options.budget.timeoutMs, startedAt,
98
+ parents: options.signal ? [{ signal: options.signal, code: () => 'IRIS_MODEL_ABORTED' }] : [] });
99
+ let disposed = false;
100
+ let invocations = 0;
101
+ let pending = 0;
102
+
103
+ const check = () => {
104
+ if (disposed) throw new ModelPortError('IRIS_MODEL_ABORTED');
105
+ total.check();
106
+ };
107
+
108
+ const invoke = async (port, request, callOptions) => {
109
+ const callStartedAt = now();
110
+ check();
111
+ const descriptor = modelPortSnapshot(port);
112
+ const complete = port.complete;
113
+ if (typeof complete !== 'function') throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE');
114
+ const local = normalizeModelCallOptions(descriptor, callOptions);
115
+ const backendId = descriptor.identity.backendId;
116
+ const child = scope({ timeoutMs: local.budget.timeoutMs, startedAt: callStartedAt, parents: [
117
+ { signal: total.signal, code: () => total.failureCode },
118
+ ...(local.signal ? [{ signal: local.signal, code: () => 'IRIS_MODEL_ABORTED' }] : [])
119
+ ] });
120
+ let entered = false;
121
+ let called = false;
122
+ let refunded = false;
123
+ try {
124
+ child.check({ backendId });
125
+ const input = normalizeModelCall(descriptor, request, local);
126
+ child.check({ backendId });
127
+ if (descriptor.availability !== 'available') {
128
+ throw new ModelPortError(descriptor.availability === 'incompatible' ? 'IRIS_MODEL_INCOMPATIBLE' : 'IRIS_MODEL_UNAVAILABLE', { backendId });
129
+ }
130
+ check();
131
+ if (invocations >= options.budget.maxInvocations) throw new ModelPortError('IRIS_MODEL_CALL_LIMIT', { backendId });
132
+ // 在调入 complete 之前占用次数,避免并发请求一起穿过最后一个额度。
133
+ invocations++;
134
+ pending++;
135
+ entered = true;
136
+ const controlledOptions = Object.freeze({ signal: child.signal,
137
+ budget: Object.freeze({ ...input.options.budget, timeoutMs: Math.max(1, Math.ceil(Math.min(child.remaining(), total.remaining()))) }) });
138
+ const work = Promise.resolve().then(() => {
139
+ child.check({ stage: 'invoke', invocation: 'not_invoked', backendId });
140
+ called = true;
141
+ return complete.call(port, input.request, controlledOptions);
142
+ });
143
+ const result = await waitForCompletion(work, child.signal);
144
+ child.check({ stage: 'normalize', invocation: 'responded', backendId });
145
+ const normalized = normalizeModelCompletion(descriptor, result, local.budget);
146
+ child.check({ stage: 'normalize', invocation: 'responded', backendId });
147
+ return normalized;
148
+ } catch (error) {
149
+ const record = modelErrorRecord(error);
150
+ // 只有可信契约错误明确说明尚未调用时,才返还预留次数。
151
+ if (entered && (!called || record.invocation === 'not_invoked' && ['validate', 'prepare', 'invoke'].includes(record.stage))) {
152
+ invocations--;
153
+ refunded = true;
154
+ }
155
+ if (child.signal.aborted) {
156
+ if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(child.failureCode)) total.abort(child.failureCode);
157
+ throw new ModelPortError(child.failureCode, { stage: entered && !refunded ? 'invoke' : 'validate',
158
+ invocation: !entered || refunded ? 'not_invoked' : record.invocation === 'responded' ? 'responded' : 'unknown', backendId });
159
+ }
160
+ // 输出/协议失败也通知底层停止,结果从不自动转为下一次调用。
161
+ child.abort(record.code);
162
+ if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(record.code)) total.abort(record.code);
163
+ throw normalizeModelError(error, { backendId });
164
+ } finally {
165
+ if (entered) pending--;
166
+ child.dispose();
167
+ }
168
+ };
169
+
170
+ return Object.freeze({
171
+ signal: total.signal,
172
+ invoke,
173
+ async runCandidates(ports, request, callOptions, policy = {}) {
174
+ check();
175
+ const rules = candidatePolicy(policy);
176
+ if (!Array.isArray(ports) || !ports.length || [...ports].some(port => !port)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
177
+ let lastError;
178
+ for (const port of [...ports]) {
179
+ check();
180
+ try { return await invoke(port, request, callOptions); }
181
+ catch (error) {
182
+ if (total.signal.aborted || disposed) throw error;
183
+ check();
184
+ if (!mayContinue(error, rules)) throw error;
185
+ lastError = error;
186
+ }
187
+ }
188
+ throw lastError;
189
+ },
190
+ snapshot() {
191
+ return Object.freeze({ invocations, pending, maxInvocations: options.budget.maxInvocations,
192
+ state: disposed ? 'disposed' : total.failureCode === 'IRIS_MODEL_TIMEOUT' ? 'timed_out' : total.signal.aborted ? 'aborted' : 'active' });
193
+ },
194
+ dispose() {
195
+ if (disposed) return;
196
+ disposed = true;
197
+ total.abort('IRIS_MODEL_ABORTED');
198
+ total.dispose();
199
+ }
200
+ });
201
+ }
202
+
203
+ /** 一个 OCR/候选等复合业务共用此 scope;调用方在 finally 中 dispose。 */
204
+ export function createModelOperation(options) {
205
+ const startedAt = now();
206
+ return operation(normalizeModelOperationOptions(options), startedAt);
207
+ }
208
+
209
+ /** 默认只允许一次调用,自动释放操作 scope;不运行候选策略。 */
210
+ export async function invokeModel(port, request, options) {
211
+ const startedAt = now();
212
+ const descriptor = modelPortSnapshot(port);
213
+ const local = normalizeModelCallOptions(descriptor, options);
214
+ const op = operation(normalizeModelOperationOptions({ budget: { timeoutMs: local.budget.timeoutMs, maxInvocations: 1 },
215
+ ...(local.signal ? { signal: local.signal } : {}) }), startedAt);
216
+ try { return await op.invoke(port, request, local); }
217
+ finally { op.dispose(); }
218
+ }
@@ -0,0 +1,333 @@
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_UNAVAILABLE: '模型能力不可用',
10
+ IRIS_MODEL_INCOMPATIBLE: '模型端口或协议不兼容',
11
+ IRIS_MODEL_UNSUPPORTED: '模型不支持所请求的能力或参数',
12
+ IRIS_MODEL_AUTH_FAILED: '模型请求被认证或权限检查拒绝',
13
+ IRIS_MODEL_RATE_LIMITED: '模型请求受到速率或额度限制',
14
+ IRIS_MODEL_REQUEST_FAILED: '模型请求失败,未自动重试',
15
+ IRIS_MODEL_PROTOCOL_INVALID: '模型响应不符合协议',
16
+ IRIS_MODEL_INCOMPLETE: '模型结果缺少正常结束信号',
17
+ IRIS_MODEL_OUTPUT_LIMIT: '模型结果超过输出预算或被截断',
18
+ IRIS_MODEL_CALL_LIMIT: '模型操作的调用次数已用尽',
19
+ IRIS_MODEL_EMPTY_RESULT: '模型正常结束但没有正文',
20
+ IRIS_MODEL_UNEXPECTED_TOOL: '模型意外请求了工具',
21
+ IRIS_MODEL_CONTENT_BLOCKED: '模型结果被内容策略阻断',
22
+ IRIS_MODEL_ABORTED: '模型操作已在本地取消',
23
+ IRIS_MODEL_TIMEOUT: '模型操作超过时间预算'
24
+ });
25
+ export const MODEL_ERROR_CODES = Object.freeze(Object.keys(MESSAGES));
26
+ const ERROR_RECORDS = new WeakMap();
27
+ const STAGES = new Set(['validate', 'prepare', 'invoke', 'read', 'normalize']);
28
+ const INVOCATIONS = new Set(['not_invoked', 'rejected', 'responded', 'unknown']);
29
+ const SUPPORT = new Set(['supported', 'unsupported', 'unknown']);
30
+ const BACKEND_ID = /^[a-z][a-z0-9-]*:[A-Za-z0-9._~%:-]+$/;
31
+ const encoder = new TextEncoder();
32
+
33
+ function backendIdValid(value) {
34
+ return typeof value === 'string' && value.length <= 512 && BACKEND_ID.test(value);
35
+ }
36
+
37
+ /** 不接受供应商原文;私有记录确保篡改 Error.message/code 不影响安全序列化。 */
38
+ export class ModelPortError extends Error {
39
+ constructor(code, details = {}) {
40
+ if (typeof code !== 'string' || !Object.hasOwn(MESSAGES, code)) throw new TypeError('未知模型错误码');
41
+ super(MESSAGES[code]);
42
+ this.name = 'ModelPortError';
43
+ const { stage = 'validate', invocation = 'not_invoked', backendId, status } = details;
44
+ if (!STAGES.has(stage) || !INVOCATIONS.has(invocation)
45
+ || (backendId !== undefined && !backendIdValid(backendId))
46
+ || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599))) {
47
+ throw new TypeError('模型错误上下文不合法');
48
+ }
49
+ const record = Object.freeze({ code, message: MESSAGES[code], stage, invocation,
50
+ ...(backendId !== undefined ? { backendId } : {}), ...(status !== undefined ? { status } : {}) });
51
+ ERROR_RECORDS.set(this, record);
52
+ Object.assign(this, record);
53
+ }
54
+ toJSON() { return modelErrorRecord(this); }
55
+ }
56
+
57
+ /** 任意原始错误默认按未知调用失败处理,不信任其 code/status/message/cause。 */
58
+ export function modelErrorRecord(error) {
59
+ return Object.freeze({ ...(ERROR_RECORDS.get(error) || {
60
+ code: 'IRIS_MODEL_REQUEST_FAILED', message: MESSAGES.IRIS_MODEL_REQUEST_FAILED,
61
+ stage: 'invoke', invocation: 'unknown'
62
+ }) });
63
+ }
64
+
65
+ export function normalizeModelError(error, context = {}) {
66
+ const record = modelErrorRecord(error);
67
+ return new ModelPortError(record.code, {
68
+ stage: record.stage, invocation: record.invocation,
69
+ ...(record.backendId !== undefined ? { backendId: record.backendId } : {}),
70
+ ...(record.status !== undefined ? { status: record.status } : {}), ...context
71
+ });
72
+ }
73
+
74
+ function fail(code, context) { throw new ModelPortError(code, context); }
75
+
76
+ // DTO 必须为自有数据字段;不执行 getter、不接受原型 live object 或 symbol 字段。
77
+ function dto(value, allowed, code, context) {
78
+ if (value === null || typeof value !== 'object'
79
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail(code, context);
80
+ const fields = Object.getOwnPropertyDescriptors(value);
81
+ for (const key of Reflect.ownKeys(fields)) {
82
+ if (typeof key !== 'string' || !allowed.includes(key) || !Object.hasOwn(fields[key], 'value')) fail(code, context);
83
+ }
84
+ }
85
+
86
+ function positiveInteger(value) { return Number.isSafeInteger(value) && value > 0; }
87
+ function stableId(value) {
88
+ return typeof value === 'string' && value.length > 0 && value.length <= 256
89
+ && value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value)
90
+ && !value.startsWith('/') && !value.includes('://');
91
+ }
92
+
93
+ function stringList(value, accepts, code) {
94
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail(code);
95
+ const fields = Object.getOwnPropertyDescriptors(value);
96
+ const length = fields.length.value;
97
+ // 不读取元素 getter,也不调用自定义 iterator;只接受密集的自有数据元素。
98
+ if (Reflect.ownKeys(fields).length !== length + 1) fail(code);
99
+ const out = [];
100
+ for (let index = 0; index < length; index++) {
101
+ const field = fields[index];
102
+ if (!field || !Object.hasOwn(field, 'value') || !accepts(field.value)) fail(code);
103
+ out.push(field.value);
104
+ }
105
+ if (new Set(out).size !== length) fail(code);
106
+ return Object.freeze(out);
107
+ }
108
+
109
+ function identity(value, code, context) {
110
+ dto(value, ['origin', 'backendId', 'providerId', 'modelId'], code, context);
111
+ if (!['provider', 'host'].includes(value.origin) || !backendIdValid(value.backendId)) fail(code, context);
112
+ const out = { origin: value.origin, backendId: value.backendId };
113
+ for (const key of ['providerId', 'modelId']) {
114
+ if (value[key] !== undefined) {
115
+ if (!stableId(value[key])) fail(code, context);
116
+ out[key] = value[key];
117
+ } else if (value.origin === 'provider') fail(code, context);
118
+ }
119
+ return Object.freeze(out);
120
+ }
121
+
122
+ /** 验证并复制描述;unknown 保持 unknown,缺省 Host 身份与限制不编造。 */
123
+ export function normalizeModelDescriptor(value) {
124
+ const code = 'IRIS_MODEL_INCOMPATIBLE';
125
+ dto(value, ['contractVersion', 'kind', 'identity', 'availability', 'reasonCode', 'features', 'reasoning', 'image'], code);
126
+ if (value.contractVersion !== MODEL_PORT_CONTRACT_VERSION || !['text', 'vision'].includes(value.kind)
127
+ || !['available', 'unavailable', 'incompatible'].includes(value.availability)) fail(code);
128
+ const validReason = typeof value.reasonCode === 'string' && Object.hasOwn(MESSAGES, value.reasonCode);
129
+ if ((value.availability !== 'available' && !validReason)
130
+ || (value.reasonCode !== undefined && !validReason)) fail(code);
131
+ const bound = identity(value.identity, code);
132
+ dto(value.features, ['system', 'temperature', 'maxOutputTokens', 'reasoning'], code);
133
+ const features = {};
134
+ for (const name of ['system', 'temperature', 'maxOutputTokens', 'reasoning']) {
135
+ if (!SUPPORT.has(value.features[name])) fail(code);
136
+ features[name] = value.features[name];
137
+ }
138
+ const out = { contractVersion: MODEL_PORT_CONTRACT_VERSION, kind: value.kind, identity: bound,
139
+ availability: value.availability, ...(value.reasonCode !== undefined ? { reasonCode: value.reasonCode } : {}),
140
+ features: Object.freeze(features) };
141
+ if (value.reasoning !== undefined) {
142
+ dto(value.reasoning, ['off', 'effortIds'], code);
143
+ if (!SUPPORT.has(value.reasoning.off)) fail(code);
144
+ const reasoning = { off: value.reasoning.off };
145
+ if (value.reasoning.effortIds !== undefined) {
146
+ reasoning.effortIds = stringList(value.reasoning.effortIds, stableId, code);
147
+ }
148
+ if (features.reasoning !== 'supported' && (reasoning.off === 'supported' || reasoning.effortIds?.length)) fail(code);
149
+ out.reasoning = Object.freeze(reasoning);
150
+ }
151
+ if (value.image !== undefined) {
152
+ if (value.kind !== 'vision') fail(code);
153
+ dto(value.image, ['mediaTypes', 'maxBytes'], code);
154
+ const image = {};
155
+ if (value.image.mediaTypes !== undefined) {
156
+ image.mediaTypes = stringList(value.image.mediaTypes, type => MODEL_IMAGE_MEDIA_TYPES.includes(type), code);
157
+ }
158
+ if (value.image.maxBytes !== undefined) {
159
+ if (!positiveInteger(value.image.maxBytes)) fail(code);
160
+ image.maxBytes = value.image.maxBytes;
161
+ }
162
+ out.image = Object.freeze(image);
163
+ }
164
+ return Object.freeze(out);
165
+ }
166
+
167
+ export function modelPortSnapshot(port) {
168
+ try {
169
+ dto(port, ['describe', 'complete'], 'IRIS_MODEL_INCOMPATIBLE');
170
+ if (typeof port.describe !== 'function' || typeof port.complete !== 'function') fail('IRIS_MODEL_INCOMPATIBLE');
171
+ return normalizeModelDescriptor(port.describe());
172
+ } catch (_) { fail('IRIS_MODEL_INCOMPATIBLE'); }
173
+ }
174
+
175
+ export function normalizeModelCallOptions(descriptor, options) {
176
+ const code = 'IRIS_MODEL_INPUT_INVALID';
177
+ const context = { backendId: descriptor.identity.backendId };
178
+ dto(options, ['signal', 'budget'], code, context);
179
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) fail(code, context);
180
+ dto(options.budget, ['timeoutMs', 'maxInputTextBytes', 'maxOutputChars', ...(descriptor.kind === 'vision' ? ['maxImageBytes'] : [])], code, context);
181
+ const budget = {};
182
+ for (const key of ['timeoutMs', 'maxInputTextBytes', 'maxOutputChars', ...(descriptor.kind === 'vision' ? ['maxImageBytes'] : [])]) {
183
+ if (!positiveInteger(options.budget[key])) fail(code, context);
184
+ budget[key] = options.budget[key];
185
+ }
186
+ return Object.freeze({ budget: Object.freeze(budget), ...(options.signal !== undefined ? { signal: options.signal } : {}) });
187
+ }
188
+
189
+ export function normalizeModelOperationOptions(value) {
190
+ const code = 'IRIS_MODEL_INPUT_INVALID';
191
+ dto(value, ['signal', 'budget'], code);
192
+ if (value.signal !== undefined && !(value.signal instanceof AbortSignal)) fail(code);
193
+ dto(value.budget, ['timeoutMs', 'maxInvocations'], code);
194
+ const maxInvocations = value.budget.maxInvocations === undefined ? 1 : value.budget.maxInvocations;
195
+ if (!positiveInteger(value.budget.timeoutMs) || !positiveInteger(maxInvocations)) fail(code);
196
+ return Object.freeze({ budget: Object.freeze({ timeoutMs: value.budget.timeoutMs, maxInvocations }),
197
+ ...(value.signal !== undefined ? { signal: value.signal } : {}) });
198
+ }
199
+
200
+ /** 只做纯校验,不启动 deadline;Vision 字节在验证大小后复制。 */
201
+ export function normalizeModelCall(descriptor, request, options) {
202
+ const code = 'IRIS_MODEL_INPUT_INVALID';
203
+ const context = { backendId: descriptor.identity.backendId };
204
+ const normalizedOptions = normalizeModelCallOptions(descriptor, options);
205
+ const budget = normalizedOptions.budget;
206
+ dto(request, ['prompt', 'system', 'generation', ...(descriptor.kind === 'vision' ? ['image'] : [])], code, context);
207
+ if (typeof request.prompt !== 'string' || !request.prompt.trim()) fail(code, context);
208
+ if (request.system !== undefined && (typeof request.system !== 'string' || !request.system.trim())) fail(code, context);
209
+ if (encoder.encode(request.prompt).byteLength + encoder.encode(request.system || '').byteLength > budget.maxInputTextBytes) fail(code, context);
210
+ const out = { prompt: request.prompt, ...(request.system !== undefined ? { system: request.system } : {}) };
211
+ if (request.system !== undefined && descriptor.features.system !== 'supported') fail('IRIS_MODEL_UNSUPPORTED', context);
212
+ if (request.generation !== undefined) {
213
+ dto(request.generation, ['temperature', 'maxOutputTokens', 'reasoning'], code, context);
214
+ const generation = {};
215
+ for (const key of ['temperature', 'maxOutputTokens']) {
216
+ const value = request.generation[key];
217
+ if (value !== undefined) {
218
+ if (key === 'temperature' ? !Number.isFinite(value) || value < 0 : !positiveInteger(value)) fail(code, context);
219
+ if (descriptor.features[key] !== 'supported') fail('IRIS_MODEL_UNSUPPORTED', context);
220
+ generation[key] = value;
221
+ }
222
+ }
223
+ if (request.generation.reasoning !== undefined) {
224
+ const value = request.generation.reasoning;
225
+ dto(value, ['mode', 'effortId'], code, context);
226
+ if (!['provider-default', 'off', 'effort'].includes(value.mode)) fail(code, context);
227
+ if (value.mode !== 'effort' && Object.hasOwn(value, 'effortId')) fail(code, context);
228
+ if (value.mode === 'effort' && !stableId(value.effortId)) fail(code, context);
229
+ if (value.mode !== 'provider-default' && (descriptor.features.reasoning !== 'supported'
230
+ || (value.mode === 'off' ? descriptor.reasoning?.off !== 'supported' : !descriptor.reasoning?.effortIds?.includes(value.effortId)))) {
231
+ fail('IRIS_MODEL_UNSUPPORTED', context);
232
+ }
233
+ generation.reasoning = Object.freeze({ mode: value.mode, ...(value.mode === 'effort' ? { effortId: value.effortId } : {}) });
234
+ }
235
+ out.generation = Object.freeze(generation);
236
+ }
237
+ if (descriptor.kind === 'vision') {
238
+ dto(request.image, ['bytes', 'mediaType'], code, context);
239
+ const { bytes, mediaType } = request.image;
240
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || !MODEL_IMAGE_MEDIA_TYPES.includes(mediaType)) fail(code, context);
241
+ if (bytes.byteLength > budget.maxImageBytes || (descriptor.image?.maxBytes !== undefined && bytes.byteLength > descriptor.image.maxBytes)) fail(code, context);
242
+ if (descriptor.image?.mediaTypes && !descriptor.image.mediaTypes.includes(mediaType)) fail('IRIS_MODEL_UNSUPPORTED', context);
243
+ out.image = Object.freeze({ bytes: new Uint8Array(bytes), mediaType });
244
+ }
245
+ return Object.freeze({ request: Object.freeze(out), options: normalizedOptions });
246
+ }
247
+
248
+ function requireStop(reason, context) {
249
+ if (reason === 'stop') return;
250
+ const codes = { length: 'IRIS_MODEL_OUTPUT_LIMIT', 'max-tokens': 'IRIS_MODEL_OUTPUT_LIMIT',
251
+ 'tool-calls': 'IRIS_MODEL_UNEXPECTED_TOOL', 'content-blocked': 'IRIS_MODEL_CONTENT_BLOCKED',
252
+ error: 'IRIS_MODEL_REQUEST_FAILED', aborted: 'IRIS_MODEL_ABORTED' };
253
+ fail(reason === undefined || reason === null ? 'IRIS_MODEL_INCOMPLETE'
254
+ : typeof reason === 'string' && Object.hasOwn(codes, reason) ? codes[reason] : 'IRIS_MODEL_PROTOCOL_INVALID', context);
255
+ }
256
+
257
+ export function normalizeModelCompletion(descriptor, value, budget) {
258
+ const context = { stage: 'normalize', invocation: 'unknown', backendId: descriptor.identity.backendId };
259
+ dto(value, ['contractVersion', 'text', 'finishReason', 'identity', 'usage'], 'IRIS_MODEL_PROTOCOL_INVALID', context);
260
+ if (typeof value.text === 'string' && value.text.length || value.finishReason === 'stop') context.invocation = 'responded';
261
+ if (value.contractVersion !== MODEL_PORT_CONTRACT_VERSION || typeof value.text !== 'string') fail('IRIS_MODEL_PROTOCOL_INVALID', context);
262
+ requireStop(value.finishReason, context);
263
+ if (!value.text.trim()) fail('IRIS_MODEL_EMPTY_RESULT', context);
264
+ if (value.text.length > budget.maxOutputChars) fail('IRIS_MODEL_OUTPUT_LIMIT', context);
265
+ const actual = identity(value.identity, 'IRIS_MODEL_PROTOCOL_INVALID', context);
266
+ for (const key of ['origin', 'backendId', 'providerId', 'modelId']) {
267
+ if (descriptor.identity[key] !== undefined && actual[key] !== descriptor.identity[key]) fail('IRIS_MODEL_PROTOCOL_INVALID', context);
268
+ }
269
+ const out = { contractVersion: MODEL_PORT_CONTRACT_VERSION, text: value.text, finishReason: 'stop', identity: actual };
270
+ if (value.usage !== undefined) {
271
+ dto(value.usage, ['inputTokens', 'outputTokens', 'totalTokens', 'reasoningTokens'], 'IRIS_MODEL_PROTOCOL_INVALID', context);
272
+ const usage = {};
273
+ for (const [key, count] of Object.entries(value.usage)) {
274
+ if (!Number.isSafeInteger(count) || count < 0) fail('IRIS_MODEL_PROTOCOL_INVALID', context);
275
+ usage[key] = count;
276
+ }
277
+ out.usage = Object.freeze(usage);
278
+ }
279
+ return Object.freeze(out);
280
+ }
281
+
282
+ /** 协议无关的正文收集器;适配器先映射事件,思考/工具事件不得作为 text 传入。 */
283
+ export function createModelTextCollector({ maxOutputChars, signal, backendId } = {}) {
284
+ if (!positiveInteger(maxOutputChars) || (signal !== undefined && !(signal instanceof AbortSignal))) fail('IRIS_MODEL_INPUT_INVALID');
285
+ if (backendId !== undefined && !backendIdValid(backendId)) fail('IRIS_MODEL_INPUT_INVALID');
286
+ const blocks = new Map();
287
+ let length = 0;
288
+ let responded = false;
289
+ let stopped = false;
290
+ let failure;
291
+ const context = () => ({ stage: 'read', invocation: responded ? 'responded' : 'unknown', ...(backendId ? { backendId } : {}) });
292
+ const reject = (code) => { failure = new ModelPortError(code, context()); throw failure; };
293
+ const check = () => {
294
+ if (failure) throw failure;
295
+ if (signal?.aborted) reject('IRIS_MODEL_ABORTED');
296
+ };
297
+ const update = (text, id, replace) => {
298
+ check();
299
+ if (stopped || typeof text !== 'string' || !stableId(id)) reject('IRIS_MODEL_PROTOCOL_INVALID');
300
+ if (text.length) responded = true;
301
+ const previous = blocks.get(id) || '';
302
+ const nextLength = length + text.length - (replace ? previous.length : 0);
303
+ if (nextLength > maxOutputChars) {
304
+ failure = new ModelPortError('IRIS_MODEL_OUTPUT_LIMIT', { ...context(), invocation: 'responded' });
305
+ throw failure;
306
+ }
307
+ blocks.set(id, replace ? text : previous + text);
308
+ length = nextLength;
309
+ };
310
+ return Object.freeze({
311
+ append: (text, id = 'default') => update(text, id, false),
312
+ replace: (text, id = 'default') => update(text, id, true),
313
+ fail(error) {
314
+ check();
315
+ failure = normalizeModelError(error, context());
316
+ throw failure;
317
+ },
318
+ finish(reason) {
319
+ check();
320
+ if (stopped) reject('IRIS_MODEL_PROTOCOL_INVALID');
321
+ if (['stop', 'length', 'max-tokens', 'tool-calls', 'content-blocked'].includes(reason)) responded = true;
322
+ try { requireStop(reason, context()); } catch (error) { failure = error; throw error; }
323
+ stopped = true;
324
+ },
325
+ complete() {
326
+ check();
327
+ if (!stopped) reject('IRIS_MODEL_INCOMPLETE');
328
+ const text = [...blocks.values()].join('');
329
+ if (!text.trim()) reject('IRIS_MODEL_EMPTY_RESULT');
330
+ return text;
331
+ }
332
+ });
333
+ }