dsh-draw 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-08-27
9
+
10
+ ### Fixed
11
+
12
+ - Declare the web-client inject packages (`@deepseek-ai/dsh-client-connection`,
13
+ `@deepseek-ai/dsh-client-locale`, `@deepseek-ai/dsh-client-runtime`,
14
+ `@deepseek-ai/dsh-client-ui-settings`, `@deepseek-ai/dsh-client-ui-slots`,
15
+ `@deepseek-ai/dsh-client-ui-tool`) as optional peerDependencies so the
16
+ bundle composition is explicit and standalone installs stay clean.
17
+
18
+ ## [0.2.0] - 2026-08-26
19
+
20
+ ### Added
21
+
22
+ - Pluggable engine provider seam (Replicate / fal.ai).
23
+
8
24
  ## [0.1.3] - 2026-08-23
9
25
 
10
26
  ### Added
package/README.es.md CHANGED
@@ -153,6 +153,7 @@ pnpm pack # el tarball publicado
153
153
  ## Contributors
154
154
 
155
155
  - [@PerryLink](https://github.com/PerryLink) — creador y mantenedor: enrutador de motores, drawer, contabilidad de cuota, vocabulario wire de Typert, mitad de navegador y la documentación en cinco idiomas.
156
+ - [@Mohei-Muun](https://github.com/Mohei-Muun) — informó del fallo de carga del registro de sesión `draw/generated` en hosts rc.7 ([#2](https://github.com/PerryLink/dsh-draw/issues/2)), que condujo a la puerta de eventos adaptativa.
156
157
 
157
158
  ## PerryLink DSH Plugin Family
158
159
 
package/README.hi.md CHANGED
@@ -153,6 +153,7 @@ pnpm pack # प्रकाशित tarball
153
153
  ## Contributors
154
154
 
155
155
  - [@PerryLink](https://github.com/PerryLink) — निर्माता और मेंटेनर: इंजन रूटर, drawer, कोटा लेखा, Typert wire शब्दावली, ब्राउज़र आधा और पाँच-भाषा दस्तावेज़।
156
+ - [@Mohei-Muun](https://github.com/Mohei-Muun) — rc.7 होस्ट पर `draw/generated` सत्र-लॉग लोड विफलता की सूचना दी ([#2](https://github.com/PerryLink/dsh-draw/issues/2)), जिससे अनुकूली इवेंट गेट बना।
156
157
 
157
158
  ## PerryLink DSH Plugin Family
158
159
 
package/README.md CHANGED
@@ -85,7 +85,7 @@ All tunables are Schemastery `Config` fields (changeable from cordis.yml). An id
85
85
 
86
86
  | Key | Default | Meaning |
87
87
  |---|---|---|
88
- | `engines` | OpenAI + CogView presets | Ordered engine chain, walked top-down with fallback; each entry: `id`, `baseUrl` (no credentials), `model`, `apiKeyRef` (env-var name), `enabled`, `sizeMap`, `qualitySupported`, `styleSupported`, `responseFormat` (`b64_json`/`url`), `imageMediaType` |
88
+ | `engines` | OpenAI + CogView presets | Ordered engine chain, walked top-down with fallback; each entry: `id`, `baseUrl` (no credentials), `model`, `apiKeyRef` (env-var name), `provider` (`openai`/`replicate`/`fal`, default `openai`), `enabled`, `sizeMap`, `qualitySupported`, `styleSupported`, `responseFormat` (`b64_json`/`url`), `imageMediaType` |
89
89
  | `defaultEngine` | `openai` | Engine id the router prefers; must name a configured engine |
90
90
  | `requestTimeoutMs` | `120000` | Per-generation HTTP timeout (1000..600000) |
91
91
  | `maxImagesPerCall` | `4` | Cap on images one call may produce (1..10) |
@@ -130,7 +130,7 @@ Example override in your profile patch:
130
130
  ## Known limitations
131
131
 
132
132
  - **Image models only.** No video, audio, or edit endpoints; no vision understanding.
133
- - **Engine compatibility.** Engines must speak the OpenAI `POST /images/generations` shape (base64 or URL delivery); provider-specific extras are out of scope.
133
+ - **Engine compatibility.** Three vocabularies are supported through the provider seam: `openai` (the `POST /images/generations` shape), `replicate` (prediction create + poll), and `fal` (the `fal.run` queue). Provider-specific extras beyond `prompt`/size/count (e.g. seed, scheduler) are out of scope.
134
134
  - **Cost awareness is structural.** The plugin counts calls and bytes but does not know engine pricing — pair with `dsh-budget` for cost governance.
135
135
  - **Quota durability on rc.6/rc.7.** On hosts whose session log cannot carry `draw/generated` (static event whitelist, no `ignorable` envelope), quota stays exact for the live session from the in-memory fallback ledger but resets on restart; durable accounting resumes on hosts with a plugin event surface.
136
136
 
@@ -154,6 +154,7 @@ pnpm pack # the published tarball
154
154
  ## Contributors
155
155
 
156
156
  - [@PerryLink](https://github.com/PerryLink) — creator and maintainer: engine router, drawer, quota accounting, Typert wire vocabulary, browser half, and the five-language docs.
157
+ - [@Mohei-Muun](https://github.com/Mohei-Muun) — reported the `draw/generated` session-log unload bug on rc.7 hosts ([#2](https://github.com/PerryLink/dsh-draw/issues/2)), which led to the adaptive event gate.
157
158
 
158
159
  ## PerryLink DSH Plugin Family
159
160
 
package/README.pt.md CHANGED
@@ -153,6 +153,7 @@ pnpm pack # o tarball publicado
153
153
  ## Contributors
154
154
 
155
155
  - [@PerryLink](https://github.com/PerryLink) — criador e mantenedor: roteador de motores, drawer, contabilidade de cota, vocabulário wire do Typert, metade de navegador e a documentação em cinco idiomas.
156
+ - [@Mohei-Muun](https://github.com/Mohei-Muun) — relatou a falha de carregamento do log de sessão `draw/generated` em hosts rc.7 ([#2](https://github.com/PerryLink/dsh-draw/issues/2)), que levou ao gate de eventos adaptativo.
156
157
 
157
158
  ## PerryLink DSH Plugin Family
158
159
 
package/README.zh.md CHANGED
@@ -153,6 +153,7 @@ pnpm pack # 发布用 tarball
153
153
  ## Contributors
154
154
 
155
155
  - [@PerryLink](https://github.com/PerryLink) —— 创建者与维护者:引擎路由、drawer、配额记账、Typert wire 词汇、浏览器半与五语文档。
156
+ - [@Mohei-Muun](https://github.com/Mohei-Muun) —— 报告了 rc.7 主机上 `draw/generated` 会话日志导致会话无法重载的缺陷([#2](https://github.com/PerryLink/dsh-draw/issues/2)),推动了自适应事件门控的实现。
156
157
 
157
158
  ## PerryLink DSH Plugin Family
158
159
 
package/cordis.patch.yml CHANGED
@@ -8,12 +8,15 @@
8
8
  config:
9
9
  # Ordered engine chain; the router walks it top-down and falls back on
10
10
  # failure. apiKeyRef names an environment variable (credential
11
- # reference) — never put a literal key here.
11
+ # reference) — never put a literal key here. `provider` selects the
12
+ # request/response vocabulary: openai (OpenAI-compatible images API),
13
+ # replicate (prediction polling), or fal (fal.run queue).
12
14
  engines:
13
15
  - id: openai
14
16
  baseUrl: https://api.openai.com/v1
15
17
  model: gpt-image-1
16
18
  apiKeyRef: OPENAI_API_KEY
19
+ provider: openai
17
20
  enabled: true
18
21
  sizeMap: { square: 1024x1024, landscape: 1536x1024, portrait: 1024x1536, auto: auto }
19
22
  qualitySupported: true
@@ -24,6 +27,7 @@
24
27
  baseUrl: https://open.bigmodel.cn/api/paas/v4
25
28
  model: cogview-3-flash
26
29
  apiKeyRef: ZHIPU_API_KEY
30
+ provider: openai
27
31
  enabled: true
28
32
  sizeMap: { square: 1024x1024, landscape: 1344x768, portrait: 768x1344, auto: 1024x1024 }
29
33
  qualitySupported: false
package/lib/index.js CHANGED
@@ -39,6 +39,7 @@ const DEFAULT_ENGINES = Object.freeze([Object.freeze({
39
39
  baseUrl: "https://api.openai.com/v1",
40
40
  model: "gpt-image-1",
41
41
  apiKeyRef: "OPENAI_API_KEY",
42
+ provider: "openai",
42
43
  enabled: true,
43
44
  sizeMap: Object.freeze({
44
45
  square: "1024x1024",
@@ -55,6 +56,7 @@ const DEFAULT_ENGINES = Object.freeze([Object.freeze({
55
56
  baseUrl: "https://open.bigmodel.cn/api/paas/v4",
56
57
  model: "cogview-3-flash",
57
58
  apiKeyRef: "ZHIPU_API_KEY",
59
+ provider: "openai",
58
60
  enabled: true,
59
61
  sizeMap: Object.freeze({
60
62
  square: "1024x1024",
@@ -81,6 +83,11 @@ const Config = z.object({
81
83
  baseUrl: z.string().min(1).max(2048),
82
84
  model: z.string().min(1).max(128),
83
85
  apiKeyRef: z.string().min(1).max(128),
86
+ provider: z.union([
87
+ "openai",
88
+ "replicate",
89
+ "fal"
90
+ ]),
84
91
  enabled: z.boolean(),
85
92
  sizeMap: z.object({
86
93
  square: z.string().min(1).max(64),
@@ -141,6 +148,8 @@ function resolveConfig(config) {
141
148
  if (model === "") throw new Error(`dsh-draw: engine "${id}" model must be a non-empty string of at most 128 characters`);
142
149
  const apiKeyRef = typeof engine.apiKeyRef === "string" && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : "";
143
150
  if (apiKeyRef === "") throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`);
151
+ const provider = engine.provider ?? "openai";
152
+ if (provider !== "openai" && provider !== "replicate" && provider !== "fal") throw new Error(`dsh-draw: engine "${id}" provider must be one of "openai", "replicate", "fal", got ${JSON.stringify(provider)}`);
144
153
  const sizeMap = engine.sizeMap === void 0 ? DEFAULT_SIZE_MAP : engine.sizeMap;
145
154
  for (const key of [
146
155
  "square",
@@ -166,6 +175,7 @@ function resolveConfig(config) {
166
175
  baseUrl: baseUrl.replace(/\/+$/u, ""),
167
176
  model,
168
177
  apiKeyRef,
178
+ provider,
169
179
  enabled,
170
180
  sizeMap: Object.freeze({ ...sizeMap }),
171
181
  qualitySupported,
@@ -948,6 +958,163 @@ async function downloadImageUrl(engine, url, credential, deps, signal) {
948
958
  }
949
959
  }
950
960
  //#endregion
961
+ //#region src/provider.ts
962
+ /** The OpenAI-compatible adapter: delegates to the existing `/images/generations` path. */
963
+ const openaiProvider = {
964
+ vocabulary: "openai",
965
+ generate(engine, request, deps, signal) {
966
+ return callEngine(engine, translateRequest(engine, request), deps, signal);
967
+ }
968
+ };
969
+ /** Replicate prediction polling interval (ms). */
970
+ const REPLICATE_POLL_INTERVAL_MS = 1e3;
971
+ /** Maximum prediction polls before the call fails as a slow request. */
972
+ const REPLICATE_MAX_POLLS = 120;
973
+ /** Parse one concrete `WIDTHxHEIGHT` size string into dimensions. */
974
+ function dimensionsOf(size) {
975
+ const match = /^(\d{2,5})x(\d{2,5})$/u.exec(size);
976
+ if (match === null) return void 0;
977
+ return {
978
+ width: Number(match[1]),
979
+ height: Number(match[2])
980
+ };
981
+ }
982
+ /** Resolve an engine credential, failing closed when it is unconfigured. */
983
+ async function requireCredential(engine, deps) {
984
+ const credential = await deps.resolveCredential(engine.apiKeyRef);
985
+ if (credential === void 0) throw new EngineCallError("credential", "unconfigured", `engine "${engine.id}" has no resolved credential reference ${engine.apiKeyRef}`);
986
+ return credential;
987
+ }
988
+ /** Parse a JSON response body, failing with a structured parse error. */
989
+ function parseJson(engine, body) {
990
+ let parsed;
991
+ try {
992
+ parsed = JSON.parse(new TextDecoder().decode(body));
993
+ } catch (cause) {
994
+ throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned a non-JSON response`, { cause });
995
+ }
996
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned a non-object payload`);
997
+ return parsed;
998
+ }
999
+ /** Download one image URL with an explicit authorization header value. */
1000
+ async function downloadImage(engine, url, authorization, deps, signal) {
1001
+ const response = await deps.transport.request({
1002
+ method: "GET",
1003
+ url,
1004
+ headers: { authorization },
1005
+ ...signal === void 0 ? {} : { signal }
1006
+ });
1007
+ if (response.status < 200 || response.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" image download failed with HTTP ${response.status}`, { status: response.status });
1008
+ return response.body;
1009
+ }
1010
+ /** Sleep without swallowing the caller's abort (checked by the caller's loop). */
1011
+ function sleep(ms) {
1012
+ return new Promise((resolve) => setTimeout(resolve, ms));
1013
+ }
1014
+ /** The provider registry, keyed by vocabulary. */
1015
+ const PROVIDERS = Object.freeze({
1016
+ openai: openaiProvider,
1017
+ replicate: {
1018
+ vocabulary: "replicate",
1019
+ async generate(engine, request, deps, signal) {
1020
+ const credential = await requireCredential(engine, deps);
1021
+ const dimensions = dimensionsOf(engine.sizeMap[request.size ?? "square"]);
1022
+ const input = {
1023
+ prompt: request.prompt,
1024
+ num_outputs: request.count ?? 1,
1025
+ ...dimensions === void 0 ? {} : {
1026
+ width: dimensions.width,
1027
+ height: dimensions.height
1028
+ }
1029
+ };
1030
+ const create = await deps.transport.request({
1031
+ method: "POST",
1032
+ url: `${engine.baseUrl}/models/${engine.model}/predictions`,
1033
+ headers: {
1034
+ "content-type": "application/json",
1035
+ authorization: `Bearer ${credential}`
1036
+ },
1037
+ body: new TextEncoder().encode(JSON.stringify({ input })),
1038
+ ...signal === void 0 ? {} : { signal }
1039
+ });
1040
+ if (create.status === 401 || create.status === 403) throw new EngineCallError("request", "auth", `engine "${engine.id}" rejected the credential (HTTP ${create.status})`, { status: create.status });
1041
+ if (create.status < 200 || create.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" failed with HTTP ${create.status}`, { status: create.status });
1042
+ let prediction = parseJson(engine, create.body);
1043
+ const id = typeof prediction["id"] === "string" ? prediction["id"] : "";
1044
+ const urls = typeof prediction["urls"] === "object" && prediction["urls"] !== null ? prediction["urls"] : {};
1045
+ const pollUrl = typeof urls["get"] === "string" && urls["get"] !== "" ? urls["get"] : `${engine.baseUrl}/predictions/${id}`;
1046
+ for (let poll = 0; poll < REPLICATE_MAX_POLLS; poll += 1) {
1047
+ signal?.throwIfAborted();
1048
+ const status = typeof prediction["status"] === "string" ? prediction["status"] : "";
1049
+ if (status === "succeeded") break;
1050
+ if (status === "failed" || status === "canceled") throw new EngineCallError("request", "http", `engine "${engine.id}" prediction ${status}`);
1051
+ await sleep(REPLICATE_POLL_INTERVAL_MS);
1052
+ const polled = await deps.transport.request({
1053
+ method: "GET",
1054
+ url: pollUrl,
1055
+ headers: { authorization: `Bearer ${credential}` },
1056
+ ...signal === void 0 ? {} : { signal }
1057
+ });
1058
+ if (polled.status < 200 || polled.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" prediction poll failed with HTTP ${polled.status}`, { status: polled.status });
1059
+ prediction = parseJson(engine, polled.body);
1060
+ }
1061
+ if (prediction["status"] !== "succeeded") throw new EngineCallError("request", "http", `engine "${engine.id}" prediction did not finish within ${REPLICATE_MAX_POLLS} polls`);
1062
+ const output = prediction["output"];
1063
+ if (!Array.isArray(output)) throw new EngineCallError("parse", "parse", `engine "${engine.id}" prediction has no output array`);
1064
+ const images = [];
1065
+ for (const item of output) {
1066
+ if (typeof item !== "string" || item === "") throw new EngineCallError("parse", "parse", `engine "${engine.id}" prediction returned a non-URL output entry`);
1067
+ images.push({
1068
+ data: await downloadImage(engine, item, `Bearer ${credential}`, deps, signal),
1069
+ mediaType: engine.imageMediaType
1070
+ });
1071
+ }
1072
+ if (images.length === 0) throw new EngineCallError("parse", "parse", `engine "${engine.id}" prediction returned no images`);
1073
+ return images;
1074
+ }
1075
+ },
1076
+ fal: {
1077
+ vocabulary: "fal",
1078
+ async generate(engine, request, deps, signal) {
1079
+ const credential = await requireCredential(engine, deps);
1080
+ const body = {
1081
+ prompt: request.prompt,
1082
+ image_size: engine.sizeMap[request.size ?? "square"],
1083
+ num_images: request.count ?? 1
1084
+ };
1085
+ const response = await deps.transport.request({
1086
+ method: "POST",
1087
+ url: `${engine.baseUrl}/${engine.model}`,
1088
+ headers: {
1089
+ "content-type": "application/json",
1090
+ authorization: `Key ${credential}`
1091
+ },
1092
+ body: new TextEncoder().encode(JSON.stringify(body)),
1093
+ ...signal === void 0 ? {} : { signal }
1094
+ });
1095
+ if (response.status === 401 || response.status === 403) throw new EngineCallError("request", "auth", `engine "${engine.id}" rejected the credential (HTTP ${response.status})`, { status: response.status });
1096
+ if (response.status < 200 || response.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" failed with HTTP ${response.status}`, { status: response.status });
1097
+ const entries = parseJson(engine, response.body)["images"];
1098
+ if (!Array.isArray(entries)) throw new EngineCallError("parse", "parse", `engine "${engine.id}" response has no images array`);
1099
+ const images = [];
1100
+ for (const raw of entries) {
1101
+ const url = typeof raw === "object" && raw !== null ? raw["url"] : void 0;
1102
+ if (typeof url !== "string" || url === "") throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned an image entry without a URL`);
1103
+ images.push({
1104
+ data: await downloadImage(engine, url, `Key ${credential}`, deps, signal),
1105
+ mediaType: engine.imageMediaType
1106
+ });
1107
+ }
1108
+ if (images.length === 0) throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned no images`);
1109
+ return images;
1110
+ }
1111
+ }
1112
+ });
1113
+ /** Resolve the provider for one vocabulary (always present — the config gate restricts the value). */
1114
+ function providerFor(vocabulary) {
1115
+ return PROVIDERS[vocabulary];
1116
+ }
1117
+ //#endregion
951
1118
  //#region src/router.ts
952
1119
  /**
953
1120
  * The engine chain with per-engine health and cooldown. All mutations are
@@ -1001,7 +1168,7 @@ var EngineRouter = class {
1001
1168
  }
1002
1169
  tried += 1;
1003
1170
  try {
1004
- const images = await callEngine(engine, this.translate(engine, request), deps, signal);
1171
+ const images = await providerFor(engine.provider).generate(engine, request, deps, signal);
1005
1172
  this.recordSuccess(engine.id);
1006
1173
  attempts.push({
1007
1174
  engine: engine.id,
@@ -1145,10 +1312,6 @@ var EngineRouter = class {
1145
1312
  message: `engine "${engine.id}" is cooling down after repeated failures`
1146
1313
  };
1147
1314
  }
1148
- /** Translate the standard request against one engine (the pure translate step). */
1149
- translate(engine, request) {
1150
- return translateRequest(engine, request);
1151
- }
1152
1315
  /** Record a success: reset consecutive failures and cooldown. */
1153
1316
  recordSuccess(engineId) {
1154
1317
  const health = this.health.get(engineId);
@@ -1192,7 +1355,7 @@ var EngineRouter = class {
1192
1355
  * @module dsh-draw/version
1193
1356
  */
1194
1357
  /** Plugin version; must equal the `version` field in `package.json`. */
1195
- const PLUGIN_VERSION = "0.1.3";
1358
+ const PLUGIN_VERSION = "0.2.1";
1196
1359
  //#endregion
1197
1360
  //#region src/service.ts
1198
1361
  /**
@@ -1555,4 +1718,4 @@ async function apply(ctx, config) {
1555
1718
  logger.info(`image generation enabled: ${resolved.engines.map((engine) => engine.id).join(", ")} (preferred ${resolved.defaultEngine})`);
1556
1719
  }
1557
1720
  //#endregion
1558
- export { Config, DEFAULT_ENGINES, DRAW_INVOCATIONS, DrawService, Drawer, EngineCallError, EngineRouter, HttpError, PLUGIN_VERSION, REDACTED, appendDrawGenerated, apply, callEngine, checkQuotaBytes, checkQuotaGenerations, commitDrawGenerated, defaultHttpTransport, drawGeneratedEvents, engineById, fallbackDrawGeneratedEvents, fusedSignal, imageGenerateTool, imageToWire, inject, makeEventGate, makeHostEventGate, name, normalizeRequest, probeIgnorableAppend, probeToWire, quotaState, resolveConfig, sanitizeError, sanitizeText, sanitizeUrl, statusToView, translateRequest };
1721
+ export { Config, DEFAULT_ENGINES, DRAW_INVOCATIONS, DrawService, Drawer, EngineCallError, EngineRouter, HttpError, PLUGIN_VERSION, PROVIDERS, REDACTED, appendDrawGenerated, apply, callEngine, checkQuotaBytes, checkQuotaGenerations, commitDrawGenerated, defaultHttpTransport, drawGeneratedEvents, engineById, fallbackDrawGeneratedEvents, fusedSignal, imageGenerateTool, imageToWire, inject, makeEventGate, makeHostEventGate, name, normalizeRequest, probeIgnorableAppend, probeToWire, providerFor, quotaState, resolveConfig, sanitizeError, sanitizeText, sanitizeUrl, statusToView, translateRequest };
@@ -51,6 +51,8 @@ export interface EngineSizeMap {
51
51
  }
52
52
  /** Whether generated images arrive as base64 payloads or downloadable URLs. */
53
53
  export type EngineResponseFormat = 'b64_json' | 'url';
54
+ /** The image-generation vocabularies a configured engine may declare (provider seam). */
55
+ export type EngineVocabulary = 'openai' | 'replicate' | 'fal';
54
56
  /** One OpenAI-compatible image-generation endpoint the router may use. */
55
57
  export interface EngineConfig {
56
58
  /** Stable engine id used in tool args, results, and the settings panel. */
@@ -61,6 +63,8 @@ export interface EngineConfig {
61
63
  model: string;
62
64
  /** Credential reference (environment-variable name) the API key resolves from; never a literal key. */
63
65
  apiKeyRef: string;
66
+ /** Which request/response vocabulary this engine speaks (default `openai`). */
67
+ provider?: EngineVocabulary;
64
68
  /** Whether the router may use this engine (default true). */
65
69
  enabled?: boolean;
66
70
  /** Standard-size → concrete-size translation (default: the OpenAI sizes). */
@@ -109,6 +113,8 @@ export interface ResolvedEngineConfig {
109
113
  model: string;
110
114
  /** Credential reference the API key resolves from. */
111
115
  apiKeyRef: string;
116
+ /** Request/response vocabulary this engine speaks. */
117
+ provider: EngineVocabulary;
112
118
  /** Whether the router may use this engine. */
113
119
  enabled: boolean;
114
120
  /** Standard-size → concrete-size translation. */
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"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"}
@@ -42,6 +42,7 @@ export const DEFAULT_ENGINES = Object.freeze([
42
42
  baseUrl: 'https://api.openai.com/v1',
43
43
  model: 'gpt-image-1',
44
44
  apiKeyRef: 'OPENAI_API_KEY',
45
+ provider: 'openai',
45
46
  enabled: true,
46
47
  sizeMap: Object.freeze({ square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', auto: 'auto' }),
47
48
  qualitySupported: true,
@@ -54,6 +55,7 @@ export const DEFAULT_ENGINES = Object.freeze([
54
55
  baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
55
56
  model: 'cogview-3-flash',
56
57
  apiKeyRef: 'ZHIPU_API_KEY',
58
+ provider: 'openai',
57
59
  enabled: true,
58
60
  sizeMap: Object.freeze({ square: '1024x1024', landscape: '1344x768', portrait: '768x1344', auto: '1024x1024' }),
59
61
  qualitySupported: false,
@@ -76,6 +78,7 @@ export const Config = z.object({
76
78
  baseUrl: z.string().min(1).max(2048),
77
79
  model: z.string().min(1).max(128),
78
80
  apiKeyRef: z.string().min(1).max(128),
81
+ provider: z.union(['openai', 'replicate', 'fal']),
79
82
  enabled: z.boolean(),
80
83
  sizeMap: z.object({
81
84
  square: z.string().min(1).max(64),
@@ -141,6 +144,10 @@ export function resolveConfig(config) {
141
144
  const apiKeyRef = typeof engine.apiKeyRef === 'string' && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : '';
142
145
  if (apiKeyRef === '')
143
146
  throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`);
147
+ const provider = engine.provider ?? 'openai';
148
+ if (provider !== 'openai' && provider !== 'replicate' && provider !== 'fal') {
149
+ throw new Error(`dsh-draw: engine "${id}" provider must be one of "openai", "replicate", "fal", got ${JSON.stringify(provider)}`);
150
+ }
144
151
  const sizeMap = engine.sizeMap === undefined ? DEFAULT_SIZE_MAP : engine.sizeMap;
145
152
  for (const key of ['square', 'landscape', 'portrait', 'auto']) {
146
153
  const value = sizeMap[key];
@@ -170,6 +177,7 @@ export function resolveConfig(config) {
170
177
  baseUrl: baseUrl.replace(/\/+$/u, ''),
171
178
  model,
172
179
  apiKeyRef,
180
+ provider,
173
181
  enabled,
174
182
  sizeMap: Object.freeze({ ...sizeMap }),
175
183
  qualitySupported,
@@ -25,6 +25,7 @@ export { imageGenerateTool } from './tool.js';
25
25
  export { DrawService } from './service.js';
26
26
  export { defaultHttpTransport, fusedSignal, type HttpTransport, type HttpRequest, type HttpResponse, HttpError } from './http.js';
27
27
  export { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from './engine.js';
28
+ export { providerFor, PROVIDERS, type EngineProvider, type EngineVocabulary } from './provider.js';
28
29
  export { translateRequest, normalizeRequest, type StandardImageRequest } from './translate.js';
29
30
  export { quotaState, checkQuotaGenerations, checkQuotaBytes, type QuotaLimits, type QuotaState } from './quota.js';
30
31
  export { sanitizeUrl, sanitizeText, sanitizeError, REDACTED } from './sanitize.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAE,MAAM,EAAiB,MAAM,aAAa,CAAA;AAQnD,eAAO,MAAM,IAAI,aAAa,CAAA;AAE9B,oEAAoE;AACpE,eAAO,MAAM,MAAM,UAAY,CAAA;AAE/B,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,MAAM,IAAI,UAAU,EAAE,KAAK,cAAc,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAChI,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAClG,OAAO,EAAE,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AAClI,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AACjI,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAC9F,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,eAAe,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAA;AAClH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAClF,OAAO,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,2BAA2B,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACzJ,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAChI,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAK7G;;;;;;;GAOG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAE,MAAM,EAAiB,MAAM,aAAa,CAAA;AAQnD,eAAO,MAAM,IAAI,aAAa,CAAA;AAE9B,oEAAoE;AACpE,eAAO,MAAM,MAAM,UAAY,CAAA;AAE/B,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,MAAM,IAAI,UAAU,EAAE,KAAK,cAAc,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAChI,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAClG,OAAO,EAAE,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AAClI,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AACjI,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAC9F,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAA;AAClG,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAC9F,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,eAAe,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAA;AAClH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAClF,OAAO,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,2BAA2B,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAA;AACzJ,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAChI,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAK7G;;;;;;;GAOG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCvE"}
@@ -31,6 +31,7 @@ export { imageGenerateTool } from './tool.js';
31
31
  export { DrawService } from './service.js';
32
32
  export { defaultHttpTransport, fusedSignal, HttpError } from './http.js';
33
33
  export { callEngine, EngineCallError } from './engine.js';
34
+ export { providerFor, PROVIDERS } from './provider.js';
34
35
  export { translateRequest, normalizeRequest } from './translate.js';
35
36
  export { quotaState, checkQuotaGenerations, checkQuotaBytes } from './quota.js';
36
37
  export { sanitizeUrl, sanitizeText, sanitizeError, REDACTED } from './sanitize.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The engine-provider seam: one adapter per image-generation vocabulary. The
3
+ * OpenAI provider is the existing OpenAI-compatible `POST /images/generations`
4
+ * adapter; `replicate` and `fal` adapters speak those vendors' native request
5
+ * and response shapes (prediction polling and `fal.run` queue respectively).
6
+ * A configured engine declares its vocabulary (`provider`); the router
7
+ * dispatches through {@link providerFor}, so cooldown fallback, quota, and the
8
+ * audit path stay vocabulary-agnostic.
9
+ *
10
+ * @module dsh-draw/provider
11
+ */
12
+ import type { ResolvedEngineConfig } from './config.js';
13
+ import { type EngineDeps, type ProducedImage } from './engine.js';
14
+ import type { StandardImageRequest } from './translate.js';
15
+ /** The image-generation vocabularies a configured engine may declare. */
16
+ export type EngineVocabulary = 'openai' | 'replicate' | 'fal';
17
+ /**
18
+ * One engine adapter: translates the standard tool request into a vendor
19
+ * request, calls it, and returns produced image bytes. A provider must never
20
+ * leak the credential — `deps.resolveCredential` resolves it per call.
21
+ */
22
+ export interface EngineProvider {
23
+ /** Which vocabulary this adapter speaks. */
24
+ readonly vocabulary: EngineVocabulary;
25
+ /**
26
+ * Generate images for one standardized request.
27
+ * @param engine - resolved engine configuration.
28
+ * @param request - normalized standard request.
29
+ * @param deps - transport and credential resolution.
30
+ * @param signal - caller cancellation.
31
+ * @returns the produced images.
32
+ */
33
+ generate(engine: ResolvedEngineConfig, request: StandardImageRequest, deps: EngineDeps, signal?: AbortSignal): Promise<ProducedImage[]>;
34
+ }
35
+ /** The provider registry, keyed by vocabulary. */
36
+ export declare const PROVIDERS: Readonly<Record<EngineVocabulary, EngineProvider>>;
37
+ /** Resolve the provider for one vocabulary (always present — the config gate restricts the value). */
38
+ export declare function providerFor(vocabulary: EngineVocabulary): EngineProvider;
39
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AACvD,OAAO,EAGL,KAAK,UAAU,EACf,KAAK,aAAa,EACnB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAG1D,yEAAyE;AACzE,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,WAAW,GAAG,KAAK,CAAA;AAE7D;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAA;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,oBAAoB,EAAE,OAAO,EAAE,oBAAoB,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;CACxI;AAwLD,kDAAkD;AAClD,eAAO,MAAM,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAIvE,CAAA;AAEF,sGAAsG;AACtG,wBAAgB,WAAW,CAAC,UAAU,EAAE,gBAAgB,GAAG,cAAc,CAExE"}
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The engine-provider seam: one adapter per image-generation vocabulary. The
3
+ * OpenAI provider is the existing OpenAI-compatible `POST /images/generations`
4
+ * adapter; `replicate` and `fal` adapters speak those vendors' native request
5
+ * and response shapes (prediction polling and `fal.run` queue respectively).
6
+ * A configured engine declares its vocabulary (`provider`); the router
7
+ * dispatches through {@link providerFor}, so cooldown fallback, quota, and the
8
+ * audit path stay vocabulary-agnostic.
9
+ *
10
+ * @module dsh-draw/provider
11
+ */
12
+ import { callEngine, EngineCallError, } from './engine.js';
13
+ import { translateRequest } from './translate.js';
14
+ /** The OpenAI-compatible adapter: delegates to the existing `/images/generations` path. */
15
+ const openaiProvider = {
16
+ vocabulary: 'openai',
17
+ generate(engine, request, deps, signal) {
18
+ return callEngine(engine, translateRequest(engine, request), deps, signal);
19
+ },
20
+ };
21
+ /** Replicate prediction polling interval (ms). */
22
+ const REPLICATE_POLL_INTERVAL_MS = 1_000;
23
+ /** Maximum prediction polls before the call fails as a slow request. */
24
+ const REPLICATE_MAX_POLLS = 120;
25
+ /** Parse one concrete `WIDTHxHEIGHT` size string into dimensions. */
26
+ function dimensionsOf(size) {
27
+ const match = /^(\d{2,5})x(\d{2,5})$/u.exec(size);
28
+ if (match === null)
29
+ return undefined;
30
+ return { width: Number(match[1]), height: Number(match[2]) };
31
+ }
32
+ /** Resolve an engine credential, failing closed when it is unconfigured. */
33
+ async function requireCredential(engine, deps) {
34
+ const credential = await deps.resolveCredential(engine.apiKeyRef);
35
+ if (credential === undefined) {
36
+ throw new EngineCallError('credential', 'unconfigured', `engine "${engine.id}" has no resolved credential reference ${engine.apiKeyRef}`);
37
+ }
38
+ return credential;
39
+ }
40
+ /** Parse a JSON response body, failing with a structured parse error. */
41
+ function parseJson(engine, body) {
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(new TextDecoder().decode(body));
45
+ }
46
+ catch (cause) {
47
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a non-JSON response`, { cause });
48
+ }
49
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
50
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a non-object payload`);
51
+ }
52
+ return parsed;
53
+ }
54
+ /** Download one image URL with an explicit authorization header value. */
55
+ async function downloadImage(engine, url, authorization, deps, signal) {
56
+ const response = await deps.transport.request({
57
+ method: 'GET',
58
+ url,
59
+ headers: { authorization },
60
+ ...(signal === undefined ? {} : { signal }),
61
+ });
62
+ if (response.status < 200 || response.status >= 300) {
63
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" image download failed with HTTP ${response.status}`, { status: response.status });
64
+ }
65
+ return response.body;
66
+ }
67
+ /** Sleep without swallowing the caller's abort (checked by the caller's loop). */
68
+ function sleep(ms) {
69
+ return new Promise(resolve => setTimeout(resolve, ms));
70
+ }
71
+ /** The Replicate adapter: create a prediction, poll to completion, then download its output URLs. */
72
+ const replicateProvider = {
73
+ vocabulary: 'replicate',
74
+ async generate(engine, request, deps, signal) {
75
+ const credential = await requireCredential(engine, deps);
76
+ const dimensions = dimensionsOf(engine.sizeMap[request.size ?? 'square']);
77
+ const input = {
78
+ prompt: request.prompt,
79
+ num_outputs: request.count ?? 1,
80
+ ...(dimensions === undefined ? {} : { width: dimensions.width, height: dimensions.height }),
81
+ };
82
+ const create = await deps.transport.request({
83
+ method: 'POST',
84
+ url: `${engine.baseUrl}/models/${engine.model}/predictions`,
85
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
86
+ body: new TextEncoder().encode(JSON.stringify({ input })),
87
+ ...(signal === undefined ? {} : { signal }),
88
+ });
89
+ if (create.status === 401 || create.status === 403) {
90
+ throw new EngineCallError('request', 'auth', `engine "${engine.id}" rejected the credential (HTTP ${create.status})`, { status: create.status });
91
+ }
92
+ if (create.status < 200 || create.status >= 300) {
93
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" failed with HTTP ${create.status}`, { status: create.status });
94
+ }
95
+ let prediction = parseJson(engine, create.body);
96
+ const id = typeof prediction['id'] === 'string' ? prediction['id'] : '';
97
+ const urls = typeof prediction['urls'] === 'object' && prediction['urls'] !== null
98
+ ? prediction['urls']
99
+ : {};
100
+ const pollUrl = typeof urls['get'] === 'string' && urls['get'] !== '' ? urls['get'] : `${engine.baseUrl}/predictions/${id}`;
101
+ for (let poll = 0; poll < REPLICATE_MAX_POLLS; poll += 1) {
102
+ signal?.throwIfAborted();
103
+ const status = typeof prediction['status'] === 'string' ? prediction['status'] : '';
104
+ if (status === 'succeeded')
105
+ break;
106
+ if (status === 'failed' || status === 'canceled') {
107
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction ${status}`);
108
+ }
109
+ await sleep(REPLICATE_POLL_INTERVAL_MS);
110
+ const polled = await deps.transport.request({
111
+ method: 'GET',
112
+ url: pollUrl,
113
+ headers: { authorization: `Bearer ${credential}` },
114
+ ...(signal === undefined ? {} : { signal }),
115
+ });
116
+ if (polled.status < 200 || polled.status >= 300) {
117
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction poll failed with HTTP ${polled.status}`, { status: polled.status });
118
+ }
119
+ prediction = parseJson(engine, polled.body);
120
+ }
121
+ if (prediction['status'] !== 'succeeded') {
122
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction did not finish within ${REPLICATE_MAX_POLLS} polls`);
123
+ }
124
+ const output = prediction['output'];
125
+ if (!Array.isArray(output)) {
126
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction has no output array`);
127
+ }
128
+ const images = [];
129
+ for (const item of output) {
130
+ if (typeof item !== 'string' || item === '') {
131
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction returned a non-URL output entry`);
132
+ }
133
+ images.push({ data: await downloadImage(engine, item, `Bearer ${credential}`, deps, signal), mediaType: engine.imageMediaType });
134
+ }
135
+ if (images.length === 0) {
136
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction returned no images`);
137
+ }
138
+ return images;
139
+ },
140
+ };
141
+ /** The fal.ai adapter: POST the `fal.run` queue endpoint and download the returned image URLs. */
142
+ const falProvider = {
143
+ vocabulary: 'fal',
144
+ async generate(engine, request, deps, signal) {
145
+ const credential = await requireCredential(engine, deps);
146
+ const body = {
147
+ prompt: request.prompt,
148
+ image_size: engine.sizeMap[request.size ?? 'square'],
149
+ num_images: request.count ?? 1,
150
+ };
151
+ const response = await deps.transport.request({
152
+ method: 'POST',
153
+ url: `${engine.baseUrl}/${engine.model}`,
154
+ headers: { 'content-type': 'application/json', authorization: `Key ${credential}` },
155
+ body: new TextEncoder().encode(JSON.stringify(body)),
156
+ ...(signal === undefined ? {} : { signal }),
157
+ });
158
+ if (response.status === 401 || response.status === 403) {
159
+ throw new EngineCallError('request', 'auth', `engine "${engine.id}" rejected the credential (HTTP ${response.status})`, { status: response.status });
160
+ }
161
+ if (response.status < 200 || response.status >= 300) {
162
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" failed with HTTP ${response.status}`, { status: response.status });
163
+ }
164
+ const payload = parseJson(engine, response.body);
165
+ const entries = payload['images'];
166
+ if (!Array.isArray(entries)) {
167
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" response has no images array`);
168
+ }
169
+ const images = [];
170
+ for (const raw of entries) {
171
+ const url = typeof raw === 'object' && raw !== null ? raw['url'] : undefined;
172
+ if (typeof url !== 'string' || url === '') {
173
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned an image entry without a URL`);
174
+ }
175
+ images.push({ data: await downloadImage(engine, url, `Key ${credential}`, deps, signal), mediaType: engine.imageMediaType });
176
+ }
177
+ if (images.length === 0) {
178
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned no images`);
179
+ }
180
+ return images;
181
+ },
182
+ };
183
+ /** The provider registry, keyed by vocabulary. */
184
+ export const PROVIDERS = Object.freeze({
185
+ openai: openaiProvider,
186
+ replicate: replicateProvider,
187
+ fal: falProvider,
188
+ });
189
+ /** Resolve the provider for one vocabulary (always present — the config gate restricts the value). */
190
+ export function providerFor(vocabulary) {
191
+ return PROVIDERS[vocabulary];
192
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { ResolvedConfig, ResolvedEngineConfig } from './config.js';
11
11
  import { type EngineDeps, type ProducedImage } from './engine.js';
12
- import { type StandardImageRequest } from './translate.js';
12
+ import type { StandardImageRequest } from './translate.js';
13
13
  /** One recorded attempt against one engine, success or failure. */
14
14
  export interface AttemptView {
15
15
  /** Engine id. */
@@ -131,8 +131,6 @@ export declare class EngineRouter {
131
131
  private chainOrder;
132
132
  /** Why an engine may not even be attempted: disabled, cooling down. */
133
133
  private skipReason;
134
- /** Translate the standard request against one engine (the pure translate step). */
135
- private translate;
136
134
  /** Record a success: reset consecutive failures and cooldown. */
137
135
  private recordSuccess;
138
136
  /** Record a failure: bump the counter, trip cooldown at the threshold, and build the attempt view. */
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/router.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/router.ts"],"names":[],"mappings":"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"}
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * @module dsh-draw/router
9
9
  */
10
- import { callEngine, EngineCallError } from './engine.js';
10
+ import { EngineCallError } from './engine.js';
11
+ import { providerFor } from './provider.js';
11
12
  import { sanitizeError, sanitizeText, sanitizeUrl } from './sanitize.js';
12
- import { translateRequest } from './translate.js';
13
13
  /**
14
14
  * The engine chain with per-engine health and cooldown. All mutations are
15
15
  * synchronous bookkeeping guarded by `generate`'s single-writer path (the
@@ -56,7 +56,7 @@ export class EngineRouter {
56
56
  }
57
57
  tried += 1;
58
58
  try {
59
- const images = await callEngine(engine, this.translate(engine, request), deps, signal);
59
+ const images = await providerFor(engine.provider).generate(engine, request, deps, signal);
60
60
  this.recordSuccess(engine.id);
61
61
  attempts.push({ engine: engine.id, code: 'ok' });
62
62
  return {
@@ -172,10 +172,6 @@ export class EngineRouter {
172
172
  }
173
173
  return undefined;
174
174
  }
175
- /** Translate the standard request against one engine (the pure translate step). */
176
- translate(engine, request) {
177
- return translateRequest(engine, request);
178
- }
179
175
  /** Record a success: reset consecutive failures and cooldown. */
180
176
  recordSuccess(engineId) {
181
177
  const health = this.health.get(engineId);
@@ -7,5 +7,5 @@
7
7
  * @module dsh-draw/version
8
8
  */
9
9
  /** Plugin version; must equal the `version` field in `package.json`. */
10
- export declare const PLUGIN_VERSION = "0.1.3";
10
+ export declare const PLUGIN_VERSION = "0.2.1";
11
11
  //# sourceMappingURL=version.d.ts.map
@@ -7,4 +7,4 @@
7
7
  * @module dsh-draw/version
8
8
  */
9
9
  /** Plugin version; must equal the `version` field in `package.json`. */
10
- export const PLUGIN_VERSION = '0.1.3';
10
+ export const PLUGIN_VERSION = '0.2.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-draw",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Unified static-image generation router for DeepSeek Harness: one image_generate tool with standard parameters, config-driven OpenAI-compatible engine routing (OpenAI Images, Zhipu CogView, and any compatible endpoint) with health-aware fallback, durable workspace attachment results, per-session quota accounting, an in-conversation result card, and a Plugins settings panel that stores API keys as credential references.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -129,7 +129,33 @@
129
129
  "@deepseek-ai/dsh-session": ">=0.1.0-rc.8 <0.2.0",
130
130
  "@deepseek-ai/dsh-tools": ">=0.1.0-rc.8 <0.2.0",
131
131
  "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.8 <0.2.0",
132
- "@deepseek-ai/schemastery": "^3.18.0"
132
+ "@deepseek-ai/schemastery": "^3.18.0",
133
+ "@deepseek-ai/dsh-client-connection": ">=0.1.1-rc.2 <0.2.0",
134
+ "@deepseek-ai/dsh-client-locale": ">=0.1.1-rc.2 <0.2.0",
135
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.1-rc.2 <0.2.0",
136
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.1-rc.2 <0.2.0",
137
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.1-rc.2 <0.2.0",
138
+ "@deepseek-ai/dsh-client-ui-tool": ">=0.1.1-rc.2 <0.2.0"
139
+ },
140
+ "peerDependenciesMeta": {
141
+ "@deepseek-ai/dsh-client-connection": {
142
+ "optional": true
143
+ },
144
+ "@deepseek-ai/dsh-client-locale": {
145
+ "optional": true
146
+ },
147
+ "@deepseek-ai/dsh-client-runtime": {
148
+ "optional": true
149
+ },
150
+ "@deepseek-ai/dsh-client-ui-settings": {
151
+ "optional": true
152
+ },
153
+ "@deepseek-ai/dsh-client-ui-slots": {
154
+ "optional": true
155
+ },
156
+ "@deepseek-ai/dsh-client-ui-tool": {
157
+ "optional": true
158
+ }
133
159
  },
134
160
  "dependencies": {
135
161
  "tsdown": "^0.22.14",
package/src/config.ts CHANGED
@@ -70,6 +70,9 @@ export interface EngineSizeMap {
70
70
  /** Whether generated images arrive as base64 payloads or downloadable URLs. */
71
71
  export type EngineResponseFormat = 'b64_json' | 'url'
72
72
 
73
+ /** The image-generation vocabularies a configured engine may declare (provider seam). */
74
+ export type EngineVocabulary = 'openai' | 'replicate' | 'fal'
75
+
73
76
  /** One OpenAI-compatible image-generation endpoint the router may use. */
74
77
  export interface EngineConfig {
75
78
  /** Stable engine id used in tool args, results, and the settings panel. */
@@ -80,6 +83,8 @@ export interface EngineConfig {
80
83
  model: string
81
84
  /** Credential reference (environment-variable name) the API key resolves from; never a literal key. */
82
85
  apiKeyRef: string
86
+ /** Which request/response vocabulary this engine speaks (default `openai`). */
87
+ provider?: EngineVocabulary
83
88
  /** Whether the router may use this engine (default true). */
84
89
  enabled?: boolean
85
90
  /** Standard-size → concrete-size translation (default: the OpenAI sizes). */
@@ -123,6 +128,7 @@ export const DEFAULT_ENGINES: ReadonlyArray<Required<EngineConfig>> = Object.fre
123
128
  baseUrl: 'https://api.openai.com/v1',
124
129
  model: 'gpt-image-1',
125
130
  apiKeyRef: 'OPENAI_API_KEY',
131
+ provider: 'openai',
126
132
  enabled: true,
127
133
  sizeMap: Object.freeze({ square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', auto: 'auto' }),
128
134
  qualitySupported: true,
@@ -135,6 +141,7 @@ export const DEFAULT_ENGINES: ReadonlyArray<Required<EngineConfig>> = Object.fre
135
141
  baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
136
142
  model: 'cogview-3-flash',
137
143
  apiKeyRef: 'ZHIPU_API_KEY',
144
+ provider: 'openai',
138
145
  enabled: true,
139
146
  sizeMap: Object.freeze({ square: '1024x1024', landscape: '1344x768', portrait: '768x1344', auto: '1024x1024' }),
140
147
  qualitySupported: false,
@@ -162,6 +169,8 @@ export interface ResolvedEngineConfig {
162
169
  model: string
163
170
  /** Credential reference the API key resolves from. */
164
171
  apiKeyRef: string
172
+ /** Request/response vocabulary this engine speaks. */
173
+ provider: EngineVocabulary
165
174
  /** Whether the router may use this engine. */
166
175
  enabled: boolean
167
176
  /** Standard-size → concrete-size translation. */
@@ -205,6 +214,7 @@ export const Config: z<Config> = z.object({
205
214
  baseUrl: z.string().min(1).max(2048),
206
215
  model: z.string().min(1).max(128),
207
216
  apiKeyRef: z.string().min(1).max(128),
217
+ provider: z.union(['openai', 'replicate', 'fal'] as const),
208
218
  enabled: z.boolean(),
209
219
  sizeMap: z.object({
210
220
  square: z.string().min(1).max(64),
@@ -269,6 +279,10 @@ export function resolveConfig(config: Config | undefined): ResolvedConfig {
269
279
  if (model === '') throw new Error(`dsh-draw: engine "${id}" model must be a non-empty string of at most 128 characters`)
270
280
  const apiKeyRef = typeof engine.apiKeyRef === 'string' && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : ''
271
281
  if (apiKeyRef === '') throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`)
282
+ const provider = engine.provider ?? 'openai'
283
+ if (provider !== 'openai' && provider !== 'replicate' && provider !== 'fal') {
284
+ throw new Error(`dsh-draw: engine "${id}" provider must be one of "openai", "replicate", "fal", got ${JSON.stringify(provider)}`)
285
+ }
272
286
  const sizeMap = engine.sizeMap === undefined ? DEFAULT_SIZE_MAP : engine.sizeMap
273
287
  for (const key of ['square', 'landscape', 'portrait', 'auto'] as const) {
274
288
  const value = sizeMap[key]
@@ -295,6 +309,7 @@ export function resolveConfig(config: Config | undefined): ResolvedConfig {
295
309
  baseUrl: baseUrl.replace(/\/+$/u, ''),
296
310
  model,
297
311
  apiKeyRef,
312
+ provider,
298
313
  enabled,
299
314
  sizeMap: Object.freeze({ ...sizeMap }),
300
315
  qualitySupported,
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export { imageGenerateTool } from './tool.ts'
40
40
  export { DrawService } from './service.ts'
41
41
  export { defaultHttpTransport, fusedSignal, type HttpTransport, type HttpRequest, type HttpResponse, HttpError } from './http.ts'
42
42
  export { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from './engine.ts'
43
+ export { providerFor, PROVIDERS, type EngineProvider, type EngineVocabulary } from './provider.ts'
43
44
  export { translateRequest, normalizeRequest, type StandardImageRequest } from './translate.ts'
44
45
  export { quotaState, checkQuotaGenerations, checkQuotaBytes, type QuotaLimits, type QuotaState } from './quota.ts'
45
46
  export { sanitizeUrl, sanitizeText, sanitizeError, REDACTED } from './sanitize.ts'
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The engine-provider seam: one adapter per image-generation vocabulary. The
3
+ * OpenAI provider is the existing OpenAI-compatible `POST /images/generations`
4
+ * adapter; `replicate` and `fal` adapters speak those vendors' native request
5
+ * and response shapes (prediction polling and `fal.run` queue respectively).
6
+ * A configured engine declares its vocabulary (`provider`); the router
7
+ * dispatches through {@link providerFor}, so cooldown fallback, quota, and the
8
+ * audit path stay vocabulary-agnostic.
9
+ *
10
+ * @module dsh-draw/provider
11
+ */
12
+
13
+ import type { ResolvedEngineConfig } from './config.ts'
14
+ import {
15
+ callEngine,
16
+ EngineCallError,
17
+ type EngineDeps,
18
+ type ProducedImage,
19
+ } from './engine.ts'
20
+ import type { StandardImageRequest } from './translate.ts'
21
+ import { translateRequest } from './translate.ts'
22
+
23
+ /** The image-generation vocabularies a configured engine may declare. */
24
+ export type EngineVocabulary = 'openai' | 'replicate' | 'fal'
25
+
26
+ /**
27
+ * One engine adapter: translates the standard tool request into a vendor
28
+ * request, calls it, and returns produced image bytes. A provider must never
29
+ * leak the credential — `deps.resolveCredential` resolves it per call.
30
+ */
31
+ export interface EngineProvider {
32
+ /** Which vocabulary this adapter speaks. */
33
+ readonly vocabulary: EngineVocabulary
34
+ /**
35
+ * Generate images for one standardized request.
36
+ * @param engine - resolved engine configuration.
37
+ * @param request - normalized standard request.
38
+ * @param deps - transport and credential resolution.
39
+ * @param signal - caller cancellation.
40
+ * @returns the produced images.
41
+ */
42
+ generate(engine: ResolvedEngineConfig, request: StandardImageRequest, deps: EngineDeps, signal?: AbortSignal): Promise<ProducedImage[]>
43
+ }
44
+
45
+ /** The OpenAI-compatible adapter: delegates to the existing `/images/generations` path. */
46
+ const openaiProvider: EngineProvider = {
47
+ vocabulary: 'openai',
48
+ generate(engine, request, deps, signal) {
49
+ return callEngine(engine, translateRequest(engine, request), deps, signal)
50
+ },
51
+ }
52
+
53
+ /** Replicate prediction polling interval (ms). */
54
+ const REPLICATE_POLL_INTERVAL_MS = 1_000
55
+
56
+ /** Maximum prediction polls before the call fails as a slow request. */
57
+ const REPLICATE_MAX_POLLS = 120
58
+
59
+ /** Parse one concrete `WIDTHxHEIGHT` size string into dimensions. */
60
+ function dimensionsOf(size: string): { width: number; height: number } | undefined {
61
+ const match = /^(\d{2,5})x(\d{2,5})$/u.exec(size)
62
+ if (match === null) return undefined
63
+ return { width: Number(match[1]), height: Number(match[2]) }
64
+ }
65
+
66
+ /** Resolve an engine credential, failing closed when it is unconfigured. */
67
+ async function requireCredential(engine: ResolvedEngineConfig, deps: EngineDeps): Promise<string> {
68
+ const credential = await deps.resolveCredential(engine.apiKeyRef)
69
+ if (credential === undefined) {
70
+ throw new EngineCallError('credential', 'unconfigured', `engine "${engine.id}" has no resolved credential reference ${engine.apiKeyRef}`)
71
+ }
72
+ return credential
73
+ }
74
+
75
+ /** Parse a JSON response body, failing with a structured parse error. */
76
+ function parseJson(engine: ResolvedEngineConfig, body: Uint8Array): Record<string, unknown> {
77
+ let parsed: unknown
78
+ try {
79
+ parsed = JSON.parse(new TextDecoder().decode(body))
80
+ } catch (cause) {
81
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a non-JSON response`, { cause })
82
+ }
83
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
84
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a non-object payload`)
85
+ }
86
+ return parsed as Record<string, unknown>
87
+ }
88
+
89
+ /** Download one image URL with an explicit authorization header value. */
90
+ async function downloadImage(
91
+ engine: ResolvedEngineConfig,
92
+ url: string,
93
+ authorization: string,
94
+ deps: EngineDeps,
95
+ signal?: AbortSignal,
96
+ ): Promise<Uint8Array> {
97
+ const response = await deps.transport.request({
98
+ method: 'GET',
99
+ url,
100
+ headers: { authorization },
101
+ ...(signal === undefined ? {} : { signal }),
102
+ })
103
+ if (response.status < 200 || response.status >= 300) {
104
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" image download failed with HTTP ${response.status}`, { status: response.status })
105
+ }
106
+ return response.body
107
+ }
108
+
109
+ /** Sleep without swallowing the caller's abort (checked by the caller's loop). */
110
+ function sleep(ms: number): Promise<void> {
111
+ return new Promise(resolve => setTimeout(resolve, ms))
112
+ }
113
+
114
+ /** The Replicate adapter: create a prediction, poll to completion, then download its output URLs. */
115
+ const replicateProvider: EngineProvider = {
116
+ vocabulary: 'replicate',
117
+ async generate(engine, request, deps, signal) {
118
+ const credential = await requireCredential(engine, deps)
119
+ const dimensions = dimensionsOf(engine.sizeMap[request.size ?? 'square'])
120
+ const input: Record<string, unknown> = {
121
+ prompt: request.prompt,
122
+ num_outputs: request.count ?? 1,
123
+ ...(dimensions === undefined ? {} : { width: dimensions.width, height: dimensions.height }),
124
+ }
125
+ const create = await deps.transport.request({
126
+ method: 'POST',
127
+ url: `${engine.baseUrl}/models/${engine.model}/predictions`,
128
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
129
+ body: new TextEncoder().encode(JSON.stringify({ input })),
130
+ ...(signal === undefined ? {} : { signal }),
131
+ })
132
+ if (create.status === 401 || create.status === 403) {
133
+ throw new EngineCallError('request', 'auth', `engine "${engine.id}" rejected the credential (HTTP ${create.status})`, { status: create.status })
134
+ }
135
+ if (create.status < 200 || create.status >= 300) {
136
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" failed with HTTP ${create.status}`, { status: create.status })
137
+ }
138
+ let prediction = parseJson(engine, create.body)
139
+ const id = typeof prediction['id'] === 'string' ? prediction['id'] : ''
140
+ const urls = typeof prediction['urls'] === 'object' && prediction['urls'] !== null
141
+ ? prediction['urls'] as Record<string, unknown>
142
+ : {}
143
+ const pollUrl = typeof urls['get'] === 'string' && urls['get'] !== '' ? urls['get'] : `${engine.baseUrl}/predictions/${id}`
144
+ for (let poll = 0; poll < REPLICATE_MAX_POLLS; poll += 1) {
145
+ signal?.throwIfAborted()
146
+ const status = typeof prediction['status'] === 'string' ? prediction['status'] : ''
147
+ if (status === 'succeeded') break
148
+ if (status === 'failed' || status === 'canceled') {
149
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction ${status}`)
150
+ }
151
+ await sleep(REPLICATE_POLL_INTERVAL_MS)
152
+ const polled = await deps.transport.request({
153
+ method: 'GET',
154
+ url: pollUrl,
155
+ headers: { authorization: `Bearer ${credential}` },
156
+ ...(signal === undefined ? {} : { signal }),
157
+ })
158
+ if (polled.status < 200 || polled.status >= 300) {
159
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction poll failed with HTTP ${polled.status}`, { status: polled.status })
160
+ }
161
+ prediction = parseJson(engine, polled.body)
162
+ }
163
+ if (prediction['status'] !== 'succeeded') {
164
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" prediction did not finish within ${REPLICATE_MAX_POLLS} polls`)
165
+ }
166
+ const output = prediction['output']
167
+ if (!Array.isArray(output)) {
168
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction has no output array`)
169
+ }
170
+ const images: ProducedImage[] = []
171
+ for (const item of output) {
172
+ if (typeof item !== 'string' || item === '') {
173
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction returned a non-URL output entry`)
174
+ }
175
+ images.push({ data: await downloadImage(engine, item, `Bearer ${credential}`, deps, signal), mediaType: engine.imageMediaType })
176
+ }
177
+ if (images.length === 0) {
178
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" prediction returned no images`)
179
+ }
180
+ return images
181
+ },
182
+ }
183
+
184
+ /** The fal.ai adapter: POST the `fal.run` queue endpoint and download the returned image URLs. */
185
+ const falProvider: EngineProvider = {
186
+ vocabulary: 'fal',
187
+ async generate(engine, request, deps, signal) {
188
+ const credential = await requireCredential(engine, deps)
189
+ const body: Record<string, unknown> = {
190
+ prompt: request.prompt,
191
+ image_size: engine.sizeMap[request.size ?? 'square'],
192
+ num_images: request.count ?? 1,
193
+ }
194
+ const response = await deps.transport.request({
195
+ method: 'POST',
196
+ url: `${engine.baseUrl}/${engine.model}`,
197
+ headers: { 'content-type': 'application/json', authorization: `Key ${credential}` },
198
+ body: new TextEncoder().encode(JSON.stringify(body)),
199
+ ...(signal === undefined ? {} : { signal }),
200
+ })
201
+ if (response.status === 401 || response.status === 403) {
202
+ throw new EngineCallError('request', 'auth', `engine "${engine.id}" rejected the credential (HTTP ${response.status})`, { status: response.status })
203
+ }
204
+ if (response.status < 200 || response.status >= 300) {
205
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" failed with HTTP ${response.status}`, { status: response.status })
206
+ }
207
+ const payload = parseJson(engine, response.body)
208
+ const entries = payload['images']
209
+ if (!Array.isArray(entries)) {
210
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" response has no images array`)
211
+ }
212
+ const images: ProducedImage[] = []
213
+ for (const raw of entries) {
214
+ const url = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>)['url'] : undefined
215
+ if (typeof url !== 'string' || url === '') {
216
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned an image entry without a URL`)
217
+ }
218
+ images.push({ data: await downloadImage(engine, url, `Key ${credential}`, deps, signal), mediaType: engine.imageMediaType })
219
+ }
220
+ if (images.length === 0) {
221
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned no images`)
222
+ }
223
+ return images
224
+ },
225
+ }
226
+
227
+ /** The provider registry, keyed by vocabulary. */
228
+ export const PROVIDERS: Readonly<Record<EngineVocabulary, EngineProvider>> = Object.freeze({
229
+ openai: openaiProvider,
230
+ replicate: replicateProvider,
231
+ fal: falProvider,
232
+ })
233
+
234
+ /** Resolve the provider for one vocabulary (always present — the config gate restricts the value). */
235
+ export function providerFor(vocabulary: EngineVocabulary): EngineProvider {
236
+ return PROVIDERS[vocabulary]
237
+ }
package/src/router.ts CHANGED
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  import type { ResolvedConfig, ResolvedEngineConfig } from './config.ts'
12
- import { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from './engine.ts'
12
+ import { EngineCallError, type EngineDeps, type ProducedImage } from './engine.ts'
13
+ import { providerFor } from './provider.ts'
13
14
  import { sanitizeError, sanitizeText, sanitizeUrl } from './sanitize.ts'
14
- import { translateRequest, type StandardImageRequest } from './translate.ts'
15
+ import type { StandardImageRequest } from './translate.ts'
15
16
 
16
17
  /** One recorded attempt against one engine, success or failure. */
17
18
  export interface AttemptView {
@@ -156,7 +157,7 @@ export class EngineRouter {
156
157
  }
157
158
  tried += 1
158
159
  try {
159
- const images = await callEngine(engine, this.translate(engine, request), deps, signal)
160
+ const images = await providerFor(engine.provider).generate(engine, request, deps, signal)
160
161
  this.recordSuccess(engine.id)
161
162
  attempts.push({ engine: engine.id, code: 'ok' })
162
163
  return {
@@ -273,11 +274,6 @@ export class EngineRouter {
273
274
  return undefined
274
275
  }
275
276
 
276
- /** Translate the standard request against one engine (the pure translate step). */
277
- private translate(engine: ResolvedEngineConfig, request: StandardImageRequest) {
278
- return translateRequest(engine, request)
279
- }
280
-
281
277
  /** Record a success: reset consecutive failures and cooldown. */
282
278
  private recordSuccess(engineId: string): void {
283
279
  const health = this.health.get(engineId)
package/src/version.ts CHANGED
@@ -8,4 +8,4 @@
8
8
  */
9
9
 
10
10
  /** Plugin version; must equal the `version` field in `package.json`. */
11
- export const PLUGIN_VERSION = '0.1.3'
11
+ export const PLUGIN_VERSION = '0.2.1'