dsh-draw 0.1.3 → 0.2.2

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.es.md +39 -32
  3. package/README.hi.md +39 -32
  4. package/README.md +41 -34
  5. package/README.pt.md +39 -32
  6. package/README.zh.md +39 -32
  7. package/cordis.patch.yml +5 -1
  8. package/lib/client.js +3 -2
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +180 -13
  11. package/lib/types/client/DrawResultCard.d.ts +1 -1
  12. package/lib/types/client/DrawResultCard.d.ts.map +1 -1
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/index.d.ts.map +1 -1
  15. package/lib/types/client/index.js +3 -2
  16. package/lib/types/client/present.d.ts +40 -1
  17. package/lib/types/client/present.d.ts.map +1 -1
  18. package/lib/types/config.d.ts +6 -0
  19. package/lib/types/config.d.ts.map +1 -1
  20. package/lib/types/config.js +8 -0
  21. package/lib/types/event-gate.d.ts +8 -5
  22. package/lib/types/event-gate.d.ts.map +1 -1
  23. package/lib/types/event-gate.js +8 -5
  24. package/lib/types/index.d.ts +1 -0
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +1 -0
  27. package/lib/types/provider.d.ts +39 -0
  28. package/lib/types/provider.d.ts.map +1 -0
  29. package/lib/types/provider.js +192 -0
  30. package/lib/types/router.d.ts +1 -3
  31. package/lib/types/router.d.ts.map +1 -1
  32. package/lib/types/router.js +3 -7
  33. package/lib/types/session-events.d.ts +10 -8
  34. package/lib/types/session-events.d.ts.map +1 -1
  35. package/lib/types/session-events.js +12 -9
  36. package/lib/types/version.d.ts +1 -1
  37. package/lib/types/version.js +1 -1
  38. package/package.json +32 -6
  39. package/src/client/DrawResultCard.tsx +1 -2
  40. package/src/client/index.ts +18 -3
  41. package/src/client/present.ts +42 -1
  42. package/src/config.ts +15 -0
  43. package/src/event-gate.ts +9 -6
  44. package/src/index.ts +1 -0
  45. package/src/provider.ts +237 -0
  46. package/src/router.ts +4 -8
  47. package/src/session-events.ts +13 -10
  48. package/src/version.ts +1 -1
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)
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * The dsh-draw session event: one durable record per completed generation
3
3
  * (tool call or panel regenerate), appended to the owning session log. It is
4
- * log-only (`ignorable`) and exists so quota accounting and the durable audit
5
- * trail are reconstructable from the log alone. The model-visible input and
6
- * output of the call ride the loop-owned `tool/call` and `tool/result`
7
- * events; this event carries the accounting facts those events do not.
4
+ * log-only and exists so quota accounting and the durable audit trail are
5
+ * reconstructable from the log alone. The model-visible input and output of
6
+ * the call ride the loop-owned `tool/call` and `tool/result` events; this
7
+ * event carries the accounting facts those events do not.
8
8
  *
9
9
  * The event type is declared only here, so plugin paths append it through
10
10
  * {@link commitDrawGenerated}: the adaptive gate (event-gate.ts) appends only
11
- * when the host knows the type or honors the `ignorable` envelope, and
12
- * otherwise records the payload in the in-memory fallback ledger (live-session
13
- * quota keeps working; the durable trail resumes once the host gains a plugin
14
- * event surface).
11
+ * when the host knows the type or honors the `ignorable` envelope (pre-0.1.2
12
+ * host lines), and otherwise records the payload in the in-memory fallback
13
+ * ledger (live-session quota keeps working; the durable trail resumes once
14
+ * the host gains a plugin event surface). `0.1.2-alpha.1` removed the
15
+ * envelope and fails closed on unknown event types at read, so the probe
16
+ * reports no support there and every commit degrades to the ledger.
15
17
  *
16
18
  * @module dsh-draw/session-events
17
19
  */
@@ -66,12 +68,13 @@ export function appendDrawGenerated(session: Session, event: DrawGeneratedEvent)
66
68
  return session.append('draw/generated', event)
67
69
  }
68
70
 
69
- /** The append face with the envelope option; rc.6 types declare none. */
71
+ /** The append face with the envelope option; only pre-0.1.2 host lines honor it. */
70
72
  type EnvelopeAppend = (type: string, data: unknown, opts: { ignorable: boolean }) => unknown
71
73
 
72
74
  /**
73
75
  * In-memory accounting ledger for hosts whose session log cannot carry
74
- * `draw/generated` safely (rc.6/rc.7 static whitelist, no ignorable envelope).
76
+ * `draw/generated` safely (rc.6/rc.7 static whitelist and the envelope-less
77
+ * `0.1.2-alpha.1`, which fails closed on unknown types at read).
75
78
  * Keyed by Session identity: the entries live exactly as long as the session.
76
79
  */
77
80
  const fallbackLedger = new WeakMap<Session, DrawGeneratedEvent[]>()
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.2'