dsh-endpoint 0.0.0-stage → 1.0.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.
package/src/config.js ADDED
@@ -0,0 +1,89 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+
3
+ /**
4
+ * Plugin configuration. The Settings -> Plugins form is generated from this
5
+ * schema, so every field here is also the management surface.
6
+ */
7
+ export const Config = z.object({
8
+ host: z
9
+ .string()
10
+ .default('127.0.0.1')
11
+ .description('Bind address. 127.0.0.1 keeps the endpoint on this machine; 0.0.0.0 exposes every interface.'),
12
+ port: z
13
+ .natural()
14
+ .max(65535)
15
+ .default(43130)
16
+ .description('Listening port. The GUI owns 43129, so the endpoint defaults to the next one.'),
17
+ defaultModel: z
18
+ .string()
19
+ .default('')
20
+ .description('Model used when a request omits `model`, as `provider/model`.'),
21
+ modelAliases: z
22
+ .dict(z.string())
23
+ .default({})
24
+ .description('Extra request names mapped to `provider/model`, e.g. `gpt-4o: opencode-go/deepseek-v4.1-flash`.'),
25
+ session: z
26
+ .object({
27
+ header: z
28
+ .string()
29
+ .default('x-dsh-session-id')
30
+ .description('Request/response header carrying the DSH session id.'),
31
+ mirror: z
32
+ .boolean()
33
+ .default(true)
34
+ .description('Mirror each conversation into a real DSH session so it appears in the sidebar.'),
35
+ deriveFrom: z
36
+ .array(z.string())
37
+ .default(['model', 'first-user'])
38
+ .description('Fingerprint inputs used when a request carries no session id: model, system, first-user.'),
39
+ mappingTtlDays: z
40
+ .natural()
41
+ .default(3)
42
+ .description('Days an external conversation id keeps its DSH session after its last use.'),
43
+ archiveOnExpire: z
44
+ .boolean()
45
+ .default(true)
46
+ .description('Archive the mirrored session when its mapping expires.'),
47
+ workspacePath: z
48
+ .string()
49
+ .default('')
50
+ .description('Directory whose workspace groups mirrored sessions. Empty means $DSH_HOME/dsh-endpoint/conversations.'),
51
+ maxOpenSessions: z
52
+ .natural()
53
+ .default(64)
54
+ .description('Upper bound on sessions whose write handle stays open (each one also blocks GUI takeover).'),
55
+ })
56
+ .default({}),
57
+ limits: z
58
+ .object({
59
+ maxBodyBytes: z
60
+ .natural()
61
+ .default(8 * 1024 * 1024)
62
+ .description('Reject request bodies larger than this.'),
63
+ maxOutputTokens: z
64
+ .natural()
65
+ .default(32768)
66
+ .description('Upper bound applied to max_tokens when a request does not set one.'),
67
+ firstTokenTimeoutMs: z
68
+ .natural()
69
+ .default(120000)
70
+ .description('Abort a model call that produces no first token within this window.'),
71
+ idleTimeoutMs: z
72
+ .natural()
73
+ .default(120000)
74
+ .description('Abort a model call whose stream stalls for this long.'),
75
+ })
76
+ .default({}),
77
+ log: z
78
+ .object({
79
+ accessFile: z
80
+ .string()
81
+ .default('')
82
+ .description('JSONL access log path. Empty disables it.'),
83
+ includePayload: z
84
+ .boolean()
85
+ .default(false)
86
+ .description('Record request/response bodies in the access log (may contain user text).'),
87
+ })
88
+ .default({}),
89
+ })
@@ -0,0 +1,97 @@
1
+ import { notFound, badRequest } from '../openai/errors.js'
2
+
3
+ const CACHE_TTL_MS = 60_000
4
+
5
+ /**
6
+ * Reads the live provider/model directory from `ctx.llm` and resolves the
7
+ * `model` string of an OpenAI request to a concrete `provider/model` route.
8
+ */
9
+ export class ModelCatalog {
10
+ /**
11
+ * @param {{ listProviders: Function, listModels: Function }} llm
12
+ * @param {{ aliases?: Record<string,string>, defaultModel?: string, log?: Function }} [options]
13
+ */
14
+ constructor(llm, options = {}) {
15
+ this.llm = llm
16
+ this.aliases = options.aliases ?? {}
17
+ this.defaultModel = options.defaultModel ?? ''
18
+ this.log = options.log ?? (() => {})
19
+ this.cache = new Map()
20
+ this.created = Math.floor(Date.now() / 1000)
21
+ }
22
+
23
+ /** @returns {Array<{id:string}>} */
24
+ providers() {
25
+ try {
26
+ const listed = this.llm.listProviders()
27
+ return Array.isArray(listed) ? listed.filter((entry) => typeof entry?.id === 'string') : []
28
+ } catch (error) {
29
+ this.log('warn', `listProviders failed: ${String(error)}`)
30
+ return []
31
+ }
32
+ }
33
+
34
+ async modelsOf(provider) {
35
+ const cached = this.cache.get(provider)
36
+ const now = Date.now()
37
+ if (cached !== undefined && now - cached.at < CACHE_TTL_MS) return cached.models
38
+ try {
39
+ const models = await this.llm.listModels(provider)
40
+ const safe = Array.isArray(models) ? models : []
41
+ this.cache.set(provider, { at: now, models: safe })
42
+ return safe
43
+ } catch (error) {
44
+ this.log('warn', `listModels(${provider}) failed: ${String(error)}`)
45
+ this.cache.set(provider, { at: now, models: cached?.models ?? [] })
46
+ return cached?.models ?? []
47
+ }
48
+ }
49
+
50
+ /** OpenAI `/v1/models` payload. */
51
+ async list() {
52
+ const data = []
53
+ const seen = new Set()
54
+ for (const provider of this.providers()) {
55
+ const models = await this.modelsOf(provider.id)
56
+ for (const model of models) {
57
+ const id = `${provider.id}/${model.id}`
58
+ if (seen.has(id)) continue
59
+ seen.add(id)
60
+ data.push({
61
+ id,
62
+ object: 'model',
63
+ created: this.created,
64
+ owned_by: provider.id,
65
+ ...(typeof model.name === 'string' ? { name: model.name } : {}),
66
+ })
67
+ }
68
+ }
69
+ for (const [alias, target] of Object.entries(this.aliases)) {
70
+ if (seen.has(alias)) continue
71
+ data.push({ id: alias, object: 'model', created: this.created, owned_by: target.split('/')[0] ?? 'dsh-endpoint' })
72
+ }
73
+ return data
74
+ }
75
+
76
+ /**
77
+ * Resolve a requested model name to a concrete route.
78
+ * @param {unknown} requested
79
+ * @returns {{ provider: string, model: string, id: string }}
80
+ */
81
+ resolve(requested) {
82
+ const name = typeof requested === 'string' && requested.length > 0 ? requested : this.defaultModel
83
+ if (typeof name !== 'string' || name.length === 0) {
84
+ throw badRequest('`model` is required (use `provider/model` or a configured alias)', 'model')
85
+ }
86
+ const aliased = this.aliases[name] ?? name
87
+ const slash = aliased.indexOf('/')
88
+ if (slash <= 0 || slash === aliased.length - 1) {
89
+ throw notFound(`model "${name}" is not a provider/model route; configure modelAliases or use provider/model`)
90
+ }
91
+ const provider = aliased.slice(0, slash)
92
+ const model = aliased.slice(slash + 1)
93
+ const known = this.providers().some((entry) => entry.id === provider)
94
+ if (!known) throw notFound(`provider "${provider}" is not configured in this DSH instance`)
95
+ return { provider, model, id: aliased }
96
+ }
97
+ }
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import { Config } from './config.js'
2
+ import { startEndpoint } from './server.js'
3
+
4
+ export const name = 'dsh-endpoint'
5
+
6
+ /** `ctx.llm` is the only hard dependency: every provider call goes through it. */
7
+ export const inject = ['llm']
8
+
9
+ export { Config }
10
+
11
+ /**
12
+ * @param {object} ctx - Cordis host context
13
+ * @param {object} [config]
14
+ */
15
+ export function apply(ctx, config) {
16
+ const resolved = config ?? Config({})
17
+ const log = (level, message) => {
18
+ const logger = ctx.logger
19
+ if (typeof logger?.[level] === 'function') logger[level](`[dsh-endpoint] ${message}`)
20
+ else if (level === 'warn') console.warn(`[dsh-endpoint] ${message}`)
21
+ else console.log(`[dsh-endpoint] ${message}`)
22
+ }
23
+
24
+ ctx.effect(async () => {
25
+ const handle = await startEndpoint(ctx, resolved, { log })
26
+ return handle.dispose
27
+ })
28
+ }
package/src/mirror.js ADDED
@@ -0,0 +1,286 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ /**
5
+ * Mirrors endpoint conversations into real DSH sessions.
6
+ *
7
+ * A mirrored session is created through the public session store, given a
8
+ * persistence write handle, and attached to a dedicated workspace, so it shows
9
+ * up in the sidebar and can be read — but not continued — from the GUI. The
10
+ * open write handle is also the single-writer lock that refuses `resume`.
11
+ */
12
+ export class SessionMirror {
13
+ /**
14
+ * @param {object} ctx - Cordis host context
15
+ * @param {object} options
16
+ * @param {string} options.workspacePath - directory grouping mirrored sessions
17
+ * @param {(level: string, message: string) => void} [options.log]
18
+ */
19
+ constructor(ctx, options) {
20
+ this.ctx = ctx
21
+ this.workspacePath = options.workspacePath
22
+ this.log = options.log ?? (() => {})
23
+ this.states = new Map()
24
+ this.workspace = undefined
25
+ this.workspacePromise = undefined
26
+ this.closed = false
27
+ }
28
+
29
+ get available() {
30
+ return (
31
+ typeof this.ctx.get('sessions')?.create === 'function' &&
32
+ typeof this.ctx.get('sessionPersistence')?.create === 'function'
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Return the live mirror state for a stored mapping, restoring the session
38
+ * from its log when the process no longer holds it.
39
+ *
40
+ * @param {{ dshSessionId: string, turns?: number }} entry
41
+ */
42
+ async ensure(entry) {
43
+ const existing = this.states.get(entry.dshSessionId)
44
+ if (existing !== undefined) return existing
45
+
46
+ const { session, handle } = await this.#adopt(entry.dshSessionId)
47
+ const state = {
48
+ session,
49
+ handle,
50
+ turn: Number.isFinite(entry.turns) ? entry.turns : 0,
51
+ step: 0,
52
+ lastError: undefined,
53
+ }
54
+ this.states.set(entry.dshSessionId, state)
55
+ await this.#attachWorkspace(entry.dshSessionId)
56
+ return state
57
+ }
58
+
59
+ /**
60
+ * Create a brand-new mirrored session owned by this plugin fiber.
61
+ * @returns {Promise<{ dshSessionId: string, state: object }>}
62
+ */
63
+ async create() {
64
+ const sessions = this.ctx.get('sessions')
65
+ const persistence = this.ctx.get('sessionPersistence')
66
+ // Mint the id ourselves: the store's counter restarts with the process, so
67
+ // `sessions.create()` would hand out `session-1` again after a restart and
68
+ // collide with an already persisted mirror.
69
+ const session = sessions.create(`session-${randomUUID()}`, { meta: { cwd: this.workspacePath } })
70
+ let handle
71
+ try {
72
+ handle = await persistence.create(session.header)
73
+ // `sessions.create` publishes a few bookkeeping events (permission,
74
+ // sandbox, approval) while announcing the session, before a write handle
75
+ // exists — the live router only forwards what happens after `create`.
76
+ // Persist that prefix explicitly so the routed batch starts at the right
77
+ // cursor instead of failing contiguity.
78
+ if (session.seq > 0) {
79
+ const prefix = []
80
+ for (let seq = 0; seq < session.seq; seq += 1) {
81
+ const event = session.eventAt(seq)
82
+ if (event !== undefined) prefix.push(event)
83
+ }
84
+ await handle.append(prefix)
85
+ }
86
+ // Materialize the header so `attachSession` can read the stored cwd.
87
+ await handle.flush()
88
+ } catch (error) {
89
+ this.log('warn', `could not persist mirrored session ${session.id}: ${String(error)}`)
90
+ throw error
91
+ }
92
+ const state = { session, handle, turn: 0, step: 0 }
93
+ this.states.set(session.id, state)
94
+ await this.#attachWorkspace(session.id)
95
+ return { dshSessionId: session.id, state }
96
+ }
97
+
98
+ /**
99
+ * Append the client-side part of one turn and open the turn for completion.
100
+ *
101
+ * @param {object} state - mirror state from `ensure`/`create`
102
+ * @param {Array<object>} events - DSH surface events to append
103
+ * @returns {{ turn: number, step: number }}
104
+ */
105
+ beginTurn(state, events) {
106
+ state.turn += 1
107
+ state.step = 1
108
+ state.lastError = undefined
109
+ const session = state.session
110
+ session.append('turn/start', { turn: state.turn })
111
+ session.append('step/start', { turn: state.turn, step: state.step })
112
+ for (const event of events) {
113
+ if (event.type === 'user/message') session.append('user/message', event.data, { surfaceOp: 'append' })
114
+ else if (event.type === 'tool/result') {
115
+ // `tool/result` is not the message itself: the event carries turn/step
116
+ // and nests the ToolResultMessage under `message`.
117
+ session.append(
118
+ 'tool/result',
119
+ { turn: state.turn, step: state.step, message: event.message },
120
+ { surfaceOp: 'append' },
121
+ )
122
+ } else throw new Error(`unsupported mirrored event ${event.type}`)
123
+ }
124
+ return { turn: state.turn, step: state.step }
125
+ }
126
+
127
+ /**
128
+ * Close the open turn with a successful assistant message.
129
+ *
130
+ * @param {object} state
131
+ * @param {object} result
132
+ * @param {Array<object>} result.blocks - assembled assistant content blocks
133
+ * @param {Array<object>} result.chunks - raw stream chunks, in order
134
+ * @param {object} [result.usage]
135
+ * @param {string} result.provider
136
+ * @param {string} result.model
137
+ */
138
+ completeTurn(state, { blocks, chunks, usage, provider, model }) {
139
+ const session = state.session
140
+ const message = {
141
+ id: randomUUID(),
142
+ role: 'assistant',
143
+ content: blocks,
144
+ source: { kind: 'model', provider, model },
145
+ }
146
+ session.append(
147
+ 'assistant/message',
148
+ {
149
+ turn: state.turn,
150
+ step: state.step,
151
+ message,
152
+ stream: chunks.map((chunk) => ({ type: 'chunk', time: Date.now(), chunk: sanitize(chunk) })),
153
+ ...(usage === undefined ? {} : { usage: sanitize(usage) }),
154
+ },
155
+ { surfaceOp: 'append' },
156
+ )
157
+ session.append('step/end', { turn: state.turn, step: state.step })
158
+ session.append('turn/end', { turn: state.turn, reason: { kind: 'completed' } })
159
+ }
160
+
161
+ /**
162
+ * Close the open turn without an assistant message.
163
+ *
164
+ * @param {object} state
165
+ * @param {{ message: string, code: string }} failure
166
+ */
167
+ failTurn(state, failure) {
168
+ const session = state.session
169
+ state.lastError = failure
170
+ session.append('step/end', { turn: state.turn, step: state.step })
171
+ session.append('turn/end', {
172
+ turn: state.turn,
173
+ reason: { kind: 'error', error: { message: failure.message, code: failure.code } },
174
+ })
175
+ }
176
+
177
+ /** Best-effort durability barrier for one mirrored session. */
178
+ async flush(state) {
179
+ try {
180
+ await this.ctx.get('sessions').flush(state.session)
181
+ } catch (error) {
182
+ this.log('warn', `flush failed for ${state.session.id}: ${String(error)}`)
183
+ }
184
+ }
185
+
186
+ async close() {
187
+ if (this.closed) return
188
+ this.closed = true
189
+ for (const state of this.states.values()) {
190
+ await this.flush(state)
191
+ await state.handle?.close?.().catch((error) => this.log('warn', `handle close failed: ${String(error)}`))
192
+ }
193
+ this.states.clear()
194
+ }
195
+
196
+ async #adopt(id) {
197
+ const sessions = this.ctx.get('sessions')
198
+ const live = sessions.get(id)
199
+ if (live !== undefined) {
200
+ // Same-process reload: the session is still live, only the handle is ours.
201
+ const handle = await this.ctx.get('sessionPersistence').open(id, 'write')
202
+ return { session: live, handle }
203
+ }
204
+ const persistence = this.ctx.get('sessionPersistence')
205
+ const handle = await persistence.open(id, 'write')
206
+ try {
207
+ const read = await handle.read(0)
208
+ const session = sessions.prepare(id, {
209
+ seed: read.events,
210
+ meta: handle.header,
211
+ inheritedEventCount: handle.inheritedEventCount,
212
+ eventState: read.eventState,
213
+ })
214
+ sessions.enter(session)
215
+ sessions.announce(session)
216
+ return { session, handle }
217
+ } catch (error) {
218
+ await handle.close().catch(() => {})
219
+ throw error
220
+ }
221
+ }
222
+
223
+ async #attachWorkspace(sessionId) {
224
+ const registry = this.ctx.get('workspaceRegistry')
225
+ if (registry === undefined) return
226
+ try {
227
+ this.workspacePromise ??= (async () => {
228
+ await mkdir(this.workspacePath, { recursive: true })
229
+ return registry.create(this.workspacePath)
230
+ })()
231
+ this.workspace = await this.workspacePromise
232
+ await this.workspace?.attachSession(sessionId)
233
+ } catch (error) {
234
+ this.workspacePromise = undefined
235
+ this.workspace = undefined
236
+ this.log('warn', `could not attach ${sessionId} to workspace ${this.workspacePath}: ${String(error)}`)
237
+ }
238
+ }
239
+ }
240
+
241
+ /** Strip undefined/function values that the session log refuses to snapshot. */
242
+ function sanitize(value) {
243
+ return JSON.parse(JSON.stringify(value ?? null))
244
+ }
245
+
246
+ /**
247
+ * Build a DSH `user/message` surface event payload.
248
+ * @param {string} text
249
+ */
250
+ export function userMessageEvent(textOrBlocks) {
251
+ const content =
252
+ typeof textOrBlocks === 'string'
253
+ ? textOrBlocks.length > 0
254
+ ? [{ type: 'text', text: textOrBlocks }]
255
+ : [{ type: 'text', text: '[empty]' }]
256
+ : Array.isArray(textOrBlocks) && textOrBlocks.length > 0
257
+ ? textOrBlocks
258
+ : [{ type: 'text', text: '[empty]' }]
259
+ return {
260
+ type: 'user/message',
261
+ data: {
262
+ id: randomUUID(),
263
+ role: 'user',
264
+ content,
265
+ source: { kind: 'user' },
266
+ },
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Build a DSH `tool/result` surface event payload.
272
+ * @param {object} input
273
+ */
274
+ export function toolResultEvent({ callId, text, isError = false }) {
275
+ return {
276
+ type: 'tool/result',
277
+ message: {
278
+ id: randomUUID(),
279
+ role: 'tool',
280
+ toolCallId: callId,
281
+ content: [{ type: 'text', text }],
282
+ source: { kind: 'tool', callId },
283
+ ...(isError ? { isError: true } : {}),
284
+ },
285
+ }
286
+ }
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { badRequest } from './errors.js'
3
+ import { decodeFilePart, decodeInlineFile } from './media.js'
4
+
5
+ /**
6
+ * Convert OpenAI chat messages into DSH request messages.
7
+ *
8
+ * DSH accepts request-only user input, but assistant and tool turns need a
9
+ * full `Message` (identity + source), so every message gets a fresh id. The
10
+ * ids exist only for this request; the durable record is the mirrored session.
11
+ *
12
+ * Inline `image_url` data URLs and `file` parts are materialised through the
13
+ * host attachment service; remote URLs are refused (see `media.js`).
14
+ *
15
+ * @param {Array<object>} messages
16
+ * @param {{ provider: string, model: string }} route
17
+ * @param {{ saveImages?: Function }} [options]
18
+ */
19
+ export async function toDshMessages(messages, route, options = {}) {
20
+ if (!Array.isArray(messages) || messages.length === 0) {
21
+ throw badRequest('`messages` must be a non-empty array', 'messages')
22
+ }
23
+ const converted = []
24
+ for (const [index, message] of messages.entries()) {
25
+ converted.push(await toDshMessage(message, index, route, options))
26
+ }
27
+ return converted
28
+ }
29
+
30
+ async function toDshMessage(message, index, route, options) {
31
+ const param = `messages[${index}]`
32
+ if (message === null || typeof message !== 'object') throw badRequest(`${param} must be an object`, param)
33
+ const role = message.role
34
+ if (typeof role !== 'string') throw badRequest(`${param}.role is required`, `${param}.role`)
35
+
36
+ if (role === 'system' || role === 'developer') {
37
+ const text = coerceText(message.content, `${param}.content`)
38
+ return {
39
+ id: randomUUID(),
40
+ role: 'system',
41
+ content: text.length === 0 ? [] : [{ type: 'text', text }],
42
+ source: { kind: 'system-prompt' },
43
+ }
44
+ }
45
+
46
+ if (role === 'user') {
47
+ return {
48
+ id: randomUUID(),
49
+ role: 'user',
50
+ content: await toContentBlocks(message.content, `${param}.content`, options),
51
+ source: { kind: 'user' },
52
+ }
53
+ }
54
+
55
+ if (role === 'assistant') {
56
+ const content = await toContentBlocks(message.content ?? [], `${param}.content`, {
57
+ ...options,
58
+ allowEmpty: true,
59
+ allowMedia: false,
60
+ })
61
+ for (const call of message.tool_calls ?? []) {
62
+ const fn = call?.function
63
+ if (typeof fn?.name !== 'string' || fn.name.length === 0) {
64
+ throw badRequest(`${param}.tool_calls[].function.name is required`, `${param}.tool_calls`)
65
+ }
66
+ content.push({
67
+ type: 'tool-call',
68
+ id: typeof call.id === 'string' && call.id.length > 0 ? call.id : randomUUID(),
69
+ name: fn.name,
70
+ arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
71
+ })
72
+ }
73
+ return {
74
+ id: randomUUID(),
75
+ role: 'assistant',
76
+ content,
77
+ source: { kind: 'model', provider: route.provider, model: route.model },
78
+ }
79
+ }
80
+
81
+ if (role === 'tool') {
82
+ const callId = message.tool_call_id
83
+ if (typeof callId !== 'string' || callId.length === 0) {
84
+ throw badRequest(`${param}.tool_call_id is required for tool messages`, `${param}.tool_call_id`)
85
+ }
86
+ return {
87
+ id: randomUUID(),
88
+ role: 'tool',
89
+ toolCallId: callId,
90
+ content: await toContentBlocks(message.content, `${param}.content`, {
91
+ ...options,
92
+ allowEmpty: true,
93
+ allowMedia: false,
94
+ }),
95
+ source: { kind: 'tool', callId },
96
+ ...(message.is_error === true ? { isError: true } : {}),
97
+ }
98
+ }
99
+
100
+ throw badRequest(`${param}.role "${role}" is not supported`, `${param}.role`)
101
+ }
102
+
103
+ async function toContentBlocks(content, param, options = {}) {
104
+ if (typeof content === 'string') return content.length === 0 ? [] : [{ type: 'text', text: content }]
105
+ if (content === null || content === undefined) {
106
+ if (options.allowEmpty === true) return []
107
+ throw badRequest(`${param} is required`, param)
108
+ }
109
+ if (!Array.isArray(content)) throw badRequest(`${param} must be a string or an array of parts`, param)
110
+
111
+ const blocks = []
112
+ for (const [index, part] of content.entries()) {
113
+ const partParam = `${param}[${index}]`
114
+ if (part === null || typeof part !== 'object') throw badRequest(`${partParam} must be an object`, partParam)
115
+
116
+ if (part.type === 'text') {
117
+ if (typeof part.text !== 'string') throw badRequest(`${partParam}.text must be a string`, partParam)
118
+ if (part.text.length > 0) blocks.push({ type: 'text', text: part.text })
119
+ continue
120
+ }
121
+
122
+ if (part.type === 'image_url') {
123
+ if (options.allowMedia === false) throw badRequest(`${partParam} is not allowed here`, partParam)
124
+ const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url
125
+ if (typeof url !== 'string') throw badRequest(`${partParam}.image_url.url is required`, partParam)
126
+ blocks.push(await safeDecode(partParam, () => decodeInlineFile({ url, param: partParam }, options)))
127
+ continue
128
+ }
129
+
130
+ if (part.type === 'file') {
131
+ if (options.allowMedia === false) throw badRequest(`${partParam} is not allowed here`, partParam)
132
+ blocks.push(await safeDecode(partParam, () => decodeFilePart(part, partParam, options)))
133
+ continue
134
+ }
135
+
136
+ throw badRequest(`${partParam}.type "${String(part.type)}" is not supported`, `${partParam}.type`)
137
+ }
138
+ if (blocks.length === 0 && options.allowEmpty !== true) throw badRequest(`${param} is empty`, param)
139
+ return blocks
140
+ }
141
+
142
+ async function safeDecode(param, run) {
143
+ try {
144
+ return await run()
145
+ } catch (error) {
146
+ if (error?.status !== undefined) throw error
147
+ throw badRequest(`attachment rejected: ${error?.message ?? error}`, param)
148
+ }
149
+ }
150
+
151
+ function coerceText(content, param) {
152
+ if (typeof content === 'string') return content
153
+ if (Array.isArray(content)) {
154
+ return content
155
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
156
+ .map((part) => part.text)
157
+ .join('')
158
+ }
159
+ if (content === null || content === undefined) return ''
160
+ throw badRequest(`${param} must be a string or an array of text parts`, param)
161
+ }
162
+
163
+ /** Concatenated system/developer text, used by the fingerprint fallback. */
164
+ export function systemText(messages) {
165
+ return messages
166
+ .filter((message) => message?.role === 'system' || message?.role === 'developer')
167
+ .map((message) => coerceText(message.content, 'messages'))
168
+ .join('\n')
169
+ }
170
+
171
+ /** Text of the first user message, used by the fingerprint fallback. */
172
+ export function firstUserText(messages) {
173
+ const first = messages.find((message) => message?.role === 'user')
174
+ return first === undefined ? '' : coerceText(first.content, 'messages')
175
+ }
176
+
177
+ /** Convert OpenAI tool declarations into DSH tool schemas. */
178
+ export function toDshTools(tools) {
179
+ if (tools === undefined || tools === null) return undefined
180
+ if (!Array.isArray(tools)) throw badRequest('`tools` must be an array', 'tools')
181
+ const declarations = []
182
+ for (const [index, tool] of tools.entries()) {
183
+ const fn = tool?.type === 'function' ? tool.function : undefined
184
+ if (fn === undefined || typeof fn.name !== 'string' || fn.name.length === 0) {
185
+ throw badRequest(`tools[${index}] must be a function declaration with a name`, `tools[${index}]`)
186
+ }
187
+ declarations.push({
188
+ name: fn.name,
189
+ description: typeof fn.description === 'string' ? fn.description : '',
190
+ parameters:
191
+ fn.parameters !== null && typeof fn.parameters === 'object'
192
+ ? fn.parameters
193
+ : { type: 'object', properties: {} },
194
+ })
195
+ }
196
+ return declarations.length === 0 ? undefined : declarations
197
+ }