@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 已支持协议到 Provider Adapter v0 的映射。
|
|
5
|
+
*
|
|
6
|
+
* 凭据和 baseUrl 只被操作闭包捕获,不进入 Adapter 描述或能力快照。
|
|
7
|
+
* transport 默认为现有低层 HTTP 实现;测试可注入零网络 fixture。
|
|
8
|
+
*/
|
|
9
|
+
import * as defaultTransport from './adapters.js';
|
|
10
|
+
import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
|
|
11
|
+
import { writePrivateFile } from './private-storage.js';
|
|
12
|
+
import { defineProviderAdapter } from './provider-adapter.js';
|
|
13
|
+
import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError } from './provider-protocol.js';
|
|
14
|
+
|
|
15
|
+
const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
|
|
16
|
+
|
|
17
|
+
function providerIdentity(provider) {
|
|
18
|
+
const id = String(provider?.id || '').trim();
|
|
19
|
+
const key = providerApiKey(provider);
|
|
20
|
+
const baseUrl = providerMediaBaseUrl(provider);
|
|
21
|
+
if (!id) throw new TypeError('Provider 配置缺少 id');
|
|
22
|
+
if (!baseUrl) throw new TypeError('Provider 配置缺少 baseUrl');
|
|
23
|
+
return { id, key, baseUrl };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mappedCategory(error, status) {
|
|
27
|
+
const raw = String(error?.category || '').toLowerCase();
|
|
28
|
+
if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'aborted';
|
|
29
|
+
if (error?.name === 'TimeoutError' || /timeout|timed out|超时/i.test(String(error?.message || ''))) return 'timeout';
|
|
30
|
+
if (status === 401 || status === 403 || raw === 'auth') return 'authentication';
|
|
31
|
+
if (status === 429 && raw === 'quota') return 'quota';
|
|
32
|
+
if (status === 429 || raw === 'rate_limit') return 'rate_limit';
|
|
33
|
+
if (raw === 'invalid_parameter') return 'invalid_request';
|
|
34
|
+
if (raw === 'network' || error instanceof TypeError) return 'network';
|
|
35
|
+
if (raw === 'server' || status >= 500) return 'provider';
|
|
36
|
+
if (!status && /未返回|响应|协议|结构|json/i.test(String(error?.message || ''))) return 'protocol';
|
|
37
|
+
return 'provider';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 当前两个协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
|
|
41
|
+
export function mapProviderAdapterError(error, context = {}) {
|
|
42
|
+
const statusValue = Number(error?.httpStatus ?? error?.status);
|
|
43
|
+
const httpStatus = Number.isInteger(statusValue) && statusValue >= 100 && statusValue <= 599
|
|
44
|
+
? statusValue
|
|
45
|
+
: undefined;
|
|
46
|
+
const stage = String(context.stage || error?.stage || 'submit');
|
|
47
|
+
const category = mappedCategory(error, httpStatus);
|
|
48
|
+
let acceptance = context.acceptance || error?.acceptance;
|
|
49
|
+
if (stage === 'validate' || stage === 'prepare' || stage === 'upload') {
|
|
50
|
+
acceptance = 'not_accepted';
|
|
51
|
+
} else if (stage === 'submit') {
|
|
52
|
+
acceptance = httpStatus && httpStatus >= 400 && httpStatus < 500 && !AMBIGUOUS_4XX.has(httpStatus)
|
|
53
|
+
? 'not_accepted'
|
|
54
|
+
: (error?.acceptance === 'not_accepted' ? 'not_accepted' : 'unknown');
|
|
55
|
+
} else if (!acceptance) {
|
|
56
|
+
acceptance = 'accepted';
|
|
57
|
+
}
|
|
58
|
+
const retryable = Boolean(
|
|
59
|
+
context.retryable ?? error?.retryable
|
|
60
|
+
?? (httpStatus === 429 || (httpStatus && httpStatus >= 500)
|
|
61
|
+
|| category === 'network' || category === 'timeout')
|
|
62
|
+
);
|
|
63
|
+
return providerErrorRecord(error, {
|
|
64
|
+
stage,
|
|
65
|
+
category,
|
|
66
|
+
acceptance,
|
|
67
|
+
retryable,
|
|
68
|
+
...(httpStatus ? { httpStatus } : {})
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unsupportedCapability(capability, protocol) {
|
|
73
|
+
return new ProviderContractError(
|
|
74
|
+
'协议 ' + protocol + ' 不支持任务能力 ' + String(capability || ''),
|
|
75
|
+
{ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted' }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pollFailureKind(status) {
|
|
80
|
+
const value = String(status || '').toUpperCase();
|
|
81
|
+
if (value === 'CANCELED' || value === 'CANCELLED') return 'canceled';
|
|
82
|
+
if (value === 'UNKNOWN') return 'unknown';
|
|
83
|
+
return 'failed';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function remoteImageArtifacts(urls) {
|
|
87
|
+
return (urls || []).map((url) => ({ kind: 'remote-url', url, mediaType: 'image/png' }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function openAiImageArtifacts(items) {
|
|
91
|
+
return (items || []).map((item) => {
|
|
92
|
+
if (item && item.b64) {
|
|
93
|
+
return { kind: 'inline-base64', data: item.b64, mediaType: 'image/png' };
|
|
94
|
+
}
|
|
95
|
+
if (item && item.url) {
|
|
96
|
+
return { kind: 'remote-url', url: item.url, mediaType: 'image/png' };
|
|
97
|
+
}
|
|
98
|
+
throw new ProviderContractError('同步图片结果缺少 b64 或 URL', {
|
|
99
|
+
stage: 'response', category: 'protocol', acceptance: 'accepted'
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeInlineArtifact(artifact, targetPath) {
|
|
105
|
+
const data = String(artifact?.data || '').trim();
|
|
106
|
+
const body = Buffer.from(data, 'base64');
|
|
107
|
+
if (!data || body.length === 0 || body.toString('base64') !== data) {
|
|
108
|
+
throw new ProviderContractError('inline-base64 图片数据无效', {
|
|
109
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
writePrivateFile(targetPath, body, { flag: 'wx' });
|
|
113
|
+
return { bytes: body.length };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function operationsForDashScope(provider, transport) {
|
|
117
|
+
const { key, baseUrl } = providerIdentity(provider);
|
|
118
|
+
return {
|
|
119
|
+
async discover({ signal, timeoutMs } = {}) {
|
|
120
|
+
return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async submit(request = {}) {
|
|
124
|
+
const input = request.input || {};
|
|
125
|
+
const common = {
|
|
126
|
+
key, baseUrl, model: request.model, signal: request.signal,
|
|
127
|
+
...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
|
|
128
|
+
};
|
|
129
|
+
if (request.capability === 'image') {
|
|
130
|
+
if (transport.dashscopeImageMode(request.model) === 'legacy-async') {
|
|
131
|
+
const remoteTaskId = await transport.submitImage({
|
|
132
|
+
...common, prompt: input.prompt, size: input.size, n: input.n
|
|
133
|
+
});
|
|
134
|
+
return { kind: 'accepted', remoteTaskId };
|
|
135
|
+
}
|
|
136
|
+
const urls = await transport.generateImageMultimodal({
|
|
137
|
+
...common, prompt: input.prompt, size: input.size, n: input.n
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
kind: 'completed', value: { kind: 'urls', items: urls },
|
|
141
|
+
artifacts: remoteImageArtifacts(urls)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (request.capability === 'video') {
|
|
145
|
+
const remoteTaskId = await transport.submitVideo({
|
|
146
|
+
...common,
|
|
147
|
+
prompt: input.prompt,
|
|
148
|
+
imgDataUrl: input.imgDataUrl,
|
|
149
|
+
size: input.size,
|
|
150
|
+
duration: input.duration,
|
|
151
|
+
audioUrl: input.audioUrl,
|
|
152
|
+
resolution: input.resolution
|
|
153
|
+
});
|
|
154
|
+
return { kind: 'accepted', remoteTaskId };
|
|
155
|
+
}
|
|
156
|
+
if (request.capability === 'transcribe') {
|
|
157
|
+
const remoteTaskId = await transport.submitTranscription({
|
|
158
|
+
...common, audioUrl: input.audioUrl
|
|
159
|
+
});
|
|
160
|
+
return { kind: 'accepted', remoteTaskId };
|
|
161
|
+
}
|
|
162
|
+
if (request.capability === 'tts') {
|
|
163
|
+
const value = await transport.synthesizeTts({
|
|
164
|
+
...common, text: input.text, voice: input.voice
|
|
165
|
+
});
|
|
166
|
+
// E2:同步合成产物物化为 Core Artifact——URL 走下载,inline base64 直接落盘。
|
|
167
|
+
const artifacts = value.audioUrl
|
|
168
|
+
? [{ kind: 'remote-url', url: value.audioUrl, mediaType: 'audio/mpeg' }]
|
|
169
|
+
: [{ kind: 'inline-base64', data: value.audioB64, mediaType: 'audio/mpeg' }];
|
|
170
|
+
return { kind: 'completed', value, artifacts };
|
|
171
|
+
}
|
|
172
|
+
throw unsupportedCapability(request.capability, 'dashscope');
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async poll(request = {}) {
|
|
176
|
+
const common = {
|
|
177
|
+
key, baseUrl, remoteTaskId: request.remoteTaskId, signal: request.signal,
|
|
178
|
+
...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
|
|
179
|
+
};
|
|
180
|
+
if (request.capability === 'transcribe') {
|
|
181
|
+
const result = await transport.pollTranscriptionTask(common);
|
|
182
|
+
if (!result.done) return { kind: 'pending', progress: result.status || 'running' };
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
return {
|
|
185
|
+
kind: pollFailureKind(result.status),
|
|
186
|
+
error: mapProviderAdapterError(result.message || '转写任务失败', {
|
|
187
|
+
stage: 'poll', acceptance: 'accepted'
|
|
188
|
+
})
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// E3:转写正文物化为 text/plain Artifact(inline-base64,不再依赖签名 URL 生命周期)。
|
|
192
|
+
const text = String(result.text || '');
|
|
193
|
+
return {
|
|
194
|
+
kind: 'succeeded',
|
|
195
|
+
value: { kind: 'text', text },
|
|
196
|
+
artifacts: [{ kind: 'inline-base64', data: Buffer.from(text, 'utf8').toString('base64'), mediaType: 'text/plain' }]
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (request.capability === 'image' || request.capability === 'video') {
|
|
200
|
+
const result = await transport.pollTask(common);
|
|
201
|
+
if (!result.done) return { kind: 'pending', progress: result.status || 'running' };
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
return {
|
|
204
|
+
kind: pollFailureKind(result.status),
|
|
205
|
+
error: mapProviderAdapterError(result.message || '生成任务失败', {
|
|
206
|
+
stage: 'poll', acceptance: 'accepted'
|
|
207
|
+
})
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
kind: 'succeeded',
|
|
212
|
+
artifacts: (result.urls || []).map((url) => ({ kind: 'remote-url', url }))
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
throw unsupportedCapability(request.capability, 'dashscope');
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async download(request = {}) {
|
|
219
|
+
const artifact = request.artifact || {};
|
|
220
|
+
const targetPath = String(request.targetPath || '').trim();
|
|
221
|
+
if (!targetPath) {
|
|
222
|
+
throw new ProviderContractError('download 需要 targetPath', {
|
|
223
|
+
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (artifact.kind === 'inline-base64') {
|
|
227
|
+
return writeInlineArtifact(artifact, targetPath);
|
|
228
|
+
}
|
|
229
|
+
if (artifact.kind !== 'remote-url' || !artifact.url) {
|
|
230
|
+
throw new ProviderContractError('download 需要 remote-url artifact', {
|
|
231
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
bytes: await transport.downloadTo(artifact.url, targetPath, {
|
|
236
|
+
signal: request.signal,
|
|
237
|
+
...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
|
|
238
|
+
})
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
mapError: mapProviderAdapterError
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function createDashScopeProviderAdapter(provider, { transport = defaultTransport } = {}) {
|
|
247
|
+
const { key, baseUrl } = providerIdentity(provider);
|
|
248
|
+
return defineProviderAdapter({
|
|
249
|
+
id: providerIdentity(provider).id,
|
|
250
|
+
protocol: 'dashscope',
|
|
251
|
+
capabilities: ['image', 'video', 'tts', 'transcribe'],
|
|
252
|
+
operations: operationsForDashScope(provider, transport),
|
|
253
|
+
async prepareInput({ model, filePath, signal, timeoutMs } = {}) {
|
|
254
|
+
return { url: await transport.uploadTempFile({ key, baseUrl, model, filePath, signal, timeoutMs }) };
|
|
255
|
+
},
|
|
256
|
+
unsupported: {
|
|
257
|
+
cancel: '当前 DashScope 媒体协议未提供经过验证的远端取消实现'
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function operationsForOpenAiImages(provider, transport) {
|
|
263
|
+
const { key, baseUrl } = providerIdentity(provider);
|
|
264
|
+
return {
|
|
265
|
+
async discover({ signal, timeoutMs } = {}) {
|
|
266
|
+
return { models: await transport.listModels({ key, baseUrl, signal, timeoutMs }) };
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async submit(request = {}) {
|
|
270
|
+
if (request.capability !== 'image') {
|
|
271
|
+
throw unsupportedCapability(request.capability, 'openai-images');
|
|
272
|
+
}
|
|
273
|
+
const input = request.input || {};
|
|
274
|
+
const items = await transport.openAiGenerateImage({
|
|
275
|
+
key,
|
|
276
|
+
baseUrl,
|
|
277
|
+
model: request.model,
|
|
278
|
+
prompt: input.prompt,
|
|
279
|
+
size: input.size,
|
|
280
|
+
n: input.n,
|
|
281
|
+
signal: request.signal,
|
|
282
|
+
...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
|
|
283
|
+
});
|
|
284
|
+
return {
|
|
285
|
+
kind: 'completed', value: { kind: 'openai', items },
|
|
286
|
+
artifacts: openAiImageArtifacts(items)
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async download(request = {}) {
|
|
291
|
+
const artifact = request.artifact || {};
|
|
292
|
+
const targetPath = String(request.targetPath || '').trim();
|
|
293
|
+
if (!targetPath) {
|
|
294
|
+
throw new ProviderContractError('download 需要 targetPath', {
|
|
295
|
+
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (artifact.kind === 'inline-base64') return writeInlineArtifact(artifact, targetPath);
|
|
299
|
+
if (artifact.kind !== 'remote-url' || !artifact.url) {
|
|
300
|
+
throw new ProviderContractError('download 需要可物化的图片 artifact', {
|
|
301
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
bytes: await transport.downloadTo(artifact.url, targetPath, {
|
|
306
|
+
signal: request.signal,
|
|
307
|
+
...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
|
|
308
|
+
})
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
mapError: mapProviderAdapterError
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function createOpenAiImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
|
|
317
|
+
return defineProviderAdapter({
|
|
318
|
+
id: providerIdentity(provider).id,
|
|
319
|
+
protocol: 'openai-images',
|
|
320
|
+
capabilities: ['image'],
|
|
321
|
+
operations: operationsForOpenAiImages(provider, transport),
|
|
322
|
+
unsupportedPreparation: 'OpenAI Images 兼容路径未实现临时输入上传',
|
|
323
|
+
unsupported: {
|
|
324
|
+
poll: 'OpenAI Images 兼容路径在当前实现中同步返回结果',
|
|
325
|
+
cancel: '同步图片响应没有可验证的远端取消任务'
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const PROTOCOL_FACTORIES = new Map([
|
|
331
|
+
['dashscope', createDashScopeProviderAdapter],
|
|
332
|
+
['openai-images', createOpenAiImagesProviderAdapter]
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
export function createConfiguredProviderAdapter(provider, options = {}) {
|
|
336
|
+
const selection = options.protocol
|
|
337
|
+
? selectMediaProtocol({ ...provider, mediaProtocol: options.protocol, protocolInferred: false })
|
|
338
|
+
: selectMediaProtocol(provider);
|
|
339
|
+
const factory = PROTOCOL_FACTORIES.get(selection.mediaProtocol);
|
|
340
|
+
if (!factory) throw unsupportedProtocolError(selection.mediaProtocol);
|
|
341
|
+
return factory(provider, options);
|
|
342
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { CAPABILITIES } from './capability.js';
|
|
7
|
+
import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
|
|
8
|
+
import { dashscopeApiBase, selectMediaProtocol, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
|
|
9
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
10
|
+
|
|
11
|
+
export class ProviderCatalogError extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'ProviderCatalogError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(code, message) {
|
|
20
|
+
throw new ProviderCatalogError(code, message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Headless 边界只读取显式配置文件;不推断 DSH_HOME、HOME 或 cwd。 */
|
|
24
|
+
export function loadProviderCatalog(configPath) {
|
|
25
|
+
const supplied = String(configPath || '').trim();
|
|
26
|
+
if (!supplied || !path.isAbsolute(supplied)) {
|
|
27
|
+
fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 必须是显式绝对路径');
|
|
28
|
+
}
|
|
29
|
+
let source;
|
|
30
|
+
try {
|
|
31
|
+
const stat = fs.lstatSync(supplied);
|
|
32
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
|
|
33
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
|
34
|
+
fail('IRIS_PROVIDER_CONFIG_PERMISSIONS', 'provider-config 权限过宽;请限制为仅当前用户可读写');
|
|
35
|
+
}
|
|
36
|
+
source = fs.readFileSync(supplied, 'utf8');
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error instanceof ProviderCatalogError) throw error;
|
|
39
|
+
fail('IRIS_PROVIDER_CONFIG_UNREADABLE', 'provider-config 不存在、不是普通文件或无法读取');
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try { parsed = JSON.parse(source); }
|
|
43
|
+
catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 不是有效 JSON'); }
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
|
|
45
|
+
|| !Array.isArray(parsed.providers)
|
|
46
|
+
|| (parsed.assignments !== undefined && (!parsed.assignments || typeof parsed.assignments !== 'object'
|
|
47
|
+
|| Array.isArray(parsed.assignments)))) {
|
|
48
|
+
fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CANDIDATE_CAP_LABEL = Object.freeze({
|
|
54
|
+
[CAPABILITIES.IMAGE]: '图片', [CAPABILITIES.VIDEO]: '视频',
|
|
55
|
+
[CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则(image/video/tts/transcribe 同构)。 */
|
|
59
|
+
function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
60
|
+
const label = CANDIDATE_CAP_LABEL[capability];
|
|
61
|
+
if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
|
|
62
|
+
const providers = catalog.providers.filter(isConfiguredProvider);
|
|
63
|
+
const pool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
|
|
64
|
+
const requested = String(requestedModelRef || '').trim();
|
|
65
|
+
if (requested) {
|
|
66
|
+
const parsed = parseModelRef(requested);
|
|
67
|
+
if (!parsed && requested.includes('::')) fail('IRIS_PROVIDER_MODEL_INVALID', 'model_ref 格式无效');
|
|
68
|
+
const exact = resolvePoolModel(pool, requested, capability);
|
|
69
|
+
if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型不存在、未启用或没有${label}能力`);
|
|
70
|
+
const provider = providers.find((item) => item.id === exact.providerId);
|
|
71
|
+
return Object.freeze([Object.freeze({
|
|
72
|
+
provider, model: exact.id, modelRef: resolveModelRef(provider, capability, requested),
|
|
73
|
+
selectionReason: 'explicit'
|
|
74
|
+
})]);
|
|
75
|
+
}
|
|
76
|
+
const assignment = catalog.assignments?.[capability];
|
|
77
|
+
const ordered = orderedModels(pool, capability, assignment);
|
|
78
|
+
if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `provider-config 中没有可用${label}模型`);
|
|
79
|
+
return Object.freeze(ordered.map((item) => Object.freeze({
|
|
80
|
+
provider: providers.find((provider) => provider.id === item.providerId),
|
|
81
|
+
model: item.id,
|
|
82
|
+
modelRef: item.ref,
|
|
83
|
+
selectionReason: selectionReasonForModel(pool, capability, assignment, item)
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
88
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
92
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
96
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
100
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
|
|
104
|
+
export function providerCatalogSnapshot(catalog) {
|
|
105
|
+
const safeLabel = (value) => value
|
|
106
|
+
? redactProviderMessage(String(value).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
|
|
107
|
+
return {
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
providers: catalog.providers.filter((provider) => provider && typeof provider === 'object').map((provider) => {
|
|
110
|
+
const models = providerModels(provider);
|
|
111
|
+
const selection = selectMediaProtocol(provider);
|
|
112
|
+
return {
|
|
113
|
+
id: safeLabel(provider.id), name: safeLabel(provider.name || provider.id),
|
|
114
|
+
enabled: provider.enabled !== false, configured: isConfiguredProvider(provider),
|
|
115
|
+
auth: provider.auth === 'none' ? 'none' : 'bearer',
|
|
116
|
+
mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
|
|
117
|
+
capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
|
|
118
|
+
modelCount: models.length
|
|
119
|
+
};
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function catalogCapabilitySnapshot(catalog) {
|
|
125
|
+
const pool = modelPool(catalog.providers.filter(isConfiguredProvider));
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
source: 'configuration',
|
|
129
|
+
capabilities: Object.values(CAPABILITIES).map((capability) => {
|
|
130
|
+
const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
|
|
131
|
+
return { capability, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
|
|
132
|
+
})
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 非敏感摘要把 Task 绑定到提交时实际使用的媒体端点与协议。 */
|
|
137
|
+
export function providerTaskBinding(provider) {
|
|
138
|
+
const baseUrl = providerMediaBaseUrl(provider);
|
|
139
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
140
|
+
let endpoint;
|
|
141
|
+
try {
|
|
142
|
+
const url = new URL(baseUrl);
|
|
143
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
|
|
144
|
+
if (!['dashscope', 'openai-images'].includes(protocol)) throw new Error('unknown protocol');
|
|
145
|
+
endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
|
|
146
|
+
} catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
|
|
147
|
+
return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 恢复只使用 Task 已保存的 Provider/模型;忽略当前 assignment 与 failover 顺序。 */
|
|
151
|
+
export function providerForTaskFromCatalog(catalog, task) {
|
|
152
|
+
const capability = CAPABILITIES[
|
|
153
|
+
task?.capability === 'video' ? 'VIDEO'
|
|
154
|
+
: task?.capability === 'image' ? 'IMAGE'
|
|
155
|
+
: task?.capability === 'tts' ? 'TTS'
|
|
156
|
+
: task?.capability === 'transcribe' ? 'TRANSCRIBE' : ''
|
|
157
|
+
];
|
|
158
|
+
if (!capability) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 恢复解析只开放图片、视频、语音与转写 Task');
|
|
159
|
+
const label = { video: '视频', tts: '语音', transcribe: '转写' }[task.capability] || '图片';
|
|
160
|
+
const parsed = parseModelRef(task.modelRef);
|
|
161
|
+
if (!parsed || parsed.providerId !== task.providerId) fail('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task 的 Provider 与模型身份不一致');
|
|
162
|
+
const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
|
|
163
|
+
if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
|
|
164
|
+
const provider = matches[0];
|
|
165
|
+
if (!provider || provider.enabled === false || (provider.auth !== 'none' && (typeof provider.apiKey !== 'string' || !provider.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
|
|
166
|
+
if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
|
|
167
|
+
if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
|
|
168
|
+
const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
|
|
169
|
+
if (!model) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `Task 原模型已移除或不再具备${label}能力`);
|
|
170
|
+
return provider;
|
|
171
|
+
}
|
package/lib/provider-contract.js
CHANGED
|
@@ -42,10 +42,32 @@ export const PROVIDER_ERROR_CATEGORIES = Object.freeze([
|
|
|
42
42
|
]);
|
|
43
43
|
|
|
44
44
|
const ACCEPTANCE = new Set(['not_accepted', 'accepted', 'unknown']);
|
|
45
|
-
|
|
45
|
+
// 凭据查询参数:允许厂商前缀(x-amz- / x-oss- / OSSAccessKeyId 这类驼峰),但**限制尾部后缀**
|
|
46
|
+
// (审计 C-9 要求覆盖带前缀参数名)。
|
|
47
|
+
//
|
|
48
|
+
// 正则分两级,是为了同时满足两个方向:
|
|
49
|
+
// ① 强凭据词(key/credential/signature/secret):允许任意字母前缀 + 有限后缀(id|secret|ids),
|
|
50
|
+
// 保证 OSSAccessKeyId / X-Amz-Credential / gemini-signature 一律脱敏;
|
|
51
|
+
// ② 弱凭据词(token/auth/authorization):只允许【白名单厂商前缀】,且后缀限 id|secret|ids。
|
|
52
|
+
// 这是 C-9 首版修复的更正——首版给两级都开了无限 `[a-z0-9_-]*` 后缀,导致
|
|
53
|
+
// page_token / tokenizer / signature_mode 这类非凭据参数被误脱敏,诊断信息丢失。
|
|
54
|
+
const SECRET_STRONG = '(?:access[_-]?key|api[_-]?key|apikey|credential|signature|secret)';
|
|
55
|
+
const SECRET_WEAK = '(?:token|auth|authorization)';
|
|
56
|
+
const SECRET_SUFFIX = '(?:id|secret|ids)?';
|
|
57
|
+
const SECRET_VENDOR = '(?:(?:x|amz|oss|aliyun|aws|s3|gcp|azure|sts|sec|security|sig|sign|auth|oauth|bearer|jwt|session|csrf|access|api|private|secret|refresh|id|tmp|temp)[-_]?)*';
|
|
58
|
+
const SECRET_NAME = '(?:' + '(?:[a-z0-9]+[-_])*[a-z0-9]*' + SECRET_STRONG + SECRET_SUFFIX
|
|
59
|
+
+ '|' + SECRET_VENDOR + SECRET_WEAK + SECRET_SUFFIX + ')';
|
|
60
|
+
const SECRET_QUERY = new RegExp('([?&]' + SECRET_NAME + '=)[^&#\\s]+', 'gi');
|
|
46
61
|
const BEARER = /\bBearer\s+[^\s,;]+/gi;
|
|
47
62
|
const API_KEY = /\bsk-[A-Za-z0-9_-]{8,}\b/g;
|
|
48
|
-
|
|
63
|
+
// 结构化凭据字段:与 SECRET_QUERY 同构,同样限制弱凭据词的后缀。
|
|
64
|
+
// 左边界 `NAME_START` 不可省:否则 `page_token=x` 里的子串 `token=x` 也会被匹配,
|
|
65
|
+
// 非凭据参数将被误脱敏(本次收紧时发现,旧实现同样存在该缺陷)。
|
|
66
|
+
const NAME_START = '(?<![A-Za-z0-9_-])';
|
|
67
|
+
const JSON_SECRET = new RegExp('(\"?\'?' + NAME_START
|
|
68
|
+
+ '(?:' + '(?:[A-Za-z0-9]+[-_])*' + SECRET_STRONG + SECRET_SUFFIX
|
|
69
|
+
+ '|' + SECRET_VENDOR.replace(/\[-_\]\?/g, '[-_]?') + SECRET_WEAK + SECRET_SUFFIX + ')'
|
|
70
|
+
+ '[\"\']?\\s*[:=]\\s*[\"\']?)[^\"\',\\s}]+', 'gi');
|
|
49
71
|
const POSIX_PATH = /(^|[\s("'=])\/(?:[^/\s"'`,;)}\]]+\/)*[^/\s"'`,;)}\]]+/gm;
|
|
50
72
|
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[^\\\s"'`,;)}\]]+\\)*[^\\\s"'`,;)}\]]+/g;
|
|
51
73
|
|
|
@@ -103,6 +125,43 @@ export class ProviderContractError extends Error {
|
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Provider 交付描述只允许远端 URL 或短生命周期的内联 base64。内联正文故意设为
|
|
130
|
+
* non-enumerable:Runner 可以物化它,但日志、JSON 和 Task 持久化不会顺手复制图片。
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeProviderArtifacts(value) {
|
|
133
|
+
if (value === undefined) return Object.freeze([]);
|
|
134
|
+
if (!Array.isArray(value)) throw new TypeError('Provider artifacts 必须是数组');
|
|
135
|
+
return Object.freeze(value.map((item) => {
|
|
136
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
137
|
+
throw new TypeError('Provider artifact 必须是对象');
|
|
138
|
+
}
|
|
139
|
+
const mediaType = typeof item.mediaType === 'string' && item.mediaType.trim()
|
|
140
|
+
? item.mediaType.trim()
|
|
141
|
+
: undefined;
|
|
142
|
+
if (item.kind === 'remote-url') {
|
|
143
|
+
const url = String(item.url || '').trim();
|
|
144
|
+
if (!url) throw new TypeError('Provider remote-url artifact 缺少 url');
|
|
145
|
+
return Object.freeze({ kind: 'remote-url', url, ...(mediaType ? { mediaType } : {}) });
|
|
146
|
+
}
|
|
147
|
+
if (item.kind === 'inline-base64') {
|
|
148
|
+
const data = String(item.data || '').trim();
|
|
149
|
+
if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
|
|
150
|
+
throw new TypeError('Provider inline-base64 artifact 数据无效');
|
|
151
|
+
}
|
|
152
|
+
const padding = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
|
|
153
|
+
const artifact = {
|
|
154
|
+
kind: 'inline-base64',
|
|
155
|
+
byteLength: (data.length / 4) * 3 - padding,
|
|
156
|
+
...(mediaType ? { mediaType } : {})
|
|
157
|
+
};
|
|
158
|
+
Object.defineProperty(artifact, 'data', { value: data, enumerable: false });
|
|
159
|
+
return Object.freeze(artifact);
|
|
160
|
+
}
|
|
161
|
+
throw new TypeError('Provider artifact kind 仅支持 remote-url/inline-base64');
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
106
165
|
export function normalizeSubmissionResult(value) {
|
|
107
166
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
108
167
|
throw new TypeError('Provider submit 必须返回结构化结果');
|
|
@@ -111,7 +170,17 @@ export function normalizeSubmissionResult(value) {
|
|
|
111
170
|
throw new TypeError('未知 Provider submit kind: ' + String(value.kind));
|
|
112
171
|
}
|
|
113
172
|
if (value.kind === 'completed') {
|
|
114
|
-
|
|
173
|
+
const result = {
|
|
174
|
+
kind: 'completed', acceptance: 'accepted',
|
|
175
|
+
...(value.artifacts !== undefined ? { artifacts: normalizeProviderArtifacts(value.artifacts) } : {})
|
|
176
|
+
};
|
|
177
|
+
if (value.value !== undefined) {
|
|
178
|
+
Object.defineProperty(result, 'value', {
|
|
179
|
+
value: value.value,
|
|
180
|
+
enumerable: value.artifacts === undefined
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
115
184
|
}
|
|
116
185
|
if (value.kind === 'accepted') {
|
|
117
186
|
const remoteTaskId = String(value.remoteTaskId || '').trim();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
export const PROVIDER_HEALTH_SCHEMA_VERSION = 1;
|
|
4
|
+
export const PROVIDER_HEALTH_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export const PROVIDER_HEALTH_STATES = Object.freeze([
|
|
6
|
+
'unconfigured',
|
|
7
|
+
'configured',
|
|
8
|
+
'verified',
|
|
9
|
+
'failed'
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
|
|
13
|
+
|
|
14
|
+
function validTime(value) {
|
|
15
|
+
const ms = Date.parse(String(value || ''));
|
|
16
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isDefinitiveHealthFailure(value) {
|
|
20
|
+
const status = Number(value && (value.httpStatus ?? value.status));
|
|
21
|
+
const category = String(value && value.category || '').toLowerCase();
|
|
22
|
+
return status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function healthObservationState(observation, {
|
|
26
|
+
now = Date.now(),
|
|
27
|
+
freshMs = PROVIDER_HEALTH_FRESH_MS
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!observation || typeof observation !== 'object') return 'configured';
|
|
30
|
+
const successAt = validTime(observation.lastSuccess && observation.lastSuccess.at);
|
|
31
|
+
const failureAt = validTime(observation.lastFailure && observation.lastFailure.at);
|
|
32
|
+
const cutoff = Number(now) - Number(freshMs);
|
|
33
|
+
|
|
34
|
+
if (failureAt >= cutoff && failureAt >= successAt
|
|
35
|
+
&& isDefinitiveHealthFailure(observation.lastFailure)) {
|
|
36
|
+
return 'failed';
|
|
37
|
+
}
|
|
38
|
+
if (successAt >= cutoff && successAt > failureAt) return 'verified';
|
|
39
|
+
return 'configured';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function aggregateHealthStates(states) {
|
|
43
|
+
const list = (Array.isArray(states) ? states : []).filter((state) =>
|
|
44
|
+
PROVIDER_HEALTH_STATES.includes(state) && state !== 'unconfigured');
|
|
45
|
+
if (!list.length) return 'unconfigured';
|
|
46
|
+
if (list.includes('verified')) return 'verified';
|
|
47
|
+
if (list.includes('configured')) return 'configured';
|
|
48
|
+
return 'failed';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newestHealthEvidence(observation) {
|
|
52
|
+
const entries = [
|
|
53
|
+
observation && observation.lastSuccess,
|
|
54
|
+
observation && observation.lastFailure,
|
|
55
|
+
observation && observation.lastTransient
|
|
56
|
+
].filter((item) => item && validTime(item.at));
|
|
57
|
+
entries.sort((a, b) => validTime(b.at) - validTime(a.at));
|
|
58
|
+
return entries[0] || null;
|
|
59
|
+
}
|