feat: 发布 DSH 生图插件与中文安装使用文档
This commit is contained in:
25 files changed
+4138
No files matched your search
@@ -0,0 +1,215 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { validateBaseUrl, validateGeneration, listModels, generateImages } from '../lib/engine.mjs';
|
||||
|
||||
const apiKey = 'secret-do-not-echo';
|
||||
const input = { model: 'explicit-third-party-model', prompt: 'a blue bird' };
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64');
|
||||
const json = (res, value) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); };
|
||||
|
||||
async function server(t, handler) {
|
||||
const failures = [];
|
||||
const instance = http.createServer((req, res) => {
|
||||
Promise.resolve().then(() => handler(req, res)).catch(error => { failures.push(error); res.destroy(); });
|
||||
});
|
||||
instance.listen(0, '127.0.0.1');
|
||||
await once(instance, 'listening');
|
||||
t.after(async () => {
|
||||
const closed = new Promise(resolve => instance.close(resolve));
|
||||
instance.closeAllConnections();
|
||||
await closed;
|
||||
if (failures.length) throw failures[0];
|
||||
});
|
||||
return `http://127.0.0.1:${instance.address().port}`;
|
||||
}
|
||||
|
||||
async function requestJson(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return JSON.parse(Buffer.concat(chunks).toString());
|
||||
}
|
||||
|
||||
test('base URL normalization preserves API prefix and rejects unsafe parts', () => {
|
||||
assert.equal(validateBaseUrl(' HTTPS://EXAMPLE.COM/v1/// '), 'https://example.com/v1');
|
||||
for (const value of ['', null, '/v1', 'ftp://example.com', 'https://a:b@example.com', 'https://example.com?x=1', 'https://example.com#x', 'https://example.com?', 'https://example.com#']) {
|
||||
assert.throws(() => validateBaseUrl(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('generation requires explicit fields and sends only specified supported options', () => {
|
||||
assert.deepEqual(validateGeneration({ ...input, ignored: true }), { ...input, n: 1 });
|
||||
const options = { ...input, n: 4, size: '1536x1024', quality: 'high', background: 'transparent', output_format: 'webp' };
|
||||
assert.deepEqual(validateGeneration(options), options);
|
||||
assert.equal(validateGeneration({ ...input, size: 'auto' }).size, 'auto');
|
||||
for (const change of [{ model: undefined }, { model: ' ' }, { model: 'x'.repeat(201) }, { prompt: '' }, { prompt: 'x'.repeat(32001) }, { n: 0 }, { n: 5 }, { n: 1.5 }, { n: '1' }, { n: null }, { size: '1024X1024' }, { quality: 'best' }, { background: null }, { output_format: 'gif' }]) {
|
||||
assert.throws(() => validateGeneration({ ...input, ...change }));
|
||||
}
|
||||
assert.throws(() => validateGeneration(null));
|
||||
assert.equal(validateGeneration({ model: 'm'.repeat(200), prompt: 'p'.repeat(32000) }).n, 1);
|
||||
});
|
||||
|
||||
test('listModels uses exact API prefix, GET and bearer authentication', async t => {
|
||||
const base = await server(t, (req, res) => {
|
||||
assert.equal(req.method, 'GET');
|
||||
assert.equal(req.url, '/custom/v1/models');
|
||||
assert.equal(req.headers.authorization, `Bearer ${apiKey}`);
|
||||
json(res, { data: [{ id: 'model-b' }, { id: 'model-a' }] });
|
||||
});
|
||||
assert.deepEqual(await listModels({ baseUrl: `${base}/custom/v1///`, apiKey }), ['model-b', 'model-a']);
|
||||
});
|
||||
|
||||
test('generation posts explicit model, omits optional defaults and decodes base64', async t => {
|
||||
let requests = 0;
|
||||
const baseUrl = await server(t, async (req, res) => {
|
||||
requests++;
|
||||
assert.equal(req.url, '/images/generations');
|
||||
assert.equal(req.method, 'POST');
|
||||
assert.equal(req.headers.authorization, `Bearer ${apiKey}`);
|
||||
assert.equal(req.headers['content-type'], 'application/json');
|
||||
assert.deepEqual(await requestJson(req), { ...input, n: 1 });
|
||||
json(res, { model: 'provider-model', data: [{ b64_json: png.toString('base64'), revised_prompt: 'revised bird' }] });
|
||||
});
|
||||
const result = await generateImages({ baseUrl, apiKey, input });
|
||||
assert.deepEqual(result, { ...input, images: [{ data: png, mediaType: 'image/png', revisedPrompt: 'revised bird' }] });
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test('URL images never receive bearer auth and bytes determine media type', async t => {
|
||||
let downloads = 0;
|
||||
const imageBase = await server(t, (req, res) => {
|
||||
downloads++;
|
||||
assert.equal(req.url, '/asset?signature=public-token');
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(png);
|
||||
});
|
||||
const baseUrl = await server(t, (req, res) => json(res, { data: [{ url: `${imageBase}/asset?signature=public-token` }] }));
|
||||
const result = await generateImages({ baseUrl, apiKey, input });
|
||||
assert.deepEqual(result.images, [{ data: png, mediaType: 'image/png' }]);
|
||||
assert.equal(downloads, 1);
|
||||
});
|
||||
|
||||
test('recognizes JPEG, WebP and GIF signatures', async t => {
|
||||
const fixtures = [Buffer.from([255, 216, 255, 224, 0, 0]), Buffer.from('RIFF\x04\0\0\0WEBP', 'latin1'), Buffer.from('GIF89a\x01\0\x01\0', 'latin1')];
|
||||
const baseUrl = await server(t, (req, res) => json(res, { data: fixtures.map(data => ({ b64_json: data.toString('base64') })) }));
|
||||
const result = await generateImages({ baseUrl, apiKey, input: { ...input, n: 3 } });
|
||||
assert.deepEqual(result.images.map(image => image.mediaType), ['image/jpeg', 'image/webp', 'image/gif']);
|
||||
});
|
||||
|
||||
test('HTTP errors expose status but never provider payload or key, with no retries', async t => {
|
||||
let count = 0;
|
||||
const baseUrl = await server(t, (req, res) => { count++; res.writeHead(429); res.end(`provider-private ${apiKey}`); });
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input }), error => {
|
||||
assert.match(error.message, /HTTP 429/);
|
||||
assert.doesNotMatch(error.message, /secret-do-not-echo|provider-private/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
});
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
test('API redirects are rejected without contacting redirect destination', async t => {
|
||||
let redirected = 0;
|
||||
const target = await server(t, (req, res) => { redirected++; json(res, { data: [] }); });
|
||||
const baseUrl = await server(t, (req, res) => { res.writeHead(302, { Location: `${target}/models` }); res.end(); });
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /network request failed/);
|
||||
assert.equal(redirected, 0);
|
||||
});
|
||||
|
||||
test('malformed JSON and model lists are rejected without raw response echo', async t => {
|
||||
let payload = 'private-provider-body';
|
||||
const baseUrl = await server(t, (req, res) => res.end(payload));
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /malformed JSON/);
|
||||
for (const value of [null, {}, { data: [null] }, { data: [{ id: 4 }] }, { data: [{ id: ' ' }] }]) {
|
||||
payload = JSON.stringify(value);
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /malformed model list/);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid base64, invalid bytes and malformed image responses fail', async t => {
|
||||
let payload;
|
||||
const baseUrl = await server(t, (req, res) => json(res, payload));
|
||||
for (const value of [null, {}, { data: [] }, { data: [null] }, { data: [{}] }, { data: [{ revised_prompt: 1, b64_json: png.toString('base64') }] }]) {
|
||||
payload = value;
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input }));
|
||||
}
|
||||
for (const value of ['', '!!!!', 'aGk', 'aGk=\n', 'Zh==', 123, null]) {
|
||||
payload = { data: [{ b64_json: value }] };
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input }), /invalid base64/);
|
||||
}
|
||||
payload = { data: [{ b64_json: Buffer.from('<html>not an image</html>').toString('base64') }] };
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input }), /not a supported/);
|
||||
for (const url of ['file:///secret', 'http://user:password@localhost/image', '/relative']) {
|
||||
payload = { data: [{ url }] };
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input }), /invalid image URL/);
|
||||
}
|
||||
});
|
||||
|
||||
test('response Content-Length above 64 MB is rejected before buffering', async t => {
|
||||
const baseUrl = await server(t, (req, res) => { res.writeHead(200, { 'Content-Length': 64 * 1024 * 1024 + 1 }); res.flushHeaders(); });
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /64 MB response limit/);
|
||||
});
|
||||
|
||||
test('streamed responses enforce 64 MB even without Content-Length', async t => {
|
||||
const block = Buffer.alloc(1024 * 1024, 32);
|
||||
const baseUrl = await server(t, async (req, res) => {
|
||||
for (let i = 0; i < 65; i++) {
|
||||
if (res.destroyed) return;
|
||||
if (!res.write(block)) {
|
||||
await new Promise(resolve => {
|
||||
const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
|
||||
res.once('drain', done);
|
||||
res.once('close', done);
|
||||
});
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /64 MB response limit/);
|
||||
});
|
||||
|
||||
test('caller abort interrupts pending API request and sanitizes abort reason', async t => {
|
||||
const controller = new AbortController();
|
||||
const baseUrl = await server(t, () => controller.abort(new Error(apiKey)));
|
||||
await assert.rejects(listModels({ baseUrl, apiKey, signal: controller.signal }), /^Error: Request aborted\.$/);
|
||||
});
|
||||
|
||||
test('caller abort also interrupts image download', async t => {
|
||||
const controller = new AbortController();
|
||||
const imageBase = await server(t, (req, res) => { res.writeHead(200); res.write(png.subarray(0, 4)); controller.abort(); });
|
||||
const baseUrl = await server(t, (req, res) => json(res, { data: [{ url: `${imageBase}/slow` }] }));
|
||||
await assert.rejects(generateImages({ baseUrl, apiKey, input, signal: controller.signal }), /Request aborted/);
|
||||
});
|
||||
|
||||
test('specified generation options are preserved on the wire', async t => {
|
||||
const specified = { ...input, n: 2, size: 'auto', quality: 'hd', background: 'opaque', output_format: 'jpeg' };
|
||||
const baseUrl = await server(t, async (req, res) => {
|
||||
assert.deepEqual(await requestJson(req), specified);
|
||||
json(res, { data: [{ b64_json: png.toString('base64') }] });
|
||||
});
|
||||
await generateImages({ baseUrl, apiKey, input: specified });
|
||||
});
|
||||
|
||||
test('180 second timeout aborts a stalled request', async t => {
|
||||
const baseUrl = await server(t, () => {});
|
||||
const original = globalThis.setTimeout;
|
||||
let configuredTimeout;
|
||||
t.mock.method(globalThis, 'setTimeout', (callback, milliseconds, ...args) => {
|
||||
if (milliseconds === 180_000) {
|
||||
configuredTimeout = milliseconds;
|
||||
return original(callback, 20, ...args);
|
||||
}
|
||||
return original(callback, milliseconds, ...args);
|
||||
});
|
||||
await assert.rejects(listModels({ baseUrl, apiKey }), /timed out after 180 seconds/);
|
||||
assert.equal(configuredTimeout, 180_000);
|
||||
});
|
||||
|
||||
test('pre-aborted signal does not send an API request', async t => {
|
||||
let count = 0;
|
||||
const baseUrl = await server(t, (req, res) => { count++; json(res, { data: [] }); });
|
||||
await assert.rejects(listModels({ baseUrl, apiKey, signal: AbortSignal.abort(apiKey) }), /Request aborted/);
|
||||
assert.equal(count, 0);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jVv8AAAAASUVORK5CYII=', 'base64');
|
||||
|
||||
test('native host integration using only a local image API', async t => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-imagegen-host-test-'));
|
||||
const previousHome = process.env.DSH_HOME;
|
||||
process.env.DSH_HOME = home;
|
||||
t.after(async () => {
|
||||
if (previousHome === undefined) delete process.env.DSH_HOME;
|
||||
else process.env.DSH_HOME = previousHome;
|
||||
assert.equal(dirname(home), tmpdir());
|
||||
assert.match(basename(home), /^dsh-imagegen-host-test-/);
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
const requests = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
let raw = '';
|
||||
for await (const chunk of request) raw += chunk;
|
||||
requests.push({ path: request.url, authorization: request.headers.authorization, body: raw ? JSON.parse(raw) : undefined });
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (request.url === '/v1/models') response.end(JSON.stringify({ data: [{ id: 'test-image-model' }] }));
|
||||
else if (request.url === '/v1/images/generations') response.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64'), revised_prompt: 'Local test image' }] }));
|
||||
else { response.statusCode = 404; response.end('{}'); }
|
||||
} catch { response.statusCode = 500; response.end('{}'); }
|
||||
});
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||||
t.after(() => new Promise((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve());
|
||||
server.closeAllConnections();
|
||||
}));
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`;
|
||||
const records = new Map(), routes = new Map(), tools = new Map(), skills = [], attachments = [], listeners = new Map();
|
||||
const ctx = {
|
||||
on(event, listener) { listeners.set(event, listener); },
|
||||
connection: { fetch: { register(route) {
|
||||
assert.equal(route.requestBody, 'buffered');
|
||||
assert.ok(!routes.has(route.path));
|
||||
routes.set(route.path, route);
|
||||
return async () => routes.delete(route.path);
|
||||
} } },
|
||||
tools: { register(tool) { tools.set(tool.name, tool); return () => tools.delete(tool.name); } },
|
||||
credentials: {
|
||||
async readRecord(key) { return records.get(key); },
|
||||
async modifyRecord(key, change) { const value = await change(records.get(key)); records.set(key, value); return value; },
|
||||
async deleteRecord(key) { records.delete(key); },
|
||||
},
|
||||
attachments: { async saveImage(image) {
|
||||
assert.deepEqual(Buffer.from(image.data), PNG);
|
||||
assert.equal(image.mediaType, 'image/png');
|
||||
const ref = { attachmentId: `test-attachment-${attachments.length}`, mediaType: image.mediaType, bytes: image.data.length, width: 1, height: 1, name: image.name };
|
||||
attachments.push(ref);
|
||||
return ref;
|
||||
} },
|
||||
skills: { register(skill) { skills.push(skill); return () => {}; } },
|
||||
};
|
||||
const host = await import(`../lib/index.mjs?host-test=${Date.now()}`);
|
||||
await host.apply(ctx);
|
||||
t.after(() => listeners.get('dispose')?.());
|
||||
const call = async (path, method = 'GET', body) => {
|
||||
const url = new URL(path, 'http://localhost'), route = routes.get(url.pathname);
|
||||
assert.ok(route, `route registered: ${url.pathname}`);
|
||||
assert.ok(route.methods.includes(method));
|
||||
return route.fetch(new Request(url, { method, ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }) }));
|
||||
};
|
||||
const settings = { baseUrl, models: ['test-image-model'], defaultModel: 'test-image-model' };
|
||||
const key = 'test-only-key-never-a-real-secret';
|
||||
|
||||
await t.test('registers dependencies, routes, tools and skill', () => {
|
||||
assert.deepEqual(host.inject, ['connection', 'tools', 'credentials', 'attachments', 'skills']);
|
||||
assert.deepEqual([...routes.keys()].sort(), ['/api/imagegen/file', '/api/imagegen/generate', '/api/imagegen/models', '/api/imagegen/settings']);
|
||||
assert.deepEqual([...tools.keys()].sort(), ['imagegen_generate', 'imagegen_status']);
|
||||
assert.equal(skills.length, 1);
|
||||
assert.equal(skills[0].name, 'dsh-imagegen');
|
||||
assert.ok(skills[0].content.length > 0);
|
||||
assert.equal(typeof listeners.get('dispose'), 'function');
|
||||
});
|
||||
await t.test('stores keys in credentials, not settings JSON or returned values', async () => {
|
||||
const response = await call('/api/imagegen/settings', 'POST', { ...settings, apiKey: key });
|
||||
assert.equal(response.status, 200);
|
||||
const value = await response.json();
|
||||
assert.equal(value.configured, true);
|
||||
assert.equal(value.apiKey, undefined);
|
||||
assert.ok(!JSON.stringify(value).includes(key));
|
||||
assert.equal(records.size, 1);
|
||||
const [credentialName, credential] = [...records][0];
|
||||
assert.match(credentialName, /^dsh-imagegen:[0-9a-f]{64}$/);
|
||||
assert.deepEqual(credential, { kind: 'api-key', key });
|
||||
const disk = await readFile(join(home, 'imagegen', 'settings.json'), 'utf8');
|
||||
assert.deepEqual(JSON.parse(disk), settings);
|
||||
assert.ok(!disk.includes(key));
|
||||
const status = await tools.get('imagegen_status').execute();
|
||||
assert.equal(status.configured, true);
|
||||
assert.ok(!JSON.stringify(status).includes(key));
|
||||
assert.ok(!(await (await call('/api/imagegen/settings')).text()).includes(key));
|
||||
});
|
||||
await t.test('lists models using the local authenticated API', async () => {
|
||||
const response = await call('/api/imagegen/models', 'POST');
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { models: ['test-image-model'] });
|
||||
assert.equal(requests.at(-1).authorization, `Bearer ${key}`);
|
||||
});
|
||||
await t.test('a different Base URL cannot reuse the original credential', async () => {
|
||||
const response = await call('/api/imagegen/settings', 'POST', { ...settings, baseUrl: `${baseUrl}/other-provider` });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal((await response.json()).configured, false);
|
||||
const before = requests.length;
|
||||
const rejected = await call('/api/imagegen/generate', 'POST', { model: 'test-image-model', prompt: 'A local test' });
|
||||
assert.equal(rejected.status, 400);
|
||||
assert.equal(requests.length, before);
|
||||
assert.equal(records.size, 1);
|
||||
const restored = await call('/api/imagegen/settings', 'POST', settings);
|
||||
assert.equal((await restored.json()).configured, true);
|
||||
});
|
||||
await t.test('requires explicit model despite a configured default', async () => {
|
||||
const before = requests.length;
|
||||
const response = await call('/api/imagegen/generate', 'POST', { prompt: 'No model' });
|
||||
assert.equal(response.status, 400);
|
||||
assert.match((await response.json()).error, /model/i);
|
||||
await assert.rejects(tools.get('imagegen_generate').execute({ prompt: 'No model' }, { signal: new AbortController().signal }), /model/i);
|
||||
assert.equal(requests.length, before);
|
||||
});
|
||||
await t.test('tool generation saves original bytes and renders an image attachment', async () => {
|
||||
const tool = tools.get('imagegen_generate');
|
||||
const args = { model: 'test-image-model', prompt: 'A local test', n: 1 };
|
||||
const value = await tool.execute(args, { signal: new AbortController().signal });
|
||||
assert.equal(value.model, args.model);
|
||||
assert.equal(value.images.length, 1);
|
||||
const image = value.images[0];
|
||||
assert.equal(dirname(image.path), join(home, 'imagegen', 'outputs'));
|
||||
assert.deepEqual(await readFile(image.path), PNG);
|
||||
assert.equal(image.revisedPrompt, 'Local test image');
|
||||
assert.deepEqual(image.attachment, attachments[0]);
|
||||
const content = tool.output.render(args, value);
|
||||
assert.equal(content[0].type, 'text');
|
||||
assert.deepEqual(content[1], { type: 'image', attachment: attachments[0] });
|
||||
assert.ok(!JSON.stringify(content).includes(key));
|
||||
assert.deepEqual(requests.at(-1).body, args);
|
||||
assert.equal(requests.at(-1).authorization, `Bearer ${key}`);
|
||||
const file = await call(image.url);
|
||||
assert.equal(file.status, 200);
|
||||
assert.equal(file.headers.get('Content-Type'), 'image/png');
|
||||
assert.equal(file.headers.get('X-Content-Type-Options'), 'nosniff');
|
||||
assert.deepEqual(Buffer.from(await file.arrayBuffer()), PNG);
|
||||
});
|
||||
await t.test('HTTP generation returns a saved image without credentials', async () => {
|
||||
const response = await call('/api/imagegen/generate', 'POST', { model: 'test-image-model', prompt: 'HTTP local test' });
|
||||
assert.equal(response.status, 200);
|
||||
const value = await response.json();
|
||||
assert.equal(value.images.length, 1);
|
||||
assert.deepEqual(await readFile(value.images[0].path), PNG);
|
||||
assert.ok(!JSON.stringify(value).includes(key));
|
||||
});
|
||||
await t.test('file traversal, absolute paths and missing files return 404', async () => {
|
||||
for (const filename of ['../settings.json', '..\\settings.json', join(home, 'imagegen', 'settings.json'), '/etc/passwd', '', '123-00000000-0000-0000-0000-000000000000.png']) {
|
||||
assert.equal((await call(`/api/imagegen/file?name=${encodeURIComponent(filename)}`)).status, 404);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user