@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2
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 +69 -2
- package/README.en.md +67 -31
- package/README.md +67 -31
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +34 -19
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +183 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +21 -2
- package/docs/HEADLESS_CLI.md +15 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +21 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +77 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +37 -19
- package/docs/SECURITY.md +5 -5
- 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/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/docs/releases/0.2.2.md +31 -0
- package/lib/actions.js +199 -286
- package/lib/adapters.js +216 -41
- package/lib/api.js +49 -7
- package/lib/capability.js +23 -16
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +933 -117
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +102 -14
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- 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 +205 -175
- 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 +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +29 -42
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +21 -10
- package/lib/prompt-optimizer-core.js +247 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +87 -10
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +235 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +84 -0
- package/lib/provider-task-runner.js +63 -19
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- 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 +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +74 -30
package/lib/provider-adapters.js
CHANGED
|
@@ -11,6 +11,8 @@ import { ProviderContractError, providerErrorRecord } from './provider-contract.
|
|
|
11
11
|
import { writePrivateFile } from './private-storage.js';
|
|
12
12
|
import { defineProviderAdapter } from './provider-adapter.js';
|
|
13
13
|
import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError } from './provider-protocol.js';
|
|
14
|
+
import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted } from './provider-health.js';
|
|
15
|
+
import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
|
|
14
16
|
|
|
15
17
|
const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
|
|
16
18
|
|
|
@@ -26,18 +28,21 @@ function providerIdentity(provider) {
|
|
|
26
28
|
function mappedCategory(error, status) {
|
|
27
29
|
const raw = String(error?.category || '').toLowerCase();
|
|
28
30
|
if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'aborted';
|
|
29
|
-
if (error?.name === 'TimeoutError' ||
|
|
31
|
+
if (error?.name === 'TimeoutError' || raw === 'timeout'
|
|
32
|
+
|| ((!raw || raw === 'unknown') && /timeout|timed out|超时/i.test(String(error?.message || '')))) return 'timeout';
|
|
33
|
+
if (isFreeQuotaExhausted(error)) return 'quota';
|
|
30
34
|
if (status === 401 || status === 403 || raw === 'auth') return 'authentication';
|
|
31
35
|
if (status === 429 && raw === 'quota') return 'quota';
|
|
32
36
|
if (status === 429 || raw === 'rate_limit') return 'rate_limit';
|
|
33
|
-
if (raw === 'invalid_parameter') return 'invalid_request';
|
|
37
|
+
if (raw === 'invalid_parameter' || raw === 'invalid_request') return 'invalid_request';
|
|
38
|
+
if (raw === 'protocol') return 'protocol';
|
|
34
39
|
if (raw === 'network' || error instanceof TypeError) return 'network';
|
|
35
40
|
if (raw === 'server' || status >= 500) return 'provider';
|
|
36
41
|
if (!status && /未返回|响应|协议|结构|json/i.test(String(error?.message || ''))) return 'protocol';
|
|
37
42
|
return 'provider';
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
/**
|
|
45
|
+
/** 已支持协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
|
|
41
46
|
export function mapProviderAdapterError(error, context = {}) {
|
|
42
47
|
const statusValue = Number(error?.httpStatus ?? error?.status);
|
|
43
48
|
const httpStatus = Number.isInteger(statusValue) && statusValue >= 100 && statusValue <= 599
|
|
@@ -90,10 +95,10 @@ function remoteImageArtifacts(urls) {
|
|
|
90
95
|
function openAiImageArtifacts(items) {
|
|
91
96
|
return (items || []).map((item) => {
|
|
92
97
|
if (item && item.b64) {
|
|
93
|
-
return { kind: 'inline-base64', data: item.b64, mediaType: 'image/png' };
|
|
98
|
+
return { kind: 'inline-base64', data: item.b64, mediaType: item.mediaType || 'image/png' };
|
|
94
99
|
}
|
|
95
100
|
if (item && item.url) {
|
|
96
|
-
return { kind: 'remote-url', url: item.url, mediaType: 'image/png' };
|
|
101
|
+
return { kind: 'remote-url', url: item.url, mediaType: item.mediaType || 'image/png' };
|
|
97
102
|
}
|
|
98
103
|
throw new ProviderContractError('同步图片结果缺少 b64 或 URL', {
|
|
99
104
|
stage: 'response', category: 'protocol', acceptance: 'accepted'
|
|
@@ -259,7 +264,7 @@ export function createDashScopeProviderAdapter(provider, { transport = defaultTr
|
|
|
259
264
|
});
|
|
260
265
|
}
|
|
261
266
|
|
|
262
|
-
function operationsForOpenAiImages(provider, transport) {
|
|
267
|
+
function operationsForOpenAiImages(provider, transport, protocol = 'openai-images') {
|
|
263
268
|
const { key, baseUrl } = providerIdentity(provider);
|
|
264
269
|
return {
|
|
265
270
|
async discover({ signal, timeoutMs } = {}) {
|
|
@@ -268,14 +273,27 @@ function operationsForOpenAiImages(provider, transport) {
|
|
|
268
273
|
|
|
269
274
|
async submit(request = {}) {
|
|
270
275
|
if (request.capability !== 'image') {
|
|
271
|
-
throw unsupportedCapability(request.capability,
|
|
276
|
+
throw unsupportedCapability(request.capability, protocol);
|
|
272
277
|
}
|
|
273
278
|
const input = request.input || {};
|
|
274
|
-
|
|
279
|
+
let image;
|
|
280
|
+
if (input.image) {
|
|
281
|
+
if (protocol !== 'openai-chat-images') throw new ProviderContractError('该图片协议不支持聊天改图', {
|
|
282
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
283
|
+
});
|
|
284
|
+
image = await prepareVisionImage(input.image, visionInputLimits(provider, request.model), { signal: request.signal, orient: true });
|
|
285
|
+
}
|
|
286
|
+
const method = {
|
|
287
|
+
'openai-images': 'openAiGenerateImage',
|
|
288
|
+
'openai-chat-images': 'openAiChatGenerateImage',
|
|
289
|
+
'openai-responses-images': 'openAiResponsesGenerateImage'
|
|
290
|
+
}[protocol];
|
|
291
|
+
const items = await transport[method]({
|
|
275
292
|
key,
|
|
276
293
|
baseUrl,
|
|
277
294
|
model: request.model,
|
|
278
295
|
prompt: input.prompt,
|
|
296
|
+
...(image ? { image } : {}),
|
|
279
297
|
size: input.size,
|
|
280
298
|
n: input.n,
|
|
281
299
|
signal: request.signal,
|
|
@@ -327,9 +345,39 @@ export function createOpenAiImagesProviderAdapter(provider, { transport = defaul
|
|
|
327
345
|
});
|
|
328
346
|
}
|
|
329
347
|
|
|
348
|
+
export function createOpenAiChatImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
|
|
349
|
+
return defineProviderAdapter({
|
|
350
|
+
id: providerIdentity(provider).id,
|
|
351
|
+
protocol: 'openai-chat-images',
|
|
352
|
+
capabilities: ['image'],
|
|
353
|
+
operations: operationsForOpenAiImages(provider, transport, 'openai-chat-images'),
|
|
354
|
+
unsupported: {
|
|
355
|
+
poll: 'Chat Completions 生图为同步协议,不提供远端任务轮询',
|
|
356
|
+
cancel: 'Chat Completions 生图不提供远端取消'
|
|
357
|
+
},
|
|
358
|
+
unsupportedPreparation: 'Chat Completions 生图未实现临时输入上传'
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function createOpenAiResponsesImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
|
|
363
|
+
return defineProviderAdapter({
|
|
364
|
+
id: providerIdentity(provider).id,
|
|
365
|
+
protocol: 'openai-responses-images',
|
|
366
|
+
capabilities: ['image'],
|
|
367
|
+
operations: operationsForOpenAiImages(provider, transport, 'openai-responses-images'),
|
|
368
|
+
unsupported: {
|
|
369
|
+
poll: 'Responses 生图当前只接同步结果,不提供后台任务轮询',
|
|
370
|
+
cancel: '同步 Responses 生图不提供远端取消'
|
|
371
|
+
},
|
|
372
|
+
unsupportedPreparation: 'Responses 生图未实现临时输入上传'
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
330
376
|
const PROTOCOL_FACTORIES = new Map([
|
|
331
377
|
['dashscope', createDashScopeProviderAdapter],
|
|
332
|
-
['openai-images', createOpenAiImagesProviderAdapter]
|
|
378
|
+
['openai-images', createOpenAiImagesProviderAdapter],
|
|
379
|
+
['openai-chat-images', createOpenAiChatImagesProviderAdapter],
|
|
380
|
+
['openai-responses-images', createOpenAiResponsesImagesProviderAdapter]
|
|
333
381
|
]);
|
|
334
382
|
|
|
335
383
|
export function createConfiguredProviderAdapter(provider, options = {}) {
|
|
@@ -338,5 +386,34 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
|
|
|
338
386
|
: selectMediaProtocol(provider);
|
|
339
387
|
const factory = PROTOCOL_FACTORIES.get(selection.mediaProtocol);
|
|
340
388
|
if (!factory) throw unsupportedProtocolError(selection.mediaProtocol);
|
|
341
|
-
|
|
389
|
+
const adapter = factory(provider, options);
|
|
390
|
+
const operations = { ...adapter.operations };
|
|
391
|
+
for (const name of ['submit', 'poll']) {
|
|
392
|
+
if (!operations[name]) continue;
|
|
393
|
+
const run = operations[name];
|
|
394
|
+
operations[name] = async request => {
|
|
395
|
+
const modelId = request.model || options.modelId;
|
|
396
|
+
const report = error => reportModelRateLimit(provider, modelId, {
|
|
397
|
+
httpStatus: error.httpStatus ?? error.status, category: error.category,
|
|
398
|
+
code: error.code, providerCode: error.providerCode, message: error.safeMessage || error.message,
|
|
399
|
+
retryAfterMs: error.retryAfterMs,
|
|
400
|
+
source: options.allowRateLimited ? 'probe' : 'task'
|
|
401
|
+
}, options.onRateLimit, request.capability);
|
|
402
|
+
if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
|
|
403
|
+
const blocked = modelRateLimit(provider, modelId);
|
|
404
|
+
throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
|
|
405
|
+
stage: 'validate', category: 'rate_limit', acceptance: 'not_accepted'
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const result = await run(request);
|
|
410
|
+
if (result.error) report(result.error);
|
|
411
|
+
return result;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
report(error);
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return Object.freeze({ ...adapter, operations: Object.freeze(operations) });
|
|
342
419
|
}
|
package/lib/provider-catalog.js
CHANGED
|
@@ -5,8 +5,10 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { CAPABILITIES } from './capability.js';
|
|
7
7
|
import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
|
|
8
|
-
import { dashscopeApiBase, selectMediaProtocol, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
|
|
8
|
+
import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
|
|
9
9
|
import { redactProviderMessage } from './provider-contract.js';
|
|
10
|
+
import { modelRateLimit } from './provider-health.js';
|
|
11
|
+
import { normalizeVisionInputLimits } from './vision-image-input.js';
|
|
10
12
|
|
|
11
13
|
export class ProviderCatalogError extends Error {
|
|
12
14
|
constructor(code, message) {
|
|
@@ -52,10 +54,10 @@ export function loadProviderCatalog(configPath) {
|
|
|
52
54
|
|
|
53
55
|
const CANDIDATE_CAP_LABEL = Object.freeze({
|
|
54
56
|
[CAPABILITIES.IMAGE]: '图片', [CAPABILITIES.VIDEO]: '视频',
|
|
55
|
-
[CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写'
|
|
57
|
+
[CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写', [CAPABILITIES.VISION]: '视觉'
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
/**
|
|
60
|
+
/** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则。 */
|
|
59
61
|
function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
60
62
|
const label = CANDIDATE_CAP_LABEL[capability];
|
|
61
63
|
if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
|
|
@@ -68,6 +70,9 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
|
68
70
|
const exact = resolvePoolModel(pool, requested, capability);
|
|
69
71
|
if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型不存在、未启用或没有${label}能力`);
|
|
70
72
|
const provider = providers.find((item) => item.id === exact.providerId);
|
|
73
|
+
if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
|
|
74
|
+
? '指定模型正在限流冷却,请稍后使用或手动实测'
|
|
75
|
+
: '指定模型额度或预算耗尽,请手动实测成功后再使用');
|
|
71
76
|
return Object.freeze([Object.freeze({
|
|
72
77
|
provider, model: exact.id, modelRef: resolveModelRef(provider, capability, requested),
|
|
73
78
|
selectionReason: 'explicit'
|
|
@@ -85,7 +90,8 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
88
|
-
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE)
|
|
93
|
+
return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE).map(route =>
|
|
94
|
+
Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model) })));
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
@@ -100,6 +106,10 @@ export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '')
|
|
|
100
106
|
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
export function visionCandidatesFromCatalog(catalog, requestedModelRef = '') {
|
|
110
|
+
return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION);
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
/** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
|
|
104
114
|
export function providerCatalogSnapshot(catalog) {
|
|
105
115
|
const safeLabel = (value) => value
|
|
@@ -114,6 +124,7 @@ export function providerCatalogSnapshot(catalog) {
|
|
|
114
124
|
enabled: provider.enabled !== false, configured: isConfiguredProvider(provider),
|
|
115
125
|
auth: provider.auth === 'none' ? 'none' : 'bearer',
|
|
116
126
|
mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
|
|
127
|
+
...(provider.visionInput ? { visionInput: normalizeVisionInputLimits(provider.visionInput) } : {}),
|
|
117
128
|
capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
|
|
118
129
|
modelCount: models.length
|
|
119
130
|
};
|
|
@@ -141,7 +152,7 @@ export function providerTaskBinding(provider) {
|
|
|
141
152
|
try {
|
|
142
153
|
const url = new URL(baseUrl);
|
|
143
154
|
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');
|
|
155
|
+
if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) throw new Error('unknown protocol');
|
|
145
156
|
endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
|
|
146
157
|
} catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
|
|
147
158
|
return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
|
|
@@ -161,8 +172,9 @@ export function providerForTaskFromCatalog(catalog, task) {
|
|
|
161
172
|
if (!parsed || parsed.providerId !== task.providerId) fail('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task 的 Provider 与模型身份不一致');
|
|
162
173
|
const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
|
|
163
174
|
if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
175
|
+
const configured = matches[0];
|
|
176
|
+
if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
|
|
177
|
+
const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId) : configured;
|
|
166
178
|
if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
|
|
167
179
|
if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
|
|
168
180
|
const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot } from './provider-catalog.js';
|
|
5
|
+
import { isKnownCapability } from './capability.js';
|
|
6
|
+
import { providerModels, modelPool, parseModelRef, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
|
|
7
|
+
import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
|
|
8
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
9
|
+
import { selectImageProtocol, normalizeImageProtocol } from './provider-protocol.js';
|
|
10
|
+
import { invokeProviderOperation } from './provider-adapter.js';
|
|
11
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
12
|
+
import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure } from './provider-health.js';
|
|
13
|
+
import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
|
|
14
|
+
|
|
15
|
+
const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
|
|
16
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
const safe = value => redactProviderMessage(String(value || '').replace(/https?:\/\/[^\s<>"']+/gi, '[URL]'));
|
|
18
|
+
const text = value => { if (typeof value !== 'string' || !value.trim()) fail('ID 必须是非空字符串'); return value.trim(); };
|
|
19
|
+
const caps = value => {
|
|
20
|
+
if (!Array.isArray(value) || !value.every(isKnownCapability) || new Set(value).size !== value.length) fail('capabilities 必须是不重复的已知能力数组');
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function assignmentSnapshot(catalog) {
|
|
25
|
+
const pool = modelPool(catalog.providers);
|
|
26
|
+
return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
|
|
27
|
+
const refs = (Array.isArray(value) ? value : [value]).map(ref => resolvePoolModel(pool, ref, capability));
|
|
28
|
+
return [safe(capability), { model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configSnapshot(catalog) {
|
|
33
|
+
return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
|
|
34
|
+
ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
|
|
35
|
+
...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
|
|
36
|
+
...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
|
|
37
|
+
...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
|
|
38
|
+
...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id) } : {}),
|
|
39
|
+
verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: safe(value.at), category: safe(value.category) }])),
|
|
40
|
+
...(model.rateLimited ? { rateLimited: true, rateLimitedAt: modelRateLimit(provider, model.id).at,
|
|
41
|
+
...(modelRateLimit(provider, model.id).until ? { retryAt: modelRateLimit(provider, model.id).until }
|
|
42
|
+
: { reason: modelRateLimit(provider, model.id).reason }) } : {})
|
|
43
|
+
}))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function executeConfigCommand(configPath, command, input = {}, { signal, probe } = {}) {
|
|
47
|
+
if (command === 'init') {
|
|
48
|
+
if (!path.isAbsolute(configPath)) fail('provider-config 必须是绝对路径');
|
|
49
|
+
ensurePrivateDir(path.dirname(configPath));
|
|
50
|
+
fs.writeFileSync(configPath, JSON.stringify({ providers: [], assignments: {} }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
51
|
+
return { command, created: true };
|
|
52
|
+
}
|
|
53
|
+
const catalog = loadProviderCatalog(configPath);
|
|
54
|
+
const before = fs.readFileSync(configPath);
|
|
55
|
+
if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(catalog)) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
56
|
+
if (command === 'show') return configSnapshot(catalog);
|
|
57
|
+
if (command === 'check') {
|
|
58
|
+
const issues = [];
|
|
59
|
+
const ids = catalog.providers.map(provider => provider.id);
|
|
60
|
+
if (new Set(ids).size !== ids.length) issues.push('duplicate_provider_id');
|
|
61
|
+
const pool = modelPool(catalog.providers);
|
|
62
|
+
for (const provider of catalog.providers) {
|
|
63
|
+
try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
|
|
64
|
+
for (const model of provider.models || []) {
|
|
65
|
+
try { normalizeVisionInputLimits(model?.visionInput); } catch (_) { issues.push('invalid_model_vision_input'); }
|
|
66
|
+
if (model?.imageProtocol === undefined) continue;
|
|
67
|
+
try { normalizeImageProtocol(model.imageProtocol); } catch (_) { issues.push('invalid_model_image_protocol'); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
71
|
+
if (!isKnownCapability(capability)) issues.push('unknown_assignment_capability');
|
|
72
|
+
for (const ref of (Array.isArray(assignment) ? assignment : [assignment])) if (!resolvePoolModel(pool, ref, capability)) issues.push('unavailable_assignment_model');
|
|
73
|
+
}
|
|
74
|
+
return { valid: issues.length === 0, issues: [...new Set(issues)] };
|
|
75
|
+
}
|
|
76
|
+
if (command === 'models.list') return { models: configSnapshot(catalog).models };
|
|
77
|
+
if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog), assignments: assignmentSnapshot(catalog) };
|
|
78
|
+
let result = { command, changed: true };
|
|
79
|
+
const initialPool = modelPool(catalog.providers);
|
|
80
|
+
let provider;
|
|
81
|
+
const providerId = input.provider_id || parseModelRef(input.model_ref)?.providerId || input.provider?.id;
|
|
82
|
+
if (providerId) {
|
|
83
|
+
const matches = catalog.providers.filter(value => value.id === providerId);
|
|
84
|
+
if (matches.length > 1) fail('Provider ID 不唯一');
|
|
85
|
+
provider = matches[0];
|
|
86
|
+
}
|
|
87
|
+
if (command === 'providers.add' || command === 'providers.set') {
|
|
88
|
+
const value = input.provider;
|
|
89
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('需要 provider 对象');
|
|
90
|
+
const id = text(value.id);
|
|
91
|
+
if (command === 'providers.add' ? Boolean(provider) : !provider) fail(command === 'providers.add' ? 'Provider 已存在' : 'Provider 不存在');
|
|
92
|
+
const updated = { ...(provider || {}), ...value, id };
|
|
93
|
+
if (updated.visionInput !== undefined) updated.visionInput = normalizeVisionInputLimits(updated.visionInput) || null;
|
|
94
|
+
let url;
|
|
95
|
+
try { url = new URL(updated.baseUrl); } catch (_) { fail('baseUrl 必须是 HTTP(S) 地址'); }
|
|
96
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('baseUrl 含不支持的协议或凭据参数');
|
|
97
|
+
if (updated.enabled !== undefined && typeof updated.enabled !== 'boolean') fail('enabled 必须是布尔值');
|
|
98
|
+
if (updated.models !== undefined) {
|
|
99
|
+
if (!Array.isArray(updated.models)) fail('models 必须是数组');
|
|
100
|
+
for (const model of updated.models) {
|
|
101
|
+
text(typeof model === 'string' ? model : model?.id);
|
|
102
|
+
if (model?.capabilities !== undefined) caps(model.capabilities);
|
|
103
|
+
}
|
|
104
|
+
updated.models = updated.models.map(model => typeof model === 'string' ? model : ({ ...model,
|
|
105
|
+
...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
|
|
106
|
+
...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
|
|
107
|
+
}
|
|
108
|
+
if (provider) {
|
|
109
|
+
for (const model of providerModels(provider)) {
|
|
110
|
+
if (selectImageProtocol(provider, model.id).mediaProtocol !== selectImageProtocol(updated, model.id).mediaProtocol) {
|
|
111
|
+
clearModelCapabilityHealth(updated, model.id, 'image-gen');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (provider) catalog.providers[catalog.providers.indexOf(provider)] = updated;
|
|
116
|
+
else catalog.providers.push(updated);
|
|
117
|
+
} else if (command === 'providers.remove') {
|
|
118
|
+
if (!provider) fail('Provider 不存在');
|
|
119
|
+
catalog.providers.splice(catalog.providers.indexOf(provider), 1);
|
|
120
|
+
} else if (command.startsWith('models.')) {
|
|
121
|
+
if (!provider) fail('Provider 不存在');
|
|
122
|
+
const parsed = parseModelRef(input.model_ref);
|
|
123
|
+
const modelId = parsed?.modelId || input.model_id;
|
|
124
|
+
let entries = Array.isArray(provider.models) ? provider.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
|
|
125
|
+
: providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
|
|
126
|
+
let entry = entries.find(model => model.id === modelId);
|
|
127
|
+
if (command === 'models.discover') {
|
|
128
|
+
const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider), 'discover', { signal, timeoutMs: 15000 });
|
|
129
|
+
const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
|
|
130
|
+
result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
|
|
131
|
+
if (input.apply !== true) return result;
|
|
132
|
+
for (const model of models) {
|
|
133
|
+
const old = entries.find(value => value.id === model.id);
|
|
134
|
+
if (!old) entries.push(model);
|
|
135
|
+
else if (!hasConfiguredModelCapabilities(provider, model.id)) Object.assign(old, model);
|
|
136
|
+
}
|
|
137
|
+
} else if (command === 'models.add') {
|
|
138
|
+
text(modelId);
|
|
139
|
+
const capabilities = input.capabilities === undefined ? undefined : caps(input.capabilities);
|
|
140
|
+
if (entry) fail('模型已存在;请用 models caps 修改能力');
|
|
141
|
+
entries.push({ id: modelId, capabilities, source: 'manual',
|
|
142
|
+
...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
|
|
143
|
+
...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
|
|
144
|
+
} else if (command === 'models.remove') {
|
|
145
|
+
if (!entry) fail('模型不存在');
|
|
146
|
+
entries = entries.filter(model => model.id !== modelId);
|
|
147
|
+
} else if (command === 'models.caps') {
|
|
148
|
+
if (!entry) fail('模型不存在');
|
|
149
|
+
entry.capabilities = caps(input.capabilities); entry.source = 'manual';
|
|
150
|
+
} else if (command === 'models.protocol') {
|
|
151
|
+
if (!entry) fail('模型不存在');
|
|
152
|
+
const protocol = normalizeImageProtocol(input.imageProtocol);
|
|
153
|
+
const previous = selectImageProtocol(provider, modelId).mediaProtocol;
|
|
154
|
+
if (protocol === 'auto') delete entry.imageProtocol;
|
|
155
|
+
else entry.imageProtocol = protocol;
|
|
156
|
+
provider.models = entries;
|
|
157
|
+
if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
|
|
158
|
+
result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
|
|
159
|
+
} else if (command === 'models.vision-input') {
|
|
160
|
+
if (!entry || !Object.hasOwn(input, 'visionInput')) fail('需要已存在的模型和 visionInput 对象或 null');
|
|
161
|
+
const limits = normalizeVisionInputLimits(input.visionInput);
|
|
162
|
+
if (limits) entry.visionInput = limits;
|
|
163
|
+
else delete entry.visionInput;
|
|
164
|
+
provider.models = entries;
|
|
165
|
+
result = { ...result, model_ref: safe(input.model_ref), visionInputEffective: visionInputLimits(provider, modelId) };
|
|
166
|
+
} else if (command === 'models.test') {
|
|
167
|
+
if (!entry || !isKnownCapability(input.capability) || !providerModels(provider).find(model => model.id === modelId)?.capabilities.includes(input.capability)) fail('模型不存在或没有指定能力');
|
|
168
|
+
if (typeof probe !== 'function') fail('模型实测缺少 Probe Port');
|
|
169
|
+
const tested = await probe({ provider, modelId, capability: input.capability, signal });
|
|
170
|
+
if (tested.skipped) return { command, changed: false, ...tested };
|
|
171
|
+
const evidence = { at: new Date().toISOString(), source: 'probe', category: tested.category || (tested.ok ? 'success' : 'unknown'),
|
|
172
|
+
...(tested.httpStatus || tested.status ? { httpStatus: tested.httpStatus || tested.status } : {}) };
|
|
173
|
+
entry.verified = { ...(entry.verified || {}), [input.capability]: { ok: tested.ok === true, ...evidence } };
|
|
174
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
175
|
+
const observations = provider.health.observations;
|
|
176
|
+
let observation = observations.find(value => value.modelId === modelId && value.capability === input.capability);
|
|
177
|
+
if (!observation) { observation = { modelId, capability: input.capability }; observations.push(observation); }
|
|
178
|
+
if (tested.ok === true) { observation.lastSuccess = evidence; clearModelRateLimit(provider, modelId); }
|
|
179
|
+
else {
|
|
180
|
+
observation[isDefinitiveHealthFailure(evidence) ? 'lastFailure' : 'lastTransient'] = evidence;
|
|
181
|
+
recordModelRateLimit(provider, modelId, evidence);
|
|
182
|
+
}
|
|
183
|
+
result = { command, changed: true, passed: tested.ok === true, ...(tested.taskId ? { taskId: tested.taskId } : {}), ...(tested.code ? { code: tested.code } : {}) };
|
|
184
|
+
} else fail('未知模型命令');
|
|
185
|
+
provider.models = entries;
|
|
186
|
+
} else if (command === 'assignments.set' || command === 'assignments.clear') {
|
|
187
|
+
if (!isKnownCapability(input.capability)) fail('未知能力');
|
|
188
|
+
catalog.assignments ||= {};
|
|
189
|
+
if (command === 'assignments.clear') delete catalog.assignments[input.capability];
|
|
190
|
+
else {
|
|
191
|
+
if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
|
|
192
|
+
const pool = modelPool(catalog.providers);
|
|
193
|
+
for (const ref of input.model_refs) if (!parseModelRef(ref) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
|
|
194
|
+
catalog.assignments[input.capability] = input.model_refs;
|
|
195
|
+
}
|
|
196
|
+
} else fail('未知配置命令');
|
|
197
|
+
|
|
198
|
+
// 移除/降级条目时剪去明确指向该条目的分配,保留其他账号和旧裸引用格式。
|
|
199
|
+
if (command === 'providers.remove' || command === 'models.remove' || command === 'models.caps') {
|
|
200
|
+
const pool = modelPool(catalog.providers);
|
|
201
|
+
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
202
|
+
const values = (Array.isArray(assignment) ? assignment : [assignment]).filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
|
|
203
|
+
if (!values.length) delete catalog.assignments[capability];
|
|
204
|
+
else catalog.assignments[capability] = Array.isArray(assignment) ? values : values[0];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
signal?.throwIfAborted();
|
|
208
|
+
return writeConfigChange(configPath, catalog, before, result);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 请求边界持久化冷却或明确耗尽;沿用配置锁、备份与原子写入。 */
|
|
212
|
+
export function recordCatalogRateLimit(configPath, event) {
|
|
213
|
+
const { providerId, modelId } = event;
|
|
214
|
+
const catalog = loadProviderCatalog(configPath);
|
|
215
|
+
const before = fs.readFileSync(configPath);
|
|
216
|
+
const provider = catalog.providers.find(value => value.id === providerId);
|
|
217
|
+
if (!provider || !recordModelRateLimit(provider, modelId, { ...event, source: 'task' })) return;
|
|
218
|
+
return writeConfigChange(configPath, catalog, before, { changed: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function writeConfigChange(configPath, catalog, before, result) {
|
|
222
|
+
const lock = configPath + '.lock';
|
|
223
|
+
let fd;
|
|
224
|
+
try { fd = fs.openSync(lock, 'wx', 0o600); }
|
|
225
|
+
catch (_) { throw Object.assign(new Error('配置正在被另一进程修改'), { code: 'IRIS_CONFIG_BUSY' }); }
|
|
226
|
+
try {
|
|
227
|
+
loadProviderCatalog(configPath);
|
|
228
|
+
if (hash(fs.readFileSync(configPath)) !== hash(before)) throw Object.assign(new Error('配置在操作期间发生变化,请重新读取后重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
229
|
+
const backupPath = privateSibling(configPath, 'backup');
|
|
230
|
+
fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
|
|
231
|
+
atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
232
|
+
result.backupPath = backupPath;
|
|
233
|
+
return result;
|
|
234
|
+
} finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
|
235
|
+
}
|
package/lib/provider-contract.js
CHANGED
|
@@ -146,7 +146,8 @@ export function normalizeProviderArtifacts(value) {
|
|
|
146
146
|
}
|
|
147
147
|
if (item.kind === 'inline-base64') {
|
|
148
148
|
const data = String(item.data || '').trim();
|
|
149
|
-
|
|
149
|
+
// 避免按四字符重复分组:4K 图片的大 base64 正文会耗尽正则回溯栈。
|
|
150
|
+
if (!data || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
150
151
|
throw new TypeError('Provider inline-base64 artifact 数据无效');
|
|
151
152
|
}
|
|
152
153
|
const padding = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
|
|
@@ -287,7 +288,7 @@ export async function submitWithAcceptanceBoundary(candidates, input, hooks = {}
|
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
if (!allowsAutomaticFailover(result)) {
|
|
291
|
+
if (hooks.signal?.aborted || !allowsAutomaticFailover(result)) {
|
|
291
292
|
return { candidate, result, attempts, localError: null };
|
|
292
293
|
}
|
|
293
294
|
}
|
package/lib/provider-health.js
CHANGED
|
@@ -10,6 +10,85 @@ export const PROVIDER_HEALTH_STATES = Object.freeze([
|
|
|
10
10
|
]);
|
|
11
11
|
|
|
12
12
|
const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
|
|
13
|
+
export const MODEL_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
14
|
+
|
|
15
|
+
export function retryAfterDelay(value, now = Date.now()) {
|
|
16
|
+
const raw = String(value ?? '').trim();
|
|
17
|
+
if (!raw) return MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
18
|
+
const seconds = Number(raw);
|
|
19
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.max(1000, seconds * 1000);
|
|
20
|
+
const at = Date.parse(raw);
|
|
21
|
+
return Number.isFinite(at) && at > now ? at - now : MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 百炼“免费额度用完即停”返回 403,不应误归类为 Key 无效。 */
|
|
25
|
+
export function isFreeQuotaExhausted(value) {
|
|
26
|
+
const status = Number(value?.httpStatus ?? value?.status);
|
|
27
|
+
if (![403, 429].includes(status)) return false;
|
|
28
|
+
return status === 403 && (value.category === 'quota' || value.code === 'IRIS_MODEL_RATE_LIMITED')
|
|
29
|
+
|| (value.providerCode || value.code) === 'AllocationQuota.FreeTierOnly'
|
|
30
|
+
|| /\bfree (?:allocated quota|quota|tier)\b[^\n]{0,120}\b(?:exhausted|expired|exceeded)\b/i.test(String(value.message || value.safeMessage || value.note || ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function quotaExhaustionReason(value) {
|
|
34
|
+
if (isFreeQuotaExhausted(value) || value?.reason === 'free_quota') return 'free_quota';
|
|
35
|
+
const status = Number(value?.httpStatus ?? value?.status);
|
|
36
|
+
if (status !== 429) return null;
|
|
37
|
+
if ((value.providerCode || value.code) === 'BudgetLimitExceeded' || value.reason === 'budget') return 'budget';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 短时限流到期即可参与候选;额度/预算耗尽只有成功实测才解除。 */
|
|
42
|
+
export function modelRateLimit(provider, modelId, { now = Date.now() } = {}) {
|
|
43
|
+
const blocked = provider?.health?.rateLimits?.find(item => item.modelId === modelId);
|
|
44
|
+
return blocked && (!blocked.until || Date.parse(blocked.until) > now) ? blocked : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function recordModelRateLimit(provider, modelId, result) {
|
|
48
|
+
const status = Number(result?.httpStatus ?? result?.status);
|
|
49
|
+
const reason = quotaExhaustionReason(result);
|
|
50
|
+
const category = reason ? 'quota' : status === 429 ? 'rate_limit' : null;
|
|
51
|
+
const existing = modelRateLimit(provider, modelId);
|
|
52
|
+
if (!category || !modelId || existing && (!reason || existing.reason)) return false;
|
|
53
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
54
|
+
provider.health.rateLimits ||= [];
|
|
55
|
+
const at = Date.parse(result.at || '');
|
|
56
|
+
const timestamp = Number.isFinite(at) ? at : Date.now();
|
|
57
|
+
const delay = Number.isFinite(result.retryAfterMs) ? result.retryAfterMs : MODEL_RATE_LIMIT_COOLDOWN_MS;
|
|
58
|
+
provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
|
|
59
|
+
provider.health.rateLimits.push({ modelId,
|
|
60
|
+
at: new Date(timestamp).toISOString(),
|
|
61
|
+
source: result.source === 'probe' ? 'probe' : 'task', category, httpStatus: status,
|
|
62
|
+
...(reason ? { reason } : { until: result.until || new Date(timestamp + Math.max(1000, delay)).toISOString() }) });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 只有显式模型实测通过才调用;普通任务成功不能解除停用。 */
|
|
67
|
+
export function clearModelRateLimit(provider, modelId) {
|
|
68
|
+
if (!provider?.health?.rateLimits) return;
|
|
69
|
+
provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
|
|
70
|
+
if (!provider.health.rateLimits.length) delete provider.health.rateLimits;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 配置改变时使一项能力验证失效;保留同模型的限流/耗尽资格和其他能力。 */
|
|
74
|
+
export function clearModelCapabilityHealth(provider, modelId, capability) {
|
|
75
|
+
if (provider.health?.observations) {
|
|
76
|
+
provider.health = { ...provider.health, revision: (provider.health.revision || 0) + 1,
|
|
77
|
+
observations: provider.health.observations.filter(item => item.modelId !== modelId || item.capability !== capability) };
|
|
78
|
+
}
|
|
79
|
+
const entry = provider.models?.find(model => model?.id?.trim() === modelId);
|
|
80
|
+
if (entry?.verified) {
|
|
81
|
+
entry.verified = { ...entry.verified };
|
|
82
|
+
delete entry.verified[capability];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function reportModelRateLimit(provider, modelId, result, onRateLimit, capability) {
|
|
87
|
+
if (!recordModelRateLimit(provider, modelId, result)) return;
|
|
88
|
+
const blocked = modelRateLimit(provider, modelId);
|
|
89
|
+
try { onRateLimit?.({ providerId: provider.id, capability, ...blocked }); }
|
|
90
|
+
catch (_) { console.warn('[iris] 模型停用状态未能持久化,请检查配置写入权限'); }
|
|
91
|
+
}
|
|
13
92
|
|
|
14
93
|
function validTime(value) {
|
|
15
94
|
const ms = Date.parse(String(value || ''));
|
|
@@ -19,7 +98,7 @@ function validTime(value) {
|
|
|
19
98
|
export function isDefinitiveHealthFailure(value) {
|
|
20
99
|
const status = Number(value && (value.httpStatus ?? value.status));
|
|
21
100
|
const category = String(value && value.category || '').toLowerCase();
|
|
22
|
-
return status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category);
|
|
101
|
+
return !isFreeQuotaExhausted(value) && (status === 401 || status === 403 || DEFINITIVE_FAILURES.has(category));
|
|
23
102
|
}
|
|
24
103
|
|
|
25
104
|
export function healthObservationState(observation, {
|