@mokuyoaxis/dsh-iris 0.1.3 → 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.
- package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +77 -2
- package/README.en.md +98 -19
- package/README.md +98 -19
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +82 -52
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
- package/docs/HOST_DOCTOR.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +42 -76
- package/docs/SECURITY.md +10 -5
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.1.4.md +59 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +673 -525
- package/lib/adapters.js +58 -21
- package/lib/api.js +182 -16
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +1116 -86
- package/lib/command-service.js +420 -0
- package/lib/config.js +245 -49
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +260 -30
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +240 -0
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +255 -166
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +397 -0
- package/lib/provider-adapters.js +342 -0
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-health.js +59 -0
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +45 -9
- package/lib/vision.js +51 -56
- package/package.json +20 -14
- package/user_guide.md +67 -12
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
package/lib/locate.js
CHANGED
|
@@ -38,7 +38,7 @@ export function extractBboxJson(answer) {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* 在原图上定位目标,返回原像素 bbox。
|
|
41
|
-
* @param {Array} backends 有序视觉后端(
|
|
41
|
+
* @param {Array} backends 有序视觉后端(buildVisionBackendsFromHost 产物)
|
|
42
42
|
* @param {{target:string, imageDataUrl:string, width:number, height:number, signal?:AbortSignal}}
|
|
43
43
|
* @returns {Promise<{found:boolean, x1?:number, y1?:number, x2?:number, y2?:number, via?:string, model?:string}>}
|
|
44
44
|
* @throws LocateError 模型未返回有效 bbox / bbox 完全越界 / 字段非法
|
package/lib/media.js
CHANGED
|
@@ -15,13 +15,16 @@ import crypto from 'node:crypto';
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import * as tasks from './tasks.js';
|
|
18
|
+
import * as artifacts from './artifacts.js';
|
|
18
19
|
|
|
19
20
|
const MIME = {
|
|
20
21
|
'.mp4': 'video/mp4',
|
|
21
22
|
'.mov': 'video/quicktime',
|
|
22
23
|
'.webm': 'video/webm',
|
|
23
|
-
'.wav': 'audio/wav',
|
|
24
|
-
'.
|
|
24
|
+
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
|
|
25
|
+
'.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.flac': 'audio/flac',
|
|
26
|
+
'.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mpeg': 'video/mpeg', '.mpg': 'video/mpeg',
|
|
27
|
+
'.avif': 'image/avif', '.bmp': 'image/bmp',
|
|
25
28
|
'.png': 'image/png',
|
|
26
29
|
'.jpg': 'image/jpeg',
|
|
27
30
|
'.jpeg': 'image/jpeg',
|
|
@@ -61,9 +64,11 @@ export function registerMedia(taskId, absPath) {
|
|
|
61
64
|
createdAt: new Date().toISOString()
|
|
62
65
|
};
|
|
63
66
|
tasks.update(taskId, { media: [...(t.media || []), entry] });
|
|
67
|
+
artifacts.register(absPath);
|
|
64
68
|
return { ...entry, url: mediaUrl(taskId, entry) };
|
|
65
69
|
}
|
|
66
70
|
const entry = t.media.find((e) => e.file === file);
|
|
71
|
+
artifacts.register(absPath);
|
|
67
72
|
return { ...entry, url: mediaUrl(taskId, entry) };
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -127,15 +132,20 @@ export function serveMedia(req, res) {
|
|
|
127
132
|
const method = String(req.method || 'GET').toUpperCase();
|
|
128
133
|
if (method !== 'GET' && method !== 'HEAD') return sendText(res, 405, 'method not allowed');
|
|
129
134
|
const parts = String(req.url || '').split('?')[0].split('/').filter(Boolean);
|
|
130
|
-
// /iris/media/:taskId/:token/:name
|
|
131
|
-
|
|
135
|
+
// Task media: /iris/media/:taskId/:token/:name
|
|
136
|
+
// Independent work: /iris/media/artifact/:artifactId/:token/:name
|
|
137
|
+
const artifactRoute = parts.length === 6 && parts[0] === 'iris' && parts[1] === 'media' && parts[2] === 'artifact';
|
|
138
|
+
const taskRoute = parts.length === 5 && parts[0] === 'iris' && parts[1] === 'media';
|
|
139
|
+
if (!artifactRoute && !taskRoute) return sendText(res, 404, 'not found');
|
|
132
140
|
let name;
|
|
133
141
|
try {
|
|
134
|
-
name = decodeURIComponent(parts[4]);
|
|
142
|
+
name = decodeURIComponent(parts[artifactRoute ? 5 : 4]);
|
|
135
143
|
} catch (_) {
|
|
136
144
|
return sendText(res, 404, 'not found');
|
|
137
145
|
}
|
|
138
|
-
const hit =
|
|
146
|
+
const hit = artifactRoute
|
|
147
|
+
? artifacts.authorize(parts[3], parts[4], name)
|
|
148
|
+
: authorizeMedia(parts[2], parts[3], name);
|
|
139
149
|
if (!hit) return sendText(res, 404, 'not found'); // 未命中一律 404,不给枚举线索
|
|
140
150
|
const base = {
|
|
141
151
|
'Content-Type': hit.entry.mime,
|
|
@@ -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
|
+
}
|