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.
- package/CHANGELOG.md +38 -0
- package/README.es.md +39 -32
- package/README.hi.md +39 -32
- package/README.md +41 -34
- package/README.pt.md +39 -32
- package/README.zh.md +39 -32
- package/cordis.patch.yml +5 -1
- package/lib/client.js +3 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +180 -13
- package/lib/types/client/DrawResultCard.d.ts +1 -1
- package/lib/types/client/DrawResultCard.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +3 -2
- package/lib/types/client/present.d.ts +40 -1
- package/lib/types/client/present.d.ts.map +1 -1
- package/lib/types/config.d.ts +6 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +8 -0
- package/lib/types/event-gate.d.ts +8 -5
- package/lib/types/event-gate.d.ts.map +1 -1
- package/lib/types/event-gate.js +8 -5
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +1 -0
- package/lib/types/provider.d.ts +39 -0
- package/lib/types/provider.d.ts.map +1 -0
- package/lib/types/provider.js +192 -0
- package/lib/types/router.d.ts +1 -3
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +3 -7
- package/lib/types/session-events.d.ts +10 -8
- package/lib/types/session-events.d.ts.map +1 -1
- package/lib/types/session-events.js +12 -9
- package/lib/types/version.d.ts +1 -1
- package/lib/types/version.js +1 -1
- package/package.json +32 -6
- package/src/client/DrawResultCard.tsx +1 -2
- package/src/client/index.ts +18 -3
- package/src/client/present.ts +42 -1
- package/src/config.ts +15 -0
- package/src/event-gate.ts +9 -6
- package/src/index.ts +1 -0
- package/src/provider.ts +237 -0
- package/src/router.ts +4 -8
- package/src/session-events.ts +13 -10
- 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'
|
package/src/provider.ts
ADDED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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/session-events.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
12
|
-
* otherwise records the payload in the in-memory fallback
|
|
13
|
-
* quota keeps working; the durable trail resumes once
|
|
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;
|
|
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
|
|
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