@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.
Files changed (93) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +69 -2
  5. package/README.en.md +67 -31
  6. package/README.md +67 -31
  7. package/bin/dsh-iris.js +222 -21
  8. package/docs/AGENT_QUICKSTART.md +10 -6
  9. package/docs/ARCHITECTURE.md +34 -19
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +1 -1
  12. package/docs/CLI_MANAGEMENT.md +183 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
  15. package/docs/DSH_CORE_MIGRATION.md +48 -19
  16. package/docs/DSH_RC2_ADAPTATION.md +4 -2
  17. package/docs/FAULT_INJECTION.md +21 -2
  18. package/docs/HEADLESS_CLI.md +15 -7
  19. package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
  20. package/docs/HOST_DOCTOR.md +3 -3
  21. package/docs/MODEL_PORT_CONTRACT.md +21 -14
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +77 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +37 -19
  29. package/docs/SECURITY.md +5 -5
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/file-access-across-environments.md +8 -5
  35. package/docs/releases/0.2.1.md +56 -0
  36. package/docs/releases/0.2.2.md +31 -0
  37. package/lib/actions.js +199 -286
  38. package/lib/adapters.js +216 -41
  39. package/lib/api.js +49 -7
  40. package/lib/capability.js +23 -16
  41. package/lib/chromium-browser.js +117 -0
  42. package/lib/client.js +933 -117
  43. package/lib/command-service.js +121 -7
  44. package/lib/composite-vision-routing.js +42 -0
  45. package/lib/config.js +102 -14
  46. package/lib/core-artifact-store.js +45 -13
  47. package/lib/core-lease-recovery.js +64 -92
  48. package/lib/core-maintenance.js +213 -0
  49. package/lib/core-runtime.js +45 -47
  50. package/lib/core-tasks.js +12 -5
  51. package/lib/core-user-projection.js +1 -0
  52. package/lib/core-writer-lease.js +134 -0
  53. package/lib/dsh-core-adapter.js +63 -9
  54. package/lib/dsh-text-model-adapter.js +222 -0
  55. package/lib/dsh-vision-model-adapter.js +75 -0
  56. package/lib/generation-input.js +36 -5
  57. package/lib/headless-vision.js +181 -0
  58. package/lib/host-runtime.js +1 -0
  59. package/lib/http-vision-model-adapter.js +187 -0
  60. package/lib/image-edit-input.js +34 -0
  61. package/lib/index.js +205 -175
  62. package/lib/locate.js +86 -28
  63. package/lib/media-probe.js +2 -1
  64. package/lib/model-call-runtime.js +45 -0
  65. package/lib/model-invoker.js +4 -2
  66. package/lib/model-port-contract.js +13 -5
  67. package/lib/models.js +29 -42
  68. package/lib/ocr-model-routing.js +26 -0
  69. package/lib/ocr.js +110 -64
  70. package/lib/prompt-optimizer-config.js +21 -10
  71. package/lib/prompt-optimizer-core.js +247 -0
  72. package/lib/prompt-optimizer.js +37 -193
  73. package/lib/provider-adapters.js +87 -10
  74. package/lib/provider-catalog.js +19 -7
  75. package/lib/provider-config-service.js +235 -0
  76. package/lib/provider-contract.js +3 -2
  77. package/lib/provider-health.js +80 -1
  78. package/lib/provider-protocol.js +84 -0
  79. package/lib/provider-task-runner.js +63 -19
  80. package/lib/summarize.js +31 -20
  81. package/lib/summary-input.js +52 -0
  82. package/lib/task-wait.js +40 -0
  83. package/lib/video-input.js +30 -0
  84. package/lib/vision-core.js +42 -0
  85. package/lib/vision-image-input.js +83 -0
  86. package/lib/vision-input.js +31 -0
  87. package/lib/vision-model-routing.js +180 -0
  88. package/lib/vision.js +3 -2
  89. package/lib/workbench-artifacts.js +52 -0
  90. package/lib/workbench-management.js +151 -0
  91. package/lib/workbench-zip.js +39 -0
  92. package/package.json +1 -1
  93. package/user_guide.md +74 -30
@@ -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' || /timeout|timed out|超时/i.test(String(error?.message || ''))) return 'timeout';
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
- /** 当前两个协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
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, 'openai-images');
276
+ throw unsupportedCapability(request.capability, protocol);
272
277
  }
273
278
  const input = request.input || {};
274
- const items = await transport.openAiGenerateImage({
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
- return factory(provider, options);
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
  }
@@ -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
- /** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则(image/video/tts/transcribe 同构)。 */
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 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 已移除、停用或缺少凭据');
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
+ }
@@ -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
- if (!data || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
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
  }
@@ -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, {