@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
package/lib/locate.js CHANGED
@@ -6,20 +6,80 @@
6
6
  * 保证 iris_crop 可以无缝接力裁剪(vision-router 的 vision_ground 同款契约)。
7
7
  *
8
8
  * 实现:
9
- * - 复用阶段 1 的 VisionBackend 链(askWithBackends)做识图;
9
+ * - 消费共享 Vision Ports;
10
10
  * - 提示词强制模型只回严格 JSON bbox;
11
11
  * - 解析/校验/钳制:JSON 提取、字段数字校验、x1<x2 且 y1<y2、轻微越界 clamp、
12
12
  * 完全越界报错、found=false 明确返回。
13
13
  */
14
- import { askWithBackends } from './vision.js';
14
+ import { completeVision } from './vision-core.js';
15
+ import { ModelPortError } from './model-port-contract.js';
16
+ import { createVisionInputObserver } from './vision-image-input.js';
15
17
 
16
- export class LocateError extends Error {
18
+ export class LocateError extends ModelPortError {
17
19
  constructor(message) {
18
- super(message);
20
+ super('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'normalize', invocation: 'responded' });
21
+ this.message = message;
19
22
  this.name = 'LocateError';
20
23
  }
21
24
  }
22
25
 
26
+ export function normalizeLocateTarget(target) {
27
+ const value = String(target || '').trim();
28
+ if (!value) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
29
+ return value.slice(0, 200);
30
+ }
31
+
32
+ function locatePrompt(target, width, height, normalized = false) {
33
+ const coordinates = normalized
34
+ ? `坐标必须使用 0–1000 归一化坐标:x=像素x/${width}*1000,y=像素y/${height}*1000;左上角(0,0),右下角(1000,1000),不得直接输出像素坐标`
35
+ : '本次输入图片的像素坐标,图片左上角为(0,0)';
36
+ return `在图片中定位「${target}」。只返回一个 JSON 对象,不要输出任何其他文字或解释。格式:`
37
+ + `{"x1":左上角x,"y1":左上角y,"x2":右下角x,"y2":右下角y}`
38
+ + `(图片尺寸 ${width}x${height};${coordinates};要求 x1<x2 且 y1<y2)。`
39
+ + `请确保 bbox 完全包围目标,不得遗漏目标的任何部分。如果图片中没有该目标,返回 {"found":false}。`;
40
+ }
41
+
42
+ /** 输入已显式转正;将四角逆变换回原始像素,向外取整以供原图裁剪。 */
43
+ export function mapLocateBbox(bbox, input) {
44
+ if (bbox.found === false) return bbox;
45
+ const { width: W, height: H } = input.source;
46
+ const orientation = input.orientation;
47
+ const swapped = orientation >= 5 && orientation <= 8;
48
+ const sx = (swapped ? H : W) / (input.coordinateSpace === 'normalized-1000' ? 1000 : input.sent.width);
49
+ const sy = (swapped ? W : H) / (input.coordinateSpace === 'normalized-1000' ? 1000 : input.sent.height);
50
+ const inverse = (x, y) => {
51
+ switch (orientation) {
52
+ case 2: return [W - x, y];
53
+ case 3: return [W - x, H - y];
54
+ case 4: return [x, H - y];
55
+ case 5: return [y, x];
56
+ case 6: return [y, H - x];
57
+ case 7: return [W - y, H - x];
58
+ case 8: return [W - y, x];
59
+ default: return [x, y];
60
+ }
61
+ };
62
+ const corners = [[bbox.x1, bbox.y1], [bbox.x1, bbox.y2], [bbox.x2, bbox.y1], [bbox.x2, bbox.y2]]
63
+ .map(([x, y]) => inverse(x * sx, y * sy));
64
+ const xs = corners.map(([x]) => x), ys = corners.map(([, y]) => y);
65
+ // 消除比例换算在整数边缘的浮点误差,再对真实小数向外取整。
66
+ const edge = (value, round) => Math.abs(value - Math.round(value)) < 1e-9 ? Math.round(value) : round(value);
67
+ return { found: true, x1: Math.max(0, edge(Math.min(...xs), Math.floor)), y1: Math.max(0, edge(Math.min(...ys), Math.floor)),
68
+ x2: Math.min(W, edge(Math.max(...xs), Math.ceil)), y2: Math.min(H, edge(Math.max(...ys), Math.ceil)) };
69
+ }
70
+
71
+ /** 工厂钩子局限于本次业务,协议适配器不解析 bbox;候选各用自己的真实输入。 */
72
+ export function createLocateImageRequest(target, inputs) {
73
+ target = normalizeLocateTarget(target);
74
+ const observe = createVisionInputObserver(inputs);
75
+ return async args => {
76
+ const input = await observe(args);
77
+ input.coordinateSpace = 'normalized-1000';
78
+ return { prompt: locatePrompt(target, input.sent.width, input.sent.height, true),
79
+ mapText: text => JSON.stringify(mapLocateBbox(parseLocateBbox(text, 1000, 1000, { round: false }), input)) };
80
+ };
81
+ }
82
+
23
83
  /** 从模型回答里提取第一个平衡的 {...} JSON 对象文本;无则 null */
24
84
  export function extractBboxJson(answer) {
25
85
  const text = String(answer || '');
@@ -38,57 +98,55 @@ export function extractBboxJson(answer) {
38
98
 
39
99
  /**
40
100
  * 在原图上定位目标,返回原像素 bbox。
41
- * @param {Array} backends 有序视觉后端(buildVisionBackendsFromHost 产物)
42
- * @param {{target:string, imageDataUrl:string, width:number, height:number, signal?:AbortSignal}}
43
- * @returns {Promise<{found:boolean, x1?:number, y1?:number, x2?:number, y2?:number, via?:string, model?:string}>}
101
+ * @param {Array} ports 有序 Vision Ports
102
+ * @param {{target:string, image:object, width:number, height:number}}
103
+ * @returns {Promise<object>} 完整模型结果及 bbox
44
104
  * @throws LocateError 模型未返回有效 bbox / bbox 完全越界 / 字段非法
45
105
  */
46
- export async function locateObject(backends, { target, imageDataUrl, width, height, signal }) {
106
+ export async function locateObject(ports, { target, image, width, height }, options = {}) {
107
+ target = normalizeLocateTarget(target);
47
108
  const W = Math.floor(width);
48
109
  const H = Math.floor(height);
49
110
  if (!Number.isFinite(W) || !Number.isFinite(H) || W <= 0 || H <= 0) {
50
- throw new LocateError('iris_locate: 图片尺寸无效');
111
+ throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
51
112
  }
52
- const prompt =
53
- `在图片中定位「${String(target).slice(0, 200)}」。` +
54
- `只返回一个 JSON 对象,不要输出任何其他文字或解释。格式:` +
55
- `{"x1":左上角x,"y1":左上角y,"x2":右下角x,"y2":右下角y}` +
56
- `(原像素坐标,图片左上角为(0,0),尺寸 ${W}x${H},要求 x1<x2 且 y1<y2)。` +
57
- `请确保 bbox 完全包围目标,不得遗漏目标的任何部分。` +
58
- `如果图片中没有该目标,返回 {"found":false}。`;
113
+ const prompt = locatePrompt(target, W, H);
59
114
 
60
- const { answer, via, model } = await askWithBackends(backends, { question: prompt, imageDataUrl, signal });
115
+ const result = await completeVision(ports, { prompt, image }, options);
116
+ return { ...result, bbox: parseLocateBbox(result.text, W, H) };
117
+ }
61
118
 
119
+ /** 保持原像素解析与钳制规则;格式失败不再发起模型调用。 */
120
+ export function parseLocateBbox(answer, W, H, { round = true } = {}) {
62
121
  const jsonText = extractBboxJson(answer);
63
- if (!jsonText) throw new LocateError('iris_locate: 模型未返回有效 JSON:' + String(answer).slice(0, 160));
122
+ if (!jsonText) throw new LocateError('iris_locate: 模型未返回有效 JSON');
64
123
  let obj;
65
124
  try {
66
125
  obj = JSON.parse(jsonText);
67
126
  } catch (_) {
68
- throw new LocateError('iris_locate: bbox JSON 解析失败:' + jsonText.slice(0, 160));
127
+ throw new LocateError('iris_locate: bbox JSON 解析失败');
69
128
  }
70
- if (obj && obj.found === false) return { found: false, via, model };
129
+ if (obj && obj.found === false) return { found: false };
71
130
 
72
131
  const { x1, y1, x2, y2 } = obj || {};
73
132
  if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
74
- throw new LocateError('iris_locate: bbox 字段必须是数字:' + jsonText.slice(0, 160));
133
+ throw new LocateError('iris_locate: bbox 字段必须是数字');
75
134
  }
76
135
  if (!(x1 < x2 && y1 < y2)) {
77
- throw new LocateError(`iris_locate: bbox 无效(要求 x1<x2 且 y1<y2,得到 ${x1},${y1},${x2},${y2})`);
136
+ throw new LocateError('iris_locate: bbox 无效(要求 x1<x2 且 y1<y2)');
78
137
  }
79
138
  // 完全越界(与图片无交集)→ 报错;否则钳制到图片边界
80
139
  if (x1 >= W || x2 <= 0 || y1 >= H || y2 <= 0) {
81
- throw new LocateError(`iris_locate: bbox 完全超出图片边界 ${W}x${H}:${x1},${y1},${x2},${y2}`);
140
+ throw new LocateError('iris_locate: bbox 完全超出图片边界');
82
141
  }
83
- const clamp = (v) => Math.max(0, Math.min(v < 0 ? Math.round(v) : Math.ceil(v), W));
84
- const clampY = (v) => Math.max(0, Math.min(v < 0 ? Math.round(v) : Math.ceil(v), H));
142
+ const pixel = v => round ? (v < 0 ? Math.round(v) : Math.ceil(v)) : v;
143
+ const clamp = (v) => Math.max(0, Math.min(pixel(v), W));
144
+ const clampY = (v) => Math.max(0, Math.min(pixel(v), H));
85
145
  return {
86
146
  found: true,
87
147
  x1: clamp(x1),
88
148
  y1: clampY(y1),
89
149
  x2: clamp(x2),
90
- y2: clampY(y2),
91
- via,
92
- model
150
+ y2: clampY(y2)
93
151
  };
94
152
  }
@@ -125,7 +125,8 @@ export async function extractFrames({ inputPath, maxFrames = 8, targetWidth = 64
125
125
  else args.push('-q:v', String(opts.quality), outFile);
126
126
  await runFfmpeg(args, { signal });
127
127
  const frames = [];
128
- const names = fs.readdirSync(outDir).filter((f) => f.endsWith('.jpg') || f.endsWith('.png')).sort();
128
+ const names = fs.readdirSync(outDir).filter((f) => f.endsWith('.jpg') || f.endsWith('.png'))
129
+ .sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
129
130
  for (const name of names) {
130
131
  const idx = Number(name.replace(/^f/, '').replace(/\.(jpg|png)$/, ''));
131
132
  if (!Number.isFinite(idx) || idx < 1 || idx > n) continue; // 只取前 N 帧
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ /** 协议适配器的有界等待;调用方仍须把 signal 传给实际 I/O。 */
3
+ import { ModelPortError, modelErrorRecord } from './model-port-contract.js';
4
+
5
+ export function modelAbortError(signal, stage = 'prepare', invocation = 'not_invoked') {
6
+ return new ModelPortError(modelErrorRecord(signal?.reason).code === 'IRIS_MODEL_TIMEOUT'
7
+ ? 'IRIS_MODEL_TIMEOUT' : 'IRIS_MODEL_ABORTED', { stage, invocation });
8
+ }
9
+
10
+ export async function waitForModelWork(work, signal, stage = 'prepare', invocation = 'not_invoked') {
11
+ if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
12
+ let listener;
13
+ const canceled = new Promise((_, reject) => {
14
+ listener = () => reject(modelAbortError(signal, stage, invocation));
15
+ signal?.addEventListener('abort', listener, { once: true });
16
+ });
17
+ // 延迟调用以便预先取消时零 I/O;race 同时消费晚到的 rejection。
18
+ const pending = Promise.resolve().then(() => {
19
+ if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
20
+ return work();
21
+ });
22
+ try {
23
+ const result = await Promise.race([pending, canceled]);
24
+ if (signal?.aborted) throw modelAbortError(signal, stage, invocation);
25
+ return result;
26
+ } finally { signal?.removeEventListener('abort', listener); }
27
+ }
28
+
29
+ export function modelCallScope(parent, timeoutMs) {
30
+ const controller = new AbortController();
31
+ const onAbort = () => controller.abort(parent.reason);
32
+ if (parent?.aborted) onAbort();
33
+ else parent?.addEventListener('abort', onAbort, { once: true });
34
+ const deadline = performance.now() + timeoutMs;
35
+ let timer;
36
+ const schedule = () => {
37
+ if (controller.signal.aborted) return;
38
+ const left = deadline - performance.now();
39
+ if (left <= 0) controller.abort(new ModelPortError('IRIS_MODEL_TIMEOUT'));
40
+ else timer = setTimeout(schedule, Math.min(2147483647, Math.ceil(left)));
41
+ };
42
+ schedule();
43
+ return { signal: controller.signal, abort: reason => controller.abort(reason),
44
+ dispose() { clearTimeout(timer); parent?.removeEventListener('abort', onAbort); } };
45
+ }
@@ -0,0 +1,220 @@
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
+ let cancelTimer;
64
+ const interrupted = new Promise((_, reject) => {
65
+ // 给合作的适配器一个事件循环拍完成清理并报告 responded;不合作端口仍有界退出。
66
+ listener = () => { cancelTimer = setTimeout(() => reject(signal.reason), 0); };
67
+ if (signal.aborted) listener();
68
+ else signal.addEventListener('abort', listener, { once: true });
69
+ });
70
+ try { return await Promise.race([work, interrupted]); }
71
+ finally { clearTimeout(cancelTimer); signal.removeEventListener('abort', listener); }
72
+ }
73
+
74
+ function candidatePolicy(value) {
75
+ if (!value || Object.getPrototypeOf(value) !== Object.prototype) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
76
+ const out = { skipUnavailable: false, allowRejected: false, allowEmptyResult: false };
77
+ for (const [key, field] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
78
+ if (!Object.hasOwn(out, key) || !Object.hasOwn(field, 'value') || typeof field.value !== 'boolean') {
79
+ throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
80
+ }
81
+ out[key] = field.value;
82
+ }
83
+ if (Object.getOwnPropertySymbols(value).length) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
84
+ return out;
85
+ }
86
+
87
+ function mayContinue(error, policy) {
88
+ const { code, invocation } = modelErrorRecord(error);
89
+ if (invocation === 'not_invoked' && policy.skipUnavailable) {
90
+ return ['IRIS_MODEL_UNAVAILABLE', 'IRIS_MODEL_INCOMPATIBLE', 'IRIS_MODEL_UNSUPPORTED'].includes(code);
91
+ }
92
+ if (invocation === 'rejected' && policy.allowRejected) {
93
+ return ['IRIS_MODEL_AUTH_FAILED', 'IRIS_MODEL_RATE_LIMITED'].includes(code);
94
+ }
95
+ return policy.allowEmptyResult && invocation === 'responded' && code === 'IRIS_MODEL_EMPTY_RESULT';
96
+ }
97
+
98
+ function operation(options, startedAt) {
99
+ const total = scope({ timeoutMs: options.budget.timeoutMs, startedAt,
100
+ parents: options.signal ? [{ signal: options.signal, code: () => 'IRIS_MODEL_ABORTED' }] : [] });
101
+ let disposed = false;
102
+ let invocations = 0;
103
+ let pending = 0;
104
+
105
+ const check = () => {
106
+ if (disposed) throw new ModelPortError('IRIS_MODEL_ABORTED');
107
+ total.check();
108
+ };
109
+
110
+ const invoke = async (port, request, callOptions) => {
111
+ const callStartedAt = now();
112
+ check();
113
+ const descriptor = modelPortSnapshot(port);
114
+ const complete = port.complete;
115
+ if (typeof complete !== 'function') throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE');
116
+ const local = normalizeModelCallOptions(descriptor, callOptions);
117
+ const backendId = descriptor.identity.backendId;
118
+ const child = scope({ timeoutMs: local.budget.timeoutMs, startedAt: callStartedAt, parents: [
119
+ { signal: total.signal, code: () => total.failureCode },
120
+ ...(local.signal ? [{ signal: local.signal, code: () => 'IRIS_MODEL_ABORTED' }] : [])
121
+ ] });
122
+ let entered = false;
123
+ let called = false;
124
+ let refunded = false;
125
+ try {
126
+ child.check({ backendId });
127
+ const input = normalizeModelCall(descriptor, request, local);
128
+ child.check({ backendId });
129
+ if (descriptor.availability !== 'available') {
130
+ throw new ModelPortError(descriptor.availability === 'incompatible' ? 'IRIS_MODEL_INCOMPATIBLE' : 'IRIS_MODEL_UNAVAILABLE', { backendId });
131
+ }
132
+ check();
133
+ if (invocations >= options.budget.maxInvocations) throw new ModelPortError('IRIS_MODEL_CALL_LIMIT', { backendId });
134
+ // 在调入 complete 之前占用次数,避免并发请求一起穿过最后一个额度。
135
+ invocations++;
136
+ pending++;
137
+ entered = true;
138
+ const controlledOptions = Object.freeze({ signal: child.signal,
139
+ budget: Object.freeze({ ...input.options.budget, timeoutMs: Math.max(1, Math.ceil(Math.min(child.remaining(), total.remaining()))) }) });
140
+ const work = Promise.resolve().then(() => {
141
+ child.check({ stage: 'invoke', invocation: 'not_invoked', backendId });
142
+ called = true;
143
+ return complete.call(port, input.request, controlledOptions);
144
+ });
145
+ const result = await waitForCompletion(work, child.signal);
146
+ child.check({ stage: 'normalize', invocation: 'responded', backendId });
147
+ const normalized = normalizeModelCompletion(descriptor, result, local.budget);
148
+ child.check({ stage: 'normalize', invocation: 'responded', backendId });
149
+ return normalized;
150
+ } catch (error) {
151
+ const record = modelErrorRecord(error);
152
+ // 只有可信契约错误明确说明尚未调用时,才返还预留次数。
153
+ if (entered && (!called || record.invocation === 'not_invoked' && ['validate', 'prepare', 'invoke'].includes(record.stage))) {
154
+ invocations--;
155
+ refunded = true;
156
+ }
157
+ if (child.signal.aborted) {
158
+ if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(child.failureCode)) total.abort(child.failureCode);
159
+ throw new ModelPortError(child.failureCode, { stage: entered && !refunded ? 'invoke' : 'validate',
160
+ invocation: !entered || refunded ? 'not_invoked' : record.invocation === 'responded' ? 'responded' : 'unknown', backendId });
161
+ }
162
+ // 输出/协议失败也通知底层停止,结果从不自动转为下一次调用。
163
+ child.abort(record.code);
164
+ if (['IRIS_MODEL_ABORTED', 'IRIS_MODEL_TIMEOUT'].includes(record.code)) total.abort(record.code);
165
+ throw normalizeModelError(error, { backendId });
166
+ } finally {
167
+ if (entered) pending--;
168
+ child.dispose();
169
+ }
170
+ };
171
+
172
+ return Object.freeze({
173
+ signal: total.signal,
174
+ invoke,
175
+ async runCandidates(ports, request, callOptions, policy = {}) {
176
+ check();
177
+ const rules = candidatePolicy(policy);
178
+ if (!Array.isArray(ports) || !ports.length || [...ports].some(port => !port)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
179
+ let lastError;
180
+ for (const port of [...ports]) {
181
+ check();
182
+ try { return await invoke(port, request, callOptions); }
183
+ catch (error) {
184
+ if (total.signal.aborted || disposed) throw error;
185
+ check();
186
+ if (!mayContinue(error, rules)) throw error;
187
+ lastError = error;
188
+ }
189
+ }
190
+ throw lastError;
191
+ },
192
+ snapshot() {
193
+ return Object.freeze({ invocations, pending, maxInvocations: options.budget.maxInvocations,
194
+ state: disposed ? 'disposed' : total.failureCode === 'IRIS_MODEL_TIMEOUT' ? 'timed_out' : total.signal.aborted ? 'aborted' : 'active' });
195
+ },
196
+ dispose() {
197
+ if (disposed) return;
198
+ disposed = true;
199
+ total.abort('IRIS_MODEL_ABORTED');
200
+ total.dispose();
201
+ }
202
+ });
203
+ }
204
+
205
+ /** 一个 OCR/候选等复合业务共用此 scope;调用方在 finally 中 dispose。 */
206
+ export function createModelOperation(options) {
207
+ const startedAt = now();
208
+ return operation(normalizeModelOperationOptions(options), startedAt);
209
+ }
210
+
211
+ /** 默认只允许一次调用,自动释放操作 scope;不运行候选策略。 */
212
+ export async function invokeModel(port, request, options) {
213
+ const startedAt = now();
214
+ const descriptor = modelPortSnapshot(port);
215
+ const local = normalizeModelCallOptions(descriptor, options);
216
+ const op = operation(normalizeModelOperationOptions({ budget: { timeoutMs: local.budget.timeoutMs, maxInvocations: 1 },
217
+ ...(local.signal ? { signal: local.signal } : {}) }), startedAt);
218
+ try { return await op.invoke(port, request, local); }
219
+ finally { op.dispose(); }
220
+ }