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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/README.zh.md +225 -0
- package/SECURITY.md +35 -0
- package/cordis.patch.yml +9 -0
- package/docs/curl.png +0 -0
- package/docs/settings.png +0 -0
- package/examples/opencode.json +18 -0
- package/icon.svg +13 -0
- package/package.json +68 -4
- package/src/admin.js +178 -0
- package/src/client.js +517 -0
- package/src/config.js +89 -0
- package/src/dsh/catalog.js +97 -0
- package/src/index.js +28 -0
- package/src/mirror.js +286 -0
- package/src/openai/convert.js +197 -0
- package/src/openai/errors.js +68 -0
- package/src/openai/media.js +120 -0
- package/src/openai/stream.js +294 -0
- package/src/server.js +624 -0
- package/src/session/key.js +129 -0
- package/src/session/registry.js +209 -0
|
@@ -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
|
+
}
|