@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.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 +143 -1
- package/README.en.md +109 -311
- package/README.md +106 -309
- package/bin/dsh-iris.js +452 -92
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/ARTIFACT_MANIFEST.md +4 -0
- package/docs/AUDIO_FLOWS.md +87 -0
- package/docs/BETA_SCOPE.md +42 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +101 -0
- package/docs/CORE_QUICKSTART.md +110 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +101 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +21 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +30 -5
- package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +42 -15
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +207 -0
- package/docs/MCP.md +212 -0
- package/docs/MEDIA_SCHEDULING.md +72 -0
- package/docs/MUSIC.md +2 -0
- package/docs/NETWORK_RESOURCES.md +106 -0
- package/docs/NETWORK_ROUTING.md +103 -0
- package/docs/NETWORK_WORKBENCH.md +65 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +193 -0
- package/docs/PLUGIN_DEVELOPMENT.md +39 -0
- package/docs/PLUGIN_STARTUP.md +113 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
- package/docs/PROVIDER_CREDENTIALS.md +67 -0
- package/docs/PROVIDER_EXTENSIONS.md +82 -0
- package/docs/PROVIDER_HEALTH.md +5 -1
- package/docs/PROVIDER_NETWORK.md +45 -0
- package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
- package/docs/PROVIDER_SETTINGS.md +91 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +1 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
- package/docs/VIDEO_FLOWS.md +89 -0
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +2 -0
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_ARTIFACTS.md +6 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/releases/0.3.0-beta.1.md +34 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +56 -4
- package/lib/adapters.js +75 -49
- package/lib/api.js +23 -13
- package/lib/application-runtime.js +73 -17
- package/lib/application-writer.js +193 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.d.ts +3 -3
- package/lib/client.js +402 -72
- package/lib/comfyui-adapter.js +95 -0
- package/lib/comfyui-client.js +60 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/command-service.js +18 -5
- package/lib/config.js +49 -10
- package/lib/configured-image-runtime.js +69 -0
- package/lib/connection-skill.js +98 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +84 -28
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +85 -0
- package/lib/core-capability-catalog.js +192 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-delivery-cache.js +8 -3
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +174 -0
- package/lib/core-tasks.js +113 -21
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +41 -64
- package/lib/doctor.js +2 -2
- package/lib/dsh-core-adapter.js +61 -9
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-provider-config.js +1 -1
- package/lib/dsh-service-bridge.js +67 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +156 -14
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +97 -31
- package/lib/index.js +47 -22
- package/lib/local-api-projection.js +15 -6
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +861 -71
- package/lib/local-creation.js +96 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +39 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +174 -0
- package/lib/mcp-server.js +69 -0
- package/lib/media-scheduler.js +194 -0
- package/lib/model-directory-service.js +37 -10
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +6 -3
- package/lib/network-config-service.js +123 -0
- package/lib/network-config.js +127 -0
- package/lib/network-doctor.js +94 -0
- package/lib/network-management.js +51 -0
- package/lib/network-policy.js +19 -0
- package/lib/network-route-evidence.js +38 -0
- package/lib/network-routing.js +283 -0
- package/lib/operation-runner.js +281 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +307 -0
- package/lib/plugins.js +59 -0
- package/lib/provider-adapter.js +7 -3
- package/lib/provider-adapters.js +92 -22
- package/lib/provider-catalog.js +64 -36
- package/lib/provider-config-format.js +51 -0
- package/lib/provider-config-service.js +143 -31
- package/lib/provider-context.d.ts +90 -0
- package/lib/provider-context.js +42 -0
- package/lib/provider-credentials.js +97 -0
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +18 -6
- package/lib/provider-monitor.js +81 -0
- package/lib/provider-network.js +52 -0
- package/lib/provider-protocol.js +73 -22
- package/lib/provider-registry.js +128 -0
- package/lib/provider-response.js +38 -0
- package/lib/provider-settings.js +136 -0
- package/lib/provider-task-runner.js +132 -43
- package/lib/provider-transport.js +16 -0
- package/lib/render.js +11 -1
- package/lib/scheduling-policy.js +66 -0
- package/lib/skill-installation.js +23 -11
- package/lib/tasks.js +3 -2
- package/lib/video-flow-input.js +16 -0
- package/lib/video-input.js +12 -5
- package/lib/vision-model-routing.js +5 -0
- package/lib/web/app.js +703 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +516 -0
- package/lib/web/creation.js +359 -0
- package/lib/web/flow-view.js +34 -0
- package/lib/web/gallery.js +133 -0
- package/lib/web/index.html +344 -0
- package/lib/web/media-client.js +218 -0
- package/lib/web/network.js +232 -0
- package/lib/web/plugin-panel.js +223 -0
- package/lib/web/plugin-status.js +140 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +513 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +571 -0
- package/lib/web/transport.js +101 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +3 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +24 -6
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/templates/plugin-starter/LICENSE +21 -0
- package/templates/plugin-starter/README.md +74 -0
- package/templates/plugin-starter/assets/peach.png +0 -0
- package/templates/plugin-starter/assets/violet.png +0 -0
- package/templates/plugin-starter/index.mjs +27 -0
- package/templates/plugin-starter/package.json +18 -0
- package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
- package/templates/plugin-starter/scripts/environment.mjs +65 -0
- package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
- package/templates/plugin-starter/scripts/setup.mjs +42 -0
- package/templates/plugin-starter/scripts/smoke.mjs +76 -0
- package/templates/plugin-starter/web/board.css +27 -0
- package/templates/plugin-starter/web/board.mjs +160 -0
- package/templates/plugin-starter/web/png.mjs +23 -0
- package/templates/provider-starter/LICENSE +21 -0
- package/templates/provider-starter/README.md +50 -0
- package/templates/provider-starter/index.d.mts +5 -0
- package/templates/provider-starter/index.mjs +165 -0
- package/templates/provider-starter/package.json +13 -0
- package/templates/provider-starter/scripts/core-cli.mjs +22 -0
- package/templates/provider-starter/scripts/environment.mjs +122 -0
- package/templates/provider-starter/scripts/fixture.mjs +54 -0
- package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
- package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
- package/templates/provider-starter/scripts/setup.mjs +44 -0
- package/templates/provider-starter/scripts/smoke.mjs +145 -0
- package/user_guide.md +5 -1
package/lib/mcp-http.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { pipeline } from 'node:stream/promises';
|
|
3
|
+
import { createCoreServiceClient } from './core-service-client.js';
|
|
4
|
+
import { createMcpServerFactory, reportMcpClientInfo } from './mcp-server.js';
|
|
5
|
+
import { projectedMcpClient } from './core-connections.js';
|
|
6
|
+
|
|
7
|
+
const MAX_REQUEST_BYTES = 9 * 1024 * 1024, MAX_ACTIVE_MESSAGES = 4;
|
|
8
|
+
|
|
9
|
+
async function readBody(request) {
|
|
10
|
+
if (Number(request.headers['content-length']) > MAX_REQUEST_BYTES) return { tooLarge: true };
|
|
11
|
+
const chunks = []; let size = 0;
|
|
12
|
+
// Keep the Node stream available to discard unread bytes after an early rejection.
|
|
13
|
+
// An unconsumed Web-stream bridge can pause that drain at its own high-water mark.
|
|
14
|
+
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
|
|
15
|
+
size += chunk.length;
|
|
16
|
+
if (size > MAX_REQUEST_BYTES) return { tooLarge: true };
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
}
|
|
19
|
+
return { tooLarge: false, text: new TextDecoder().decode(Buffer.concat(chunks, size)) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function rejectRequest(request, response, status, code, message, rpcCode = -32000) {
|
|
23
|
+
if (response.destroyed) return Promise.resolve();
|
|
24
|
+
const bytes = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: rpcCode, message, data: { code } } }));
|
|
25
|
+
return new Promise(resolve => {
|
|
26
|
+
let cleanupRequest = () => {};
|
|
27
|
+
const finish = () => { cleanupRequest(); response.off('finish', finish); response.off('close', finish); resolve(); };
|
|
28
|
+
response.once('finish', finish); response.once('close', finish);
|
|
29
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': bytes.length,
|
|
30
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
31
|
+
Connection: 'close', ...(status === 429 ? { 'Retry-After': '1' } : {}) });
|
|
32
|
+
if (!request.complete && !request.destroyed) {
|
|
33
|
+
// As in the Core HTTP edge, deliver the error before closing an unread upload.
|
|
34
|
+
// Discard without buffering; the HTTP server's existing timeouts bound the drain.
|
|
35
|
+
const end = () => { cleanupRequest(); response.end(); };
|
|
36
|
+
cleanupRequest = () => { request.off('end', end); request.off('close', end); };
|
|
37
|
+
request.once('end', end); request.once('close', end);
|
|
38
|
+
if (request.method === 'HEAD') response.flushHeaders(); else response.write(bytes);
|
|
39
|
+
request.resume();
|
|
40
|
+
} else response.end(request.method === 'HEAD' ? undefined : bytes);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Standard HTTP MCP edge. Core owns every business write; this edge only owns its local connections. */
|
|
45
|
+
export function createMcpHttpGateway({ getOrigin, authenticate, availableScopes, version }) {
|
|
46
|
+
const entries = new Map(), jobs = new Set(), lifetime = new AbortController();
|
|
47
|
+
let state = 'new', createMcpHandler, disposal, starting, activeMessages = 0;
|
|
48
|
+
function start() {
|
|
49
|
+
if (state !== 'new') return Promise.reject(Object.assign(new Error('MCP HTTP 入口只能显式启动一次'), { code: 'IRIS_MCP_CLOSED' }));
|
|
50
|
+
state = 'starting';
|
|
51
|
+
starting = (async () => {
|
|
52
|
+
({ createMcpHandler } = await import('@modelcontextprotocol/server'));
|
|
53
|
+
if (state !== 'starting') throw Object.assign(new Error('MCP HTTP 启动已停止'), { code: 'IRIS_MCP_CLOSED' });
|
|
54
|
+
state = 'open';
|
|
55
|
+
})();
|
|
56
|
+
return starting;
|
|
57
|
+
}
|
|
58
|
+
function entryFor(grant, accessToken) {
|
|
59
|
+
if (entries.has(grant.connectionId)) return entries.get(grant.connectionId).ready;
|
|
60
|
+
const { scopes, ...identity } = grant;
|
|
61
|
+
const client = createCoreServiceClient({ connection: { schemaVersion: 0, origin: getOrigin(), ...identity, accessToken },
|
|
62
|
+
requestedScopes: scopes.filter(scope => availableScopes.includes(scope)), harnessVersion: 'Iris HTTP MCP bridge ' + version });
|
|
63
|
+
const entry = { client, ready: null };
|
|
64
|
+
entry.ready = (async () => {
|
|
65
|
+
try {
|
|
66
|
+
await client.connect();
|
|
67
|
+
const { factory } = await createMcpServerFactory({ client, version, transport: 'http' });
|
|
68
|
+
const handler = createMcpHandler(context => {
|
|
69
|
+
const server = factory();
|
|
70
|
+
if (context.authInfo?.extra?.irisObservation) context.authInfo.extra.irisObservation.server = server;
|
|
71
|
+
return server;
|
|
72
|
+
}, { legacy: 'stateless', maxRequestBodySize: MAX_REQUEST_BYTES,
|
|
73
|
+
maxSubscriptions: 4, onerror: () => console.error('[iris] MCP HTTP protocol error; private input omitted') });
|
|
74
|
+
return { client, handler };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (entries.get(grant.connectionId) === entry) entries.delete(grant.connectionId);
|
|
77
|
+
await client.dispose(); throw error;
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
entries.set(grant.connectionId, entry);
|
|
81
|
+
return entry.ready;
|
|
82
|
+
}
|
|
83
|
+
async function handleRequest(request, response, accessToken) {
|
|
84
|
+
if (state !== 'open') throw Object.assign(new Error('MCP HTTP 服务正在关闭'), { code: 'IRIS_API_UNAVAILABLE' });
|
|
85
|
+
const grant = authenticate(accessToken);
|
|
86
|
+
const disconnected = new AbortController();
|
|
87
|
+
const onClose = () => { if (!response.writableFinished) disconnected.abort(); };
|
|
88
|
+
response.once('close', onClose);
|
|
89
|
+
const signal = AbortSignal.any([disconnected.signal, lifetime.signal]);
|
|
90
|
+
const onAbort = () => { request.destroy(); response.destroy(); };
|
|
91
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
let reserved = 0;
|
|
93
|
+
try {
|
|
94
|
+
if (activeMessages >= MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 429,
|
|
95
|
+
'IRIS_MCP_HTTP_BUSY', 'MCP HTTP is busy; retry after an active response finishes.');
|
|
96
|
+
activeMessages++; reserved = 1;
|
|
97
|
+
const headers = new Headers();
|
|
98
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
99
|
+
if (Array.isArray(value)) for (const item of value) headers.append(name, item);
|
|
100
|
+
else if (value !== undefined) headers.set(name, value);
|
|
101
|
+
}
|
|
102
|
+
let parsedBody;
|
|
103
|
+
if (request.method === 'POST') {
|
|
104
|
+
const body = await readBody(request);
|
|
105
|
+
if (body.tooLarge) return await rejectRequest(request, response, 413,
|
|
106
|
+
'IRIS_MCP_HTTP_BODY_TOO_LARGE', 'MCP request body exceeds the 9 MiB limit.');
|
|
107
|
+
try { parsedBody = JSON.parse(body.text); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
110
|
+
return await rejectRequest(request, response, 400, 'IRIS_MCP_HTTP_PARSE_ERROR', 'Parse error: invalid JSON.', -32700);
|
|
111
|
+
}
|
|
112
|
+
// One HTTP batch can otherwise fan out into many complete base64 media responses.
|
|
113
|
+
const count = Array.isArray(parsedBody) ? Math.max(1, parsedBody.length) : 1;
|
|
114
|
+
if (count > MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 400,
|
|
115
|
+
'IRIS_MCP_HTTP_BATCH_TOO_LARGE', 'MCP HTTP batches must not exceed 4 messages.', -32600);
|
|
116
|
+
if (activeMessages + count - reserved > MAX_ACTIVE_MESSAGES) return await rejectRequest(request, response, 429,
|
|
117
|
+
'IRIS_MCP_HTTP_BUSY', 'MCP HTTP is busy; retry after an active response finishes.');
|
|
118
|
+
activeMessages += count - reserved; reserved = count;
|
|
119
|
+
}
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
const input = new Request(getOrigin() + '/mcp', { method: request.method, headers, signal,
|
|
122
|
+
...(['GET', 'HEAD', 'POST'].includes(request.method) ? {} : { body: Readable.toWeb(request), duplex: 'half' }) });
|
|
123
|
+
const { handler, client } = await entryFor(grant, accessToken);
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
const observation = {};
|
|
126
|
+
const result = await handler.fetch(input, { ...(request.method === 'POST' ? { parsedBody } : {}),
|
|
127
|
+
authInfo: { token: accessToken, clientId: grant.connectionId,
|
|
128
|
+
scopes: grant.scopes, extra: { irisObservation: observation } } });
|
|
129
|
+
if (result.ok && observation.server) void reportMcpClientInfo(client, projectedMcpClient(observation.server.server.getClientVersion()), 'http');
|
|
130
|
+
if (signal.aborted || response.destroyed) { await result.body?.cancel(); return; }
|
|
131
|
+
response.writeHead(result.status, { ...Object.fromEntries(result.headers), 'Cache-Control': 'no-store',
|
|
132
|
+
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
|
|
133
|
+
if (result.body && request.method !== 'HEAD') await pipeline(Readable.fromWeb(result.body), response, { signal });
|
|
134
|
+
else response.end();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!signal.aborted) throw error;
|
|
137
|
+
} finally {
|
|
138
|
+
activeMessages -= reserved;
|
|
139
|
+
response.off('close', onClose); signal.removeEventListener('abort', onAbort);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function handle(request, response, accessToken) {
|
|
143
|
+
const job = handleRequest(request, response, accessToken); jobs.add(job);
|
|
144
|
+
void job.then(() => jobs.delete(job), () => jobs.delete(job));
|
|
145
|
+
return job;
|
|
146
|
+
}
|
|
147
|
+
function dispose() {
|
|
148
|
+
if (disposal) return disposal;
|
|
149
|
+
state = 'closing'; lifetime.abort();
|
|
150
|
+
const pending = [...entries.values()];
|
|
151
|
+
const disconnecting = pending.map(entry => entry.client.dispose());
|
|
152
|
+
for (const result of disconnecting) void result.catch(() => {});
|
|
153
|
+
disposal = (async () => {
|
|
154
|
+
if (starting) await Promise.allSettled([starting]);
|
|
155
|
+
const ready = await Promise.allSettled(pending.map(entry => entry.ready));
|
|
156
|
+
const results = await Promise.allSettled([...disconnecting, ...ready.filter(result => result.status === 'fulfilled').map(result => result.value.handler.close())]);
|
|
157
|
+
await Promise.allSettled([...jobs]);
|
|
158
|
+
entries.clear(); state = 'closed';
|
|
159
|
+
const failed = results.find(result => result.status === 'rejected');
|
|
160
|
+
if (failed) throw failed.reason;
|
|
161
|
+
})();
|
|
162
|
+
return disposal;
|
|
163
|
+
}
|
|
164
|
+
async function invalidateConnection(id) {
|
|
165
|
+
const pending = entries.get(id); entries.delete(id);
|
|
166
|
+
if (pending) {
|
|
167
|
+
const closing = pending.client.dispose(); void closing.catch(() => {});
|
|
168
|
+
const [ready] = await Promise.allSettled([pending.ready]);
|
|
169
|
+
const results = await Promise.allSettled([closing, ...(ready.status === 'fulfilled' ? [ready.value.handler.close()] : [])]);
|
|
170
|
+
const failed = results.find(result => result.status === 'rejected'); if (failed) throw failed.reason;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return Object.freeze({ start, handle, invalidateConnection, dispose, snapshot: () => ({ state, connections: entries.size }) });
|
|
174
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { bridgeToolsForScopes, callCoreBridgeTool } from './core-bridge-tools.js';
|
|
2
|
+
import { projectedMcpClient } from './core-connections.js';
|
|
3
|
+
import { coreConnectionDiagnostic } from './core-connection-diagnostics.js';
|
|
4
|
+
import { VIDEO_TYPES } from './video-flow-input.js';
|
|
5
|
+
|
|
6
|
+
/** Client labels are optional observations. Their failure must not delay a Core
|
|
7
|
+
* command or discard its receipt; command authorization remains in Core. */
|
|
8
|
+
export async function reportMcpClientInfo(client, clientInfo, transport) {
|
|
9
|
+
try { await client.reportClientInfo(clientInfo, transport); }
|
|
10
|
+
catch (error) {
|
|
11
|
+
console.error('[iris] MCP client declaration not recorded: ' + (/^IRIS_[A-Z_]+$/.test(error?.code) ? error.code : 'IRIS_MCP_METADATA_FAILED'));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One official-SDK tool factory for both standard transports and protocol eras. */
|
|
16
|
+
export async function createMcpServerFactory({ client, version, transport = 'stdio' }) {
|
|
17
|
+
const [{ McpServer, ResourceTemplate, ResourceNotFoundError }, { z }] = await Promise.all([import('@modelcontextprotocol/server'), import('zod')]);
|
|
18
|
+
const snapshot = client.snapshot();
|
|
19
|
+
const tools = bridgeToolsForScopes(snapshot.scopes, snapshot.capabilities, snapshot.tools, snapshot.pluginOperations);
|
|
20
|
+
const factory = () => {
|
|
21
|
+
const server = new McpServer({ name: 'iris-core', version }, { capabilities: { tools: {} },
|
|
22
|
+
instructions: 'Iris is a proxy to an explicitly started Core. Inspect the granted catalog first. Execute only a user-selected operation; preserve its request UUID and exact dependency. An Operation receipt is not completion. After interruption or lost responses, inspect/lookup the original Operation; never automatically replay a write or start another Core. Display actual saved image content only after reading it.' });
|
|
23
|
+
if (transport === 'stdio') server.server.oninitialized = () => {
|
|
24
|
+
void reportMcpClientInfo(client, projectedMcpClient(server.server.getClientVersion()), transport);
|
|
25
|
+
};
|
|
26
|
+
if (tools.some(tool => tool.name === 'iris_core_video')) {
|
|
27
|
+
server.registerResource('iris-video', new ResourceTemplate('iris://video/{artifactId}', { list: undefined }),
|
|
28
|
+
{ title: 'Saved Iris video', description: 'Original saved MP4/WebM, up to 32 MiB. Every read requires the current Core video grant.' },
|
|
29
|
+
async (uri, variables, context) => {
|
|
30
|
+
const artifactId = variables.artifactId;
|
|
31
|
+
try {
|
|
32
|
+
if (typeof artifactId !== 'string' || !/^artifact_[a-f0-9]{24}$/.test(artifactId) || uri.href !== 'iris://video/' + artifactId) {
|
|
33
|
+
throw Object.assign(new Error('Invalid video reference'), { code: 'IRIS_CONNECTION_INPUT_INVALID' });
|
|
34
|
+
}
|
|
35
|
+
const { video } = await callCoreBridgeTool(client, 'iris_core_video', { artifactId }, { signal: context.mcpReq.signal });
|
|
36
|
+
return { contents: [{ uri: uri.href, mimeType: video.mediaType, blob: video.bytes.toString('base64') }] };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const diagnostic = coreConnectionDiagnostic(error, { stage: 'tool' });
|
|
39
|
+
throw new ResourceNotFoundError('iris://video', diagnostic.code + ': ' + diagnostic.message + ' ' + diagnostic.action);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
for (const tool of tools) server.registerTool(tool.name, {
|
|
44
|
+
description: tool.description, inputSchema: z.fromJSONSchema(tool.parameters),
|
|
45
|
+
annotations: { readOnlyHint: tool.scope === 'read', destructiveHint: tool.scope === 'stop',
|
|
46
|
+
idempotentHint: true, openWorldHint: tool.scope !== 'read' }
|
|
47
|
+
}, async (args, context) => {
|
|
48
|
+
try {
|
|
49
|
+
void reportMcpClientInfo(client, projectedMcpClient(context.mcpReq.envelope?.['io.modelcontextprotocol/clientInfo'] || server.server.getClientVersion()), transport);
|
|
50
|
+
const result = await callCoreBridgeTool(client, tool.name, args, { signal: context.mcpReq.signal });
|
|
51
|
+
if (result.image) return { content: [{ type: 'image', data: result.image.bytes.toString('base64'), mimeType: result.image.mediaType },
|
|
52
|
+
{ type: 'text', text: JSON.stringify({ artifactId: result.image.artifactId, bytes: result.image.bytes.length }) }] };
|
|
53
|
+
if (result.audio) return { content: [{ type: 'audio', data: result.audio.bytes.toString('base64'), mimeType: result.audio.mediaType },
|
|
54
|
+
{ type: 'text', text: JSON.stringify({ artifactId: result.audio.artifactId, bytes: result.audio.bytes.length }) }] };
|
|
55
|
+
if (result.video) return { content: [{ type: 'resource_link', uri: 'iris://video/' + result.video.artifactId,
|
|
56
|
+
name: 'iris-' + result.video.artifactId + '.' + VIDEO_TYPES[result.video.mediaType], mimeType: result.video.mediaType, size: result.video.bytes.length },
|
|
57
|
+
{ type: 'text', text: JSON.stringify({ artifactId: result.video.artifactId, bytes: result.video.bytes.length,
|
|
58
|
+
note: 'Read this saved resource with resources/read. Playback depends on the client; this reference does not prove it was watched.' }) }] };
|
|
59
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.value) }], structuredContent: result.value };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const diagnostic = coreConnectionDiagnostic(error, { stage: 'tool', references: args });
|
|
62
|
+
return { isError: true, content: [{ type: 'text', text: diagnostic.code + ' [' + diagnostic.stage + ']: ' + diagnostic.message + ' ' + diagnostic.action
|
|
63
|
+
+ (diagnostic.requestId ? ' Request ID: ' + diagnostic.requestId : '') }], structuredContent: { contractVersion: 0, error: diagnostic } };
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return server;
|
|
67
|
+
};
|
|
68
|
+
return { factory, tools: tools.map(tool => tool.name) };
|
|
69
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Instance-owned admission. No durable work loop and no automatic request replay.
|
|
2
|
+
import { AsyncResource } from 'node:async_hooks';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { parseModelRef } from './models.js';
|
|
5
|
+
import { coreRemoteTaskIdentities, CoreTaskError } from './core-tasks.js';
|
|
6
|
+
import { checkedSchedulingPolicy, schedulingError } from './scheduling-policy.js';
|
|
7
|
+
import { retryAfterDelay } from './provider-health.js';
|
|
8
|
+
|
|
9
|
+
export function createMediaScheduler(input) {
|
|
10
|
+
const policy = checkedSchedulingPolicy(input), http = [], scopes = new Map(), changes = new Set(), remoteVersions = new Map();
|
|
11
|
+
let timer, closed = false, active = 0, sent = 0, completed = 0, pumping = false;
|
|
12
|
+
function selected(context) {
|
|
13
|
+
const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
|
|
14
|
+
const providerId = context.providerId || parsed?.providerId;
|
|
15
|
+
const provider = policy.providers.find(value => value.providerId === providerId);
|
|
16
|
+
const model = provider?.models.find(value => value.modelId === parsed?.modelId);
|
|
17
|
+
return [['global', policy], ...(provider ? [[JSON.stringify([providerId]), provider]] : []),
|
|
18
|
+
...(model ? [[JSON.stringify([providerId, parsed.modelId]), model]] : [])];
|
|
19
|
+
}
|
|
20
|
+
function state(key) {
|
|
21
|
+
if (!scopes.has(key)) scopes.set(key, { active: 0, times: [], cooldownUntil: 0 });
|
|
22
|
+
return scopes.get(key);
|
|
23
|
+
}
|
|
24
|
+
function remove(job) { const index = http.indexOf(job); if (index >= 0) http.splice(index, 1); job.signal?.removeEventListener('abort', job.abort); }
|
|
25
|
+
function pump() {
|
|
26
|
+
if (pumping) return;
|
|
27
|
+
pumping = true;
|
|
28
|
+
try { drain(); } finally { pumping = false; }
|
|
29
|
+
}
|
|
30
|
+
function drain() {
|
|
31
|
+
clearTimeout(timer); timer = undefined;
|
|
32
|
+
let next = Infinity;
|
|
33
|
+
for (const job of [...http]) {
|
|
34
|
+
const now = performance.now();
|
|
35
|
+
if (now >= job.deadline) {
|
|
36
|
+
remove(job); job.fail(schedulingError('IRIS_SCHEDULER_QUEUE_TIMEOUT', 'HTTP 派发等候超时;本次请求未发送')); continue;
|
|
37
|
+
}
|
|
38
|
+
const relevant = selected(job.context).map(([key, limits]) => ({ ...limits, state: state(key) }));
|
|
39
|
+
let ready = true, at = now + cooldownDelay(job.context);
|
|
40
|
+
for (const entry of relevant) {
|
|
41
|
+
entry.state.times = entry.state.times.filter(time => now - time < 1000);
|
|
42
|
+
if (entry.httpConcurrency && entry.state.active >= entry.httpConcurrency) ready = false;
|
|
43
|
+
if (entry.requestsPerSecond && entry.state.times.length >= entry.requestsPerSecond) at = Math.max(at, entry.state.times[0] + 1000);
|
|
44
|
+
at = Math.max(at, entry.state.cooldownUntil);
|
|
45
|
+
}
|
|
46
|
+
if (!ready || at > now) {
|
|
47
|
+
job.context.onSchedulingState?.(cooldownDelay(job.context) > 0 ? 'cooldown' : at > now ? 'rate_limit' : 'http_queue');
|
|
48
|
+
next = Math.min(next, job.deadline, at > now ? at : Infinity); continue;
|
|
49
|
+
}
|
|
50
|
+
remove(job); active++; sent++;
|
|
51
|
+
for (const entry of relevant) { entry.state.active++; entry.state.times.push(now); }
|
|
52
|
+
let released = false;
|
|
53
|
+
const release = () => {
|
|
54
|
+
if (released) return; released = true; active--; completed++;
|
|
55
|
+
for (const entry of relevant) entry.state.active--;
|
|
56
|
+
pump();
|
|
57
|
+
};
|
|
58
|
+
// Timestamp and the actual dispatcher invocation share this stack, not a reservation promise.
|
|
59
|
+
job.context.onSchedulingState?.('http_running');
|
|
60
|
+
job.start(release);
|
|
61
|
+
}
|
|
62
|
+
if (http.length && Number.isFinite(next)) timer = setTimeout(pump, Math.max(1, Math.ceil(next - performance.now())));
|
|
63
|
+
}
|
|
64
|
+
function dispatch(context, start, fail, signal) {
|
|
65
|
+
if (closed) { fail(schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭')); return; }
|
|
66
|
+
if (signal?.aborted) { fail(signal.reason); return; }
|
|
67
|
+
if (http.length >= policy.queueLimit) { fail(schedulingError('IRIS_SCHEDULER_QUEUE_FULL', 'HTTP 等候队列已满;本次请求未发送')); return; }
|
|
68
|
+
const resource = new AsyncResource('IrisHttpDispatch');
|
|
69
|
+
const job = { context, signal, deadline: performance.now() + policy.queueTimeoutMs,
|
|
70
|
+
start(release) { try { resource.runInAsyncScope(start, undefined, release); } finally { resource.emitDestroy(); } },
|
|
71
|
+
fail(error) { try { resource.runInAsyncScope(fail, undefined, error); } finally { resource.emitDestroy(); } } };
|
|
72
|
+
job.abort = () => { remove(job); job.fail(signal.reason); pump(); };
|
|
73
|
+
signal?.addEventListener('abort', job.abort, { once: true });
|
|
74
|
+
http.push(job); pump();
|
|
75
|
+
}
|
|
76
|
+
function cooldown(context, delay) {
|
|
77
|
+
if (!Number.isFinite(delay) || delay < 0) return;
|
|
78
|
+
// A model-specific 429 blocks future requests for that model, never unrelated providers.
|
|
79
|
+
const entries = selected(context), target = entries.at(-1);
|
|
80
|
+
// Unconfigured provider/model limits still need an isolated cooldown scope.
|
|
81
|
+
const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
|
|
82
|
+
const key = parsed ? JSON.stringify([parsed.providerId, parsed.modelId]) : context.providerId ? JSON.stringify([context.providerId]) : target[0];
|
|
83
|
+
state(key).cooldownUntil = Math.max(state(key).cooldownUntil, performance.now() + delay);
|
|
84
|
+
changed(); pump();
|
|
85
|
+
}
|
|
86
|
+
function cooldownDelay(context) {
|
|
87
|
+
const parsed = parseModelRef(context.modelRef || context.attempt?.model || '');
|
|
88
|
+
const ids = [JSON.stringify([context.providerId || parsed?.providerId]), ...(parsed ? [JSON.stringify([parsed.providerId, parsed.modelId])] : [])];
|
|
89
|
+
return Math.max(0, ...ids.map(key => (scopes.get(key)?.cooldownUntil || 0) - performance.now()));
|
|
90
|
+
}
|
|
91
|
+
function changed(dataRoot) {
|
|
92
|
+
if (dataRoot) {
|
|
93
|
+
if (!changes.size) return;
|
|
94
|
+
let version;
|
|
95
|
+
try { version = JSON.stringify([...remoteCounts(dataRoot)].sort(([a], [b]) => a.localeCompare(b))); }
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!(error instanceof CoreTaskError)) throw error;
|
|
98
|
+
// Wake the admissions that need this count, without turning a read error
|
|
99
|
+
// into a failed release of an unrelated writer turn.
|
|
100
|
+
for (const reject of [...changes]) reject(error);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (remoteVersions.get(dataRoot) === version) return;
|
|
104
|
+
remoteVersions.set(dataRoot, version);
|
|
105
|
+
}
|
|
106
|
+
for (const resolve of [...changes]) resolve();
|
|
107
|
+
}
|
|
108
|
+
function change(signal, timeoutMs) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
let timeout;
|
|
111
|
+
const done = (error) => { clearTimeout(timeout); changes.delete(wake); signal?.removeEventListener('abort', abort); error ? reject(error) : resolve(); };
|
|
112
|
+
const wake = error => done(error), abort = () => done(signal.reason);
|
|
113
|
+
if (signal?.aborted) { reject(signal.reason); return; }
|
|
114
|
+
changes.add(wake); signal?.addEventListener('abort', abort, { once: true });
|
|
115
|
+
timeout = setTimeout(wake, Math.max(1, timeoutMs));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function remoteCounts(dataRoot) {
|
|
119
|
+
const result = new Map([['global', 0]]);
|
|
120
|
+
for (const task of coreRemoteTaskIdentities(dataRoot)) {
|
|
121
|
+
result.set('global', result.get('global') + 1);
|
|
122
|
+
const parsed = parseModelRef(task.modelRef || '');
|
|
123
|
+
if (parsed) for (const key of [JSON.stringify([parsed.providerId]), JSON.stringify([parsed.providerId, parsed.modelId])]) result.set(key, (result.get(key) || 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
async function beforeSubmit(runtime, dataRoot, context, readyAt) {
|
|
128
|
+
const deadline = performance.now() + policy.queueTimeoutMs;
|
|
129
|
+
for (;;) {
|
|
130
|
+
if (closed) throw schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭');
|
|
131
|
+
runtime.signal.throwIfAborted();
|
|
132
|
+
const counts = remoteCounts(dataRoot), full = selected(context).some(([key, limits]) => limits.remoteLimit && (counts.get(key) || 0) >= limits.remoteLimit);
|
|
133
|
+
const delay = Math.max(cooldownDelay(context), (readyAt?.() || 0) - Date.now());
|
|
134
|
+
if (!full && delay <= 0) return;
|
|
135
|
+
const left = deadline - performance.now();
|
|
136
|
+
if (left <= 0) throw schedulingError('IRIS_SCHEDULER_QUEUE_TIMEOUT', full
|
|
137
|
+
? '远端未结束任务名额等候超时;未发送新的提交,请查询原任务'
|
|
138
|
+
: '模型冷却等候超时;未发送新的提交');
|
|
139
|
+
// A bounded wait is released by another Core commit or the known cooldown/deadline.
|
|
140
|
+
await runtime.networkWait(() => change(runtime.signal, Math.min(left, delay > 0 ? delay : left)), full ? 'remote_capacity' : 'cooldown');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function dispatcher(target, context, signal) {
|
|
144
|
+
let dispatches = 0;
|
|
145
|
+
return { get dispatches() { return dispatches; }, dispatch(options, handler) {
|
|
146
|
+
let abortRemote, finished = false, release;
|
|
147
|
+
const queued = new AbortController();
|
|
148
|
+
const combined = signal ? AbortSignal.any([signal, queued.signal]) : queued.signal;
|
|
149
|
+
const end = () => { if (finished) return false; finished = true; release?.(); return true; };
|
|
150
|
+
const fail = error => { if (end()) handler.onError(error); };
|
|
151
|
+
// Give fetch its abort port while still queued. The real dispatcher supplies the socket abort later.
|
|
152
|
+
handler.onConnect(error => { queued.abort(error); abortRemote?.(error); });
|
|
153
|
+
dispatch(context, done => {
|
|
154
|
+
release = done;
|
|
155
|
+
const wrapped = {
|
|
156
|
+
onConnect(abort) { abortRemote = abort; if (combined.aborted) abort(combined.reason); },
|
|
157
|
+
onHeaders(status, headers, resume, statusText) {
|
|
158
|
+
if (status === 429) {
|
|
159
|
+
let retry;
|
|
160
|
+
for (let i = 0; i < headers.length; i += 2) if (String(headers[i]).toLowerCase() === 'retry-after') retry = String(headers[i + 1]);
|
|
161
|
+
cooldown(context, retryAfterDelay(retry));
|
|
162
|
+
}
|
|
163
|
+
return handler.onHeaders(status, headers, resume, statusText);
|
|
164
|
+
},
|
|
165
|
+
onData: chunk => handler.onData(chunk),
|
|
166
|
+
onComplete(trailers) { if (end()) handler.onComplete(trailers); },
|
|
167
|
+
onError: fail,
|
|
168
|
+
...(handler.onResponseStarted ? { onResponseStarted: () => handler.onResponseStarted() } : {}),
|
|
169
|
+
...(handler.onBodySent ? { onBodySent: (...args) => handler.onBodySent(...args) } : {}),
|
|
170
|
+
...(handler.onUpgrade ? { onUpgrade(...args) { end(); return handler.onUpgrade(...args); } } : {}),
|
|
171
|
+
...(handler.onRequestUpgrade ? { onRequestUpgrade(...args) { end(); return handler.onRequestUpgrade(...args); } } : {})
|
|
172
|
+
};
|
|
173
|
+
try { dispatches++; target.dispatch(options, wrapped); } catch (error) { fail(error); }
|
|
174
|
+
}, fail, combined);
|
|
175
|
+
return true;
|
|
176
|
+
} };
|
|
177
|
+
}
|
|
178
|
+
function snapshot(dataRoot) {
|
|
179
|
+
let remote;
|
|
180
|
+
if (dataRoot) {
|
|
181
|
+
try { remote = { remoteOutstanding: remoteCounts(dataRoot).get('global') }; }
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (!(error instanceof CoreTaskError)) throw error;
|
|
184
|
+
remote = { remoteOutstanding: null, remoteError: error.code };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({ enabled: true, httpActive: active, httpQueued: http.length, requestsStarted: sent, requestsCompleted: completed,
|
|
188
|
+
...remote,
|
|
189
|
+
limits: { httpConcurrency: policy.httpConcurrency, requestsPerSecond: policy.requestsPerSecond, remoteLimit: policy.remoteLimit,
|
|
190
|
+
queueLimit: policy.queueLimit, queueTimeoutMs: policy.queueTimeoutMs } });
|
|
191
|
+
}
|
|
192
|
+
return Object.freeze({ policy, dispatch, dispatcher, beforeSubmit, changed, cooldown, snapshot,
|
|
193
|
+
dispose() { closed = true; clearTimeout(timer); for (const job of [...http]) { remove(job); job.fail(schedulingError('IRIS_SCHEDULER_CLOSED', '调度实例已关闭')); } changed(); } });
|
|
194
|
+
}
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { listModels } from './adapters.js';
|
|
2
2
|
import { capabilitiesOfDiscoveredModel } from './models.js';
|
|
3
|
-
import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol } from './provider-protocol.js';
|
|
3
|
+
import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerProtocolDescriptor } from './provider-protocol.js';
|
|
4
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
5
|
+
import { invokeProviderOperation } from './provider-adapter.js';
|
|
4
6
|
import { redactProviderMessage } from './provider-contract.js';
|
|
5
7
|
import { MODEL_DIRECTORY_TTL_MS, MODEL_DIRECTORY_RETRY_MS, currentModelDirectory, modelDirectoryBinding } from './model-directory.js';
|
|
8
|
+
import { assertDiscoveryCredentials } from './provider-credentials.js';
|
|
9
|
+
import { assertProviderNetwork } from './provider-network.js';
|
|
10
|
+
import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
|
|
6
11
|
|
|
7
12
|
const pending = new Map();
|
|
13
|
+
const registryPending = new WeakMap();
|
|
14
|
+
const networkPending = new WeakMap();
|
|
8
15
|
|
|
9
16
|
/** 只有完整发现成功才替换快照;相同连接合并网络读取,各调用方保存自己的配置。 */
|
|
10
|
-
export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now() } = {}) {
|
|
11
|
-
if (
|
|
17
|
+
export async function refreshModelDirectory(provider, { force = false, signal, save, discover = listModels, now = Date.now(), registry, network } = {}) {
|
|
18
|
+
if (network !== undefined) assertProviderNetwork(network);
|
|
19
|
+
const requirement = providerNetworkRequirement(provider);
|
|
20
|
+
if (requirement && (!network || !sameNetworkRequirement(requirement, network.networkRequirement) || provider.id !== network.providerId)) {
|
|
21
|
+
throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '模型目录需要供应商匹配的网络上下文,不会使用默认出口');
|
|
22
|
+
}
|
|
23
|
+
if (!providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)) {
|
|
12
24
|
return { refreshed: false, error: '当前协议未提供模型目录发现' };
|
|
13
25
|
}
|
|
14
26
|
const previous = currentModelDirectory(provider);
|
|
@@ -20,21 +32,36 @@ export async function refreshModelDirectory(provider, { force = false, signal, s
|
|
|
20
32
|
}
|
|
21
33
|
}
|
|
22
34
|
signal?.throwIfAborted();
|
|
23
|
-
|
|
24
|
-
let
|
|
35
|
+
let calls = pending;
|
|
36
|
+
let registries = registryPending;
|
|
37
|
+
if (network) {
|
|
38
|
+
if (!networkPending.has(network)) networkPending.set(network, { calls: new Map(), registries: new WeakMap() });
|
|
39
|
+
const scoped = networkPending.get(network);
|
|
40
|
+
calls = scoped.calls; registries = scoped.registries;
|
|
41
|
+
}
|
|
42
|
+
if (registry) {
|
|
43
|
+
if (!registries.has(registry)) registries.set(registry, new Map());
|
|
44
|
+
calls = registries.get(registry);
|
|
45
|
+
discover = async ({ signal, timeoutMs }) => (await invokeProviderOperation(
|
|
46
|
+
createConfiguredProviderAdapter(provider, { registry, ...(network ? { network } : {}) }), 'discover', { signal, timeoutMs })).models;
|
|
47
|
+
}
|
|
48
|
+
const binding = modelDirectoryBinding(provider), key = providerApiKey(provider);
|
|
49
|
+
let job = calls.get(binding);
|
|
25
50
|
if (!job) {
|
|
26
51
|
const budget = AbortSignal.timeout(15000);
|
|
27
|
-
|
|
28
|
-
signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 }
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
const request = { key, baseUrl: providerMediaBaseUrl(provider),
|
|
53
|
+
signal: signal ? AbortSignal.any([signal, budget]) : budget, timeoutMs: 15000 };
|
|
54
|
+
job = network && !registry
|
|
55
|
+
? network.run(fetchPort => discover({ ...request, fetchPort })) : discover(request);
|
|
56
|
+
calls.set(binding, job);
|
|
57
|
+
job.finally(() => { if (calls.get(binding) === job) calls.delete(binding); }).catch(() => {});
|
|
31
58
|
}
|
|
32
59
|
let discovered;
|
|
33
60
|
try {
|
|
34
61
|
discovered = await job;
|
|
62
|
+
assertDiscoveryCredentials(discovered, key);
|
|
35
63
|
} catch (error) {
|
|
36
64
|
signal?.throwIfAborted();
|
|
37
|
-
const key = providerApiKey(provider);
|
|
38
65
|
const message = String(error?.message || error);
|
|
39
66
|
const directory = { version: 1, binding, ...(previous || {}), failedAt: new Date(now).toISOString(),
|
|
40
67
|
lastError: redactProviderMessage(key ? message.split(key).join('[REDACTED]') : message) };
|
package/lib/model-directory.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { isKnownCapability } from './capability.js';
|
|
3
|
-
import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol,
|
|
3
|
+
import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerForModelBinding } from './provider-protocol.js';
|
|
4
4
|
|
|
5
5
|
export const MODEL_DIRECTORY_TTL_MS = 10 * 60 * 1000;
|
|
6
6
|
export const MODEL_DIRECTORY_RETRY_MS = 30 * 1000;
|
|
@@ -35,9 +35,9 @@ export function currentModelDirectory(provider, { allowUnresolvedConnections = f
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export function modelAvailabilityBinding(provider, modelId, capability) {
|
|
38
|
-
if (capability === 'image-gen') return modelDirectoryBinding(
|
|
38
|
+
if (capability === 'image-gen') return modelDirectoryBinding(providerForModelBinding(provider, modelId, capability));
|
|
39
39
|
if (capability === 'music-gen' && provider.models?.some(model => model?.id === modelId && model.musicProtocol)) {
|
|
40
|
-
return modelDirectoryBinding(
|
|
40
|
+
return modelDirectoryBinding(providerForModelBinding(provider, modelId, capability));
|
|
41
41
|
}
|
|
42
42
|
return modelDirectoryBinding(provider);
|
|
43
43
|
}
|
package/lib/models.js
CHANGED
|
@@ -207,14 +207,14 @@ export function savedAssignmentRefs(pool, capability, values) {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/** Automatic pools reject known protocol gaps before constructing any adapter. */
|
|
210
|
-
export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false } = {}) {
|
|
210
|
+
export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false, registry } = {}) {
|
|
211
211
|
const policy = functionPolicy(assignment, capability);
|
|
212
212
|
return modelPool(providers, { includeHostVision }).filter(model => {
|
|
213
213
|
if (!model.capabilities.includes(capability)) return false;
|
|
214
214
|
if (policy.mode !== 'auto' || model.ref === HOST_VISION_REF) return true;
|
|
215
215
|
const provider = providers.find(provider => provider.id === model.providerId);
|
|
216
216
|
return supportsModelCapability(provider, model.id, capability, {
|
|
217
|
-
checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider
|
|
217
|
+
checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider, registry
|
|
218
218
|
});
|
|
219
219
|
});
|
|
220
220
|
}
|
package/lib/music-transport.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { inheritedProviderFetch } from './provider-network.js';
|
|
1
2
|
import { ProviderContractError } from './provider-contract.js';
|
|
3
|
+
import { readProviderText, PROVIDER_RESPONSE_LIMITS } from './provider-response.js';
|
|
2
4
|
|
|
3
5
|
function responseError(message) {
|
|
4
6
|
return new ProviderContractError(message, { stage: 'response', category: 'protocol', acceptance: 'unknown' });
|
|
@@ -24,15 +26,16 @@ export function musicArtifactsFromResponse(body) {
|
|
|
24
26
|
return artifacts;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal }) {
|
|
29
|
+
export async function generateMusic({ key, baseUrl, model, prompt, timeoutMs = 300000, signal, fetchPort = inheritedProviderFetch }) {
|
|
28
30
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
29
|
-
const response = await
|
|
31
|
+
const response = await fetchPort(String(baseUrl).replace(/\/+$/, '') + '/chat/completions', {
|
|
30
32
|
method: 'POST',
|
|
33
|
+
redirect: 'error',
|
|
31
34
|
headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
|
|
32
35
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
33
36
|
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }] })
|
|
34
37
|
});
|
|
35
|
-
const text = await response.
|
|
38
|
+
const text = await readProviderText(response, { maxBytes: response.ok ? PROVIDER_RESPONSE_LIMITS.mediaJson : PROVIDER_RESPONSE_LIMITS.json });
|
|
36
39
|
let body;
|
|
37
40
|
try { body = JSON.parse(text); }
|
|
38
41
|
catch (error) { if (response.ok) throw responseError('Music 响应不是有效 JSON'); }
|