dsh-draw 0.1.0

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 (104) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +194 -0
  4. package/README.hi.md +194 -0
  5. package/README.md +194 -0
  6. package/README.pt.md +194 -0
  7. package/README.zh.md +194 -0
  8. package/SECURITY.md +39 -0
  9. package/THIRD_PARTY_NOTICES.md +21 -0
  10. package/cordis.patch.yml +48 -0
  11. package/lib/client.js +4787 -0
  12. package/lib/client.js.map +1 -0
  13. package/lib/index.js +1429 -0
  14. package/lib/typert.host.js +26 -0
  15. package/lib/types/client/DrawResultCard.d.ts +58 -0
  16. package/lib/types/client/DrawResultCard.d.ts.map +1 -0
  17. package/lib/types/client/DrawResultCard.js +48 -0
  18. package/lib/types/client/DrawSettingsTab.d.ts +31 -0
  19. package/lib/types/client/DrawSettingsTab.d.ts.map +1 -0
  20. package/lib/types/client/DrawSettingsTab.js +129 -0
  21. package/lib/types/client/index.d.ts +35 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/index.js +98 -0
  24. package/lib/types/client/locales.d.ts +14 -0
  25. package/lib/types/client/locales.d.ts.map +1 -0
  26. package/lib/types/client/locales.js +57 -0
  27. package/lib/types/client/present.d.ts +80 -0
  28. package/lib/types/client/present.d.ts.map +1 -0
  29. package/lib/types/client/present.js +86 -0
  30. package/lib/types/client/remote.d.ts +268 -0
  31. package/lib/types/client/remote.d.ts.map +1 -0
  32. package/lib/types/client/remote.js +15 -0
  33. package/lib/types/client/styles.d.ts +11 -0
  34. package/lib/types/client/styles.d.ts.map +1 -0
  35. package/lib/types/client/styles.js +43 -0
  36. package/lib/types/config.d.ts +160 -0
  37. package/lib/types/config.d.ts.map +1 -0
  38. package/lib/types/config.js +230 -0
  39. package/lib/types/drawer.d.ts +114 -0
  40. package/lib/types/drawer.d.ts.map +1 -0
  41. package/lib/types/drawer.js +138 -0
  42. package/lib/types/engine.d.ts +58 -0
  43. package/lib/types/engine.d.ts.map +1 -0
  44. package/lib/types/engine.js +135 -0
  45. package/lib/types/http.d.ts +89 -0
  46. package/lib/types/http.d.ts.map +1 -0
  47. package/lib/types/http.js +127 -0
  48. package/lib/types/index.d.ts +43 -0
  49. package/lib/types/index.d.ts.map +1 -0
  50. package/lib/types/index.js +78 -0
  51. package/lib/types/quota.d.ts +69 -0
  52. package/lib/types/quota.d.ts.map +1 -0
  53. package/lib/types/quota.js +56 -0
  54. package/lib/types/router.d.ts +141 -0
  55. package/lib/types/router.d.ts.map +1 -0
  56. package/lib/types/router.js +207 -0
  57. package/lib/types/sanitize.d.ts +40 -0
  58. package/lib/types/sanitize.d.ts.map +1 -0
  59. package/lib/types/sanitize.js +103 -0
  60. package/lib/types/service.d.ts +59 -0
  61. package/lib/types/service.d.ts.map +1 -0
  62. package/lib/types/service.js +131 -0
  63. package/lib/types/session-events.d.ts +66 -0
  64. package/lib/types/session-events.d.ts.map +1 -0
  65. package/lib/types/session-events.js +32 -0
  66. package/lib/types/tool.d.ts +30 -0
  67. package/lib/types/tool.d.ts.map +1 -0
  68. package/lib/types/tool.js +131 -0
  69. package/lib/types/translate.d.ts +64 -0
  70. package/lib/types/translate.d.ts.map +1 -0
  71. package/lib/types/translate.js +56 -0
  72. package/lib/types/typert.host.d.ts +250 -0
  73. package/lib/types/typert.host.d.ts.map +1 -0
  74. package/lib/types/typert.host.js +23 -0
  75. package/lib/types/version.d.ts +10 -0
  76. package/lib/types/version.d.ts.map +1 -0
  77. package/lib/types/version.js +9 -0
  78. package/lib/types/wire.d.ts +699 -0
  79. package/lib/types/wire.d.ts.map +1 -0
  80. package/lib/types/wire.js +273 -0
  81. package/lib/wire-Cc4JZ3jR.js +4370 -0
  82. package/package.json +179 -0
  83. package/src/client/DrawResultCard.tsx +100 -0
  84. package/src/client/DrawSettingsTab.tsx +159 -0
  85. package/src/client/index.ts +123 -0
  86. package/src/client/locales.ts +84 -0
  87. package/src/client/present.ts +137 -0
  88. package/src/client/remote.ts +44 -0
  89. package/src/client/styles.ts +44 -0
  90. package/src/config.ts +358 -0
  91. package/src/drawer.ts +234 -0
  92. package/src/engine.ts +182 -0
  93. package/src/http.ts +161 -0
  94. package/src/index.ts +93 -0
  95. package/src/quota.ts +98 -0
  96. package/src/router.ts +309 -0
  97. package/src/sanitize.ts +113 -0
  98. package/src/service.ts +169 -0
  99. package/src/session-events.ts +70 -0
  100. package/src/tool.ts +145 -0
  101. package/src/translate.ts +101 -0
  102. package/src/typert.host.ts +25 -0
  103. package/src/version.ts +10 -0
  104. package/src/wire.ts +417 -0
package/src/drawer.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The generation drawer: the shared orchestration behind the `image_generate`
3
+ * tool body and the settings-panel/card regenerate action. One path owns
4
+ * validation, quota, routing, durable attachment storage, and the session
5
+ * audit event, so the two entry points can never drift.
6
+ *
7
+ * @module dsh-draw/drawer
8
+ */
9
+
10
+ import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
11
+ import type { Session, SessionStore } from '@deepseek-ai/dsh-session'
12
+ import type { ResolvedConfig } from './config.ts'
13
+ import type { EngineDeps, ProducedImage } from './engine.ts'
14
+ import { checkQuotaBytes, checkQuotaGenerations, type QuotaLimits, type QuotaState } from './quota.ts'
15
+ import { EngineRouter, type AttemptView } from './router.ts'
16
+ import { appendDrawGenerated } from './session-events.ts'
17
+ import { normalizeRequest } from './translate.ts'
18
+
19
+ /** One durable result image as the canonical value and wire carry it. */
20
+ export interface DrawImage {
21
+ /** Opaque attachment id. */
22
+ attachmentId: string
23
+ /** Verified media type. */
24
+ mediaType: string
25
+ /** Exact byte length. */
26
+ bytes: number
27
+ /** Intrinsic width in pixels. */
28
+ width: number
29
+ /** Intrinsic height in pixels. */
30
+ height: number
31
+ /** Display name. */
32
+ name?: string
33
+ }
34
+
35
+ /** A successful draw. */
36
+ export interface DrawSuccess {
37
+ /** Discriminant. */
38
+ ok: true
39
+ /** Engine id that produced the images. */
40
+ engine: string
41
+ /** Engine model name. */
42
+ model: string
43
+ /** Standard size vocabulary value of the request. */
44
+ size: 'square' | 'landscape' | 'portrait' | 'auto'
45
+ /** Durable result images. */
46
+ images: readonly DrawImage[]
47
+ /** Usage after this generation committed. */
48
+ quota: QuotaState
49
+ /** Quota limits in force. */
50
+ limits: QuotaLimits
51
+ /** Whether an earlier engine in the chain failed first. */
52
+ fallbackUsed: boolean
53
+ /** Engine round-trip latency in milliseconds. */
54
+ elapsedMs: number
55
+ /** Every router attempt in chain order. */
56
+ attempts: readonly AttemptView[]
57
+ }
58
+
59
+ /** Why a draw failed before or during routing. */
60
+ export type DrawFailureReason =
61
+ | 'invalid-prompt'
62
+ | 'quota-generations'
63
+ | 'quota-bytes'
64
+ | 'no-session'
65
+ | 'attachments-unavailable'
66
+ | 'all-engines-failed'
67
+
68
+ /** A failed draw with a machine-routable reason and display-safe message. */
69
+ export interface DrawFailure {
70
+ /** Discriminant. */
71
+ ok: false
72
+ /** Which stage blocked the draw. */
73
+ reason: DrawFailureReason
74
+ /** Display-safe explanation. */
75
+ message: string
76
+ /** Quota usage at decision time for quota failures. */
77
+ quota?: QuotaState
78
+ /** Router attempts for engine failures. */
79
+ attempts?: readonly AttemptView[]
80
+ }
81
+
82
+ /** Draw outcome. */
83
+ export type DrawOutcome = DrawSuccess | DrawFailure
84
+
85
+ /** Options for one generation. */
86
+ export interface DrawOptions {
87
+ /** Caller cancellation. */
88
+ signal?: AbortSignal
89
+ /** Owning session (required: quota and the audit event live in the log). */
90
+ session: Session | undefined
91
+ /** Who requested the generation (recorded in the audit event). */
92
+ source: 'tool' | 'regenerate'
93
+ }
94
+
95
+ /** Drawer dependencies, resolved per call so optional services stay hot-swappable. */
96
+ export interface DrawerDeps {
97
+ /** Engine transport and credential resolution. */
98
+ engine: EngineDeps
99
+ /** Per-call durable attachment store accessor (absent fails the draw closed). */
100
+ attachments?: () => AttachmentStore | undefined
101
+ /** Per-call session store accessor for the regenerate path. */
102
+ sessions?: () => SessionStore | undefined
103
+ }
104
+
105
+ /**
106
+ * The shared generation path.
107
+ */
108
+ export class Drawer {
109
+ /**
110
+ * @param config - resolved plugin configuration.
111
+ * @param router - the engine router.
112
+ * @param deps - per-call dependencies (public: the remote service reads them for probes).
113
+ */
114
+ constructor(
115
+ private readonly config: ResolvedConfig,
116
+ private readonly router: EngineRouter,
117
+ readonly deps: DrawerDeps,
118
+ ) {}
119
+
120
+ /**
121
+ * Run one generation end to end: normalize and validate, check quota,
122
+ * route through the engine chain, commit images to the attachment store,
123
+ * and append the audit event.
124
+ *
125
+ * @param args - unvalidated standard request (tool args or card regenerate args).
126
+ * @param options - cancellation, session, and source.
127
+ * @returns the outcome.
128
+ */
129
+ async generate(args: unknown, options: DrawOptions): Promise<DrawOutcome> {
130
+ const request = normalizeRequest(args, this.config.maxImagesPerCall)
131
+ if (request.prompt.trim().length === 0) {
132
+ return { ok: false, reason: 'invalid-prompt', message: 'image_generate: prompt must be a non-empty string' }
133
+ }
134
+ if (request.prompt.length > this.config.maxPromptLength) {
135
+ return { ok: false, reason: 'invalid-prompt', message: `image_generate: prompt exceeds the configured ${this.config.maxPromptLength}-character cap` }
136
+ }
137
+ const session = options.session
138
+ if (session === undefined) {
139
+ return { ok: false, reason: 'no-session', message: 'image_generate: no session owns this call — quota accounting and the audit event need a session' }
140
+ }
141
+ const limits: QuotaLimits = {
142
+ maxGenerations: this.config.maxGenerationsPerSession,
143
+ maxBytes: this.config.maxBytesPerSession,
144
+ }
145
+ const generationCheck = checkQuotaGenerations(session, limits)
146
+ if (!generationCheck.allowed) {
147
+ return { ok: false, reason: 'quota-generations', message: `image_generate: session generation quota exhausted (${generationCheck.state.generations}/${limits.maxGenerations} calls)`, quota: generationCheck.state }
148
+ }
149
+ const startedAt = Date.now()
150
+ const routed = await this.router.generate(request, this.deps.engine, options.signal)
151
+ if (!routed.ok) {
152
+ return {
153
+ ok: false,
154
+ reason: 'all-engines-failed',
155
+ message: `image_generate: no configured engine produced images${routed.attempts.length === 0 ? '' : ` (${routed.attempts.map(attempt => attempt.engine).join(', ')})`}`,
156
+ attempts: routed.attempts,
157
+ quota: generationCheck.state,
158
+ }
159
+ }
160
+ const attachments = this.deps.attachments?.()
161
+ if (attachments === undefined) {
162
+ return { ok: false, reason: 'attachments-unavailable', message: 'image_generate: the attachment store is not composed — images cannot be saved durably', quota: generationCheck.state, attempts: routed.attempts }
163
+ }
164
+ const totalBytes = routed.images.reduce((sum, image) => sum + image.data.byteLength, 0)
165
+ const byteCheck = checkQuotaBytes(session, limits, totalBytes)
166
+ if (!byteCheck.allowed) {
167
+ return { ok: false, reason: 'quota-bytes', message: `image_generate: session image-byte quota exhausted (${byteCheck.state.bytes}/${limits.maxBytes} bytes)`, quota: byteCheck.state, attempts: routed.attempts }
168
+ }
169
+ let refs: readonly ImageAttachmentRef[]
170
+ try {
171
+ refs = await this.saveImages(attachments, routed.engine, routed.images, options.signal)
172
+ } catch (error) {
173
+ const message = error instanceof Error ? error.message : String(error)
174
+ return { ok: false, reason: 'attachments-unavailable', message: `image_generate: saving images failed: ${message}`, quota: generationCheck.state, attempts: routed.attempts }
175
+ }
176
+ const images: DrawImage[] = refs.map(ref => ({
177
+ attachmentId: String(ref.attachmentId),
178
+ mediaType: ref.mediaType,
179
+ bytes: ref.bytes,
180
+ width: ref.width,
181
+ height: ref.height,
182
+ ...(ref.name !== undefined ? { name: ref.name } : {}),
183
+ }))
184
+ const elapsedMs = Date.now() - startedAt
185
+ appendDrawGenerated(session, {
186
+ engine: routed.engine,
187
+ model: routed.model,
188
+ source: options.source,
189
+ prompt: request.prompt,
190
+ size: request.size ?? 'square',
191
+ quality: request.quality ?? 'auto',
192
+ count: images.length,
193
+ bytes: totalBytes,
194
+ attachmentIds: images.map(image => image.attachmentId),
195
+ elapsedMs,
196
+ })
197
+ const after = checkQuotaGenerations(session, limits)
198
+ return {
199
+ ok: true,
200
+ engine: routed.engine,
201
+ model: routed.model,
202
+ size: request.size ?? 'square',
203
+ images,
204
+ quota: after.state,
205
+ limits,
206
+ fallbackUsed: routed.fallbackUsed,
207
+ elapsedMs,
208
+ attempts: routed.attempts,
209
+ }
210
+ }
211
+
212
+ /** Save every produced image to the attachment store; an empty image fails the batch. */
213
+ private async saveImages(
214
+ attachments: AttachmentStore,
215
+ engineId: string,
216
+ produced: readonly ProducedImage[],
217
+ signal?: AbortSignal,
218
+ ): Promise<readonly ImageAttachmentRef[]> {
219
+ const refs: ImageAttachmentRef[] = []
220
+ for (let index = 0; index < produced.length; index += 1) {
221
+ signal?.throwIfAborted()
222
+ const image = produced[index]!
223
+ if (image.data.byteLength === 0) {
224
+ throw new Error(`engine "${engineId}" produced an empty image`)
225
+ }
226
+ refs.push(await attachments.saveImage({
227
+ data: image.data,
228
+ mediaType: image.mediaType,
229
+ name: `${engineId}-${index + 1}.${image.mediaType === 'image/jpeg' ? 'jpg' : image.mediaType.slice('image/'.length)}`,
230
+ }))
231
+ }
232
+ return refs
233
+ }
234
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The OpenAI-compatible images adapter: one implementation covers OpenAI
3
+ * Images, Zhipu CogView, and any config-driven compatible endpoint. The
4
+ * request is a `POST {baseUrl}/images/generations` JSON body; the response is
5
+ * `{ data: [{ b64_json } | { url }] }`. Engines declare their response format
6
+ * and media type in config, so no provider-specific code branches exist.
7
+ *
8
+ * @module dsh-draw/engine
9
+ */
10
+
11
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
12
+ import type { ResolvedEngineConfig } from './config.ts'
13
+ import { decodeBase64, fusedSignal, type HttpTransport } from './http.ts'
14
+ import { sanitizeError } from './sanitize.ts'
15
+ import type { TranslatedImageRequest } from './translate.ts'
16
+
17
+ /** Failure phases of one engine call, each mapping to a router decision. */
18
+ export type EngineFailurePhase = 'credential' | 'request' | 'parse'
19
+
20
+ /**
21
+ * A single engine call failure. `message` is display-safe (never carries the
22
+ * API key); `status` carries the HTTP status when a response existed.
23
+ */
24
+ export class EngineCallError extends Error {
25
+ /** Which stage failed. */
26
+ readonly phase: EngineFailurePhase
27
+ /** Stable machine code: `unconfigured`, `auth`, `http`, `parse`. */
28
+ readonly code: string
29
+ /** HTTP status when a response existed. */
30
+ readonly status?: number
31
+ /** @param phase - failing stage. @param code - stable code. @param message - display-safe message. @param options - optional status and cause. */
32
+ constructor(phase: EngineFailurePhase, code: string, message: string, options?: { status?: number; cause?: unknown }) {
33
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause })
34
+ this.name = 'EngineCallError'
35
+ this.phase = phase
36
+ this.code = code
37
+ if (options?.status !== undefined) this.status = options.status
38
+ }
39
+ }
40
+
41
+ /** One produced image: bytes plus the engine-declared media type. */
42
+ export interface ProducedImage {
43
+ /** Encoded image bytes. */
44
+ data: Uint8Array
45
+ /** Engine-declared media type (validated by the attachment store). */
46
+ mediaType: ImageMediaType
47
+ }
48
+
49
+ /** Dependencies the engine call resolves per operation. */
50
+ export interface EngineDeps {
51
+ /** HTTP transport for the images request and any URL download. */
52
+ transport: HttpTransport
53
+ /** Resolve the engine's credential reference to a secret value (per call; never cached). */
54
+ resolveCredential: (reference: string) => Promise<string | undefined>
55
+ }
56
+
57
+ /** Wire response shape of `POST /images/generations` (the fields we consume). */
58
+ interface ImagesResponseItem {
59
+ /** Base64-encoded image (b64_json response format). */
60
+ b64_json?: string
61
+ /** Download URL (url response format). */
62
+ url?: string
63
+ }
64
+
65
+ /** Wire response envelope. */
66
+ interface ImagesResponse {
67
+ /** Produced images. */
68
+ data?: unknown[]
69
+ }
70
+
71
+ /** Timeout for one image URL download (fraction of the request budget). */
72
+ const DOWNLOAD_TIMEOUT_MS = 60_000
73
+
74
+ /**
75
+ * Call one engine for the given translated request.
76
+ *
77
+ * @param engine - resolved engine configuration.
78
+ * @param request - translated request body fields.
79
+ * @param deps - transport and credential resolution.
80
+ * @param signal - caller cancellation.
81
+ * @returns the produced images.
82
+ * @throws {@link EngineCallError} with a phase the router can act on.
83
+ */
84
+ export async function callEngine(
85
+ engine: ResolvedEngineConfig,
86
+ request: TranslatedImageRequest,
87
+ deps: EngineDeps,
88
+ signal?: AbortSignal,
89
+ ): Promise<ProducedImage[]> {
90
+ const credential = await deps.resolveCredential(engine.apiKeyRef)
91
+ if (credential === undefined) {
92
+ throw new EngineCallError('credential', 'unconfigured', `engine "${engine.id}" has no resolved credential reference ${engine.apiKeyRef}`)
93
+ }
94
+ const headers: Record<string, string> = {
95
+ 'content-type': 'application/json',
96
+ authorization: `Bearer ${credential}`,
97
+ }
98
+ const body: Record<string, unknown> = {
99
+ model: request.model,
100
+ prompt: request.prompt,
101
+ size: request.size,
102
+ n: request.n,
103
+ ...(request.quality !== undefined ? { quality: request.quality } : {}),
104
+ ...(request.style !== undefined ? { style: request.style } : {}),
105
+ ...(request.responseFormat === 'b64_json' ? { response_format: request.responseFormat } : {}),
106
+ }
107
+ const response = await deps.transport.request({
108
+ method: 'POST',
109
+ url: `${engine.baseUrl}/images/generations`,
110
+ headers,
111
+ body: new TextEncoder().encode(JSON.stringify(body)),
112
+ ...(signal === undefined ? {} : { signal }),
113
+ })
114
+ if (response.status === 401 || response.status === 403) {
115
+ throw new EngineCallError('request', 'auth', `engine "${engine.id}" rejected the credential (HTTP ${response.status})`, { status: response.status })
116
+ }
117
+ if (response.status < 200 || response.status >= 300) {
118
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" failed with HTTP ${response.status}`, { status: response.status })
119
+ }
120
+ let parsed: ImagesResponse
121
+ try {
122
+ parsed = JSON.parse(new TextDecoder().decode(response.body)) as ImagesResponse
123
+ } catch (cause) {
124
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a non-JSON response`, { cause })
125
+ }
126
+ const items = Array.isArray(parsed.data) ? parsed.data : undefined
127
+ if (items === undefined) {
128
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" response has no data array`)
129
+ }
130
+ const images: ProducedImage[] = []
131
+ for (const raw of items) {
132
+ if (typeof raw !== 'object' || raw === null) {
133
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned a malformed image entry`)
134
+ }
135
+ const item = raw as ImagesResponseItem
136
+ if (typeof item.b64_json === 'string' && item.b64_json.length > 0) {
137
+ images.push({ data: decodeBase64(item.b64_json), mediaType: engine.imageMediaType })
138
+ continue
139
+ }
140
+ if (typeof item.url === 'string' && item.url.length > 0) {
141
+ images.push({ data: await downloadImageUrl(engine, item.url, credential, deps, signal), mediaType: engine.imageMediaType })
142
+ continue
143
+ }
144
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned an image entry without bytes or a URL`)
145
+ }
146
+ if (images.length === 0) {
147
+ throw new EngineCallError('parse', 'parse', `engine "${engine.id}" returned no images`)
148
+ }
149
+ return images
150
+ }
151
+
152
+ /**
153
+ * Download one image URL with the engine's bearer credential. The download is
154
+ * one GET request on the same transport; a non-2xx status is an engine
155
+ * failure, not silent emptiness.
156
+ */
157
+ async function downloadImageUrl(
158
+ engine: ResolvedEngineConfig,
159
+ url: string,
160
+ credential: string,
161
+ deps: EngineDeps,
162
+ signal?: AbortSignal,
163
+ ): Promise<Uint8Array> {
164
+ const { signal: downloadSignal, dispose } = fusedSignal(signal, DOWNLOAD_TIMEOUT_MS)
165
+ try {
166
+ const response = await deps.transport.request({
167
+ method: 'GET',
168
+ url,
169
+ headers: { authorization: `Bearer ${credential}` },
170
+ signal: downloadSignal,
171
+ })
172
+ if (response.status < 200 || response.status >= 300) {
173
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" image download failed with HTTP ${response.status}`, { status: response.status })
174
+ }
175
+ return response.body
176
+ } catch (error) {
177
+ if (error instanceof EngineCallError) throw error
178
+ throw new EngineCallError('request', 'http', `engine "${engine.id}" image download failed: ${sanitizeError(error)}`, { cause: error })
179
+ } finally {
180
+ dispose()
181
+ }
182
+ }
package/src/http.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The HTTP transport seam: engine calls go through one injectable
3
+ * request function so tests can pin every wire interaction without network
4
+ * I/O. The default implementation is Node's global `fetch` (undici) with a
5
+ * per-call timeout fused onto the caller's cancellation signal.
6
+ *
7
+ * @module dsh-draw/http
8
+ */
9
+
10
+ /** One transport request: URL, headers, optional body, and cancellation. */
11
+ export interface HttpRequest {
12
+ /** HTTP method. */
13
+ method: string
14
+ /** Absolute target URL. */
15
+ url: string
16
+ /** Header map (values are raw secrets — never logged). */
17
+ headers?: Readonly<Record<string, string>>
18
+ /** Request body bytes; omitted for body-less requests. */
19
+ body?: Uint8Array<ArrayBuffer>
20
+ /** Caller cancellation; transport failures must observe it. */
21
+ signal?: AbortSignal
22
+ }
23
+
24
+ /** One transport response: status plus raw bytes. */
25
+ export interface HttpResponse {
26
+ /** HTTP status code. */
27
+ status: number
28
+ /** Raw response body bytes. */
29
+ body: Uint8Array
30
+ }
31
+
32
+ /** Machine-routable failure codes of the transport seam. */
33
+ export type HttpErrorCode = 'timeout' | 'aborted' | 'network' | 'invalid-response'
34
+
35
+ /**
36
+ * A transport-level failure. `status` is absent for network/timeout failures;
37
+ * `code` routes fallback and cooldown decisions.
38
+ */
39
+ export class HttpError extends Error {
40
+ /** Stable failure code. */
41
+ readonly code: HttpErrorCode
42
+ /** HTTP status when a response existed. */
43
+ readonly status?: number
44
+ /** @param code - stable failure code. @param message - display-safe message. @param options - optional status and cause. */
45
+ constructor(code: HttpErrorCode, message: string, options?: { status?: number; cause?: unknown }) {
46
+ super(message, options?.cause === undefined ? undefined : { cause: options.cause })
47
+ this.name = 'HttpError'
48
+ this.code = code
49
+ if (options?.status !== undefined) this.status = options.status
50
+ }
51
+ }
52
+
53
+ /** Transport face the engine and probe layers consume. */
54
+ export interface HttpTransport {
55
+ /**
56
+ * Perform one request and return the raw response. Never resolves with a
57
+ * thrown engine business error; non-2xx statuses are ordinary results.
58
+ *
59
+ * @param request - method, URL, headers, body, and cancellation.
60
+ * @returns status plus raw bytes.
61
+ * @throws {@link HttpError} for timeouts, aborts, and network failures.
62
+ */
63
+ request(request: HttpRequest): Promise<HttpResponse>
64
+ }
65
+
66
+ /**
67
+ * Fuse a caller signal with a per-call timeout into one signal and a disposer
68
+ * that clears the timer when the call settles.
69
+ *
70
+ * @param signal - caller signal, or undefined.
71
+ * @param timeoutMs - positive timeout.
72
+ * @returns the fused signal plus its disposer.
73
+ */
74
+ export function fusedSignal(signal: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
75
+ if (signal === undefined) {
76
+ const controller = new AbortController()
77
+ const timer = setTimeout(() => controller.abort(new HttpError('timeout', `request exceeded ${timeoutMs} ms`)), timeoutMs)
78
+ return { signal: controller.signal, dispose: () => clearTimeout(timer) }
79
+ }
80
+ if (signal.aborted) return { signal, dispose: () => undefined }
81
+ const controller = new AbortController()
82
+ const timer = setTimeout(() => controller.abort(new HttpError('timeout', `request exceeded ${timeoutMs} ms`)), timeoutMs)
83
+ const forward = () => { controller.abort(signal.reason) }
84
+ signal.addEventListener('abort', forward, { once: true })
85
+ const dispose = () => {
86
+ clearTimeout(timer)
87
+ signal.removeEventListener('abort', forward)
88
+ }
89
+ return { signal: controller.signal, dispose }
90
+ }
91
+
92
+ /** Map an undici/fetch rejection to an {@link HttpError} by its observable identity. */
93
+ function mapFetchFailure(signal: AbortSignal, error: unknown): HttpError {
94
+ const reason = signal.reason
95
+ if (signal.aborted && reason instanceof HttpError) return reason
96
+ if (signal.aborted) return new HttpError('aborted', reason instanceof Error ? reason.message : 'request aborted', { cause: error })
97
+ if (error instanceof Error && error.name === 'TimeoutError') {
98
+ return new HttpError('timeout', error.message, { cause: error })
99
+ }
100
+ const message = error instanceof Error ? error.message : String(error)
101
+ return new HttpError('network', message, { cause: error })
102
+ }
103
+
104
+ /**
105
+ * The production transport: global `fetch` (undici under Node ≥ 22) with the
106
+ * timeout fused onto the caller signal. The response body is read to bytes;
107
+ * oversized or unreadable bodies surface as `invalid-response`.
108
+ *
109
+ * @param timeoutMs - per-call timeout.
110
+ * @param maxBytes - response byte cap (the engine's image cap plus headroom).
111
+ * @returns a transport ready for the router.
112
+ */
113
+ export function defaultHttpTransport(timeoutMs: number, maxBytes: number): HttpTransport {
114
+ return {
115
+ async request(request: HttpRequest): Promise<HttpResponse> {
116
+ const { signal, dispose } = fusedSignal(request.signal, timeoutMs)
117
+ try {
118
+ let response: Response
119
+ try {
120
+ response = await fetch(request.url, {
121
+ method: request.method,
122
+ ...(request.headers === undefined ? {} : { headers: request.headers }),
123
+ ...(request.body === undefined ? {} : { body: request.body }),
124
+ signal,
125
+ redirect: 'follow',
126
+ })
127
+ } catch (error) {
128
+ throw mapFetchFailure(signal, error)
129
+ }
130
+ let body: Uint8Array
131
+ try {
132
+ body = new Uint8Array(await response.arrayBuffer())
133
+ } catch (error) {
134
+ throw new HttpError('invalid-response', `failed to read response body: ${error instanceof Error ? error.message : String(error)}`, { cause: error })
135
+ }
136
+ if (body.byteLength > maxBytes) {
137
+ throw new HttpError('invalid-response', `response body exceeds ${maxBytes} bytes`, { status: response.status })
138
+ }
139
+ return { status: response.status, body }
140
+ } finally {
141
+ dispose()
142
+ }
143
+ },
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Decode a standard base64 string to bytes. A malformed string fails loud —
149
+ * a provider change would otherwise silently corrupt an image.
150
+ *
151
+ * @param data - base64 payload without a data: prefix.
152
+ * @returns decoded bytes.
153
+ * @throws when the payload is not valid base64.
154
+ */
155
+ export function decodeBase64(data: string): Uint8Array {
156
+ if (data.length === 0) throw new HttpError('invalid-response', 'empty base64 image payload')
157
+ const binary = atob(data)
158
+ const bytes = new Uint8Array(binary.length)
159
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
160
+ return bytes
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `dsh-draw` — the unified static-image generation router for DeepSeek Harness.
3
+ *
4
+ * Host half: resolves config, builds the health-aware engine router and the
5
+ * shared drawer (validation, quota, routing, durable attachment storage, and
6
+ * the `draw/generated` session audit event), registers the `image_generate`
7
+ * tool, and mounts the `draw` Typert Remote service the settings panel and
8
+ * result card consume. The browser half lives in `src/client/` and registers
9
+ * the keyed `tool.call.toolview` result card plus the Plugins settings tab.
10
+ *
11
+ * Function plugin — no default export (the Loader unwraps
12
+ * `exports.default ?? exports`).
13
+ *
14
+ * @module dsh-draw
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
19
+ import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
20
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
21
+ import type { SessionStore } from '@deepseek-ai/dsh-session'
22
+ import type {} from '@deepseek-ai/dsh-tools'
23
+ import { Config, resolveConfig } from './config.ts'
24
+ import { Drawer } from './drawer.ts'
25
+ import { defaultHttpTransport, type HttpTransport } from './http.ts'
26
+ import { EngineRouter } from './router.ts'
27
+ import { DrawService } from './service.ts'
28
+ import { imageGenerateTool } from './tool.ts'
29
+
30
+ export const name = 'dsh-draw'
31
+
32
+ /** Hard services: the tool registry every contribution lands in. */
33
+ export const inject = ['tools']
34
+
35
+ export { Config, resolveConfig, type Config as DrawConfig, type ResolvedConfig, DEFAULT_ENGINES, engineById } from './config.ts'
36
+ export { EngineRouter, type AttemptView, type EngineStatus, type ProbeOutcome } from './router.ts'
37
+ export { Drawer, type DrawImage, type DrawFailureReason, type DrawOutcome, type DrawOptions, type DrawSuccess } from './drawer.ts'
38
+ export { imageGenerateTool } from './tool.ts'
39
+ export { DrawService } from './service.ts'
40
+ export { defaultHttpTransport, fusedSignal, type HttpTransport, type HttpRequest, type HttpResponse, HttpError } from './http.ts'
41
+ export { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from './engine.ts'
42
+ export { translateRequest, normalizeRequest, type StandardImageRequest } from './translate.ts'
43
+ export { quotaState, checkQuotaGenerations, checkQuotaBytes, type QuotaLimits, type QuotaState } from './quota.ts'
44
+ export { sanitizeUrl, sanitizeText, sanitizeError, REDACTED } from './sanitize.ts'
45
+ export { appendDrawGenerated, drawGeneratedEvents, type DrawGeneratedEvent } from './session-events.ts'
46
+ export { PLUGIN_VERSION } from './version.ts'
47
+ export { DRAW_INVOCATIONS, imageToWire, statusToView, probeToWire, type DrawStatusSnapshot } from './wire.ts'
48
+
49
+ /** Response byte ceiling: one engine call may carry several full-size images. */
50
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024
51
+
52
+ /**
53
+ * Mount the plugin: router, drawer, the `image_generate` tool, and the `draw`
54
+ * Remote service. Every registration is an effect on this fiber, so
55
+ * unload/hot-reload removes the tool and the service together.
56
+ *
57
+ * @param ctx - context carrying tools plus the optional attachment/credentials seams.
58
+ * @param config - raw loader config; defaults applied through {@link resolveConfig}.
59
+ */
60
+ export async function apply(ctx: Context, config: Config): Promise<void> {
61
+ const resolved = resolveConfig(config)
62
+ const logger = ctx.logger('draw')
63
+
64
+ // Optional test seam: an embedding context may pre-select the transport
65
+ // under 'dsh-draw/transport'; real deployments use the fetch transport.
66
+ const transport: HttpTransport = (ctx.get('dsh-draw/transport') as HttpTransport | undefined)
67
+ ?? defaultHttpTransport(resolved.requestTimeoutMs, MAX_RESPONSE_BYTES)
68
+ const router = new EngineRouter(resolved, {
69
+ failureThreshold: resolved.failureThreshold,
70
+ cooldownMs: resolved.cooldownMs,
71
+ })
72
+
73
+ const credentials = () => ctx.get('credentials') as CredentialProvider | undefined
74
+
75
+ const drawer = new Drawer(resolved, router, {
76
+ engine: {
77
+ transport,
78
+ resolveCredential: async (reference: string): Promise<string | undefined> => {
79
+ const service = credentials()
80
+ if (service === undefined) return undefined
81
+ const resolvedCredential = await service.resolve(credentialRef(reference))
82
+ return resolvedCredential?.value
83
+ },
84
+ },
85
+ attachments: () => ctx.get('attachments') as AttachmentStore | undefined,
86
+ sessions: () => ctx.get('sessions') as SessionStore | undefined,
87
+ })
88
+
89
+ ctx.effect(() => ctx.tools.register(imageGenerateTool(drawer, resolved)), 'dsh-draw: image_generate tool')
90
+
91
+ await ctx.plugin(DrawService, { config: resolved, router, drawer, credentials: credentials() })
92
+ logger.info(`image generation enabled: ${resolved.engines.map(engine => engine.id).join(', ')} (preferred ${resolved.defaultEngine})`)
93
+ }