@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/CHANGELOG.md +31 -1
- package/README.md +28 -9
- package/docs/ROADMAP.md +31 -20
- package/docs/releases/0.1.1.md +51 -0
- package/lib/actions.js +33 -27
- package/lib/adapters.js +42 -26
- package/lib/api.js +31 -9
- package/lib/capability.js +1 -1
- package/lib/client.js +26 -17
- package/lib/config.js +56 -22
- package/lib/guard.js +37 -24
- package/lib/index.js +10 -8
- package/lib/locate.js +1 -1
- package/lib/media-probe.js +4 -4
- package/lib/media.js +1 -1
- package/lib/private-storage.js +77 -0
- package/lib/provider-protocol.js +25 -0
- package/lib/render.js +3 -2
- package/lib/tasks.js +21 -13
- package/lib/vision.js +2 -2
- package/package.json +19 -15
- package/user_guide.md +34 -9
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
|
-
|
|
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
|
|
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
|
|
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 = `${
|
|
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 = `${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(`${
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
const stream = ensureStream();
|
|
423
|
+
if (!stream.write(chunk) && typeof req.pause === 'function') {
|
|
404
424
|
req.pause();
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
124
|
+
const list = Array.isArray(raw?.providers) ? raw.providers : [];
|
|
95
125
|
let n = 0;
|
|
96
126
|
for (const p of list) {
|
|
97
|
-
if (!p ||
|
|
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
|
-
|
|
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
|
|
3
|
+
* Iris 请求守卫。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 (
|
|
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 };
|
|
66
|
+
return { ok: true };
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** 包裹路由 handler:未授权请求统一 403 人话 JSON,不进业务 handler */
|