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.
@@ -0,0 +1,68 @@
1
+ /** OpenAI-shaped errors, mapped from DSH's stable failure codes. */
2
+ export class ApiError extends Error {
3
+ /**
4
+ * @param {number} status
5
+ * @param {string} type
6
+ * @param {string} message
7
+ * @param {{ code?: string, param?: string }} [extra]
8
+ */
9
+ constructor(status, type, message, extra = {}) {
10
+ super(message)
11
+ this.status = status
12
+ this.type = type
13
+ this.code = extra.code
14
+ this.param = extra.param
15
+ }
16
+ }
17
+
18
+ export function badRequest(message, param) {
19
+ return new ApiError(400, 'invalid_request_error', message, { param })
20
+ }
21
+
22
+ export function notFound(message) {
23
+ return new ApiError(404, 'model_not_found', message, { code: 'model_not_found' })
24
+ }
25
+
26
+ const CODE_MAP = {
27
+ AUTH: [401, 'authentication_error', 'authentication_error'],
28
+ INVALID_CREDENTIAL: [401, 'authentication_error', 'invalid_api_key'],
29
+ MISSING_CREDENTIAL: [503, 'api_error', 'missing_credential'],
30
+ RATE_LIMIT: [429, 'rate_limit_error', 'rate_limit_exceeded'],
31
+ QUOTA: [429, 'insufficient_quota', 'insufficient_quota'],
32
+ ACCOUNT_QUOTA: [429, 'insufficient_quota', 'insufficient_quota'],
33
+ CONTEXT_WINDOW_EXCEEDED: [400, 'invalid_request_error', 'context_length_exceeded'],
34
+ NO_ADAPTER: [404, 'model_not_found', 'model_not_found'],
35
+ UNKNOWN_MODEL: [404, 'model_not_found', 'model_not_found'],
36
+ INVALID_MODEL_INFO: [404, 'model_not_found', 'model_not_found'],
37
+ INVALID_MODEL_CONTEXT: [404, 'model_not_found', 'model_not_found'],
38
+ INVALID_MODEL_MAX_TOKENS: [404, 'model_not_found', 'model_not_found'],
39
+ UNSUPPORTED_OPTION: [400, 'invalid_request_error', 'unsupported_parameter'],
40
+ IMAGE_OFFLOAD_REQUIRED: [400, 'invalid_request_error', 'image_too_large'],
41
+ }
42
+
43
+ /**
44
+ * Map a DSH failure (`{ code, message }`) to an OpenAI error.
45
+ * @param {{ code?: string, message?: string }} failure
46
+ */
47
+ export function fromLlmFailure(failure) {
48
+ const code = typeof failure?.code === 'string' ? failure.code : undefined
49
+ const message = typeof failure?.message === 'string' ? failure.message : 'model call failed'
50
+ const mapped = code === undefined ? undefined : CODE_MAP[code]
51
+ if (mapped === undefined) {
52
+ return new ApiError(500, 'api_error', message, { code: code ?? 'internal_error' })
53
+ }
54
+ const [status, type, openaiCode] = mapped
55
+ return new ApiError(status, type, message, { code: openaiCode })
56
+ }
57
+
58
+ export function toOpenAiBody(error, requestId) {
59
+ return {
60
+ error: {
61
+ message: error.message,
62
+ type: error.type,
63
+ param: error.param ?? null,
64
+ code: error.code ?? null,
65
+ },
66
+ ...(requestId === undefined ? {} : { request_id: requestId }),
67
+ }
68
+ }
@@ -0,0 +1,120 @@
1
+ import { badRequest } from './errors.js'
2
+
3
+ const IMAGE_TYPES = new Map([
4
+ ['image/png', 'image/png'],
5
+ ['image/jpeg', 'image/jpeg'],
6
+ ['image/jpg', 'image/jpeg'],
7
+ ['image/webp', 'image/webp'],
8
+ ['image/gif', 'image/gif'],
9
+ ])
10
+
11
+ const TEXT_TYPES = [
12
+ 'application/json',
13
+ 'application/xml',
14
+ 'application/x-yaml',
15
+ 'application/yaml',
16
+ 'application/javascript',
17
+ 'application/typescript',
18
+ 'application/sql',
19
+ ]
20
+
21
+ const TEXT_MAX_BYTES = 512 * 1024
22
+
23
+ /**
24
+ * Parse one `data:` URL. Remote URLs are rejected on purpose: the endpoint
25
+ * must not fetch arbitrary hosts on behalf of a client (SSRF), and every
26
+ * supported client can inline base64.
27
+ *
28
+ * @param {string} url
29
+ * @param {string} param
30
+ * @returns {{ mediaType: string, data: Buffer }}
31
+ */
32
+ export function parseDataUrl(url, param) {
33
+ if (typeof url !== 'string' || url.length === 0) throw badRequest(`${param} must be a string`, param)
34
+ if (/^https?:/i.test(url)) {
35
+ throw badRequest(
36
+ 'remote file URLs are not supported; send a data: URL with base64 content instead',
37
+ param,
38
+ )
39
+ }
40
+ const match = /^data:([^;,]+)((?:;[^,]*)*?),(.*)$/s.exec(url)
41
+ if (match === null) throw badRequest(`${param} must be a data: URL`, param)
42
+ const mediaType = match[1].trim().toLowerCase()
43
+ const parameters = match[2] ?? ''
44
+ const payload = match[3] ?? ''
45
+ let data
46
+ if (/;base64/i.test(parameters)) {
47
+ // Guard before decoding: base64 expands by ~4/3.
48
+ if (payload.length > 64 * 1024 * 1024) throw badRequest(`${param} is too large`, param)
49
+ data = Buffer.from(payload, 'base64')
50
+ } else {
51
+ data = Buffer.from(decodeURIComponent(payload), 'utf8')
52
+ }
53
+ if (data.length === 0) throw badRequest(`${param} is empty`, param)
54
+ return { mediaType, data }
55
+ }
56
+
57
+ /** Normalise an image media type, or `undefined` when it is not a raster image. */
58
+ export function imageMediaType(mediaType) {
59
+ return IMAGE_TYPES.get(mediaType)
60
+ }
61
+
62
+ /** Whether a media type should be inlined as UTF-8 text. */
63
+ export function isTextMediaType(mediaType) {
64
+ return mediaType.startsWith('text/') || TEXT_TYPES.includes(mediaType)
65
+ }
66
+
67
+ /**
68
+ * Decode one inline file (image or text) into a DSH content block.
69
+ *
70
+ * @param {object} input
71
+ * @param {string} input.url - data URL
72
+ * @param {string} [input.name] - original filename
73
+ * @param {string} input.param - request path for error messages
74
+ * @param {object} options
75
+ * @param {(inputs: Array<{ data: Uint8Array, mediaType: string, name?: string }>) => Promise<Array<object>>} [options.saveImages]
76
+ * @param {boolean} [options.allowText]
77
+ * @returns {Promise<object>} text or image block
78
+ */
79
+ export async function decodeInlineFile({ url, name, param }, options = {}) {
80
+ const { mediaType, data } = parseDataUrl(url, param)
81
+ const image = imageMediaType(mediaType)
82
+ if (image !== undefined) {
83
+ if (typeof options.saveImages !== 'function') {
84
+ throw badRequest('image input is not available in this profile', param)
85
+ }
86
+ const [ref] = await options.saveImages([{ data: new Uint8Array(data), mediaType: image, ...(name ? { name } : {}) }])
87
+ if (ref === undefined) throw badRequest('could not store the image attachment', param)
88
+ return { type: 'image', attachment: ref }
89
+ }
90
+ if (isTextMediaType(mediaType) && options.allowText === true) {
91
+ const truncated = data.length > TEXT_MAX_BYTES
92
+ const text = data.subarray(0, TEXT_MAX_BYTES).toString('utf8')
93
+ const header = `[file${name ? `: ${name}` : ''}${truncated ? ', truncated' : ''}]\n`
94
+ return { type: 'text', text: header + text }
95
+ }
96
+ throw badRequest(
97
+ `unsupported inline media type "${mediaType}"; images and text files are supported, other documents must be sent as text`,
98
+ param,
99
+ )
100
+ }
101
+
102
+ /**
103
+ * OpenAI `file` content part: `{ type: 'file', file: { filename, file_data } }`.
104
+ * `file_id` refers to the Files API, which this endpoint does not implement.
105
+ */
106
+ export async function decodeFilePart(part, param, options = {}) {
107
+ const file = part?.file
108
+ if (file === null || typeof file !== 'object') throw badRequest(`${param}.file is required`, param)
109
+ if (typeof file.file_id === 'string') {
110
+ throw badRequest('file_id is not supported; inline the bytes as file_data', `${param}.file.file_id`)
111
+ }
112
+ const fileData = file.file_data ?? file.data
113
+ if (typeof fileData !== 'string') {
114
+ throw badRequest(`${param}.file.file_data is required`, `${param}.file.file_data`)
115
+ }
116
+ return decodeInlineFile(
117
+ { url: fileData, name: typeof file.filename === 'string' ? file.filename : undefined, param },
118
+ { ...options, allowText: true },
119
+ )
120
+ }
@@ -0,0 +1,294 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ export function newCompletionId() {
4
+ return `chatcmpl-${randomUUID().replaceAll('-', '')}`
5
+ }
6
+
7
+ /** Map a DSH `usage` chunk to an OpenAI usage object. */
8
+ export function toOpenAiUsage(usage) {
9
+ if (usage === null || typeof usage !== 'object') return undefined
10
+ const input = number(usage.inputTokens)
11
+ const output = number(usage.outputTokens)
12
+ const cacheRead = number(usage.cacheReadTokens)
13
+ const cacheWrite = number(usage.cacheWriteTokens)
14
+ const reasoning = number(usage.reasoningTokens)
15
+ return {
16
+ prompt_tokens: input + cacheRead + cacheWrite,
17
+ completion_tokens: output,
18
+ total_tokens: input + cacheRead + cacheWrite + output,
19
+ prompt_tokens_details: { cached_tokens: cacheRead },
20
+ completion_tokens_details: { reasoning_tokens: reasoning },
21
+ }
22
+ }
23
+
24
+ function number(value) {
25
+ return Number.isFinite(value) && value > 0 ? value : 0
26
+ }
27
+
28
+ /** Map a DSH finish reason to an OpenAI `finish_reason`. */
29
+ export function toFinishReason(reason) {
30
+ switch (reason?.kind) {
31
+ case 'stop':
32
+ return 'stop'
33
+ case 'tool-calls':
34
+ return 'tool_calls'
35
+ case 'max-tokens':
36
+ return 'length'
37
+ case 'aborted':
38
+ return 'stop'
39
+ case 'error':
40
+ return 'stop'
41
+ default:
42
+ return 'stop'
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Assembles raw DSH chunks into the assistant content blocks the session log
48
+ * expects, while feeding the SSE writer.
49
+ */
50
+ export class CompletionCollector {
51
+ constructor() {
52
+ this.chunks = []
53
+ this.partials = new Map()
54
+ this.text = ''
55
+ this.reasoning = ''
56
+ this.usage = undefined
57
+ this.finishReason = undefined
58
+ this.failure = undefined
59
+ }
60
+
61
+ push(chunk) {
62
+ this.chunks.push(chunk)
63
+ switch (chunk?.type) {
64
+ case 'block-start': {
65
+ this.#ensure(chunk.index, chunk.blockType)
66
+ return
67
+ }
68
+ case 'text-delta': {
69
+ const partial = this.#ensure(chunk.index, 'text')
70
+ partial.text += chunk.text
71
+ this.text += chunk.text
72
+ return
73
+ }
74
+ case 'reasoning-delta': {
75
+ const partial = this.#ensure(chunk.index, 'reasoning')
76
+ partial.text += chunk.text
77
+ this.reasoning += chunk.text
78
+ return
79
+ }
80
+ case 'tool-call-delta': {
81
+ const partial = this.#ensure(chunk.index, 'tool-call')
82
+ if (typeof chunk.id === 'string' && chunk.id.length > 0) partial.id = chunk.id
83
+ if (typeof chunk.name === 'string' && chunk.name.length > 0) partial.name = chunk.name
84
+ partial.arguments += chunk.argumentsDelta ?? ''
85
+ return
86
+ }
87
+ case 'block-end': {
88
+ const partial = this.#ensure(chunk.index, chunk.block?.type ?? 'text')
89
+ partial.closed = chunk.block
90
+ return
91
+ }
92
+ case 'usage': {
93
+ this.usage = chunk.usage
94
+ return
95
+ }
96
+ case 'finish': {
97
+ this.finishReason = chunk.reason
98
+ if (chunk.reason?.kind === 'error') this.failure = chunk.reason.failure
99
+ return
100
+ }
101
+ default:
102
+ return
103
+ }
104
+ }
105
+
106
+ get blocks() {
107
+ const blocks = []
108
+ for (const [, partial] of [...this.partials].sort((a, b) => a[0] - b[0])) {
109
+ if (partial.closed !== undefined) {
110
+ blocks.push(partial.closed)
111
+ continue
112
+ }
113
+ if (partial.type === 'text' && partial.text.length > 0) blocks.push({ type: 'text', text: partial.text })
114
+ else if (partial.type === 'reasoning' && partial.text.length > 0) {
115
+ blocks.push({ type: 'reasoning', text: partial.text })
116
+ } else if (partial.type === 'tool-call') {
117
+ blocks.push({
118
+ type: 'tool-call',
119
+ id: partial.id ?? randomUUID(),
120
+ name: partial.name ?? '',
121
+ arguments: partial.arguments,
122
+ })
123
+ }
124
+ }
125
+ return blocks
126
+ }
127
+
128
+ get toolCalls() {
129
+ return this.blocks.filter((block) => block.type === 'tool-call')
130
+ }
131
+
132
+ #ensure(index, type) {
133
+ let partial = this.partials.get(index)
134
+ if (partial === undefined) {
135
+ partial = { type, text: '', arguments: '', id: undefined, name: undefined, closed: undefined }
136
+ this.partials.set(index, partial)
137
+ }
138
+ return partial
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Incremental OpenAI SSE writer for one completion.
144
+ *
145
+ * DSH block indexes are not OpenAI tool indexes (text and reasoning blocks are
146
+ * interleaved), so tool calls get their own 0-based counter.
147
+ */
148
+ export class SseWriter {
149
+ constructor({ res, id, created, model }) {
150
+ this.res = res
151
+ this.id = id
152
+ this.created = created
153
+ this.model = model
154
+ this.toolIndex = new Map()
155
+ this.nextToolIndex = 0
156
+ this.done = false
157
+ }
158
+
159
+ #write(payload) {
160
+ if (this.done) return
161
+ this.res.write(`data: ${JSON.stringify(payload)}\n\n`)
162
+ }
163
+
164
+ #frame(delta, finishReason = null) {
165
+ return {
166
+ id: this.id,
167
+ object: 'chat.completion.chunk',
168
+ created: this.created,
169
+ model: this.model,
170
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
171
+ }
172
+ }
173
+
174
+ start() {
175
+ this.#write(this.#frame({ role: 'assistant', content: '' }))
176
+ }
177
+
178
+ /** @param {object} chunk */
179
+ chunk(chunk) {
180
+ switch (chunk?.type) {
181
+ case 'text-delta':
182
+ if (chunk.text.length > 0) this.#write(this.#frame({ content: chunk.text }))
183
+ return
184
+ case 'reasoning-delta':
185
+ if (chunk.text.length > 0) this.#write(this.#frame({ reasoning_content: chunk.text }))
186
+ return
187
+ case 'tool-call-delta': {
188
+ let index = this.toolIndex.get(chunk.index)
189
+ const first = index === undefined
190
+ if (first) {
191
+ index = this.nextToolIndex
192
+ this.nextToolIndex += 1
193
+ this.toolIndex.set(chunk.index, index)
194
+ }
195
+ this.#write(
196
+ this.#frame({
197
+ tool_calls: [
198
+ {
199
+ index,
200
+ ...(first ? { id: chunk.id, type: 'function' } : {}),
201
+ function: {
202
+ ...(first && typeof chunk.name === 'string' ? { name: chunk.name } : {}),
203
+ arguments: chunk.argumentsDelta ?? '',
204
+ },
205
+ },
206
+ ],
207
+ }),
208
+ )
209
+ return
210
+ }
211
+ default:
212
+ return
213
+ }
214
+ }
215
+
216
+ finish(reason) {
217
+ this.#write(this.#frame({}, toFinishReason(reason)))
218
+ }
219
+
220
+ /** Emit an error frame on an already-started stream. */
221
+ writeError(body) {
222
+ this.#write(body)
223
+ }
224
+
225
+ usage(usage) {
226
+ const mapped = toOpenAiUsage(usage)
227
+ if (mapped === undefined) return
228
+ this.#write({
229
+ id: this.id,
230
+ object: 'chat.completion.chunk',
231
+ created: this.created,
232
+ model: this.model,
233
+ choices: [],
234
+ usage: mapped,
235
+ })
236
+ }
237
+
238
+ end() {
239
+ if (this.done) return
240
+ this.done = true
241
+ this.res.write('data: [DONE]\n\n')
242
+ this.res.end()
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Tracks OpenAI `stop` sequences across delta boundaries and reports where the
248
+ * visible text must be cut. DSH adapters do not all accept `stop`, so the
249
+ * endpoint honours it itself.
250
+ */
251
+ export class StopMatcher {
252
+ /** @param {string[]} sequences */
253
+ constructor(sequences) {
254
+ this.sequences = sequences.filter((value) => typeof value === 'string' && value.length > 0)
255
+ this.seen = ''
256
+ this.hit = false
257
+ }
258
+
259
+ get active() {
260
+ return this.sequences.length > 0
261
+ }
262
+
263
+ /**
264
+ * @param {string} delta
265
+ * @returns {{ text: string, hit: boolean }}
266
+ */
267
+ push(delta) {
268
+ if (!this.active || this.hit) return { text: '', hit: this.hit }
269
+ this.seen += delta
270
+ let cut = this.seen.length
271
+ for (const sequence of this.sequences) {
272
+ const index = this.seen.indexOf(sequence)
273
+ if (index >= 0 && index < cut) cut = index
274
+ }
275
+ if (cut < this.seen.length) {
276
+ this.hit = true
277
+ const text = this.seen.slice(0, cut)
278
+ return { text, hit: true }
279
+ }
280
+ // Emit everything that can no longer be part of a stop sequence.
281
+ const held = this.sequences.reduce((max, sequence) => Math.max(max, sequence.length), 1) - 1
282
+ if (this.seen.length <= held) return { text: '', hit: false }
283
+ const text = this.seen.slice(0, this.seen.length - held)
284
+ this.seen = this.seen.slice(this.seen.length - held)
285
+ return { text, hit: false }
286
+ }
287
+
288
+ /** Remaining text when the stream ends without a stop hit. */
289
+ flush() {
290
+ const text = this.seen
291
+ this.seen = ''
292
+ return text
293
+ }
294
+ }