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/quota.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Per-session quota accounting. The durable source of truth is the session
3
+ * log: every completed generation appends one `draw/generated` event, and
4
+ * quota is computed by folding those events, so usage survives restart and
5
+ * fork and cannot drift from what the log records.
6
+ *
7
+ * @module dsh-draw/quota
8
+ */
9
+
10
+ import type { Session } from '@deepseek-ai/dsh-session'
11
+ import { drawGeneratedEvents } from './session-events.ts'
12
+
13
+ /** The two quota axes. */
14
+ export interface QuotaLimits {
15
+ /** Cap on generation calls (each call may produce several images). */
16
+ maxGenerations: number
17
+ /** Cap on generated image bytes. */
18
+ maxBytes: number
19
+ }
20
+
21
+ /** Current usage, folded from the session log. */
22
+ export interface QuotaState {
23
+ /** Generation calls recorded in the log. */
24
+ generations: number
25
+ /** Sum of recorded image bytes. */
26
+ bytes: number
27
+ }
28
+
29
+ /** A denied quota check with the blocking axis and current state. */
30
+ export interface QuotaDenial {
31
+ /** Discriminant. */
32
+ allowed: false
33
+ /** Which axis blocked the call. */
34
+ reason: 'generations' | 'bytes'
35
+ /** Usage at decision time. */
36
+ state: QuotaState
37
+ }
38
+
39
+ /** An allowed quota check with the current state. */
40
+ export interface QuotaAllowance {
41
+ /** Discriminant. */
42
+ allowed: true
43
+ /** Usage at decision time. */
44
+ state: QuotaState
45
+ }
46
+
47
+ /** Quota decision. */
48
+ export type QuotaCheck = QuotaAllowance | QuotaDenial
49
+
50
+ /**
51
+ * Fold one session's `draw/generated` events into current usage.
52
+ *
53
+ * @param session - session whose log is folded.
54
+ * @returns generation and byte totals.
55
+ */
56
+ export function quotaState(session: Session): QuotaState {
57
+ let generations = 0
58
+ let bytes = 0
59
+ for (const event of drawGeneratedEvents(session)) {
60
+ generations += 1
61
+ bytes += event.data.bytes
62
+ }
63
+ return { generations, bytes }
64
+ }
65
+
66
+ /**
67
+ * Check the generation-call axis before any engine is contacted: a session at
68
+ * its cap fails fast without spending engine credits.
69
+ *
70
+ * @param session - owning session.
71
+ * @param limits - configured limits.
72
+ * @returns allowance or denial.
73
+ */
74
+ export function checkQuotaGenerations(session: Session, limits: QuotaLimits): QuotaCheck {
75
+ const state = quotaState(session)
76
+ if (state.generations >= limits.maxGenerations) {
77
+ return { allowed: false, reason: 'generations', state }
78
+ }
79
+ return { allowed: true, state }
80
+ }
81
+
82
+ /**
83
+ * Check the byte axis after the engine produced images but before anything is
84
+ * stored: the incoming bytes must fit under the cap, otherwise the images are
85
+ * discarded without touching the attachment store.
86
+ *
87
+ * @param session - owning session.
88
+ * @param limits - configured limits.
89
+ * @param incomingBytes - bytes the new images would add.
90
+ * @returns allowance or denial.
91
+ */
92
+ export function checkQuotaBytes(session: Session, limits: QuotaLimits, incomingBytes: number): QuotaCheck {
93
+ const state = quotaState(session)
94
+ if (state.bytes + incomingBytes > limits.maxBytes) {
95
+ return { allowed: false, reason: 'bytes', state }
96
+ }
97
+ return { allowed: true, state }
98
+ }
package/src/router.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Engine routing with health-aware fallback: the configured chain is walked
3
+ * top-down, every engine is attempted at most once per call, and consecutive
4
+ * failures push an engine into cooldown so a broken engine stops eating the
5
+ * request budget. The router is a plain class (not a Cordis Service): it is
6
+ * plugin-owned state, not a published capability.
7
+ *
8
+ * @module dsh-draw/router
9
+ */
10
+
11
+ import type { ResolvedConfig, ResolvedEngineConfig } from './config.ts'
12
+ import { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from './engine.ts'
13
+ import { sanitizeError, sanitizeText, sanitizeUrl } from './sanitize.ts'
14
+ import { translateRequest, type StandardImageRequest } from './translate.ts'
15
+
16
+ /** One recorded attempt against one engine, success or failure. */
17
+ export interface AttemptView {
18
+ /** Engine id. */
19
+ engine: string
20
+ /** Failure phase for a failed attempt; absent on success. */
21
+ phase?: 'credential' | 'request' | 'parse'
22
+ /** Stable machine code: `unconfigured`, `auth`, `http`, `parse`, `disabled`, `cooldown`. */
23
+ code: string
24
+ /** Display-safe failure detail; absent on success. */
25
+ message?: string
26
+ /** HTTP status when one existed. */
27
+ status?: number
28
+ }
29
+
30
+ /** A successful routed generation. */
31
+ export interface RouterSuccess {
32
+ /** Discriminant. */
33
+ ok: true
34
+ /** Engine id that produced the images. */
35
+ engine: string
36
+ /** Engine model name. */
37
+ model: string
38
+ /** Standard size vocabulary value of the request. */
39
+ size: string
40
+ /** Produced images. */
41
+ images: readonly ProducedImage[]
42
+ /** Whether an earlier engine in the chain failed first. */
43
+ fallbackUsed: boolean
44
+ /** Every attempt in chain order. */
45
+ attempts: readonly AttemptView[]
46
+ }
47
+
48
+ /** A routed generation where every usable engine failed or was skipped. */
49
+ export interface RouterFailure {
50
+ /** Discriminant. */
51
+ ok: false
52
+ /** Every attempt in chain order. */
53
+ attempts: readonly AttemptView[]
54
+ }
55
+
56
+ /** Router result. */
57
+ export type RouterResult = RouterSuccess | RouterFailure
58
+
59
+ /** One engine's health state for the settings panel. */
60
+ export interface EngineStatus {
61
+ /** Engine id. */
62
+ engineId: string
63
+ /** Consecutive failures since the last success. */
64
+ consecutiveFailures: number
65
+ /** Epoch ms until which the engine is in cooldown; `null` = not cooling down. */
66
+ cooldownUntil: number | null
67
+ /** Display-safe last failure detail; `null` = none recorded. */
68
+ lastError: string | null
69
+ /** HTTP status of the last failure, when one existed. */
70
+ lastStatus: number | null
71
+ }
72
+
73
+ /** One probe outcome (never mutates routing health). */
74
+ export interface ProbeOutcome {
75
+ /** Engine id. */
76
+ engineId: string
77
+ /** Whether the endpoint answered with any HTTP status. */
78
+ reachable: boolean
79
+ /** HTTP status when one existed. */
80
+ httpStatus: number | null
81
+ /** Sanitized base URL probed (the models listing endpoint). */
82
+ target: string
83
+ /** Display-safe result note. */
84
+ note: string
85
+ /** Whether the credential was resolvable at probe time. */
86
+ credentialConfigured: boolean
87
+ }
88
+
89
+ /** Engine bookkeeping the router mutates. */
90
+ interface EngineHealth {
91
+ /** Consecutive failures since the last success. */
92
+ consecutiveFailures: number
93
+ /** Epoch ms until which the engine is in cooldown; `null` = not cooling down. */
94
+ cooldownUntil: number | null
95
+ /** Display-safe last failure detail. */
96
+ lastError: string | null
97
+ /** HTTP status of the last failure. */
98
+ lastStatus: number | null
99
+ }
100
+
101
+ /** Router construction options. */
102
+ export interface RouterOptions {
103
+ /** Consecutive failures before an engine enters cooldown. */
104
+ failureThreshold: number
105
+ /** Cooldown length in milliseconds. */
106
+ cooldownMs: number
107
+ /** Clock override for tests. */
108
+ now?: () => number
109
+ }
110
+
111
+ /**
112
+ * The engine chain with per-engine health and cooldown. All mutations are
113
+ * synchronous bookkeeping guarded by `generate`'s single-writer path (the
114
+ * tool is not concurrency-safe, so generations serialize).
115
+ */
116
+ export class EngineRouter {
117
+ private readonly health = new Map<string, EngineHealth>()
118
+ private readonly now: () => number
119
+
120
+ /**
121
+ * @param config - resolved plugin configuration (engine order and bounds).
122
+ * @param options - failure threshold, cooldown, and clock.
123
+ */
124
+ constructor(
125
+ private readonly config: ResolvedConfig,
126
+ options: RouterOptions,
127
+ ) {
128
+ this.now = options.now ?? Date.now
129
+ for (const engine of config.engines) {
130
+ this.health.set(engine.id, { consecutiveFailures: 0, cooldownUntil: null, lastError: null, lastStatus: null })
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Route one standardized request through the configured chain. The chain
136
+ * order is the config array order, except an explicit `request.engine`
137
+ * promotes that engine to the front; every engine is attempted at most
138
+ * once, and an engine in cooldown or without a resolved credential is
139
+ * skipped with a recorded attempt.
140
+ *
141
+ * @param request - normalized standard request.
142
+ * @param deps - transport and credential resolution.
143
+ * @param signal - caller cancellation.
144
+ * @returns success with images, or the complete failure record.
145
+ */
146
+ async generate(request: StandardImageRequest, deps: EngineDeps, signal?: AbortSignal): Promise<RouterResult> {
147
+ const ordered = this.chainOrder(request.engine)
148
+ const attempts: AttemptView[] = []
149
+ let tried = 0
150
+ for (const engine of ordered) {
151
+ if (tried > 0) signal?.throwIfAborted()
152
+ const skip = this.skipReason(engine)
153
+ if (skip !== undefined) {
154
+ attempts.push({ engine: engine.id, code: skip.code, message: skip.message })
155
+ continue
156
+ }
157
+ tried += 1
158
+ try {
159
+ const images = await callEngine(engine, this.translate(engine, request), deps, signal)
160
+ this.recordSuccess(engine.id)
161
+ attempts.push({ engine: engine.id, code: 'ok' })
162
+ return {
163
+ ok: true,
164
+ engine: engine.id,
165
+ model: engine.model,
166
+ size: request.size ?? 'square',
167
+ images,
168
+ fallbackUsed: tried > 1,
169
+ attempts,
170
+ }
171
+ } catch (error) {
172
+ const view = this.recordFailure(engine.id, error)
173
+ attempts.push(view)
174
+ if (view.phase === 'credential' || view.code === 'auth' || view.code === 'parse') {
175
+ // Credential and schema failures are deterministic for this engine —
176
+ // continue the chain so a healthy next engine still serves the call.
177
+ continue
178
+ }
179
+ if (signal !== undefined && signal.aborted) throw signal.reason
180
+ continue
181
+ }
182
+ }
183
+ return { ok: false, attempts }
184
+ }
185
+
186
+ /** One engine's current health snapshot. */
187
+ statusOf(engineId: string): EngineStatus | undefined {
188
+ const health = this.health.get(engineId)
189
+ if (health === undefined) return undefined
190
+ return {
191
+ engineId,
192
+ consecutiveFailures: health.consecutiveFailures,
193
+ cooldownUntil: health.cooldownUntil,
194
+ lastError: health.lastError,
195
+ lastStatus: health.lastStatus,
196
+ }
197
+ }
198
+
199
+ /** Health snapshots for every configured engine in chain order. */
200
+ statuses(): readonly EngineStatus[] {
201
+ return this.config.engines
202
+ .map(engine => this.statusOf(engine.id))
203
+ .filter((status): status is EngineStatus => status !== undefined)
204
+ }
205
+
206
+ /**
207
+ * Probe one engine with a cheap authenticated `GET {baseUrl}/models` call.
208
+ * The probe reports reachability and credential validity without mutating
209
+ * routing health — it is a settings-panel check, not the router's memory.
210
+ *
211
+ * @param engine - engine to probe.
212
+ * @param deps - transport and credential resolution.
213
+ * @returns the probe outcome.
214
+ */
215
+ async probe(engine: ResolvedEngineConfig, deps: EngineDeps): Promise<ProbeOutcome> {
216
+ const target = sanitizeUrl(`${engine.baseUrl}/models`)
217
+ const credential = await deps.resolveCredential(engine.apiKeyRef)
218
+ if (credential === undefined) {
219
+ return { engineId: engine.id, reachable: false, httpStatus: null, target, note: `credential reference ${engine.apiKeyRef} is not configured`, credentialConfigured: false }
220
+ }
221
+ try {
222
+ const response = await deps.transport.request({
223
+ method: 'GET',
224
+ url: `${engine.baseUrl}/models`,
225
+ headers: { authorization: `Bearer ${credential}` },
226
+ })
227
+ if (response.status === 401 || response.status === 403) {
228
+ return { engineId: engine.id, reachable: true, httpStatus: response.status, target, note: `endpoint answered but rejected the credential (HTTP ${response.status})`, credentialConfigured: true }
229
+ }
230
+ if (response.status === 404 || response.status === 405 || response.status === 501) {
231
+ return { engineId: engine.id, reachable: true, httpStatus: response.status, target, note: `endpoint answered (HTTP ${response.status}); the models listing may be absent but generation can still work`, credentialConfigured: true }
232
+ }
233
+ if (response.status < 200 || response.status >= 300) {
234
+ return { engineId: engine.id, reachable: true, httpStatus: response.status, target, note: `endpoint answered with HTTP ${response.status}`, credentialConfigured: true }
235
+ }
236
+ return { engineId: engine.id, reachable: true, httpStatus: response.status, target, note: 'endpoint reachable and credential accepted', credentialConfigured: true }
237
+ } catch (error) {
238
+ return { engineId: engine.id, reachable: false, httpStatus: null, target, note: sanitizeError(error), credentialConfigured: true }
239
+ }
240
+ }
241
+
242
+ /** Chain order: an explicit engine override first, then config order minus the override. */
243
+ private chainOrder(override: string | undefined): readonly ResolvedEngineConfig[] {
244
+ const engines = [...this.config.engines]
245
+ if (override === undefined || override === this.config.defaultEngine) {
246
+ const index = engines.findIndex(engine => engine.id === this.config.defaultEngine)
247
+ if (index > 0) {
248
+ const [preferred] = engines.splice(index, 1)
249
+ engines.unshift(preferred!)
250
+ }
251
+ return engines
252
+ }
253
+ const index = engines.findIndex(engine => engine.id === override)
254
+ if (index < 0) {
255
+ // Unknown override: fall back to the configured chain (the override
256
+ // intent is recorded nowhere else; the tool output names the engine).
257
+ return engines
258
+ }
259
+ const [preferred] = engines.splice(index, 1)
260
+ engines.unshift(preferred!)
261
+ return engines
262
+ }
263
+
264
+ /** Why an engine may not even be attempted: disabled, cooling down. */
265
+ private skipReason(engine: ResolvedEngineConfig): { code: string; message: string } | undefined {
266
+ if (!engine.enabled) return { code: 'disabled', message: `engine "${engine.id}" is disabled` }
267
+ const health = this.health.get(engine.id)
268
+ const now = this.now()
269
+ const cooldown = health?.cooldownUntil
270
+ if (cooldown !== null && cooldown !== undefined && cooldown > now) {
271
+ return { code: 'cooldown', message: `engine "${engine.id}" is cooling down after repeated failures` }
272
+ }
273
+ return undefined
274
+ }
275
+
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
+ /** Record a success: reset consecutive failures and cooldown. */
282
+ private recordSuccess(engineId: string): void {
283
+ const health = this.health.get(engineId)
284
+ if (health === undefined) return
285
+ health.consecutiveFailures = 0
286
+ health.cooldownUntil = null
287
+ health.lastError = null
288
+ health.lastStatus = null
289
+ }
290
+
291
+ /** Record a failure: bump the counter, trip cooldown at the threshold, and build the attempt view. */
292
+ private recordFailure(engineId: string, error: unknown): AttemptView {
293
+ const health = this.health.get(engineId)
294
+ const engineError = error instanceof EngineCallError ? error : undefined
295
+ const phase = engineError?.phase ?? 'request'
296
+ const code = engineError?.code ?? 'http'
297
+ const message = sanitizeText(sanitizeError(error))
298
+ const status = engineError?.status
299
+ if (health !== undefined) {
300
+ health.consecutiveFailures += 1
301
+ health.lastError = message
302
+ health.lastStatus = status ?? null
303
+ if (health.consecutiveFailures >= this.config.failureThreshold) {
304
+ health.cooldownUntil = this.now() + this.config.cooldownMs
305
+ }
306
+ }
307
+ return { engine: engineId, phase, code, message, ...(status !== undefined ? { status } : {}) }
308
+ }
309
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Display sanitization for dsh-draw surfaces. Everything shown to a human —
3
+ * the tool result text, the settings panel snapshot, probe details, engine
4
+ * errors — passes through these pure functions so URL credentials, API keys,
5
+ * bearer tokens, and JWTs never reach a display. Secrets themselves are
6
+ * credential references; this module redacts what can still leak through
7
+ * configured URLs and provider error text.
8
+ *
9
+ * @module dsh-draw/sanitize
10
+ */
11
+
12
+ /** Replacement for every redacted credential value. */
13
+ export const REDACTED = '***'
14
+
15
+ /** Query/field keys whose values are credentials regardless of their name casing. */
16
+ const CREDENTIAL_KEY = /^(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|key|password|passwd|pwd|secret|sig|signature|token)$/iu
17
+
18
+ /** Credential keys for the unparseable-URL fallback and embedded-text scans. */
19
+ const CREDENTIAL_KEY_SOURCE = '(?:access[_-]?token|api[_-]?key|apikey|auth(?:orization)?|client[_-]?secret|key|passw(?:or)?d|passwd|pwd|secret|sig(?:nature)?|token)'
20
+
21
+ /** Whole userinfo before `@` (unparseable URLs only — parsed URLs redact just the password). */
22
+ const USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/giu
23
+
24
+ /** `?key=value` / `&key=value` credential pairs inside arbitrary text. */
25
+ const QUERY_CREDENTIAL = new RegExp(`([?&](?:[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*)=)[^&#\\s]*`, 'giu')
26
+
27
+ /** `#key=value` credential pairs in URL fragments and arbitrary text. */
28
+ const FRAGMENT_CREDENTIAL = new RegExp(`(#[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*=)[^&#\\s]*`, 'giu')
29
+
30
+ /** `Authorization: <value>`-style header lines in arbitrary text (quoted value first). */
31
+ const HEADER_CREDENTIAL_QUOTED = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*["'])[^"']*(["'])`, 'giu')
32
+
33
+ /** `Authorization: <value>`-style header lines with unquoted values. */
34
+ const HEADER_CREDENTIAL_BARE = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*)[^\\s,;)\\]}]+`, 'giu')
35
+
36
+ /** Environment-variable-shaped credentials (`GITHUB_TOKEN=…`) in spawn errors. */
37
+ const ENV_VAR_CREDENTIAL = /\b[A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*\s*=\s*[^\s,;)\]}]+/gu
38
+
39
+ /** Bearer tokens, including the `Bearer ` keyword and the token itself. */
40
+ const BEARER = /(bearer)\s+[A-Za-z0-9._~+/=-]+/giu
41
+
42
+ /** Quoted JSON-ish `"token": "value"` pairs in arbitrary text. */
43
+ const QUOTED_CREDENTIAL = new RegExp(`(["'](?:access[_-]?token|api[_-]?key|client[_-]?secret|secret|token)["']\\s*[:=]\\s*["'])[^"']*(["'])`, 'giu')
44
+
45
+ /** Raw JWT bodies, wherever they appear. */
46
+ const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/gu
47
+
48
+ /**
49
+ * Redact a URL for display: userinfo password, credential query values, and
50
+ * credential fragment pairs. Query keys are read through `URLSearchParams`,
51
+ * so percent-encoded key names are decoded before matching. Unparseable
52
+ * inputs fall back to pattern redaction (whole userinfo, credential query
53
+ * pairs, credential fragment pairs) instead of throwing.
54
+ *
55
+ * @param url - candidate URL text.
56
+ * @returns display-safe URL text.
57
+ */
58
+ export function sanitizeUrl(url: string): string {
59
+ let parsed: URL
60
+ try {
61
+ parsed = new URL(url)
62
+ } catch {
63
+ return url
64
+ .replace(USERINFO, '$1***@')
65
+ .replace(QUERY_CREDENTIAL, `$1${REDACTED}`)
66
+ .replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
67
+ }
68
+ if (parsed.password !== '') parsed.password = REDACTED
69
+ for (const key of [...parsed.searchParams.keys()]) {
70
+ if (CREDENTIAL_KEY.test(key)) parsed.searchParams.set(key, REDACTED)
71
+ }
72
+ if (parsed.hash !== '') parsed.hash = parsed.hash.replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
73
+ return parsed.toString()
74
+ }
75
+
76
+ /**
77
+ * Redact credential-shaped fragments from free text: header lines, bearer
78
+ * tokens, raw JWTs, embedded query pairs, and quoted token values.
79
+ *
80
+ * @param text - candidate display text.
81
+ * @returns display-safe text.
82
+ */
83
+ export function sanitizeText(text: string): string {
84
+ return text
85
+ .replace(BEARER, `$1 ${REDACTED}`)
86
+ .replace(HEADER_CREDENTIAL_QUOTED, `$1${REDACTED}$2`)
87
+ .replace(HEADER_CREDENTIAL_BARE, `$1${REDACTED}`)
88
+ .replace(ENV_VAR_CREDENTIAL, value => {
89
+ const equals = value.indexOf('=')
90
+ return equals < 0 ? value : `${value.slice(0, equals)}=${REDACTED}`
91
+ })
92
+ .replace(QUOTED_CREDENTIAL, `$1${REDACTED}$2`)
93
+ .replace(QUERY_CREDENTIAL, `$1${REDACTED}`)
94
+ .replace(FRAGMENT_CREDENTIAL, `$1${REDACTED}`)
95
+ .replace(JWT, REDACTED)
96
+ }
97
+
98
+ /**
99
+ * Stringify an arbitrary thrown value safely and redact it for display.
100
+ * Never throws: unrenderable values degrade to a fixed marker.
101
+ *
102
+ * @param error - thrown value from an engine call or probe.
103
+ * @returns display-safe error text.
104
+ */
105
+ export function sanitizeError(error: unknown): string {
106
+ let text: string
107
+ try {
108
+ text = typeof error === 'string' ? error : String(error)
109
+ } catch {
110
+ text = '<unrenderable error>'
111
+ }
112
+ return sanitizeText(text)
113
+ }
package/src/service.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The `draw` host service: the Typert Remote namespace the settings panel and
3
+ * the result card consume (`draw/status`, `draw/probe`, `draw/setCredential`,
4
+ * `draw/unsetCredential`, `draw/regenerate`). Status snapshots are read-only;
5
+ * credential writes go through the official `ctx.credentials` seam (values
6
+ * never enter a log or a snapshot); regenerate re-runs the full drawer path
7
+ * so a panel regeneration is as durable and quota-accounted as a tool call.
8
+ *
9
+ * @module dsh-draw/service
10
+ */
11
+
12
+ import type { Context } from '@deepseek-ai/cordis'
13
+ import { credentialRef, type CredentialProvider } from '@deepseek-ai/dsh-credentials'
14
+ import type {} from '@deepseek-ai/dsh-attachment'
15
+ import type {} from '@deepseek-ai/dsh-session'
16
+ import type {} from '@deepseek-ai/dsh-tools'
17
+ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
18
+ import { SessionId } from '@deepseek-ai/dsh-session'
19
+ import type { ResolvedConfig } from './config.ts'
20
+ import type { Drawer, DrawSuccess } from './drawer.ts'
21
+ import { engineById } from './config.ts'
22
+ import type { EngineRouter } from './router.ts'
23
+ import { PLUGIN_VERSION } from './version.ts'
24
+ import {
25
+ imageToWire,
26
+ probeToWire,
27
+ statusToView,
28
+ type CredentialActionResult,
29
+ type DrawProbeResult,
30
+ type DrawRegenerateResult,
31
+ type DrawStatusSnapshot,
32
+ } from './wire.ts'
33
+
34
+ declare module '@deepseek-ai/cordis' {
35
+ interface Context {
36
+ /** Image-generation host service (this package). */
37
+ draw: DrawService
38
+ }
39
+ }
40
+
41
+ /** Bindings the service reads at call time (hot-swappable optional seams). */
42
+ export interface DrawServiceOptions {
43
+ /** Resolved plugin configuration. */
44
+ config: ResolvedConfig
45
+ /** The engine router (health state for the status snapshot). */
46
+ router: EngineRouter
47
+ /** The generation drawer (regenerate path). */
48
+ drawer: Drawer
49
+ /** Per-call credential service; undefined = credential actions degrade. */
50
+ credentials: CredentialProvider | undefined
51
+ }
52
+
53
+ /**
54
+ * The `draw` Typert Remote service.
55
+ */
56
+ export class DrawService extends TypertRemoteService {
57
+ /** Per-call bindings (replaced on plugin reload). */
58
+ options: DrawServiceOptions
59
+
60
+ /**
61
+ * @param ctx - the mounting context.
62
+ * @param options - runtime bindings.
63
+ */
64
+ constructor(ctx: Context, options: DrawServiceOptions) {
65
+ super(ctx, 'draw')
66
+ this.options = options
67
+ }
68
+
69
+ /** Resolve one engine's credential view for the status snapshot. */
70
+ private async credentialView(reference: string) {
71
+ const credentials = this.options.credentials
72
+ if (credentials === undefined) return { configured: false, writable: false }
73
+ try {
74
+ const info = await credentials.describe(credentialRef(reference))
75
+ return { configured: info.configured, writable: info.writable, ...(info.source !== undefined ? { source: info.source } : {}) }
76
+ } catch {
77
+ return { configured: false, writable: false }
78
+ }
79
+ }
80
+
81
+ /** Read-only panel snapshot: engine chain, health, credential facts, quota. */
82
+ async status(): Promise<DrawStatusSnapshot> {
83
+ const { config, router } = this.options
84
+ const engines = []
85
+ for (const engine of config.engines) {
86
+ const status = router.statusOf(engine.id)
87
+ const view = statusToView(
88
+ status ?? { engineId: engine.id, consecutiveFailures: 0, cooldownUntil: null, lastError: null, lastStatus: null },
89
+ {
90
+ id: engine.id,
91
+ model: engine.model,
92
+ baseUrl: engine.baseUrl,
93
+ apiKeyRef: engine.apiKeyRef,
94
+ enabled: engine.enabled,
95
+ preferred: engine.id === config.defaultEngine,
96
+ },
97
+ await this.credentialView(engine.apiKeyRef),
98
+ )
99
+ engines.push(view)
100
+ }
101
+ return {
102
+ pluginVersion: PLUGIN_VERSION,
103
+ engines,
104
+ quota: { maxGenerationsPerSession: config.maxGenerationsPerSession, maxBytesPerSession: config.maxBytesPerSession },
105
+ requestTimeoutMs: config.requestTimeoutMs,
106
+ maxImagesPerCall: config.maxImagesPerCall,
107
+ }
108
+ }
109
+
110
+ /** Probe one engine's connectivity without mutating routing health. */
111
+ async probe(engineId: string): Promise<DrawProbeResult> {
112
+ const engine = engineById(this.options.config, engineId)
113
+ if (engine === undefined) {
114
+ return { engineId, reachable: false, httpStatus: null, target: '', note: `unknown engine "${engineId}"`, credentialConfigured: false }
115
+ }
116
+ const outcome = await this.options.router.probe(engine, {
117
+ transport: this.options.drawer.deps.engine.transport,
118
+ resolveCredential: this.options.drawer.deps.engine.resolveCredential,
119
+ })
120
+ return probeToWire(outcome)
121
+ }
122
+
123
+ /** Store one API key under the engine's credential reference (credentials seam). */
124
+ async setCredential(engineId: string, value: string): Promise<CredentialActionResult> {
125
+ const engine = engineById(this.options.config, engineId)
126
+ if (engine === undefined) throw new TypeError(`unknown engine "${engineId}"`)
127
+ const credentials = this.options.credentials
128
+ if (credentials === undefined) throw new TypeError('the credential service is not composed on this profile')
129
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError('credential value must be a non-empty string')
130
+ await credentials.set(credentialRef(engine.apiKeyRef), value)
131
+ return { engineId, reference: engine.apiKeyRef, note: `stored under the ${engine.apiKeyRef} credential reference` }
132
+ }
133
+
134
+ /** Remove a stored API key for the engine's credential reference. */
135
+ async unsetCredential(engineId: string): Promise<CredentialActionResult> {
136
+ const engine = engineById(this.options.config, engineId)
137
+ if (engine === undefined) throw new TypeError(`unknown engine "${engineId}"`)
138
+ const credentials = this.options.credentials
139
+ if (credentials === undefined) throw new TypeError('the credential service is not composed on this profile')
140
+ await credentials.unset(credentialRef(engine.apiKeyRef))
141
+ return { engineId, reference: engine.apiKeyRef, note: `removed from the ${engine.apiKeyRef} credential reference` }
142
+ }
143
+
144
+ /** Re-run a generation from the result card through the full drawer path. */
145
+ async regenerate(sessionId: string, args: unknown): Promise<DrawRegenerateResult> {
146
+ const sessions = this.options.drawer.deps.sessions?.()
147
+ if (sessions === undefined) throw new TypeError('the session store is not composed on this profile')
148
+ const session = sessions.get(SessionId(sessionId))
149
+ if (session === undefined) throw new TypeError(`unknown session "${sessionId}"`)
150
+ const outcome = await this.options.drawer.generate(args, { session, source: 'regenerate' })
151
+ if (!outcome.ok) throw new TypeError(outcome.message)
152
+ return projectRegenerate(outcome)
153
+ }
154
+ }
155
+
156
+ /** Project a successful draw onto the regenerate wire shape. */
157
+ function projectRegenerate(outcome: DrawSuccess): DrawRegenerateResult {
158
+ return {
159
+ engine: outcome.engine,
160
+ model: outcome.model,
161
+ size: outcome.size,
162
+ images: outcome.images.map(imageToWire),
163
+ quota: outcome.quota,
164
+ quotaLimits: outcome.limits,
165
+ fallbackUsed: outcome.fallbackUsed,
166
+ elapsedMs: outcome.elapsedMs,
167
+ attempts: outcome.attempts,
168
+ }
169
+ }