@mokuyoaxis/dsh-iris 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) 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 +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  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/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -0,0 +1,236 @@
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, capabilitiesOfModel, capabilitiesOfDiscoveredModel, 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
+ if (!model.capabilities.length) continue;
134
+ const old = entries.find(value => value.id === model.id);
135
+ if (!old) entries.push(model);
136
+ else if (old.source !== 'manual') Object.assign(old, model);
137
+ }
138
+ } else if (command === 'models.add') {
139
+ text(modelId);
140
+ const capabilities = input.capabilities === undefined ? capabilitiesOfModel(modelId) : caps(input.capabilities);
141
+ if (entry) fail('模型已存在;请用 models caps 修改能力');
142
+ entries.push({ id: modelId, capabilities, source: 'manual',
143
+ ...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
144
+ ...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
145
+ } else if (command === 'models.remove') {
146
+ if (!entry) fail('模型不存在');
147
+ entries = entries.filter(model => model.id !== modelId);
148
+ } else if (command === 'models.caps') {
149
+ if (!entry) fail('模型不存在');
150
+ entry.capabilities = caps(input.capabilities); entry.source = 'manual';
151
+ } else if (command === 'models.protocol') {
152
+ if (!entry) fail('模型不存在');
153
+ const protocol = normalizeImageProtocol(input.imageProtocol);
154
+ const previous = selectImageProtocol(provider, modelId).mediaProtocol;
155
+ if (protocol === 'auto') delete entry.imageProtocol;
156
+ else entry.imageProtocol = protocol;
157
+ provider.models = entries;
158
+ if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
159
+ result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
160
+ } else if (command === 'models.vision-input') {
161
+ if (!entry || !Object.hasOwn(input, 'visionInput')) fail('需要已存在的模型和 visionInput 对象或 null');
162
+ const limits = normalizeVisionInputLimits(input.visionInput);
163
+ if (limits) entry.visionInput = limits;
164
+ else delete entry.visionInput;
165
+ provider.models = entries;
166
+ result = { ...result, model_ref: safe(input.model_ref), visionInputEffective: visionInputLimits(provider, modelId) };
167
+ } else if (command === 'models.test') {
168
+ if (!entry || !isKnownCapability(input.capability) || !providerModels(provider).find(model => model.id === modelId)?.capabilities.includes(input.capability)) fail('模型不存在或没有指定能力');
169
+ if (typeof probe !== 'function') fail('模型实测缺少 Probe Port');
170
+ const tested = await probe({ provider, modelId, capability: input.capability, signal });
171
+ if (tested.skipped) return { command, changed: false, ...tested };
172
+ const evidence = { at: new Date().toISOString(), source: 'probe', category: tested.category || (tested.ok ? 'success' : 'unknown'),
173
+ ...(tested.httpStatus || tested.status ? { httpStatus: tested.httpStatus || tested.status } : {}) };
174
+ entry.verified = { ...(entry.verified || {}), [input.capability]: { ok: tested.ok === true, ...evidence } };
175
+ provider.health ||= { version: 1, revision: 0, observations: [] };
176
+ const observations = provider.health.observations;
177
+ let observation = observations.find(value => value.modelId === modelId && value.capability === input.capability);
178
+ if (!observation) { observation = { modelId, capability: input.capability }; observations.push(observation); }
179
+ if (tested.ok === true) { observation.lastSuccess = evidence; clearModelRateLimit(provider, modelId); }
180
+ else {
181
+ observation[isDefinitiveHealthFailure(evidence) ? 'lastFailure' : 'lastTransient'] = evidence;
182
+ recordModelRateLimit(provider, modelId, evidence);
183
+ }
184
+ result = { command, changed: true, passed: tested.ok === true, ...(tested.taskId ? { taskId: tested.taskId } : {}), ...(tested.code ? { code: tested.code } : {}) };
185
+ } else fail('未知模型命令');
186
+ provider.models = entries;
187
+ } else if (command === 'assignments.set' || command === 'assignments.clear') {
188
+ if (!isKnownCapability(input.capability)) fail('未知能力');
189
+ catalog.assignments ||= {};
190
+ if (command === 'assignments.clear') delete catalog.assignments[input.capability];
191
+ else {
192
+ if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
193
+ const pool = modelPool(catalog.providers);
194
+ for (const ref of input.model_refs) if (!parseModelRef(ref) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
195
+ catalog.assignments[input.capability] = input.model_refs;
196
+ }
197
+ } else fail('未知配置命令');
198
+
199
+ // 移除/降级条目时剪去明确指向该条目的分配,保留其他账号和旧裸引用格式。
200
+ if (command === 'providers.remove' || command === 'models.remove' || command === 'models.caps') {
201
+ const pool = modelPool(catalog.providers);
202
+ for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
203
+ const values = (Array.isArray(assignment) ? assignment : [assignment]).filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
204
+ if (!values.length) delete catalog.assignments[capability];
205
+ else catalog.assignments[capability] = Array.isArray(assignment) ? values : values[0];
206
+ }
207
+ }
208
+ signal?.throwIfAborted();
209
+ return writeConfigChange(configPath, catalog, before, result);
210
+ }
211
+
212
+ /** 请求边界持久化冷却或明确耗尽;沿用配置锁、备份与原子写入。 */
213
+ export function recordCatalogRateLimit(configPath, event) {
214
+ const { providerId, modelId } = event;
215
+ const catalog = loadProviderCatalog(configPath);
216
+ const before = fs.readFileSync(configPath);
217
+ const provider = catalog.providers.find(value => value.id === providerId);
218
+ if (!provider || !recordModelRateLimit(provider, modelId, { ...event, source: 'task' })) return;
219
+ return writeConfigChange(configPath, catalog, before, { changed: true });
220
+ }
221
+
222
+ function writeConfigChange(configPath, catalog, before, result) {
223
+ const lock = configPath + '.lock';
224
+ let fd;
225
+ try { fd = fs.openSync(lock, 'wx', 0o600); }
226
+ catch (_) { throw Object.assign(new Error('配置正在被另一进程修改'), { code: 'IRIS_CONFIG_BUSY' }); }
227
+ try {
228
+ loadProviderCatalog(configPath);
229
+ if (hash(fs.readFileSync(configPath)) !== hash(before)) throw Object.assign(new Error('配置在操作期间发生变化,请重新读取后重试'), { code: 'IRIS_CONFIG_CHANGED' });
230
+ const backupPath = privateSibling(configPath, 'backup');
231
+ fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
232
+ atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
233
+ result.backupPath = backupPath;
234
+ return result;
235
+ } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
236
+ }
@@ -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
- const SECRET_QUERY = /([?&](?:access[_-]?key|api[_-]?key|authorization|credential|signature|token)=)[^&#\s]+/gi;
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
- const JSON_SECRET = /(["']?(?:api[_-]?key|authorization|access[_-]?token|secret)["']?\s*[:=]\s*["']?)[^"',\s}]+/gi;
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,44 @@ 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
+ // 避免按四字符重复分组:4K 图片的大 base64 正文会耗尽正则回溯栈。
150
+ if (!data || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
151
+ throw new TypeError('Provider inline-base64 artifact 数据无效');
152
+ }
153
+ const padding = data.endsWith('==') ? 2 : (data.endsWith('=') ? 1 : 0);
154
+ const artifact = {
155
+ kind: 'inline-base64',
156
+ byteLength: (data.length / 4) * 3 - padding,
157
+ ...(mediaType ? { mediaType } : {})
158
+ };
159
+ Object.defineProperty(artifact, 'data', { value: data, enumerable: false });
160
+ return Object.freeze(artifact);
161
+ }
162
+ throw new TypeError('Provider artifact kind 仅支持 remote-url/inline-base64');
163
+ }));
164
+ }
165
+
106
166
  export function normalizeSubmissionResult(value) {
107
167
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
108
168
  throw new TypeError('Provider submit 必须返回结构化结果');
@@ -111,7 +171,17 @@ export function normalizeSubmissionResult(value) {
111
171
  throw new TypeError('未知 Provider submit kind: ' + String(value.kind));
112
172
  }
113
173
  if (value.kind === 'completed') {
114
- return { kind: 'completed', acceptance: 'accepted', value: value.value };
174
+ const result = {
175
+ kind: 'completed', acceptance: 'accepted',
176
+ ...(value.artifacts !== undefined ? { artifacts: normalizeProviderArtifacts(value.artifacts) } : {})
177
+ };
178
+ if (value.value !== undefined) {
179
+ Object.defineProperty(result, 'value', {
180
+ value: value.value,
181
+ enumerable: value.artifacts === undefined
182
+ });
183
+ }
184
+ return result;
115
185
  }
116
186
  if (value.kind === 'accepted') {
117
187
  const remoteTaskId = String(value.remoteTaskId || '').trim();
@@ -218,7 +288,7 @@ export async function submitWithAcceptanceBoundary(candidates, input, hooks = {}
218
288
  }
219
289
  }
220
290
 
221
- if (!allowsAutomaticFailover(result)) {
291
+ if (hooks.signal?.aborted || !allowsAutomaticFailover(result)) {
222
292
  return { candidate, result, attempts, localError: null };
223
293
  }
224
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, {
@@ -2,18 +2,77 @@
2
2
  /** 供应商媒体协议判断与 DashScope 凭据边界。 */
3
3
 
4
4
  const DASH_SCOPE_HOST = /^dashscope(?:-[a-z0-9-]+)?\.aliyuncs\.com$/i;
5
+ const REGIONAL_HOST = /^cn-hongkong\.dashscope\.aliyuncs\.com$/i;
6
+ const WORKSPACE_HOST = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:cn-beijing|ap-southeast-1|eu-central-1|ap-northeast-1|us-east-1)\.maas\.aliyuncs\.com$/i;
7
+
8
+ /** 可选媒体端点;空值沿用普通/视觉端点。 */
9
+ export function providerMediaBaseUrl(provider) {
10
+ return String(provider?.mediaBaseUrl || '').trim() || String(provider?.baseUrl || '').trim();
11
+ }
5
12
 
6
13
  export function isDashScopeBaseUrl(value) {
7
14
  try {
8
15
  const url = new URL(String(value || '').trim());
9
- return url.protocol === 'https:' && !url.username && !url.password && DASH_SCOPE_HOST.test(url.hostname);
16
+ return url.protocol === 'https:' && !url.username && !url.password
17
+ && (DASH_SCOPE_HOST.test(url.hostname) || REGIONAL_HOST.test(url.hostname) || WORKSPACE_HOST.test(url.hostname));
10
18
  } catch (_) {
11
19
  return false;
12
20
  }
13
21
  }
14
22
 
15
23
  export function inferMediaProtocol(baseUrl) {
16
- return isDashScopeBaseUrl(baseUrl) ? 'dashscope' : 'openai-images';
24
+ return detectMediaProtocol(baseUrl) || 'openai-images';
25
+ }
26
+
27
+ /** 未识别的端点不等于已验证的兼容协议。 */
28
+ export function detectMediaProtocol(baseUrl) {
29
+ if (isDashScopeBaseUrl(baseUrl)) return 'dashscope';
30
+ try {
31
+ const url = new URL(String(baseUrl || '').trim());
32
+ if (url.protocol === 'https:' && !url.username && !url.password
33
+ && url.hostname === 'api.openai.com') return 'openai-images';
34
+ } catch (_) { /* 未知端点保留兼容回退,由调用方展示推断状态 */ }
35
+ return null;
36
+ }
37
+
38
+ export function selectMediaProtocol(provider) {
39
+ const explicit = typeof provider?.mediaProtocol === 'string' ? provider.mediaProtocol.trim() : '';
40
+ if (explicit && explicit !== 'auto'
41
+ && (provider?.protocolInferred !== true || explicit !== 'openai-images')) {
42
+ return { mediaProtocol: explicit, protocolInferred: false };
43
+ }
44
+ const detected = detectMediaProtocol(providerMediaBaseUrl(provider));
45
+ return { mediaProtocol: detected || 'openai-images', protocolInferred: !detected };
46
+ }
47
+
48
+ /** 模型图片协议只覆盖生图;auto 沿用账号级默认,不另外猜测端点。 */
49
+ export function normalizeImageProtocol(value) {
50
+ const protocol = typeof value === 'string' ? value.trim() : '';
51
+ if (!['auto', 'dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) {
52
+ throw Object.assign(new TypeError('imageProtocol 必须是 auto、dashscope、openai-images、openai-chat-images 或 openai-responses-images'), {
53
+ code: 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID'
54
+ });
55
+ }
56
+ return protocol;
57
+ }
58
+
59
+ export function selectImageProtocol(provider, modelId) {
60
+ const entry = Array.isArray(provider?.models) ? provider.models.find(model => model?.id?.trim() === modelId) : null;
61
+ if (entry?.imageProtocol === undefined) return selectMediaProtocol(provider);
62
+ const protocol = normalizeImageProtocol(entry.imageProtocol);
63
+ return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
64
+ }
65
+
66
+ /** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
67
+ export function providerForImageModel(provider, modelId) {
68
+ return { ...provider, ...selectImageProtocol(provider, modelId) };
69
+ }
70
+
71
+ export function unsupportedProtocolError(protocol, kind = 'media') {
72
+ const safeName = /^[a-z][a-z0-9-]{0,63}$/.test(String(protocol)) ? protocol : '(invalid)';
73
+ const error = new TypeError('iris: 不支持的 ' + (kind === 'vision' ? 'Vision' : 'Provider') + ' 协议:' + safeName);
74
+ error.code = kind === 'vision' ? 'IRIS_VISION_PROTOCOL_UNSUPPORTED' : 'IRIS_PROVIDER_PROTOCOL_UNSUPPORTED';
75
+ return error;
17
76
  }
18
77
 
19
78
  /** 返回官方 DashScope API 根;不满足边界时在 fetch 前拒绝。 */
@@ -23,3 +82,16 @@ export function dashscopeApiBase(baseUrl) {
23
82
  }
24
83
  return new URL('/api/v1', String(baseUrl).trim()).toString().replace(/\/$/, '');
25
84
  }
85
+
86
+ /** 无认证模式不发送保留的旧凭据;未声明模式保持 bearer 兼容。 */
87
+ export function providerApiKey(provider) {
88
+ return provider?.auth === 'none' ? '' : String(provider?.apiKey || '');
89
+ }
90
+
91
+ /** 配置管理与 headless 候选链共用可用性门,未声明 enabled 时默认启用。 */
92
+ export function isConfiguredProvider(provider) {
93
+ return Boolean(provider && provider.enabled !== false
94
+ && typeof provider.id === 'string' && provider.id.trim()
95
+ && providerMediaBaseUrl(provider)
96
+ && (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
97
+ }