@mokuyoaxis/dsh-iris 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/adapters.js CHANGED
@@ -7,8 +7,10 @@
7
7
  */
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
-
11
- const DASHSCOPE = 'https://dashscope.aliyuncs.com/api/v1';
10
+ import { Readable } from 'node:stream';
11
+ import { pipeline } from 'node:stream/promises';
12
+ import { dashscopeApiBase } from './provider-protocol.js';
13
+ import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
12
14
 
13
15
  /**
14
16
  * 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
@@ -111,8 +113,9 @@ export function dashscopeImageMode(model) {
111
113
  }
112
114
 
113
115
  /** 新版多模态同步协议:响应中直接返回图片 URL。 */
114
- export async function generateImageMultimodal({ key, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
115
- const res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
116
+ export async function generateImageMultimodal({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
117
+ const apiBase = dashscopeApiBase(baseUrl);
118
+ const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
116
119
  method: 'POST',
117
120
  headers: authHeaders(key),
118
121
  signal: timeoutSignal(timeoutMs, signal),
@@ -142,8 +145,9 @@ export async function startImageGeneration(opts) {
142
145
  }
143
146
  return { remoteTaskId: await submitImage(opts), urls: [] };
144
147
  }
145
- export async function submitImage({ key, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
146
- const res = await fetch(`${DASHSCOPE}/services/aigc/text2image/image-synthesis`, {
148
+ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
149
+ const apiBase = dashscopeApiBase(baseUrl);
150
+ const res = await fetch(`${apiBase}/services/aigc/text2image/image-synthesis`, {
147
151
  method: 'POST',
148
152
  headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
149
153
  signal: timeoutSignal(timeoutMs, signal),
@@ -165,12 +169,13 @@ export async function submitImage({ key, model, prompt, size, n = 1, timeoutMs =
165
169
  * - 其他(t2v/i2v):POST /services/aigc/video-generation/video-synthesis,
166
170
  * imgDataUrl 支持公网 URL 或 data:image base64。
167
171
  */
168
- export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
172
+ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
173
+ const apiBase = dashscopeApiBase(baseUrl);
169
174
  const isS2V = /s2v/i.test(model || '');
170
175
  let url, input, parameters;
171
176
  const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
172
177
  if (isS2V) {
173
- url = `${DASHSCOPE}/services/aigc/image2video/video-synthesis`;
178
+ url = `${apiBase}/services/aigc/image2video/video-synthesis`;
174
179
  input = { image_url: imgDataUrl, audio_url: audioUrl };
175
180
  parameters = {};
176
181
  if (resolution) parameters.resolution = String(resolution);
@@ -178,7 +183,7 @@ export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280
178
183
  headers['X-DashScope-OssResourceResolve'] = 'enable';
179
184
  }
180
185
  } else {
181
- url = `${DASHSCOPE}/services/aigc/video-generation/video-synthesis`;
186
+ url = `${apiBase}/services/aigc/video-generation/video-synthesis`;
182
187
  input = { prompt };
183
188
  if (imgDataUrl) input.img_url = imgDataUrl;
184
189
  parameters = { size };
@@ -203,8 +208,9 @@ export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280
203
208
  * 返回 oss:// 形式 URL;调用模型时请求头须带 X-DashScope-OssResourceResolve: enable
204
209
  * (submitVideo 的 s2v 分支已自动处理)。
205
210
  */
206
- export async function uploadTempFile({ key, model, filePath, timeoutMs = T_UPLOAD, signal }) {
207
- const u = new URL(`${DASHSCOPE}/uploads`);
211
+ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs = T_UPLOAD, signal }) {
212
+ const apiBase = dashscopeApiBase(baseUrl);
213
+ const u = new URL(`${apiBase}/uploads`);
208
214
  u.searchParams.set('action', 'getPolicy');
209
215
  u.searchParams.set('model', model);
210
216
  const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
@@ -230,8 +236,9 @@ export async function uploadTempFile({ key, model, filePath, timeoutMs = T_UPLOA
230
236
  const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
231
237
 
232
238
  /** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
233
- export async function pollTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
234
- const res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
239
+ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
240
+ const apiBase = dashscopeApiBase(baseUrl);
241
+ const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
235
242
  headers: authHeaders(key),
236
243
  signal: timeoutSignal(timeoutMs)
237
244
  });
@@ -272,8 +279,9 @@ export async function pollTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
272
279
  }
273
280
 
274
281
  /* ---------------- DashScope:qwen-tts(同步) ---------------- */
275
- export async function synthesizeTts({ key, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
276
- const res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
282
+ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
283
+ const apiBase = dashscopeApiBase(baseUrl);
284
+ const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
277
285
  method: 'POST',
278
286
  headers: authHeaders(key),
279
287
  signal: timeoutSignal(timeoutMs, signal),
@@ -407,13 +415,19 @@ export function extFromUrl(url, fallback = 'bin') {
407
415
  export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal } = {}) {
408
416
  const res = await fetch(url, { signal: timeoutSignal(timeoutMs, signal) });
409
417
  if (!res.ok) throw new Error(`下载结果失败:HTTP ${res.status}`);
410
- const buf = Buffer.from(await res.arrayBuffer());
411
- const { writeFileSync, renameSync } = await import('node:fs');
412
- // 原子落盘:先写 .tmp 再 rename,失败/超时不留半截产物
413
- const tmp = filePath + '.tmp';
414
- writeFileSync(tmp, buf);
415
- renameSync(tmp, filePath);
416
- return buf.length;
418
+ if (!res.body) throw new Error('下载结果失败:响应没有内容');
419
+ ensurePrivateDir(path.dirname(filePath));
420
+ const tmp = privateSibling(filePath, 'download');
421
+ try {
422
+ const output = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 });
423
+ await pipeline(Readable.fromWeb(res.body), output);
424
+ const bytes = fs.statSync(tmp).size;
425
+ fs.renameSync(tmp, filePath);
426
+ chmodPrivateFile(filePath);
427
+ return bytes;
428
+ } finally {
429
+ try { fs.rmSync(tmp, { force: true }); } catch (_) { /* rename 成功后已不存在 */ }
430
+ }
417
431
  }
418
432
 
419
433
  /* ---------------- DashScope:非实时语音识别(异步任务) ---------------- */
@@ -422,14 +436,15 @@ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal
422
436
  * @param {{key:string, model:string, audioUrl:string}} opts audioUrl 为 oss:// 或公网 URL
423
437
  * @returns {Promise<string>} remoteTaskId
424
438
  */
425
- export async function submitTranscription({ key, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
439
+ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
440
+ const apiBase = dashscopeApiBase(baseUrl);
426
441
  const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
427
442
  if (/^oss:\/\//.test(audioUrl || '')) headers['X-DashScope-OssResourceResolve'] = 'enable';
428
443
  // Qwen3 Filetrans 使用单值 file_url;Qwen-Audio 3.0、Fun-ASR、Paraformer 使用 file_urls。
429
444
  const input = /^qwen3-asr-.*filetrans/i.test(model || '')
430
445
  ? { file_url: audioUrl }
431
446
  : { file_urls: [audioUrl] };
432
- const res = await fetch(`${DASHSCOPE}/services/audio/asr/transcription`, {
447
+ const res = await fetch(`${apiBase}/services/audio/asr/transcription`, {
433
448
  method: 'POST',
434
449
  headers,
435
450
  signal: timeoutSignal(timeoutMs, signal),
@@ -461,8 +476,9 @@ export function transcriptionText(payload) {
461
476
  }
462
477
 
463
478
  /** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
464
- export async function pollTranscriptionTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
465
- const res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
479
+ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL }) {
480
+ const apiBase = dashscopeApiBase(baseUrl);
481
+ const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
466
482
  headers: authHeaders(key),
467
483
  signal: timeoutSignal(timeoutMs)
468
484
  });
package/lib/api.js CHANGED
@@ -16,6 +16,8 @@ import { irisHome, capabilitiesOf, onChange as onConfigChange } from './config.j
16
16
  import { mimeOf, mediaUrl } from './media.js';
17
17
  import * as tasks from './tasks.js';
18
18
  import { runAction } from './actions.js';
19
+ import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
20
+ import { inferMediaProtocol } from './provider-protocol.js';
19
21
 
20
22
  function readJsonSafe(file) {
21
23
  try {
@@ -42,7 +44,7 @@ function providerPublic(p) {
42
44
  type: p.type || 'openai',
43
45
  baseUrl: p.baseUrl,
44
46
  enabled: p.enabled !== false,
45
- mediaProtocol: p.mediaProtocol || 'dashscope',
47
+ mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
46
48
  apiKeyHint: keyHint(p.apiKey),
47
49
  capabilities: capabilitiesOf(p),
48
50
  // 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
@@ -370,18 +372,28 @@ function safeName(name) {
370
372
  function handleUpload(req, res) {
371
373
  const q = new URL(req.url || '', 'http://x').searchParams;
372
374
  const name = safeName(q.get('name') || 'upload');
373
- fs.mkdirSync(uploadsDir(), { recursive: true });
375
+ ensurePrivateDir(uploadsDir());
374
376
  purgeStaleUploads();
375
377
 
376
378
  const stem = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
377
379
  const abs = path.join(uploadsDir(), stem + '-' + name);
378
380
  const tmp = abs + '.part';
379
- const out = fs.createWriteStream(tmp, { mode: 0o600, flags: 'wx' });
381
+ // 惰性建流:空上传全程不落盘。createWriteStream 的 open 是异步的,
382
+ // 若在 open 完成前 destroy,文件仍会被创建,同步 rmSync 删不到,留下孤儿 .part。
383
+ let out = null;
380
384
  let totalBytes = 0;
381
385
  let settled = false;
382
386
 
383
387
  const discard = () => {
384
- try { out.destroy(); } catch (_) { /* ignore */ }
388
+ const stream = out;
389
+ out = null;
390
+ if (stream) {
391
+ // 兜底:等 fd 真正关闭(close 事件)后再删一次,覆盖 open 晚于 destroy 的竞态。
392
+ stream.once('close', () => {
393
+ try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
394
+ });
395
+ try { stream.destroy(); } catch (_) { /* ignore */ }
396
+ }
385
397
  try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
386
398
  };
387
399
  const finish = (status, body) => {
@@ -391,7 +403,14 @@ function handleUpload(req, res) {
391
403
  sendJson(res, status, body);
392
404
  };
393
405
 
394
- out.on('error', (err) => finish(500, { error: 'upload failed: ' + String(err && err.message) }));
406
+ const ensureStream = () => {
407
+ if (!out) {
408
+ out = fs.createWriteStream(tmp, { mode: 0o600, flags: 'wx' });
409
+ out.on('error', (err) => finish(500, { error: 'upload failed: ' + String(err && err.message) }));
410
+ }
411
+ return out;
412
+ };
413
+
395
414
  req.on('data', (chunk) => {
396
415
  if (settled) return;
397
416
  totalBytes += chunk.length;
@@ -400,18 +419,21 @@ function handleUpload(req, res) {
400
419
  if (typeof req.resume === 'function') req.resume();
401
420
  return;
402
421
  }
403
- if (!out.write(chunk) && typeof req.pause === 'function') {
422
+ const stream = ensureStream();
423
+ if (!stream.write(chunk) && typeof req.pause === 'function') {
404
424
  req.pause();
405
- out.once('drain', () => { if (!settled && typeof req.resume === 'function') req.resume(); });
425
+ stream.once('drain', () => { if (!settled && typeof req.resume === 'function') req.resume(); });
406
426
  }
407
427
  });
408
428
  req.on('end', () => {
409
429
  if (settled) return;
410
430
  if (!totalBytes) return finish(400, { error: 'empty upload' });
411
- out.end(() => {
431
+ const stream = out;
432
+ stream.end(() => {
412
433
  if (settled) return;
413
434
  try {
414
435
  fs.renameSync(tmp, abs);
436
+ chmodPrivateFile(abs);
415
437
  finish(200, { ok: true, path: abs, name, expiresInMs: UPLOAD_TTL_MS });
416
438
  } catch (err) {
417
439
  finish(500, { error: 'upload failed: ' + String(err && err.message) });
@@ -464,4 +486,4 @@ function serveTaskDetail(taskId, method, res) {
464
486
  'X-Content-Type-Options': 'nosniff'
465
487
  });
466
488
  res.end(method === 'HEAD' ? undefined : body);
467
- }
489
+ }
package/lib/capability.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Iris Provider 能力系统(阶段 1)。
4
4
  *
5
- * 目标(对齐 PLAN 阶段 1 与 RESEARCH 的 "test it, don't guess it"):
5
+ * 目标(“实际验证,不只猜测”):
6
6
  * - 能力常量:image-gen / video-gen / tts / transcribe / vision
7
7
  * - 严格选择:不具备某能力的 Provider 不得被兜底选中
8
8
  * - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试
package/lib/client.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * webServer 前缀模式),client 侧 fetch 轮询。全走标量,
17
17
  * apiKey 永不明文,产物给授权播放链接。
18
18
  */
19
- window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
19
+ window.__ModuleLoader__.load({ id: '@mokuyoaxis/dsh-iris', factory: (require) => {
20
20
  var module = { exports: {} };
21
21
  var exports = module.exports;
22
22
 
@@ -438,15 +438,15 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
438
438
  var models = p.models || [];
439
439
  function verBadge(m, c) {
440
440
  var v = m.verified && m.verified[c];
441
- if (!v) return React.createElement('span', { className: 'iris-pm-ver', title: '未实测' }, c + '?');
442
- return React.createElement('span', { className: 'iris-pm-ver ' + (v.ok ? 'ok' : 'bad'), title: (v.ok ? '实测通过 ' : '实测未通过 ') + (v.note || '') }, c + (v.ok ? '✓' : '✗'));
441
+ var cls = 'iris-pm-ver' + (v ? (' ' + (v.ok ? 'ok' : 'bad')) : '');
442
+ var title = v ? ((v.ok ? '实测通过:' : '实测未通过:') + (v.note || '')) : '点击单独验证此能力';
443
+ return React.createElement('button', { key: c, className: cls, title: title, onClick: function () { props.testModel(p, m, c); } }, c + (v ? (v.ok ? '✓' : '✗') : '?'));
443
444
  }
444
445
  var rows = models.map(function (m) {
445
446
  var caps = m.capabilities || [];
446
447
  return React.createElement('div', { key: m.id, className: 'iris-pm-mrow' },
447
448
  React.createElement('span', { className: 'iris-pm-mname' }, m.id, m.source === 'manual' ? React.createElement('span', { className: 'iris-pm-mtag' }, '手动') : null),
448
449
  React.createElement('span', { className: 'iris-pm-mcaps' }, caps.map(function (c) { return verBadge(m, c); })),
449
- React.createElement('button', { className: 'iris-pm-btn', title: '逐能力实测(image/tts 产生真实调用)', onClick: function () { props.testModel(p, m); } }, '测'),
450
450
  React.createElement('button', { className: 'iris-pm-btn danger', title: '从池移除', onClick: function () { props.removeModel(p, m); } }, '✕'));
451
451
  });
452
452
  return React.createElement('div', { className: 'iris-pm-pool' },
@@ -466,7 +466,7 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
466
466
  var addingPair = React.useState(false);
467
467
  var adding = addingPair[0];
468
468
  var setAdding = addingPair[1];
469
- var newForm = React.useState({ name: '', baseUrl: '', apiKey: '', type: 'openai' });
469
+ var newForm = React.useState({ name: '', baseUrl: '', apiKey: '', type: 'openai', mediaProtocol: 'auto' });
470
470
  var notePair = React.useState('');
471
471
  var note = notePair[0];
472
472
  var setNote = notePair[1];
@@ -493,23 +493,25 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
493
493
  postAction('providers_remove', { id: p.id }).then(function (r) { if (r.ok) refresh(); else setNote(r.d.error || '删除失败'); });
494
494
  }
495
495
  function testVision(p) {
496
+ if (!window.confirm('视觉实测会向供应商发送一张内置测试图,可能产生费用。继续吗?')) return;
496
497
  setNote('测试「' + (p.name || p.id) + '」的视觉能力…');
497
- postAction('providers_test_vision', { id: p.id }).then(function (r) { setNote(r.d.text || r.d.error || '测试完成'); refresh(); });
498
+ postAction('providers_test_vision', { id: p.id, confirm_paid: true }).then(function (r) { setNote(r.d.text || r.d.error || '测试完成'); refresh(); });
498
499
  }
499
500
  function discover(p) {
500
501
  setNote('正在发现「' + (p.name || p.id) + '」的模型…');
501
502
  postAction('providers_discover', { id: p.id }).then(function (r) { setNote(r.d.text || r.d.error || '发现完成'); refresh(); });
502
503
  }
503
- function testModel(p, m) {
504
- var caps = (m.capabilities || []).length ? m.capabilities : ['vision'];
505
- setNote('实测 ' + m.id + '(' + caps.join('/') + ',image/tts 会产生真实调用)…');
506
- // 逐个能力测,串行;每个存 verified
507
- caps.reduce(function (chain, c) {
508
- return chain.then(function () {
509
- return postAction('providers_test_model', { id: p.id, model_id: m.id, capability: c })
510
- .then(function (r) { setNote(r.d.text || r.d.error || ''); });
511
- });
512
- }, Promise.resolve()).then(function () { refresh(); });
504
+ function testModel(p, m, capability) {
505
+ var noProbe = capability === 'video-gen' || capability === 'transcribe';
506
+ if (!noProbe && !window.confirm('将用模型「' + m.id + '」发起一次真实的 ' + capability + ' 请求,可能产生费用。继续吗?')) return;
507
+ setNote('验证 ' + m.id + ' · ' + capability + '…');
508
+ postAction('providers_test_model', { id: p.id, model_id: m.id, capability: capability, confirm_paid: !noProbe })
509
+ .then(function (r) { setNote(r.d.text || r.d.error || ''); refresh(); });
510
+ }
511
+ function setProtocol(p, value) {
512
+ postAction('providers_upsert', { id: p.id, mediaProtocol: value }).then(function (r) {
513
+ setNote(r.ok ? '媒体协议已更新' : (r.d.error || '更新失败')); refresh();
514
+ });
513
515
  }
514
516
  function removeModel(p, m) {
515
517
  if (!window.confirm('从池中移除模型「' + m.id + '」?')) return;
@@ -525,6 +527,9 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
525
527
  if (open) {
526
528
  body = React.createElement('div', { className: 'iris-pm-body' },
527
529
  React.createElement('div', { className: 'iris-pm-note' }, 'Key ' + (p.apiKeyHint || '未配置') + ' · ' + (p.type || '') + ' · ' + (p.mediaProtocol || '')),
530
+ React.createElement('div', { className: 'iris-pm-field' }, React.createElement('label', {}, '媒体协议'), React.createElement('select', { value: p.mediaProtocol || 'openai-images', onChange: function (e) { setProtocol(p, e.target.value); } },
531
+ React.createElement('option', { value: 'dashscope' }, 'DashScope(仅阿里云官方 HTTPS)'),
532
+ React.createElement('option', { value: 'openai-images' }, 'OpenAI Images 兼容'))),
528
533
  React.createElement('div', { className: 'iris-pm-note' }, '能力: ' + ((p.capabilities || []).join(' / ') || '无')),
529
534
  React.createElement(ModelPool, { provider: p, onNote: setNote, onDone: refresh, testModel: testModel, removeModel: removeModel, addModel: addModel }),
530
535
  React.createElement('div', { className: 'iris-wb-row' },
@@ -544,6 +549,10 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
544
549
  React.createElement('div', { className: 'iris-pm-field' }, React.createElement('label', {}, '名称'), React.createElement('input', { value: newForm[0].name, placeholder: '如 阿里云百炼', onChange: function (e) { newForm[1]({ ...newForm[0], name: e.target.value }); } })),
545
550
  React.createElement('div', { className: 'iris-pm-field' }, React.createElement('label', {}, 'Base URL *'), React.createElement('input', { value: newForm[0].baseUrl, placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', onChange: function (e) { newForm[1]({ ...newForm[0], baseUrl: e.target.value }); } })),
546
551
  React.createElement('div', { className: 'iris-pm-field' }, React.createElement('label', {}, 'API Key *'), React.createElement('input', { type: 'password', value: newForm[0].apiKey, placeholder: 'sk-...', onChange: function (e) { newForm[1]({ ...newForm[0], apiKey: e.target.value }); } })),
552
+ React.createElement('div', { className: 'iris-pm-field' }, React.createElement('label', {}, '媒体协议'), React.createElement('select', { value: newForm[0].mediaProtocol, onChange: function (e) { newForm[1]({ ...newForm[0], mediaProtocol: e.target.value }); } },
553
+ React.createElement('option', { value: 'auto' }, '自动(按 Base URL 安全判断)'),
554
+ React.createElement('option', { value: 'dashscope' }, 'DashScope(阿里云官方)'),
555
+ React.createElement('option', { value: 'openai-images' }, 'OpenAI Images 兼容'))),
547
556
  React.createElement('div', { className: 'iris-wb-row' },
548
557
  React.createElement('button', { className: 'iris-act-run', onClick: addProvider }, '保存'),
549
558
  React.createElement('button', { className: 'iris-pm-btn', onClick: function () { setAdding(false); } }, '取消')));
@@ -1184,4 +1193,4 @@ window.__ModuleLoader__.load({ id: 'dsh-iris', factory: (require) => {
1184
1193
 
1185
1194
  module.exports = { inject: inject, apply: apply };
1186
1195
  return module.exports;
1187
- }});
1196
+ }});
package/lib/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  /**
3
3
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
4
- * 结构与 ai-paint 工作台兼容(providers 数组同形),支持一键导入。
4
+ * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
5
5
  * Key 明文落盘(0600);接口层永远不回明文。
6
6
  */
7
7
  import fs from 'node:fs';
@@ -9,6 +9,37 @@ import path from 'node:path';
9
9
  import os from 'node:os';
10
10
  import * as cap from './capability.js';
11
11
  import * as models from './models.js';
12
+ import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
+ import { inferMediaProtocol } from './provider-protocol.js';
14
+
15
+ function isPlainObject(value) {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ }
18
+
19
+ function validateStoredConfig(value) {
20
+ if (!isPlainObject(value)) throw new Error('配置根节点必须是对象');
21
+ if (!Array.isArray(value.providers)) throw new Error('providers 必须是数组');
22
+ if (!isPlainObject(value.assignments)) throw new Error('assignments 必须是对象');
23
+ for (const provider of value.providers) {
24
+ if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
25
+ if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
26
+ if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
27
+ if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
28
+ if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
29
+ for (const model of provider.models || []) {
30
+ if (typeof model === 'string') continue;
31
+ if (!isPlainObject(model) || typeof model.id !== 'string' || !model.id.trim()) {
32
+ throw new Error('provider.models 条目必须是模型名或带 id 的对象');
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ function normalizeStoredProvider(provider) {
39
+ if (provider.mediaProtocol === 'dashscope' || provider.mediaProtocol === 'openai-images') return false;
40
+ provider.mediaProtocol = inferMediaProtocol(provider.baseUrl);
41
+ return true;
42
+ }
12
43
 
13
44
  export function irisHome() {
14
45
  const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
@@ -35,16 +66,7 @@ function emitChange() {
35
66
  }
36
67
 
37
68
  function persist() {
38
- fs.mkdirSync(path.dirname(storeFile()), { recursive: true });
39
- const tmp = storeFile() + '.tmp';
40
- // 0600 从创建时生效(含临时文件);rename 保留 mode,chmod 兜底不依赖 umask
41
- fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
42
- fs.renameSync(tmp, storeFile());
43
- try {
44
- fs.chmodSync(storeFile(), 0o600);
45
- } catch (_) {
46
- /* ignore */
47
- }
69
+ atomicWritePrivate(storeFile(), JSON.stringify(cache, null, 2));
48
70
  emitChange(); // 任何配置落盘 = 状态变了 → SSE 推送
49
71
  }
50
72
 
@@ -58,14 +80,22 @@ export function load() {
58
80
  return cache;
59
81
  }
60
82
  try {
61
- cache = JSON.parse(fs.readFileSync(file, 'utf8'));
62
- if (!Array.isArray(cache.providers)) cache.providers = [];
63
- if (!cache.assignments || typeof cache.assignments !== 'object') cache.assignments = {};
83
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
84
+ let normalized = false;
85
+ if (isPlainObject(parsed) && parsed.assignments === undefined) {
86
+ parsed.assignments = {};
87
+ normalized = true;
88
+ }
89
+ validateStoredConfig(parsed);
90
+ cache = parsed;
91
+ for (const provider of cache.providers) normalized = normalizeStoredProvider(provider) || normalized;
92
+ if (normalized) persist();
64
93
  } catch (err) {
65
94
  // 文件存在但损坏:隔离,绝不静默覆盖证据
66
- const backup = file + '.corrupted-' + Date.now();
95
+ const backup = privateSibling(file, 'corrupted');
67
96
  try {
68
97
  fs.renameSync(file, backup);
98
+ chmodPrivateFile(backup);
69
99
  } catch (_) {
70
100
  /* 隔离失败也继续 */
71
101
  }
@@ -81,7 +111,7 @@ export function resetCache() {
81
111
  cache = null;
82
112
  }
83
113
 
84
- /** 首次运行且 Iris 为空时,从工作台配置导入(迁移动作,非耦合) */
114
+ /** 用户显式指定来源且 Iris 为空时,从工作台配置导入。 */
85
115
  export function importFromWorkbench(workbenchConfigPath) {
86
116
  const c = load();
87
117
  if (c.providers.length) return { imported: 0, reason: '已有配置,跳过导入' };
@@ -89,12 +119,13 @@ export function importFromWorkbench(workbenchConfigPath) {
89
119
  try {
90
120
  raw = JSON.parse(fs.readFileSync(workbenchConfigPath, 'utf8'));
91
121
  } catch (_) {
92
- return { imported: 0, reason: '未找到工作台配置' };
122
+ return { imported: 0, reason: '来源文件不可读或 JSON 无效' };
93
123
  }
94
- const list = Array.isArray(raw.providers) ? raw.providers : [];
124
+ const list = Array.isArray(raw?.providers) ? raw.providers : [];
95
125
  let n = 0;
96
126
  for (const p of list) {
97
- if (!p || !p.apiKey || !p.baseUrl) continue;
127
+ if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
128
+ typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
98
129
  c.providers.push({
99
130
  id: 'iris_' + Math.random().toString(36).slice(2, 8),
100
131
  name: p.name || p.id,
@@ -102,13 +133,12 @@ export function importFromWorkbench(workbenchConfigPath) {
102
133
  baseUrl: p.baseUrl,
103
134
  apiKey: p.apiKey,
104
135
  enabled: p.enabled !== false,
105
- // 媒体生成默认走百炼协议;OpenAI Images 兼容后端由用户按需切换
106
- mediaProtocol: /dashscope/i.test(p.baseUrl) ? 'dashscope' : 'openai-images'
136
+ mediaProtocol: inferMediaProtocol(p.baseUrl)
107
137
  });
108
138
  n++;
109
139
  }
110
140
  if (n) persist();
111
- return { imported: n };
141
+ return n ? { imported: n } : { imported: 0, reason: '来源没有可导入的供应商' };
112
142
  }
113
143
 
114
144
  export function providers() {
@@ -271,12 +301,16 @@ export function upsert(provider) {
271
301
  if (id) {
272
302
  const i = c.providers.findIndex((p) => p.id === id);
273
303
  if (i >= 0) {
304
+ if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? c.providers[i].baseUrl);
274
305
  c.providers[i] = { ...c.providers[i], ...rest };
306
+ normalizeStoredProvider(c.providers[i]);
275
307
  persist();
276
308
  return c.providers[i];
277
309
  }
278
310
  }
279
311
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
312
+ if (fresh.mediaProtocol === 'auto') delete fresh.mediaProtocol;
313
+ normalizeStoredProvider(fresh);
280
314
  c.providers.push(fresh);
281
315
  persist();
282
316
  return fresh;
package/lib/guard.js CHANGED
@@ -1,41 +1,54 @@
1
1
  'use strict';
2
2
  /**
3
- * Iris 请求守卫(O2 授权边界,2026-09-03 修订为"发布安全优先")。
3
+ * Iris 请求守卫。
4
4
  *
5
- * 调研宿主结论:DSH 在 `dsh-client-connection` 里有一套权威信任栅栏
6
- * `isTrustedApiRequest`(回环∪trustedHosts + Sec-Fetch-Site + Origin==Host),
7
- * 但它**只挂在 `/api` 前缀**。iris 注册的是 `/iris/*` 裸 prefix 路由,在栅栏之外,
8
- * 所以 iris 需自备一道——但必须**对齐宿主里"永不锁死合法用户"的那半,
9
- * 舍弃"会锁死 LAN/反代用户"的那半**。
10
- *
11
- * 威胁与处置(按发布安全排序):
12
- * ① 驱动式 CSRF(真实、高价值):恶意网页对 `/iris/api/actions/*` 发跨站 POST,
13
- * 触发付费生成/写盘。→ **挡**:POST 时 `Sec-Fetch-Site === cross-site` 拒绝;
14
- * 有 `Origin` 且 `Origin.host !== 请求 Host` 拒绝。合法同源用户(不管在回环/LAN/
15
- * 反代域名上访问,浏览器 Origin 总与 Host 同源)永不误伤;无 Origin 的 CLI/curl 放行。
16
- * ② DNS 重绑定读 state / 驱动 POST(低价值):需要 Host 白名单才能挡,但白名单正是
17
- * 锁死 LAN/反代用户的元凶。用户裁定:iris 开源、反代场景无所谓 → **不挡**,
18
- * 读接口(GET/HEAD/SSE)完全放开(跨源读本就受 CORS 阻挡,媒体另有 token 能力凭证)。
19
- *
20
- * 净效果:**任何部署拓扑都不拦合法用户**,只挡跨站 CSRF 驱动付费动作。
5
+ * `/iris/*` 不经过 DSH 的 `/api` 信任栅栏,因此默认只接受回环 Host。
6
+ * LAN/反代场景需用 IRIS_TRUSTED_HOSTS 显式列出 host 或 host:port;这只是
7
+ * Host 边界,不是身份认证。POST 另外校验 Sec-Fetch-Site 与 Origin==Host。
21
8
  */
22
9
 
23
10
  /** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
24
11
  function authority(header) {
25
- const h = String(header || '').trim().toLowerCase();
26
- if (!h) return '';
27
- return h;
12
+ return String(header || '').trim().toLowerCase();
13
+ }
14
+
15
+ function hostnameOf(value) {
16
+ try { return new URL('http://' + authority(value)).hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); }
17
+ catch (_) { return ''; }
18
+ }
19
+
20
+ function isLoopbackHost(hostname) {
21
+ if (hostname === 'localhost' || hostname === '::1') return true;
22
+ const match = hostname.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
23
+ return !!match && match.slice(1).every((part) => Number(part) <= 255);
24
+ }
25
+
26
+ /** Host 是否属于默认回环或显式信任列表。列表格式:逗号分隔 host / host:port。 */
27
+ export function isTrustedHost(hostHeader, configured = process.env.IRIS_TRUSTED_HOSTS || '') {
28
+ const host = authority(hostHeader);
29
+ const hostname = hostnameOf(host);
30
+ if (!host || !hostname) return false;
31
+ if (isLoopbackHost(hostname)) return true;
32
+ for (const raw of String(configured || '').split(',')) {
33
+ const entry = authority(raw);
34
+ if (!entry) continue;
35
+ if (entry === host) return true;
36
+ if (!entry.includes(':') && entry.replace(/\.$/, '') === hostname) return true;
37
+ }
38
+ return false;
28
39
  }
29
40
 
30
41
  /**
31
42
  * 纯函数裁决:{ok:true} 放行;否则 {ok:false, status, error}。
32
- * 非 POST 一律放行;POST 仅挡跨站/跨源(同源浏览器与无 Origin 的 CLI 放行)。
33
43
  * @param {{method?:string, headers?:object}} req node:http IncomingMessage 的最小面
34
44
  */
35
- export function checkRequest(req) {
45
+ export function checkRequest(req, options = {}) {
36
46
  const headers = req.headers || {};
37
47
  const method = String(req.method || 'GET').toUpperCase();
38
- if (method !== 'POST') return { ok: true }; // 读接口放开:跨源读受 CORS 挡,媒体有 token
48
+ if (!isTrustedHost(headers.host, options.trustedHosts)) {
49
+ return { ok: false, status: 403, error: 'untrusted host' };
50
+ }
51
+ if (method !== 'POST') return { ok: true };
39
52
 
40
53
  const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
41
54
  if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
@@ -50,7 +63,7 @@ export function checkRequest(req) {
50
63
  }
51
64
  if (oh !== authority(headers.host)) return { ok: false, status: 403, error: 'cross-origin request denied' };
52
65
  }
53
- return { ok: true }; // 同源浏览器,或无 Origin 的本机 CLI/curl
66
+ return { ok: true };
54
67
  }
55
68
 
56
69
  /** 包裹路由 handler:未授权请求统一 403 人话 JSON,不进业务 handler */