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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Pure presentation projections for the dsh-draw browser half: the tool
3
+ * result value and the settings snapshot are folded into view models that the
4
+ * React components render without further logic. Everything here is a pure
5
+ * function of its input, so tests pin the projections without a DOM.
6
+ *
7
+ * @module dsh-draw/client/present
8
+ */
9
+
10
+ import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { DrawStatusSnapshot } from '../wire.ts'
12
+
13
+ /** One image of the presented result card. */
14
+ export interface PresentedImage {
15
+ attachmentId: string
16
+ name: string
17
+ }
18
+
19
+ /** The presented result card model. */
20
+ export interface PresentedDrawResult {
21
+ /** Engine id that produced the images. */
22
+ engine: string
23
+ /** Engine model name. */
24
+ model: string
25
+ /** Whether an earlier engine failed first. */
26
+ fallbackUsed: boolean
27
+ /** Image names in result order. */
28
+ images: readonly PresentedImage[]
29
+ /** Quota line: generations and bytes used. */
30
+ quota: { generations: number; bytes: number }
31
+ /** Effective limits. */
32
+ limits: { maxGenerations: number; maxBytes: number }
33
+ /** Original tool args (the regenerate input); undefined while unknown. */
34
+ args: Record<string, unknown> | undefined
35
+ }
36
+
37
+ /**
38
+ * Project one settled `image_generate` tool block onto the card model.
39
+ *
40
+ * @param block - the frozen tool-call block (running or settled).
41
+ * @returns the presented model, or `undefined` when the block is not a settled
42
+ * image_generate result.
43
+ */
44
+ export function presentDrawResult(block: ToolCallBlock): PresentedDrawResult | undefined {
45
+ if (!('kind' in block) || block.kind !== 'tool-result' || block.isError) return undefined
46
+ if (block.call?.name !== 'image_generate') return undefined
47
+ const value = (block.meta as { engine?: unknown; model?: unknown; fallbackUsed?: unknown; images?: unknown; quota?: unknown; limits?: unknown } | undefined) ?? {}
48
+ const images: PresentedImage[] = []
49
+ if (Array.isArray(value.images)) {
50
+ for (const raw of value.images) {
51
+ const image = raw as { attachmentId?: unknown; name?: unknown }
52
+ if (typeof image?.attachmentId === 'string') {
53
+ images.push({ attachmentId: image.attachmentId, name: typeof image.name === 'string' ? image.name : 'image' })
54
+ }
55
+ }
56
+ }
57
+ const quota = (value.quota ?? {}) as { generations?: unknown; bytes?: unknown }
58
+ const limits = (value.limits ?? {}) as { maxGenerations?: unknown; maxBytes?: unknown }
59
+ let args: Record<string, unknown> | undefined
60
+ if (block.call?.argsRaw !== undefined && block.call.argsRaw !== '') {
61
+ try {
62
+ const parsed = JSON.parse(block.call.argsRaw) as unknown
63
+ args = typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
64
+ } catch {
65
+ args = undefined
66
+ }
67
+ }
68
+ return {
69
+ engine: typeof value.engine === 'string' ? value.engine : 'unknown',
70
+ model: typeof value.model === 'string' ? value.model : 'unknown',
71
+ fallbackUsed: value.fallbackUsed === true,
72
+ images,
73
+ quota: {
74
+ generations: typeof quota.generations === 'number' ? quota.generations : 0,
75
+ bytes: typeof quota.bytes === 'number' ? quota.bytes : 0,
76
+ },
77
+ limits: {
78
+ maxGenerations: typeof limits.maxGenerations === 'number' ? limits.maxGenerations : 0,
79
+ maxBytes: typeof limits.maxBytes === 'number' ? limits.maxBytes : 0,
80
+ },
81
+ args,
82
+ }
83
+ }
84
+
85
+ /** One engine row of the presented settings panel. */
86
+ export interface PresentedEngineRow {
87
+ id: string
88
+ model: string
89
+ baseUrl: string
90
+ apiKeyRef: string
91
+ enabled: boolean
92
+ preferred: boolean
93
+ credentialConfigured: boolean
94
+ credentialSource?: string
95
+ credentialWritable: boolean
96
+ consecutiveFailures: number
97
+ coolingDown: boolean
98
+ lastError: string | null
99
+ }
100
+
101
+ /** The presented settings snapshot. */
102
+ export interface PresentedDrawPanel {
103
+ pluginVersion: string
104
+ engines: readonly PresentedEngineRow[]
105
+ quota: { maxGenerationsPerSession: number; maxBytesPerSession: number }
106
+ requestTimeoutMs: number
107
+ maxImagesPerCall: number
108
+ }
109
+
110
+ /**
111
+ * Project the panel snapshot onto the settings row model.
112
+ *
113
+ * @param snapshot - the `draw/status` wire snapshot.
114
+ * @returns the presented panel model.
115
+ */
116
+ export function presentDrawPanel(snapshot: DrawStatusSnapshot): PresentedDrawPanel {
117
+ return {
118
+ pluginVersion: snapshot.pluginVersion,
119
+ engines: snapshot.engines.map(engine => ({
120
+ id: engine.id,
121
+ model: engine.model,
122
+ baseUrl: engine.baseUrl,
123
+ apiKeyRef: engine.apiKeyRef,
124
+ enabled: engine.enabled,
125
+ preferred: engine.preferred,
126
+ credentialConfigured: engine.credential.configured,
127
+ ...(engine.credential.source !== undefined ? { credentialSource: engine.credential.source } : {}),
128
+ credentialWritable: engine.credential.writable,
129
+ consecutiveFailures: engine.health.consecutiveFailures,
130
+ coolingDown: engine.health.cooldownUntil !== null && engine.health.cooldownUntil > Date.now(),
131
+ lastError: engine.health.lastError,
132
+ })),
133
+ quota: { maxGenerationsPerSession: snapshot.quota.maxGenerationsPerSession, maxBytesPerSession: snapshot.quota.maxBytesPerSession },
134
+ requestTimeoutMs: snapshot.requestTimeoutMs,
135
+ maxImagesPerCall: snapshot.maxImagesPerCall,
136
+ }
137
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The client-side Remote face of the `draw` namespace: the hand-written
3
+ * `TypertRemoteContribution` mounted through `ctx.remote.$mount`, plus the
4
+ * declaration merging that types `ctx.remote.draw`. The descriptor list is
5
+ * shared with the host `./typert` manifest (`../wire.ts`), so the two faces
6
+ * can never drift.
7
+ *
8
+ * @module dsh-draw/client/remote
9
+ */
10
+
11
+ import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
12
+ import { DRAW_INVOCATIONS } from '../wire.ts'
13
+ import type { CredentialActionResult, DrawProbeResult, DrawRegenerateResult, DrawStatusSnapshot } from '../wire.ts'
14
+
15
+ declare module '@deepseek-ai/dsh-typert-protocol' {
16
+ interface TypertRemoteNamespace$draw {
17
+ /** Read the panel snapshot (engines, health, credentials, quota). */
18
+ status: () => Promise<RemoteResult<DrawStatusSnapshot>>
19
+ /** Probe one engine's connectivity. */
20
+ probe: (engineId: string) => Promise<RemoteResult<DrawProbeResult>>
21
+ /** Store one API key under the engine's credential reference. */
22
+ setCredential: (engineId: string, value: string) => Promise<RemoteResult<CredentialActionResult>>
23
+ /** Remove a stored API key. */
24
+ unsetCredential: (engineId: string) => Promise<RemoteResult<CredentialActionResult>>
25
+ /** Re-run a generation from the result card (full drawer path). */
26
+ regenerate: (sessionId: string, args: Record<string, unknown>) => Promise<RemoteResult<DrawRegenerateResult>>
27
+ }
28
+ interface TypertRemoteMap {
29
+ 'draw/status': () => Promise<RemoteResult<DrawStatusSnapshot>>
30
+ 'draw/probe': (engineId: string) => Promise<RemoteResult<DrawProbeResult>>
31
+ 'draw/setCredential': (engineId: string, value: string) => Promise<RemoteResult<CredentialActionResult>>
32
+ 'draw/unsetCredential': (engineId: string) => Promise<RemoteResult<CredentialActionResult>>
33
+ 'draw/regenerate': (sessionId: string, args: Record<string, unknown>) => Promise<RemoteResult<DrawRegenerateResult>>
34
+ }
35
+ interface TypertRemoteNamespaceMap {
36
+ draw: TypertRemoteNamespace$draw
37
+ }
38
+ }
39
+
40
+ /** The client Remote contribution for the `draw` namespace. */
41
+ export const DRAW_REMOTE = Object.freeze({
42
+ package: 'dsh-draw',
43
+ descriptors: DRAW_INVOCATIONS,
44
+ } satisfies TypertRemoteContribution)
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Scoped inline stylesheet for the dsh-draw browser half. Standalone client
3
+ * bundles cannot use the in-repo CSS-module pipeline, so the styles live in
4
+ * one scoped `style` element; every class carries the `dshdraw-` prefix and
5
+ * the root node is the only position the stylesheet may leak from.
6
+ *
7
+ * @module dsh-draw/client/styles
8
+ */
9
+
10
+ const STYLE_ID = 'dsh-draw-client-styles'
11
+
12
+ /** Install the scoped stylesheet once; returns the removal disposer. */
13
+ export function installDrawStyles(): () => void {
14
+ if (document.getElementById(STYLE_ID) !== null) return () => undefined
15
+ const style = document.createElement('style')
16
+ style.id = STYLE_ID
17
+ style.textContent = `
18
+ .dshdraw-card { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
19
+ .dshdraw-grid { display: flex; flex-wrap: wrap; gap: 8px; }
20
+ .dshdraw-figure { display: flex; flex-direction: column; gap: 4px; max-width: 320px; }
21
+ .dshdraw-figure img { max-width: 100%; border-radius: 6px; border: 1px solid var(--dsh-border, #d0d7de); }
22
+ .dshdraw-figure figcaption { font-size: 11px; opacity: 0.75; overflow-wrap: anywhere; }
23
+ .dshdraw-meta { font-size: 12px; opacity: 0.85; display: flex; flex-wrap: wrap; gap: 12px; }
24
+ .dshdraw-actions { display: flex; gap: 8px; }
25
+ .dshdraw-button {
26
+ border: 1px solid var(--dsh-border, #d0d7de); border-radius: 6px; background: transparent;
27
+ padding: 3px 10px; font-size: 12px; cursor: pointer; color: inherit;
28
+ }
29
+ .dshdraw-button:hover { background: var(--dsh-hover, rgba(0, 0, 0, 0.05)); }
30
+ .dshdraw-panel { display: flex; flex-direction: column; gap: 14px; }
31
+ .dshdraw-engine { border: 1px solid var(--dsh-border, #d0d7de); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
32
+ .dshdraw-engine h4 { margin: 0; }
33
+ .dshdraw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
34
+ .dshdraw-row label { opacity: 0.8; min-width: 110px; }
35
+ .dshdraw-badge { border-radius: 999px; padding: 1px 8px; font-size: 11px; border: 1px solid var(--dsh-border, #d0d7de); }
36
+ .dshdraw-badge.ok { color: #1a7f37; border-color: #1a7f37; }
37
+ .dshdraw-badge.warn { color: #9a6700; border-color: #9a6700; }
38
+ .dshdraw-input { flex: 1; min-width: 180px; border: 1px solid var(--dsh-border, #d0d7de); border-radius: 6px; padding: 4px 8px; font-size: 12px; background: transparent; color: inherit; }
39
+ `
40
+ document.head.append(style)
41
+ return () => {
42
+ style.remove()
43
+ }
44
+ }
package/src/config.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Plugin configuration for `dsh-draw` and its explicit resolve step.
3
+ * `resolveConfig` re-judges every default and bound so programmatic
4
+ * construction that bypasses Schemastery normalization still fails loud
5
+ * instead of running with hidden defaults (the explicit-resolve contract).
6
+ *
7
+ * @module dsh-draw/config
8
+ */
9
+
10
+ import z from '@deepseek-ai/schemastery'
11
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
12
+
13
+ /** Default per-generation HTTP timeout in milliseconds. */
14
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
15
+
16
+ /** Ceiling for one generation call: engines can take minutes per image. */
17
+ export const MAX_REQUEST_TIMEOUT_MS = 600_000
18
+
19
+ /** Default cap on images a single call may produce. */
20
+ export const DEFAULT_MAX_IMAGES_PER_CALL = 4
21
+
22
+ /** Ceiling on images a single call may produce. */
23
+ export const MAX_IMAGES_PER_CALL = 10
24
+
25
+ /** Default prompt length cap (characters). */
26
+ export const DEFAULT_MAX_PROMPT_LENGTH = 4_000
27
+
28
+ /** Ceiling on the prompt length cap. */
29
+ export const MAX_PROMPT_LENGTH = 32_000
30
+
31
+ /** Default per-session generation-call cap. */
32
+ export const DEFAULT_MAX_GENERATIONS_PER_SESSION = 200
33
+
34
+ /** Ceiling on the per-session generation-call cap. */
35
+ export const MAX_GENERATIONS_PER_SESSION = 100_000
36
+
37
+ /** Default per-session image-byte cap (200 MiB). */
38
+ export const DEFAULT_MAX_BYTES_PER_SESSION = 200 * 1024 * 1024
39
+
40
+ /** Ceiling on the per-session image-byte cap (4 GiB). */
41
+ export const MAX_BYTES_PER_SESSION = 4 * 1024 * 1024 * 1024
42
+
43
+ /** Default consecutive failures before an engine enters cooldown. */
44
+ export const DEFAULT_FAILURE_THRESHOLD = 2
45
+
46
+ /** Ceiling on the consecutive-failure threshold. */
47
+ export const MAX_FAILURE_THRESHOLD = 10
48
+
49
+ /** Default engine cooldown after the failure threshold trips (ms). */
50
+ export const DEFAULT_COOLDOWN_MS = 60_000
51
+
52
+ /** Ceiling on the engine cooldown. */
53
+ export const MAX_COOLDOWN_MS = 3_600_000
54
+
55
+ /** Standard size vocabulary mapped per engine through {@link EngineConfig.sizeMap}. */
56
+ export type StandardSize = 'square' | 'landscape' | 'portrait' | 'auto'
57
+
58
+ /** Per-engine mapping from the standard size vocabulary to concrete engine sizes. */
59
+ export interface EngineSizeMap {
60
+ /** Square composition (the default). */
61
+ square: string
62
+ /** Wider-than-tall composition. */
63
+ landscape: string
64
+ /** Taller-than-wide composition. */
65
+ portrait: string
66
+ /** Engine-chosen composition; may alias one of the concrete sizes. */
67
+ auto: string
68
+ }
69
+
70
+ /** Whether generated images arrive as base64 payloads or downloadable URLs. */
71
+ export type EngineResponseFormat = 'b64_json' | 'url'
72
+
73
+ /** One OpenAI-compatible image-generation endpoint the router may use. */
74
+ export interface EngineConfig {
75
+ /** Stable engine id used in tool args, results, and the settings panel. */
76
+ id: string
77
+ /** OpenAI-compatible API root, e.g. `https://api.openai.com/v1`; must not embed credentials. */
78
+ baseUrl: string
79
+ /** Engine model name sent in the request body, e.g. `gpt-image-1`. */
80
+ model: string
81
+ /** Credential reference (environment-variable name) the API key resolves from; never a literal key. */
82
+ apiKeyRef: string
83
+ /** Whether the router may use this engine (default true). */
84
+ enabled?: boolean
85
+ /** Standard-size → concrete-size translation (default: the OpenAI sizes). */
86
+ sizeMap?: EngineSizeMap
87
+ /** Whether the engine accepts the `quality` request field (default false). */
88
+ qualitySupported?: boolean
89
+ /** Whether the engine accepts the `style` request field (default false). */
90
+ styleSupported?: boolean
91
+ /** How generated bytes arrive: base64 in the response or a URL to download (default `b64_json`). */
92
+ responseFormat?: EngineResponseFormat
93
+ /** Declared media type of the bytes (default `image/png`). */
94
+ imageMediaType?: ImageMediaType
95
+ }
96
+
97
+ /** Plugin configuration. */
98
+ export interface Config {
99
+ /** Ordered engine chain; the router walks it top-down and falls back on failure. */
100
+ engines?: EngineConfig[]
101
+ /** Engine id the router prefers; defaults to the first configured engine. Must name a configured engine. */
102
+ defaultEngine?: string
103
+ /** Per-generation HTTP timeout in milliseconds (default 120000). */
104
+ requestTimeoutMs?: number
105
+ /** Cap on images one call may produce (default 4). */
106
+ maxImagesPerCall?: number
107
+ /** Prompt length cap in characters (default 4000). */
108
+ maxPromptLength?: number
109
+ /** Per-session generation-call cap (default 200). */
110
+ maxGenerationsPerSession?: number
111
+ /** Per-session generated-image byte cap (default 209715200). */
112
+ maxBytesPerSession?: number
113
+ /** Consecutive failures before an engine enters cooldown (default 2). */
114
+ failureThreshold?: number
115
+ /** Engine cooldown in milliseconds after the failure threshold trips (default 60000). */
116
+ cooldownMs?: number
117
+ }
118
+
119
+ /** The two shipped engine presets: OpenAI Images and Zhipu CogView. */
120
+ export const DEFAULT_ENGINES: ReadonlyArray<Required<EngineConfig>> = Object.freeze([
121
+ Object.freeze({
122
+ id: 'openai',
123
+ baseUrl: 'https://api.openai.com/v1',
124
+ model: 'gpt-image-1',
125
+ apiKeyRef: 'OPENAI_API_KEY',
126
+ enabled: true,
127
+ sizeMap: Object.freeze({ square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', auto: 'auto' }),
128
+ qualitySupported: true,
129
+ styleSupported: true,
130
+ responseFormat: 'b64_json',
131
+ imageMediaType: 'image/png',
132
+ }),
133
+ Object.freeze({
134
+ id: 'cogview',
135
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
136
+ model: 'cogview-3-flash',
137
+ apiKeyRef: 'ZHIPU_API_KEY',
138
+ enabled: true,
139
+ sizeMap: Object.freeze({ square: '1024x1024', landscape: '1344x768', portrait: '768x1344', auto: '1024x1024' }),
140
+ qualitySupported: false,
141
+ styleSupported: false,
142
+ responseFormat: 'url',
143
+ imageMediaType: 'image/png',
144
+ }),
145
+ ])
146
+
147
+ /** Default size map for user-configured engines (the OpenAI vocabulary). */
148
+ export const DEFAULT_SIZE_MAP: Readonly<EngineSizeMap> = Object.freeze({
149
+ square: '1024x1024',
150
+ landscape: '1536x1024',
151
+ portrait: '1024x1536',
152
+ auto: 'auto',
153
+ })
154
+
155
+ /** Fully resolved per-engine configuration. */
156
+ export interface ResolvedEngineConfig {
157
+ /** Stable engine id. */
158
+ id: string
159
+ /** OpenAI-compatible API root without credentials. */
160
+ baseUrl: string
161
+ /** Engine model name. */
162
+ model: string
163
+ /** Credential reference the API key resolves from. */
164
+ apiKeyRef: string
165
+ /** Whether the router may use this engine. */
166
+ enabled: boolean
167
+ /** Standard-size → concrete-size translation. */
168
+ sizeMap: EngineSizeMap
169
+ /** Whether the engine accepts the `quality` request field. */
170
+ qualitySupported: boolean
171
+ /** Whether the engine accepts the `style` request field. */
172
+ styleSupported: boolean
173
+ /** How generated bytes arrive. */
174
+ responseFormat: EngineResponseFormat
175
+ /** Declared media type of the bytes. */
176
+ imageMediaType: ImageMediaType
177
+ }
178
+
179
+ /** Fully resolved configuration captured at plugin load. */
180
+ export interface ResolvedConfig {
181
+ /** Ordered engine chain. */
182
+ engines: readonly ResolvedEngineConfig[]
183
+ /** Engine id the router prefers. */
184
+ defaultEngine: string
185
+ /** Per-generation HTTP timeout in milliseconds. */
186
+ requestTimeoutMs: number
187
+ /** Cap on images one call may produce. */
188
+ maxImagesPerCall: number
189
+ /** Prompt length cap in characters. */
190
+ maxPromptLength: number
191
+ /** Per-session generation-call cap. */
192
+ maxGenerationsPerSession: number
193
+ /** Per-session generated-image byte cap. */
194
+ maxBytesPerSession: number
195
+ /** Consecutive failures before an engine enters cooldown. */
196
+ failureThreshold: number
197
+ /** Engine cooldown in milliseconds after the failure threshold trips. */
198
+ cooldownMs: number
199
+ }
200
+
201
+ /** Schemastery schema for loader-validated configuration. */
202
+ export const Config: z<Config> = z.object({
203
+ engines: z.array(z.object({
204
+ id: z.string().min(1).max(64),
205
+ baseUrl: z.string().min(1).max(2048),
206
+ model: z.string().min(1).max(128),
207
+ apiKeyRef: z.string().min(1).max(128),
208
+ enabled: z.boolean(),
209
+ sizeMap: z.object({
210
+ square: z.string().min(1).max(64),
211
+ landscape: z.string().min(1).max(64),
212
+ portrait: z.string().min(1).max(64),
213
+ auto: z.string().min(1).max(64),
214
+ }),
215
+ qualitySupported: z.boolean(),
216
+ styleSupported: z.boolean(),
217
+ responseFormat: z.union(['b64_json', 'url'] as const),
218
+ imageMediaType: z.union(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
219
+ })).default([...DEFAULT_ENGINES]),
220
+ defaultEngine: z.string().min(1).max(64).default('openai'),
221
+ requestTimeoutMs: z.number().min(1_000).max(MAX_REQUEST_TIMEOUT_MS).default(DEFAULT_REQUEST_TIMEOUT_MS),
222
+ maxImagesPerCall: z.number().min(1).max(MAX_IMAGES_PER_CALL).default(DEFAULT_MAX_IMAGES_PER_CALL),
223
+ maxPromptLength: z.number().min(1).max(MAX_PROMPT_LENGTH).default(DEFAULT_MAX_PROMPT_LENGTH),
224
+ maxGenerationsPerSession: z.number().min(1).max(MAX_GENERATIONS_PER_SESSION).default(DEFAULT_MAX_GENERATIONS_PER_SESSION),
225
+ maxBytesPerSession: z.number().min(1024 * 1024).max(MAX_BYTES_PER_SESSION).default(DEFAULT_MAX_BYTES_PER_SESSION),
226
+ failureThreshold: z.number().min(1).max(MAX_FAILURE_THRESHOLD).default(DEFAULT_FAILURE_THRESHOLD),
227
+ cooldownMs: z.number().min(1_000).max(MAX_COOLDOWN_MS).default(DEFAULT_COOLDOWN_MS),
228
+ })
229
+
230
+ const ENGINE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u
231
+
232
+ /** Credential references are environment-variable names (POSIX identifiers). */
233
+ const API_KEY_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u
234
+
235
+ /**
236
+ * Resolve raw config to the runtime policy, re-validating defaults and bounds
237
+ * plus the cross-field facts a per-field schema cannot see (duplicate engine
238
+ * ids, credential-bearing base URLs, an unknown default engine).
239
+ *
240
+ * @param config - raw loader config; `undefined` for a bare row.
241
+ * @returns the frozen resolved config.
242
+ * @throws on any invalid value (fail-loud at load).
243
+ */
244
+ export function resolveConfig(config: Config | undefined): ResolvedConfig {
245
+ const rawEngines: readonly EngineConfig[] = config?.engines ?? DEFAULT_ENGINES
246
+ const engines: ResolvedEngineConfig[] = []
247
+ const seen = new Set<string>()
248
+ for (const engine of rawEngines) {
249
+ const id = typeof engine?.id === 'string' ? engine.id : ''
250
+ if (!ENGINE_ID_PATTERN.test(id)) {
251
+ throw new Error(`dsh-draw: engine id ${JSON.stringify(id)} must match ${ENGINE_ID_PATTERN}`)
252
+ }
253
+ if (seen.has(id)) throw new Error(`dsh-draw: duplicate engine id ${JSON.stringify(id)}`)
254
+ seen.add(id)
255
+ const baseUrl = engine.baseUrl ?? ''
256
+ let parsed: URL
257
+ try {
258
+ parsed = new URL(baseUrl)
259
+ } catch {
260
+ throw new Error(`dsh-draw: engine "${id}" baseUrl ${JSON.stringify(baseUrl)} is not a valid URL`)
261
+ }
262
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
263
+ throw new Error(`dsh-draw: engine "${id}" baseUrl must use http or https`)
264
+ }
265
+ if (parsed.username !== '' || parsed.password !== '') {
266
+ throw new Error(`dsh-draw: engine "${id}" baseUrl must not embed credentials — use the apiKeyRef credential reference instead`)
267
+ }
268
+ const model = typeof engine.model === 'string' && engine.model.length > 0 && engine.model.length <= 128 ? engine.model : ''
269
+ if (model === '') throw new Error(`dsh-draw: engine "${id}" model must be a non-empty string of at most 128 characters`)
270
+ const apiKeyRef = typeof engine.apiKeyRef === 'string' && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : ''
271
+ if (apiKeyRef === '') throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`)
272
+ const sizeMap = engine.sizeMap === undefined ? DEFAULT_SIZE_MAP : engine.sizeMap
273
+ for (const key of ['square', 'landscape', 'portrait', 'auto'] as const) {
274
+ const value = sizeMap[key]
275
+ if (typeof value !== 'string' || value.length === 0 || value.length > 64) {
276
+ throw new Error(`dsh-draw: engine "${id}" sizeMap.${key} must be a non-empty string of at most 64 characters`)
277
+ }
278
+ }
279
+ const enabled = engine.enabled ?? true
280
+ if (typeof enabled !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" enabled must be a boolean`)
281
+ const qualitySupported = engine.qualitySupported ?? false
282
+ if (typeof qualitySupported !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" qualitySupported must be a boolean`)
283
+ const styleSupported = engine.styleSupported ?? false
284
+ if (typeof styleSupported !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" styleSupported must be a boolean`)
285
+ const responseFormat = engine.responseFormat ?? 'b64_json'
286
+ if (responseFormat !== 'b64_json' && responseFormat !== 'url') {
287
+ throw new Error(`dsh-draw: engine "${id}" responseFormat must be "b64_json" or "url"`)
288
+ }
289
+ const imageMediaType = engine.imageMediaType ?? 'image/png'
290
+ if (imageMediaType !== 'image/png' && imageMediaType !== 'image/jpeg' && imageMediaType !== 'image/webp' && imageMediaType !== 'image/gif') {
291
+ throw new Error(`dsh-draw: engine "${id}" imageMediaType ${JSON.stringify(imageMediaType)} is not a supported raster type`)
292
+ }
293
+ engines.push({
294
+ id,
295
+ baseUrl: baseUrl.replace(/\/+$/u, ''),
296
+ model,
297
+ apiKeyRef,
298
+ enabled,
299
+ sizeMap: Object.freeze({ ...sizeMap }),
300
+ qualitySupported,
301
+ styleSupported,
302
+ responseFormat,
303
+ imageMediaType,
304
+ })
305
+ }
306
+ if (engines.length === 0) throw new Error('dsh-draw: at least one engine must be configured')
307
+
308
+ const defaultEngine = config?.defaultEngine ?? engines[0]!.id
309
+ if (!seen.has(defaultEngine)) {
310
+ throw new Error(`dsh-draw: defaultEngine ${JSON.stringify(defaultEngine)} does not name a configured engine`)
311
+ }
312
+
313
+ const requestTimeoutMs = config?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
314
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs < 1_000 || requestTimeoutMs > MAX_REQUEST_TIMEOUT_MS) {
315
+ throw new Error(`dsh-draw: config.requestTimeoutMs must be a finite number between 1000 and ${MAX_REQUEST_TIMEOUT_MS}`)
316
+ }
317
+ const maxImagesPerCall = config?.maxImagesPerCall ?? DEFAULT_MAX_IMAGES_PER_CALL
318
+ if (!Number.isInteger(maxImagesPerCall) || maxImagesPerCall < 1 || maxImagesPerCall > MAX_IMAGES_PER_CALL) {
319
+ throw new Error(`dsh-draw: config.maxImagesPerCall must be an integer between 1 and ${MAX_IMAGES_PER_CALL}`)
320
+ }
321
+ const maxPromptLength = config?.maxPromptLength ?? DEFAULT_MAX_PROMPT_LENGTH
322
+ if (!Number.isInteger(maxPromptLength) || maxPromptLength < 1 || maxPromptLength > MAX_PROMPT_LENGTH) {
323
+ throw new Error(`dsh-draw: config.maxPromptLength must be an integer between 1 and ${MAX_PROMPT_LENGTH}`)
324
+ }
325
+ const maxGenerationsPerSession = config?.maxGenerationsPerSession ?? DEFAULT_MAX_GENERATIONS_PER_SESSION
326
+ if (!Number.isInteger(maxGenerationsPerSession) || maxGenerationsPerSession < 1 || maxGenerationsPerSession > MAX_GENERATIONS_PER_SESSION) {
327
+ throw new Error(`dsh-draw: config.maxGenerationsPerSession must be an integer between 1 and ${MAX_GENERATIONS_PER_SESSION}`)
328
+ }
329
+ const maxBytesPerSession = config?.maxBytesPerSession ?? DEFAULT_MAX_BYTES_PER_SESSION
330
+ if (!Number.isInteger(maxBytesPerSession) || maxBytesPerSession < 1024 * 1024 || maxBytesPerSession > MAX_BYTES_PER_SESSION) {
331
+ throw new Error(`dsh-draw: config.maxBytesPerSession must be an integer between 1048576 and ${MAX_BYTES_PER_SESSION}`)
332
+ }
333
+ const failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD
334
+ if (!Number.isInteger(failureThreshold) || failureThreshold < 1 || failureThreshold > MAX_FAILURE_THRESHOLD) {
335
+ throw new Error(`dsh-draw: config.failureThreshold must be an integer between 1 and ${MAX_FAILURE_THRESHOLD}`)
336
+ }
337
+ const cooldownMs = config?.cooldownMs ?? DEFAULT_COOLDOWN_MS
338
+ if (!Number.isInteger(cooldownMs) || cooldownMs < 1_000 || cooldownMs > MAX_COOLDOWN_MS) {
339
+ throw new Error(`dsh-draw: config.cooldownMs must be an integer between 1000 and ${MAX_COOLDOWN_MS}`)
340
+ }
341
+
342
+ return Object.freeze({
343
+ engines: Object.freeze(engines),
344
+ defaultEngine,
345
+ requestTimeoutMs,
346
+ maxImagesPerCall,
347
+ maxPromptLength,
348
+ maxGenerationsPerSession,
349
+ maxBytesPerSession,
350
+ failureThreshold,
351
+ cooldownMs,
352
+ })
353
+ }
354
+
355
+ /** Engine id by which an engine is addressed, or `undefined` for an unknown id. */
356
+ export function engineById(config: ResolvedConfig, id: string): ResolvedEngineConfig | undefined {
357
+ return config.engines.find(engine => engine.id === id)
358
+ }