@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.
- package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
- package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
- package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
- package/CHANGELOG.md +99 -1
- package/README.en.md +124 -31
- package/README.md +124 -31
- package/bin/dsh-iris.js +593 -13
- package/docs/AGENT_QUICKSTART.md +39 -0
- package/docs/ARCHITECTURE.md +87 -64
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CLI_MANAGEMENT.md +181 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
- package/docs/DSH_CORE_MIGRATION.md +135 -0
- package/docs/DSH_RC2_ADAPTATION.md +38 -0
- package/docs/FAULT_INJECTION.md +12 -2
- package/docs/HEADLESS_CLI.md +223 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
- package/docs/HOST_DOCTOR.md +4 -2
- package/docs/MODEL_PORT_CONTRACT.md +305 -0
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +49 -83
- package/docs/SECURITY.md +8 -7
- package/docs/TASK_SEMANTICS.md +2 -2
- package/docs/VISION_CLI.md +132 -0
- package/docs/VISION_MODEL.md +51 -0
- package/docs/WORKBENCH_ARTIFACTS.md +67 -0
- 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/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.0.md +48 -0
- package/docs/releases/0.2.1.md +56 -0
- package/lib/actions.js +520 -591
- package/lib/adapters.js +184 -36
- package/lib/api.js +200 -11
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +1390 -105
- package/lib/command-service.js +534 -0
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +143 -51
- package/lib/core-artifact-store.js +630 -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-maintenance.js +213 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +567 -0
- package/lib/core-user-projection.js +193 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +653 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +126 -0
- package/lib/guard.js +3 -3
- package/lib/headless-vision.js +181 -0
- package/lib/host-runtime.js +1 -0
- package/lib/http-vision-model-adapter.js +187 -0
- package/lib/image-edit-input.js +34 -0
- package/lib/index.js +323 -188
- package/lib/locate.js +86 -28
- package/lib/media-probe.js +2 -1
- package/lib/model-call-runtime.js +45 -0
- package/lib/model-invoker.js +220 -0
- package/lib/model-port-contract.js +341 -0
- package/lib/models.js +105 -14
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +2 -1
- package/lib/prompt-optimizer-core.js +121 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +165 -26
- package/lib/provider-catalog.js +183 -0
- package/lib/provider-config-service.js +236 -0
- package/lib/provider-contract.js +74 -4
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +74 -2
- package/lib/provider-task-runner.js +370 -0
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- package/lib/tasks.js +16 -4
- package/lib/video-input.js +30 -0
- package/lib/vision-core.js +42 -0
- package/lib/vision-image-input.js +83 -0
- package/lib/vision-input.js +31 -0
- package/lib/vision-model-routing.js +180 -0
- package/lib/vision.js +31 -9
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +8 -8
- package/user_guide.md +71 -26
package/lib/dsh-host-adapter.js
CHANGED
|
@@ -41,6 +41,24 @@ function unavailableFor(value, reason) {
|
|
|
41
41
|
return { kind: value ? 'incompatible' : 'unavailable', reason };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function imageAttachmentDto(ref) {
|
|
45
|
+
const out = {
|
|
46
|
+
attachmentId: ref.attachmentId,
|
|
47
|
+
mediaType: ref.mediaType,
|
|
48
|
+
name: typeof ref.name === 'string' ? ref.name : ''
|
|
49
|
+
};
|
|
50
|
+
// readImage 会核对这三项完整性事实;省略未知值,不从 ID/文件名猜测。
|
|
51
|
+
for (const key of ['bytes', 'width', 'height']) {
|
|
52
|
+
if (Number.isSafeInteger(ref[key]) && ref[key] > 0) out[key] = ref[key];
|
|
53
|
+
}
|
|
54
|
+
const original = ref.originalDimensions;
|
|
55
|
+
if (original && Number.isSafeInteger(original.width) && original.width > 0
|
|
56
|
+
&& Number.isSafeInteger(original.height) && original.height > 0) {
|
|
57
|
+
out.originalDimensions = Object.freeze({ width: original.width, height: original.height });
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze(out);
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
function collectImageAttachments(nodes) {
|
|
45
63
|
const out = [];
|
|
46
64
|
const ids = new Set();
|
|
@@ -59,11 +77,7 @@ function collectImageAttachments(nodes) {
|
|
|
59
77
|
&& typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')
|
|
60
78
|
&& !ids.has(ref.attachmentId)) {
|
|
61
79
|
ids.add(ref.attachmentId);
|
|
62
|
-
out.push(
|
|
63
|
-
attachmentId: ref.attachmentId,
|
|
64
|
-
mediaType: ref.mediaType,
|
|
65
|
-
name: typeof ref.name === 'string' ? ref.name : ''
|
|
66
|
-
}));
|
|
80
|
+
out.push(imageAttachmentDto(ref));
|
|
67
81
|
}
|
|
68
82
|
for (const value of Object.values(node)) if (value && typeof value === 'object') stack.push(value);
|
|
69
83
|
}
|
|
@@ -75,16 +89,41 @@ function registerWithEffect(ctx, registry, definition) {
|
|
|
75
89
|
return registry.register(definition);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
|
-
function
|
|
92
|
+
function visionRouteError(message) {
|
|
93
|
+
return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dshVisionPort(llm, { defaults, requiresRoute }) {
|
|
79
97
|
return Object.freeze({
|
|
80
98
|
id: 'dsh-global',
|
|
81
99
|
model: '全局视觉模型',
|
|
82
100
|
async analyze({ question, ref, signal }) {
|
|
101
|
+
let route = {};
|
|
102
|
+
if (requiresRoute) {
|
|
103
|
+
// rc.2 路由检查前就停止取消请求;旧桥接继续由宿主处理 signal,
|
|
104
|
+
// 其 fallback 语义待共享调用控制迁移时统一。
|
|
105
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
106
|
+
const selection = defaults.currentSelection();
|
|
107
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
108
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
109
|
+
throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
|
|
110
|
+
}
|
|
111
|
+
// 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
|
|
112
|
+
const { provider, model } = selection;
|
|
113
|
+
const info = await llm.resolveModelInfo(provider, model, signal);
|
|
114
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
115
|
+
if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
|
|
116
|
+
|| !info.inputModalities.includes('image')) {
|
|
117
|
+
throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
|
|
118
|
+
}
|
|
119
|
+
route = { provider, model };
|
|
120
|
+
}
|
|
83
121
|
const chunks = [];
|
|
84
122
|
for await (const chunk of llm.stream({
|
|
85
123
|
sessionId: undefined,
|
|
86
124
|
provider: undefined,
|
|
87
125
|
model: undefined,
|
|
126
|
+
...route,
|
|
88
127
|
signal,
|
|
89
128
|
messages: [{
|
|
90
129
|
role: 'user',
|
|
@@ -94,6 +133,12 @@ function dshVisionPort(llm) {
|
|
|
94
133
|
]
|
|
95
134
|
}]
|
|
96
135
|
})) {
|
|
136
|
+
if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
137
|
+
// rc.2 的 Runtime 把 Adapter 异常转换为 finish,而不是单独的 error 事件。
|
|
138
|
+
if (requiresRoute && chunk?.type === 'finish') {
|
|
139
|
+
if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
140
|
+
if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
|
|
141
|
+
}
|
|
97
142
|
const text = typeof chunk?.delta === 'string'
|
|
98
143
|
? chunk.delta
|
|
99
144
|
: (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
|
|
@@ -174,7 +219,13 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
|
|
|
174
219
|
? () => defaults.currentSelection()
|
|
175
220
|
: () => null
|
|
176
221
|
});
|
|
177
|
-
|
|
222
|
+
const requiresRoute = /^0\.2\./.test(version);
|
|
223
|
+
if (!requiresRoute || defaults && typeof defaults.currentSelection === 'function'
|
|
224
|
+
&& typeof llm.resolveModelInfo === 'function') {
|
|
225
|
+
ports.visionModel = dshVisionPort(llm, { defaults, requiresRoute });
|
|
226
|
+
} else unavailable.visionModel = {
|
|
227
|
+
kind: 'unavailable', reason: 'DSH 0.2 视觉桥接需要默认模型选择和精确图片能力元数据'
|
|
228
|
+
};
|
|
178
229
|
} else {
|
|
179
230
|
unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
180
231
|
unavailable.visionModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/** DSH textModel 的单轮协议映射;宿主路由/会话封闭在适配器,不进入共享请求。 */
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { ModelPortError, modelErrorRecord, normalizeModelDescriptor, normalizeModelCall,
|
|
5
|
+
normalizeModelCompletion, createModelTextCollector } from './model-port-contract.js';
|
|
6
|
+
|
|
7
|
+
const OFF_IDS = new Set(['off', 'none', 'disabled', 'disable', 'no-thinking']);
|
|
8
|
+
const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 256
|
|
9
|
+
&& value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value) && !value.startsWith('/') && !value.includes('://');
|
|
10
|
+
|
|
11
|
+
function abortError(signal, stage, invocation) {
|
|
12
|
+
const code = modelErrorRecord(signal?.reason).code === 'IRIS_MODEL_TIMEOUT' ? 'IRIS_MODEL_TIMEOUT' : 'IRIS_MODEL_ABORTED';
|
|
13
|
+
return new ModelPortError(code, { stage, invocation });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 信号传入底层;race 仅保证不合作的元数据/迭代器不会让本地永远挂住。 */
|
|
17
|
+
async function wait(work, signal, stage, invocation) {
|
|
18
|
+
if (!signal) return work;
|
|
19
|
+
let onAbort;
|
|
20
|
+
const canceled = new Promise((_, reject) => {
|
|
21
|
+
onAbort = () => reject(abortError(signal, stage, invocation));
|
|
22
|
+
if (signal.aborted) onAbort();
|
|
23
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
24
|
+
});
|
|
25
|
+
try { return await Promise.race([work, canceled]); }
|
|
26
|
+
finally { signal.removeEventListener('abort', onAbort); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function descriptorFor(binding, metadata, kind = 'text') {
|
|
30
|
+
const efforts = metadata?.reasoning?.efforts;
|
|
31
|
+
const ids = Array.isArray(efforts) ? [...new Set(efforts.map(value => value?.id).filter(validId))] : [];
|
|
32
|
+
const offId = ids.find(id => OFF_IDS.has(id.toLowerCase()));
|
|
33
|
+
const known = Array.isArray(efforts) && efforts.every(value => validId(value?.id));
|
|
34
|
+
return { descriptor: normalizeModelDescriptor({ contractVersion: 0, kind,
|
|
35
|
+
identity: { origin: 'host', backendId: 'dsh-' + kind + ':' + crypto.createHash('sha256').update(JSON.stringify([binding.provider, binding.model])).digest('hex'),
|
|
36
|
+
providerId: binding.provider, modelId: binding.model }, availability: 'available',
|
|
37
|
+
// 这些字段有明确的 DSH GenerateOptions 映射,不代表已证明远端模型能力。
|
|
38
|
+
features: { system: 'supported', temperature: 'supported', maxOutputTokens: 'supported', reasoning: known ? 'supported' : 'unknown' },
|
|
39
|
+
...(known ? { reasoning: { off: offId ? 'supported' : 'unsupported', effortIds: ids } } : {}) }), offId: known ? offId : undefined };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usageSnapshot(value) {
|
|
43
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
44
|
+
const usage = {};
|
|
45
|
+
for (const key of ['outputTokens', 'totalTokens', 'reasoningTokens']) {
|
|
46
|
+
if (Number.isSafeInteger(value[key]) && value[key] >= 0) usage[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
// rc.2 inputTokens 不含缓存;这里只把权威的三个不重叠输入计数相加。
|
|
49
|
+
const inputs = [value.inputTokens, value.cacheReadTokens ?? 0, value.cacheWriteTokens ?? 0];
|
|
50
|
+
const inputTotal = inputs.reduce((sum, count) => sum + count, 0);
|
|
51
|
+
if (inputs.every(count => Number.isSafeInteger(count) && count >= 0) && Number.isSafeInteger(inputTotal)) usage.inputTokens = inputTotal;
|
|
52
|
+
return Object.keys(usage).length ? usage : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 元数据解析显式进行且受同一操作信号约束;describe 永远只复制已绑定事实。 */
|
|
56
|
+
export async function prepareDshTextModelPort(textModel, binding, { signal } = {}) {
|
|
57
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
58
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
59
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
60
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
61
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
62
|
+
let metadata;
|
|
63
|
+
if (typeof textModel.resolveModelInfo === 'function') {
|
|
64
|
+
try { metadata = await wait(Promise.resolve().then(() => {
|
|
65
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
66
|
+
return textModel.resolveModelInfo(route.provider, route.model, signal);
|
|
67
|
+
}), signal, 'prepare', 'not_invoked'); }
|
|
68
|
+
catch (_) { if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked'); }
|
|
69
|
+
}
|
|
70
|
+
if (signal?.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
71
|
+
return createDshTextModelPort(textModel, { ...route, sessionId }, { metadata });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 已绑定事实的纯构造;用于缓存元数据/协议 fixture,不执行元数据查询。 */
|
|
75
|
+
export function createDshTextModelPort(textModel, binding, { metadata } = {}) {
|
|
76
|
+
return createDshBoundModelPort(textModel, binding, { metadata });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 文本与视觉共用经过验证的 DSH 事件读取器;图片桥接仅由视觉适配器注入。 */
|
|
80
|
+
export function createDshBoundModelPort(textModel, binding, { metadata, prepareImage, prepareImageRequest, kind = 'text' } = {}) {
|
|
81
|
+
if (!textModel || typeof textModel.stream !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE');
|
|
82
|
+
if (!validId(binding?.provider) || !validId(binding?.model)) throw new ModelPortError('IRIS_MODEL_INPUT_INVALID');
|
|
83
|
+
const route = { provider: binding.provider, model: binding.model };
|
|
84
|
+
// 不接受绑定身份与解析出的模型漂移;旧 fixture 未提供身份时保持 unknown。
|
|
85
|
+
if (metadata && (metadata.provider !== undefined && metadata.provider !== route.provider || metadata.id !== undefined && metadata.id !== route.model)) {
|
|
86
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare', invocation: 'not_invoked' });
|
|
87
|
+
}
|
|
88
|
+
const sessionId = typeof binding.sessionId === 'string' && binding.sessionId.trim() && binding.sessionId.length <= 256 ? binding.sessionId.trim() : undefined;
|
|
89
|
+
const { descriptor, offId } = descriptorFor(route, metadata, kind);
|
|
90
|
+
const port = Object.freeze({
|
|
91
|
+
describe() { return normalizeModelDescriptor(descriptor); },
|
|
92
|
+
async complete(request, options) {
|
|
93
|
+
const input = normalizeModelCall(descriptor, request, options);
|
|
94
|
+
if (input.options.signal?.aborted) throw abortError(input.options.signal, 'invoke', 'not_invoked');
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const parent = input.options.signal;
|
|
97
|
+
const onAbort = () => controller.abort(parent.reason);
|
|
98
|
+
parent?.addEventListener('abort', onAbort, { once: true });
|
|
99
|
+
const deadline = performance.now() + input.options.budget.timeoutMs;
|
|
100
|
+
let timer;
|
|
101
|
+
const scheduleDeadline = () => {
|
|
102
|
+
const remaining = deadline - performance.now();
|
|
103
|
+
if (remaining <= 0) controller.abort(new ModelPortError('IRIS_MODEL_TIMEOUT'));
|
|
104
|
+
else timer = setTimeout(scheduleDeadline, Math.min(2147483647, Math.ceil(remaining)));
|
|
105
|
+
};
|
|
106
|
+
scheduleDeadline();
|
|
107
|
+
const signal = controller.signal;
|
|
108
|
+
const collector = createModelTextCollector({ maxOutputChars: input.options.budget.maxOutputChars, signal, backendId: descriptor.identity.backendId });
|
|
109
|
+
const sourceRequest = { ...route, messages: [{ role: 'user', content: [{ type: 'text', text: input.request.prompt }] }],
|
|
110
|
+
...(input.request.system === undefined ? {} : { system: input.request.system }), signal,
|
|
111
|
+
...(sessionId ? { sessionId } : {}) };
|
|
112
|
+
if (input.request.generation?.temperature !== undefined) sourceRequest.temperature = input.request.generation.temperature;
|
|
113
|
+
if (input.request.generation?.maxOutputTokens !== undefined) sourceRequest.maxTokens = input.request.generation.maxOutputTokens;
|
|
114
|
+
const reasoning = input.request.generation?.reasoning;
|
|
115
|
+
if (reasoning?.mode === 'off') sourceRequest.reasoningEffort = offId;
|
|
116
|
+
if (reasoning?.mode === 'effort') sourceRequest.reasoningEffort = reasoning.effortId;
|
|
117
|
+
let iterator;
|
|
118
|
+
let usage;
|
|
119
|
+
let finished = false;
|
|
120
|
+
let ended = false;
|
|
121
|
+
let invoked = false;
|
|
122
|
+
let responded = false;
|
|
123
|
+
let prepared;
|
|
124
|
+
const blocks = new Map();
|
|
125
|
+
const blockId = index => {
|
|
126
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'unknown' });
|
|
127
|
+
return String(index);
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (kind === 'vision') {
|
|
131
|
+
if (typeof prepareImage !== 'function') throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
132
|
+
const preparedImage = await wait(Promise.resolve().then(() => {
|
|
133
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
134
|
+
return prepareImage(input.request.image, signal);
|
|
135
|
+
}), signal, 'prepare', 'not_invoked');
|
|
136
|
+
if (signal.aborted) throw abortError(signal, 'prepare', 'not_invoked');
|
|
137
|
+
prepared = await wait(Promise.resolve().then(() => prepareImageRequest?.({ request: input.request,
|
|
138
|
+
image: preparedImage.image, signal, identity: descriptor.identity })), signal, 'prepare', 'not_invoked');
|
|
139
|
+
const prompt = prepared?.prompt ?? input.request.prompt;
|
|
140
|
+
normalizeModelCall(descriptor, { ...input.request, image: preparedImage.image, prompt }, input.options);
|
|
141
|
+
sourceRequest.messages[0].content[0].text = prompt;
|
|
142
|
+
sourceRequest.messages[0].content.push({ type: 'image', attachment: preparedImage.attachment });
|
|
143
|
+
}
|
|
144
|
+
invoked = true;
|
|
145
|
+
const stream = textModel.stream(sourceRequest);
|
|
146
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'invoke', invocation: 'unknown' });
|
|
147
|
+
iterator = stream[Symbol.asyncIterator]();
|
|
148
|
+
while (true) {
|
|
149
|
+
const next = await wait(Promise.resolve().then(() => iterator.next()), signal, 'read', responded ? 'responded' : 'unknown');
|
|
150
|
+
if (signal.aborted) throw abortError(signal, 'read', responded ? 'responded' : 'unknown');
|
|
151
|
+
if (next.done) { ended = true; break; }
|
|
152
|
+
const chunk = next.value;
|
|
153
|
+
if (!chunk || typeof chunk !== 'object' || finished) throw new ModelPortError('IRIS_MODEL_PROTOCOL_INVALID', { stage: 'read', invocation: 'responded' });
|
|
154
|
+
if (chunk.type === 'block-start') {
|
|
155
|
+
const id = blockId(chunk.index);
|
|
156
|
+
if (!['text', 'reasoning'].includes(chunk.blockType)) collector.finish(chunk.blockType === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
157
|
+
if (blocks.has(id)) collector.finish('unknown');
|
|
158
|
+
blocks.set(id, chunk.blockType);
|
|
159
|
+
if (chunk.blockType === 'text') collector.append('', id);
|
|
160
|
+
} else if (chunk.type === 'text-delta') {
|
|
161
|
+
const id = blockId(chunk.index);
|
|
162
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
163
|
+
blocks.set(id, 'text');
|
|
164
|
+
if (typeof chunk.text === 'string' && chunk.text.length) responded = true;
|
|
165
|
+
collector.append(chunk.text, id);
|
|
166
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
167
|
+
const id = blockId(chunk.index);
|
|
168
|
+
if (typeof chunk.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
169
|
+
blocks.set(id, 'reasoning');
|
|
170
|
+
} else if (chunk.type === 'block-end') {
|
|
171
|
+
const id = blockId(chunk.index);
|
|
172
|
+
if (chunk.block?.type === 'text') {
|
|
173
|
+
if (blocks.has(id) && blocks.get(id) !== 'text') collector.finish('unknown');
|
|
174
|
+
blocks.set(id, 'text');
|
|
175
|
+
if (typeof chunk.block.text === 'string' && chunk.block.text.length) responded = true;
|
|
176
|
+
collector.replace(chunk.block.text, id);
|
|
177
|
+
} else if (chunk.block?.type === 'reasoning') {
|
|
178
|
+
if (typeof chunk.block.text !== 'string' || blocks.has(id) && blocks.get(id) !== 'reasoning') collector.finish('unknown');
|
|
179
|
+
blocks.set(id, 'reasoning');
|
|
180
|
+
} else collector.finish(chunk.block?.type === 'tool-call' ? 'tool-calls' : 'unknown');
|
|
181
|
+
} else if (chunk.type === 'tool-call-delta') collector.finish('tool-calls');
|
|
182
|
+
else if (chunk.type === 'usage') usage = usageSnapshot(chunk.usage);
|
|
183
|
+
else if (chunk.type === 'finish') {
|
|
184
|
+
const kind = chunk.reason?.kind;
|
|
185
|
+
if (kind === 'aborted') throw new ModelPortError('IRIS_MODEL_ABORTED', { stage: 'read', invocation: responded ? 'responded' : 'unknown' });
|
|
186
|
+
if (kind === 'error') collector.fail(new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'read', invocation: 'unknown' }));
|
|
187
|
+
collector.finish(kind); finished = true;
|
|
188
|
+
} else if (chunk.type === 'error') collector.fail(chunk.error);
|
|
189
|
+
else if (chunk.type === undefined && typeof chunk.delta === 'string') {
|
|
190
|
+
if (chunk.delta.length) responded = true;
|
|
191
|
+
collector.append(chunk.delta, '0');
|
|
192
|
+
}
|
|
193
|
+
else collector.finish('unknown');
|
|
194
|
+
}
|
|
195
|
+
const collected = collector.complete();
|
|
196
|
+
const text = prepared?.mapText ? prepared.mapText(collected) : collected;
|
|
197
|
+
return normalizeModelCompletion(descriptor, { contractVersion: 0, text, finishReason: 'stop', identity: descriptor.identity,
|
|
198
|
+
...(usage ? { usage } : {}) }, input.options.budget);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (signal.aborted) throw abortError(signal, invoked ? 'read' : 'prepare', responded ? 'responded' : invoked ? 'unknown' : 'not_invoked');
|
|
201
|
+
if (!invoked) {
|
|
202
|
+
controller.abort(error);
|
|
203
|
+
const record = modelErrorRecord(error);
|
|
204
|
+
throw new ModelPortError(record.code, { stage: 'prepare', invocation: 'not_invoked',
|
|
205
|
+
...(record.imageBytes ? { imageBytes: record.imageBytes } : {}),
|
|
206
|
+
...(record.imageMaxBytes ? { imageMaxBytes: record.imageMaxBytes } : {}) });
|
|
207
|
+
}
|
|
208
|
+
// 只有受控模型错误可以离开协议边界;供应商原文不进入 UI/日志。
|
|
209
|
+
try { collector.fail(error); }
|
|
210
|
+
finally { controller.abort(error); }
|
|
211
|
+
} finally {
|
|
212
|
+
try {
|
|
213
|
+
if (!ended && iterator && typeof iterator.return === 'function') {
|
|
214
|
+
const closing = Promise.resolve().then(() => iterator.return());
|
|
215
|
+
await wait(closing, signal, 'read', 'unknown').catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
} finally { clearTimeout(timer); parent?.removeEventListener('abort', onAbort); }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return Object.freeze({ port, offId });
|
|
222
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { ModelPortError } from './model-port-contract.js';
|
|
3
|
+
import { createDshBoundModelPort } from './dsh-text-model-adapter.js';
|
|
4
|
+
import { waitForModelWork } from './model-call-runtime.js';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { prepareVisionImage } from './vision-image-input.js';
|
|
8
|
+
|
|
9
|
+
/** rc.2 只接受附件引用;业务钩子使用核验后真正发给宿主的图片。 */
|
|
10
|
+
export function createDshVisionModelPort(textModel, attachments, binding, { metadata, allowImageNormalization = false,
|
|
11
|
+
imageInput, orientImages = false, prepareImageRequest } = {}) {
|
|
12
|
+
if (!attachments || typeof attachments.saveImage !== 'function' || typeof attachments.readImage !== 'function') {
|
|
13
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
14
|
+
}
|
|
15
|
+
if (!metadata || metadata.provider !== binding?.provider || metadata.id !== binding?.model
|
|
16
|
+
|| !Array.isArray(metadata.inputModalities) || !metadata.inputModalities.includes('image')) {
|
|
17
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
18
|
+
}
|
|
19
|
+
return createDshBoundModelPort(textModel, binding, { metadata, kind: 'vision', prepareImageRequest,
|
|
20
|
+
async prepareImage(image, signal) {
|
|
21
|
+
if (imageInput) image = await prepareVisionImage(image, imageInput, { signal, orient: orientImages });
|
|
22
|
+
const data = new Uint8Array(image.bytes);
|
|
23
|
+
// saveImage 在 rc.2 无取消参数;等待有界,迟到保存可留下 Host 附件,但绝不启动生成。
|
|
24
|
+
const ref = await waitForModelWork(() => attachments.saveImage({ data, mediaType: image.mediaType, name: 'iris-vision' }), signal);
|
|
25
|
+
if (!ref || typeof ref.attachmentId !== 'string' || !ref.attachmentId) {
|
|
26
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
27
|
+
}
|
|
28
|
+
const stored = await waitForModelWork(() => attachments.readImage(ref, signal), signal);
|
|
29
|
+
if (!(stored?.data instanceof Uint8Array) || stored.mediaType !== undefined && stored.mediaType !== ref.mediaType) {
|
|
30
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
31
|
+
}
|
|
32
|
+
const unchanged = ref.mediaType === image.mediaType && Buffer.from(stored.data).equals(Buffer.from(image.bytes));
|
|
33
|
+
if (!unchanged) {
|
|
34
|
+
// 正规宿主附件用内容哈希绑定读回字节;任意旧引用/损坏数据不能冒充压缩副本。
|
|
35
|
+
if (!allowImageNormalization || ref.attachmentId !== 'sha256:' + createHash('sha256').update(stored.data).digest('hex')
|
|
36
|
+
|| ref.bytes !== stored.data.byteLength) throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
37
|
+
const [source, normalized] = await waitForModelWork(() => Promise.all([
|
|
38
|
+
sharp(Buffer.from(image.bytes)).metadata(), sharp(Buffer.from(stored.data)).metadata()
|
|
39
|
+
]), signal);
|
|
40
|
+
const original = ref.originalDimensions || ref;
|
|
41
|
+
const width = source.autoOrient?.width || source.width, height = source.autoOrient?.height || source.height;
|
|
42
|
+
if (original.width !== width || original.height !== height || ref.width !== normalized.width || ref.height !== normalized.height
|
|
43
|
+
|| ref.width > width || ref.height > height
|
|
44
|
+
|| ref.mediaType !== { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' }[normalized.format]) {
|
|
45
|
+
throw new ModelPortError('IRIS_MODEL_INCOMPATIBLE', { stage: 'prepare' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { attachment: ref, image: { bytes: new Uint8Array(stored.data), mediaType: ref.mediaType } };
|
|
49
|
+
}
|
|
50
|
+
}).port;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 只在显式调用时读取默认选择/精确元数据;不把默认文本模型猜成视觉模型。 */
|
|
54
|
+
export async function prepareDshVisionModelPort(textModel, attachments, { signal, ...options } = {}) {
|
|
55
|
+
try { return await waitForModelWork(async () => {
|
|
56
|
+
if (typeof textModel?.currentSelection !== 'function' || typeof textModel?.resolveModelInfo !== 'function') {
|
|
57
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
58
|
+
}
|
|
59
|
+
if (typeof attachments?.saveImage !== 'function' || typeof attachments?.readImage !== 'function') {
|
|
60
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
61
|
+
}
|
|
62
|
+
const selection = textModel.currentSelection();
|
|
63
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
64
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
65
|
+
throw new ModelPortError('IRIS_MODEL_UNAVAILABLE', { stage: 'prepare' });
|
|
66
|
+
}
|
|
67
|
+
const binding = { provider: selection.provider, model: selection.model };
|
|
68
|
+
const metadata = await waitForModelWork(() => textModel.resolveModelInfo(binding.provider, binding.model, signal), signal);
|
|
69
|
+
return createDshVisionModelPort(textModel, attachments, binding, { metadata, ...options });
|
|
70
|
+
}, signal); }
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof ModelPortError) throw error;
|
|
73
|
+
throw new ModelPortError('IRIS_MODEL_REQUEST_FAILED', { stage: 'prepare', invocation: 'not_invoked' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const FIELDS = Object.freeze({
|
|
7
|
+
image: ['prompt', 'size', 'n', 'source_artifact_id'],
|
|
8
|
+
video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
|
|
9
|
+
tts: ['text', 'voice'],
|
|
10
|
+
transcribe: ['audio_url', 'audio_path']
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export class GenerationInputError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'GenerationInputError';
|
|
17
|
+
this.code = 'IRIS_GENERATION_INPUT_INVALID';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fail(message) { throw new GenerationInputError(message); }
|
|
22
|
+
|
|
23
|
+
function text(value, field, maxLength, required = false) {
|
|
24
|
+
if (value === undefined && !required) return undefined;
|
|
25
|
+
const normalized = String(value || '').trim();
|
|
26
|
+
if (!normalized || normalized.length > maxLength) {
|
|
27
|
+
fail(`${field} 必须为 1–${maxLength} 字符`);
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* CLI、DSH 与知情重试共用的纯输入边界。只校验/转换调用方重新提供的参数,
|
|
34
|
+
* 不读取配置或文件,不上传,不从旧 Task 恢复 Prompt/媒体地址。
|
|
35
|
+
* Host 附件解析和本地音频上传仍由入口负责。
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeGenerationInput(capability, input, { allowAudioPath = false, allowVideoPaths = false } = {}) {
|
|
38
|
+
const fields = Object.hasOwn(FIELDS, capability) ? FIELDS[capability] : null;
|
|
39
|
+
if (!fields) fail('不支持的生成能力');
|
|
40
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail('生成输入必须是 JSON 对象');
|
|
41
|
+
for (const field of Object.keys(input)) {
|
|
42
|
+
if (!fields.includes(field) && field !== 'model_ref') {
|
|
43
|
+
fail('生成输入不支持字段:' + field + ';允许 ' + fields.join(', ') + ', model_ref');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const modelRef = String(input.model_ref || '').trim();
|
|
47
|
+
const providerInput = {};
|
|
48
|
+
let audioPath;
|
|
49
|
+
let sourceArtifactId;
|
|
50
|
+
let firstFramePath, videoMode;
|
|
51
|
+
if (capability === 'video') {
|
|
52
|
+
audioPath = String(input.audio_path || '').trim();
|
|
53
|
+
firstFramePath = String(input.first_frame_path || '').trim();
|
|
54
|
+
if ((audioPath || firstFramePath) && !allowVideoPaths) fail('本地视频输入需要支持上传的入口');
|
|
55
|
+
if (firstFramePath && input.img_data_url !== undefined) fail('首帧路径与 img_data_url 不能同时提供');
|
|
56
|
+
for (const file of [firstFramePath, audioPath].filter(Boolean)) {
|
|
57
|
+
if (!path.isAbsolute(file)) fail('首帧和音频路径必须是绝对路径');
|
|
58
|
+
try { if (!fs.statSync(file).isFile()) throw new Error(); }
|
|
59
|
+
catch (_) { fail('首帧或音频文件不存在或不是普通文件'); }
|
|
60
|
+
}
|
|
61
|
+
if (audioPath && !firstFramePath) fail('s2v 需要 first_frame_path 和 audio_path');
|
|
62
|
+
videoMode = audioPath ? 's2v' : firstFramePath || input.img_data_url ? 'i2v' : 't2v';
|
|
63
|
+
if (videoMode === 's2v') {
|
|
64
|
+
if (input.size !== undefined || input.duration !== undefined || input.img_data_url !== undefined) fail('s2v 不支持 size、duration 或 img_data_url');
|
|
65
|
+
const resolution = input.resolution ?? '480P';
|
|
66
|
+
if (!['480P', '720P'].includes(resolution)) fail('s2v resolution 只支持 480P 或 720P');
|
|
67
|
+
providerInput.resolution = resolution;
|
|
68
|
+
} else if (input.resolution !== undefined) fail('resolution 仅适用于 s2v');
|
|
69
|
+
}
|
|
70
|
+
if (capability === 'image' || capability === 'video') {
|
|
71
|
+
const prompt = text(input.prompt, 'prompt', 20000, videoMode !== 's2v');
|
|
72
|
+
if (prompt !== undefined) providerInput.prompt = prompt;
|
|
73
|
+
const size = text(input.size, 'size', 64);
|
|
74
|
+
if (size !== undefined) providerInput.size = size;
|
|
75
|
+
}
|
|
76
|
+
if (capability === 'image') {
|
|
77
|
+
if (input.source_artifact_id !== undefined) {
|
|
78
|
+
if (typeof input.source_artifact_id !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(input.source_artifact_id)) {
|
|
79
|
+
fail('source_artifact_id 必须是有效的 Core Artifact ID');
|
|
80
|
+
}
|
|
81
|
+
sourceArtifactId = input.source_artifact_id;
|
|
82
|
+
}
|
|
83
|
+
const n = input.n === undefined ? 1 : Number(input.n);
|
|
84
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
85
|
+
providerInput.n = n;
|
|
86
|
+
}
|
|
87
|
+
if (capability === 'video') {
|
|
88
|
+
if (input.duration !== undefined) {
|
|
89
|
+
const duration = Number(input.duration);
|
|
90
|
+
if (!Number.isFinite(duration) || duration < 1 || duration > 60) fail('duration 必须为 1–60 的数字');
|
|
91
|
+
providerInput.duration = duration;
|
|
92
|
+
}
|
|
93
|
+
if (input.img_data_url !== undefined) {
|
|
94
|
+
const imgDataUrl = String(input.img_data_url).trim();
|
|
95
|
+
if (!imgDataUrl.startsWith('data:image/')) fail('img_data_url 必须是 data:image/ 开头的 data URL');
|
|
96
|
+
providerInput.imgDataUrl = imgDataUrl;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (capability === 'tts') {
|
|
100
|
+
providerInput.text = text(input.text, 'text', 20000, true);
|
|
101
|
+
const voice = text(input.voice, 'voice', 64);
|
|
102
|
+
if (voice !== undefined) providerInput.voice = voice;
|
|
103
|
+
}
|
|
104
|
+
if (capability === 'transcribe') {
|
|
105
|
+
const audioUrl = String(input.audio_url || '').trim();
|
|
106
|
+
audioPath = String(input.audio_path || '').trim();
|
|
107
|
+
if (Boolean(audioUrl) === Boolean(audioPath)) {
|
|
108
|
+
fail('转写必须且只能提供 audio_url(公网/oss://)或 audio_path(本地绝对路径)之一');
|
|
109
|
+
}
|
|
110
|
+
if (audioUrl) {
|
|
111
|
+
if (audioUrl.length > 20000) fail('audio_url 最多为 20000 字符');
|
|
112
|
+
if (!/^(https:\/\/|oss:\/\/)/.test(audioUrl)) fail('audio_url 必须是 https:// 或 oss:// 地址');
|
|
113
|
+
providerInput.audioUrl = audioUrl;
|
|
114
|
+
} else {
|
|
115
|
+
if (!allowAudioPath) fail('retry 必须重新提供 audio_url;本地音频上传由调用入口负责');
|
|
116
|
+
if (!path.isAbsolute(audioPath)) fail('audio_path 必须是绝对路径');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
modelRef,
|
|
121
|
+
providerInput: Object.freeze(providerInput),
|
|
122
|
+
...(sourceArtifactId ? { sourceArtifactId } : {}),
|
|
123
|
+
...(audioPath ? { audioPath } : {}),
|
|
124
|
+
...(capability === 'video' ? { firstFramePath, videoMode } : {})
|
|
125
|
+
});
|
|
126
|
+
}
|
package/lib/guard.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
|
|
6
6
|
* LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
|
|
7
|
-
* Host
|
|
7
|
+
* Host 边界,不是身份认证。浏览器请求另校验 Sec-Fetch-Site 与 Origin==Host。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
|
|
@@ -44,12 +44,12 @@ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_
|
|
|
44
44
|
*/
|
|
45
45
|
export function checkRequest(req, options = {}) {
|
|
46
46
|
const headers = req.headers || {};
|
|
47
|
-
const method = String(req.method || 'GET').toUpperCase();
|
|
48
47
|
if (!isTrustedHost(headers.host, options.trustedHosts)) {
|
|
49
48
|
return { ok: false, status: 403, error: 'untrusted host' };
|
|
50
49
|
}
|
|
51
|
-
if (method !== 'POST') return { ok: true };
|
|
52
50
|
|
|
51
|
+
// 浏览器的读请求同样可能泄露媒体与状态;明确的跨站/跨源请求一律拒绝。
|
|
52
|
+
// curl/CLI 等不发送 Origin/Sec-Fetch-Site 的非浏览器客户端仍可在受信 Host 上使用。
|
|
53
53
|
const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
|
|
54
54
|
if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
|
|
55
55
|
|