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
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
|
+
}
|