@jedylicong/dsh-xinlab 0.0.0-stage → 0.2.2
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 +47 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/bin/dsh-providers.mjs +28 -0
- package/client.js +220 -0
- package/cordis.patch.yml +310 -0
- package/data/sources.json +7 -0
- package/data/vllm.json +63 -0
- package/data/xin-lab.json +284 -0
- package/data/zai.json +47 -0
- package/package.json +73 -4
- package/src/catalog.mjs +50 -0
- package/src/client-page.mjs +191 -0
- package/src/inspect.mjs +60 -0
- package/src/key-preview-protocol.mjs +28 -0
- package/src/key-preview.mjs +37 -0
- package/src/model-settings.mjs +37 -0
- package/src/official-adapter.mjs +52 -0
- package/src/runtime.mjs +99 -0
- package/src/state.mjs +40 -0
- package/src/vllm-mm-uuid.mjs +14 -0
- package/vendor/vllm-mm-uuid.mjs +239 -0
- package/vendor/vllm-reasoning-only-retry.mjs +41 -0
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import { rejectEmbeddedCredentials } from './catalog.mjs'
|
|
5
|
+
import { assertModelSettings, modelKeyRef, modelProfile, PROTOCOLS } from './model-settings.mjs'
|
|
6
|
+
import { officialAdapter } from './official-adapter.mjs'
|
|
7
|
+
import { ModelKeyPreviews } from './key-preview.mjs'
|
|
8
|
+
import { keyPreviewDescriptor } from './key-preview-protocol.mjs'
|
|
9
|
+
|
|
10
|
+
export const name = 'xinlab-providers'
|
|
11
|
+
export const inject = ['llm', 'credentials']
|
|
12
|
+
export const Config = z.object({
|
|
13
|
+
// Deployment-owned catalog is deliberately not volatile: the page edits only
|
|
14
|
+
// protocol choices. Secrets have no slot in this configuration schema.
|
|
15
|
+
providers: z.any().required(),
|
|
16
|
+
protocols: z.dict(z.dict(z.union(PROTOCOLS))).default({}).volatile(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export class ModelSettingsAdapter extends LlmAdapter {
|
|
20
|
+
constructor(ctx, providers, protocols) {
|
|
21
|
+
super()
|
|
22
|
+
this.ctx = ctx
|
|
23
|
+
this.providers = structuredClone(providers)
|
|
24
|
+
this.protocols = protocols
|
|
25
|
+
this.base = officialAdapter(ctx, this.providers)
|
|
26
|
+
this.models = new Map()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
delegate(provider, model) {
|
|
30
|
+
const profile = this.providers[provider]
|
|
31
|
+
if (!profile?.models?.some((item) => item.id === model)) throw new Error('未知供应商或模型。')
|
|
32
|
+
const protocol = this.protocols()[provider]?.[model] ?? profile.api
|
|
33
|
+
const id = JSON.stringify([provider, model])
|
|
34
|
+
let current = this.models.get(id)
|
|
35
|
+
if (!current || current.protocol !== protocol) {
|
|
36
|
+
const selected = modelProfile(profile, model, protocol)
|
|
37
|
+
const ref = modelKeyRef(provider, model)
|
|
38
|
+
const baseRef = selected.apiKeyEnv
|
|
39
|
+
selected.apiKeyEnv = ref
|
|
40
|
+
const next = { ...officialAdapter(this.ctx, { [provider]: selected }, { ref, baseRef }), protocol }
|
|
41
|
+
this.models.set(id, next)
|
|
42
|
+
// Prepared calls retain their official adapter snapshot; releasing the
|
|
43
|
+
// composition's listeners does not mutate that snapshot or its auth seam.
|
|
44
|
+
current?.dispose()
|
|
45
|
+
current = next
|
|
46
|
+
}
|
|
47
|
+
return current.adapter
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
providerInfo(provider) { return this.base.adapter.providerInfo(provider) }
|
|
51
|
+
providerRetryPolicy(provider) { return this.base.adapter.providerRetryPolicy(provider) }
|
|
52
|
+
imageRequestPricing(provider, model) { return this.delegate(provider, model).imageRequestPricing(provider, model) }
|
|
53
|
+
imageRequestTarget(provider, model, image) { return this.delegate(provider, model).imageRequestTarget(provider, model, image) }
|
|
54
|
+
async listModels(provider) {
|
|
55
|
+
const delegates = (this.providers[provider]?.models ?? []).map((model) => this.delegate(provider, model.id))
|
|
56
|
+
const result = []
|
|
57
|
+
// Bound concurrency on small management nodes and credential file stores.
|
|
58
|
+
for (const adapter of delegates) result.push(...await adapter.listModels(provider))
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
resolveModel(provider, model, signal) { return this.delegate(provider, model).resolveModel(provider, model, signal) }
|
|
62
|
+
prepareCall(provider, model, signal) { return this.delegate(provider, model).prepareCall(provider, model, signal) }
|
|
63
|
+
stream(options) { return this.delegate(options.provider, options.model).stream(options) }
|
|
64
|
+
async dispose() {
|
|
65
|
+
await this.base.dispose()
|
|
66
|
+
for (const entry of this.models.values()) await entry.dispose()
|
|
67
|
+
this.models.clear()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function apply(ctx, config) {
|
|
72
|
+
rejectEmbeddedCredentials(config.providers)
|
|
73
|
+
assertModelSettings(config.providers, config.protocols.get())
|
|
74
|
+
const adapter = new ModelSettingsAdapter(ctx, config.providers, () => config.protocols.get())
|
|
75
|
+
// Validate every saved protocol before persistence, including protocol-specific
|
|
76
|
+
// compatibility. A rejected draft never replaces the working configuration.
|
|
77
|
+
ctx.on('internal/config', (fiber, read) => {
|
|
78
|
+
const raw = read()
|
|
79
|
+
if (fiber !== ctx.fiber) return raw
|
|
80
|
+
const next = Config(raw)
|
|
81
|
+
assertModelSettings(config.providers, next.protocols.get())
|
|
82
|
+
for (const [provider, models] of Object.entries(next.protocols.get())) {
|
|
83
|
+
for (const [model, protocol] of Object.entries(models)) {
|
|
84
|
+
const check = officialAdapter(ctx, { [provider]: modelProfile(config.providers[provider], model, protocol) })
|
|
85
|
+
check.dispose()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return raw
|
|
89
|
+
})
|
|
90
|
+
ctx.llm.registerAdapter(Object.keys(config.providers), adapter)
|
|
91
|
+
ctx.inject(['typert'], (child) => {
|
|
92
|
+
new ModelKeyPreviews(child, config.providers)
|
|
93
|
+
const packageName = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).name
|
|
94
|
+
child.effect(() => child.typert.register({ package: packageName, face: 'host', schemas: [],
|
|
95
|
+
model: { services: [], events: [], objects: [] }, invocations: [keyPreviewDescriptor(packageName)] }))
|
|
96
|
+
})
|
|
97
|
+
ctx.inject(['settings'], (child) => child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)))
|
|
98
|
+
ctx.effect(() => () => adapter.dispose())
|
|
99
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
function privateFile(path, initial, minimumBytes = 0) {
|
|
6
|
+
let fd
|
|
7
|
+
try {
|
|
8
|
+
try {
|
|
9
|
+
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
|
|
10
|
+
writeFileSync(fd, initial)
|
|
11
|
+
fsyncSync(fd)
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error.code !== 'EEXIST') throw error
|
|
14
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
15
|
+
}
|
|
16
|
+
const stat = fstatSync(fd)
|
|
17
|
+
if (!stat.isFile() || stat.size < minimumBytes) throw new Error('vLLM 状态文件类型或长度不正确')
|
|
18
|
+
if (process.getuid && stat.uid !== process.getuid()) throw new Error('vLLM 状态文件不属于当前运行用户')
|
|
19
|
+
// Enforce the same file modes as the managed deployment without rotating state.
|
|
20
|
+
fchmodSync(fd, 0o600)
|
|
21
|
+
} finally {
|
|
22
|
+
if (fd !== undefined) closeSync(fd)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensureUUIDState(directory) {
|
|
27
|
+
if (typeof directory !== 'string' || !isAbsolute(directory)) throw new Error('vLLM 状态目录必须使用绝对路径')
|
|
28
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
29
|
+
const stat = lstatSync(directory)
|
|
30
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('vLLM 状态目录必须是实际目录')
|
|
31
|
+
if (process.getuid && stat.uid !== process.getuid()) throw new Error('vLLM 状态目录不属于当前运行用户')
|
|
32
|
+
// Open the directory itself to avoid chmod following a substituted leaf symlink.
|
|
33
|
+
const fd = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
34
|
+
try { fchmodSync(fd, 0o700) } finally { closeSync(fd) }
|
|
35
|
+
const keyFile = join(directory, 'key')
|
|
36
|
+
const warmFile = join(directory, 'warm-uuids')
|
|
37
|
+
privateFile(keyFile, randomBytes(32), 32)
|
|
38
|
+
privateFile(warmFile, '')
|
|
39
|
+
return { keyFile, warmFile }
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { apply as applyManagedUUID } from '../vendor/vllm-mm-uuid.mjs'
|
|
2
|
+
import { ensureUUIDState } from './state.mjs'
|
|
3
|
+
|
|
4
|
+
export const name = 'xinlab-vllm-mm-uuid'
|
|
5
|
+
export const inject = ['llm', 'attachments']
|
|
6
|
+
|
|
7
|
+
export function apply(ctx, config) {
|
|
8
|
+
const endpoint = new URL(config.endpoint)
|
|
9
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || !endpoint.pathname.endsWith('/chat/completions')) {
|
|
10
|
+
throw new Error('vLLM UUID 插件需要准确的 Chat Completions 地址')
|
|
11
|
+
}
|
|
12
|
+
const files = ensureUUIDState(config.stateDir)
|
|
13
|
+
applyManagedUUID(ctx, { ...files, endpoint: endpoint.href, providers: ['vllm'] })
|
|
14
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto'
|
|
2
|
+
import { appendFileSync, readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
const CACHE_MISS = /Cache miss for image at index \d+ but data is not provided\./
|
|
5
|
+
const DATA_IMAGE = /^data:image\/(?:png|jpeg|webp|gif);base64,/
|
|
6
|
+
const ATTACHMENT_FAILURES = new Set([
|
|
7
|
+
'ATTACHMENT_NOT_FOUND',
|
|
8
|
+
'ATTACHMENT_CORRUPT',
|
|
9
|
+
'ATTACHMENT_READ_FAILED',
|
|
10
|
+
])
|
|
11
|
+
const RECOVERY = Symbol('vllm-mm-uuid-recovery')
|
|
12
|
+
const PLACEHOLDER = '[historical image unavailable in the local attachment store]'
|
|
13
|
+
|
|
14
|
+
export const name = 'vllm-mm-uuid'
|
|
15
|
+
export const inject = ['llm', 'attachments']
|
|
16
|
+
|
|
17
|
+
function stableUuid(key, dataUrl) {
|
|
18
|
+
const bytes = createHmac('sha256', key).update(dataUrl).digest().subarray(0, 16)
|
|
19
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
20
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
21
|
+
const hex = bytes.toString('hex')
|
|
22
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function visitImageParts(raw, projected, key, seen, uuids) {
|
|
26
|
+
if (Array.isArray(raw)) {
|
|
27
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
28
|
+
visitImageParts(raw[index], projected[index], key, seen, uuids)
|
|
29
|
+
}
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (!raw || typeof raw !== 'object') return
|
|
33
|
+
|
|
34
|
+
const url = raw.type === 'image_url' && raw.image_url && typeof raw.image_url.url === 'string'
|
|
35
|
+
? raw.image_url.url
|
|
36
|
+
: undefined
|
|
37
|
+
if (url && DATA_IMAGE.test(url)) {
|
|
38
|
+
const uuid = stableUuid(key, url)
|
|
39
|
+
raw.uuid = uuid
|
|
40
|
+
projected.uuid = uuid
|
|
41
|
+
if (seen.has(uuid)) projected.image_url = null
|
|
42
|
+
uuids.add(uuid)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const field of Object.keys(raw)) {
|
|
47
|
+
if (projected[field] !== undefined) visitImageParts(raw[field], projected[field], key, seen, uuids)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function preparePayload(text, key, seen) {
|
|
52
|
+
const raw = JSON.parse(text)
|
|
53
|
+
const projected = structuredClone(raw)
|
|
54
|
+
const uuids = new Set()
|
|
55
|
+
visitImageParts(raw, projected, key, seen, uuids)
|
|
56
|
+
if (uuids.size === 0) return undefined
|
|
57
|
+
return {
|
|
58
|
+
projected: JSON.stringify(projected),
|
|
59
|
+
raw: JSON.stringify(raw),
|
|
60
|
+
uuids: [...uuids],
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requestUrl(input) {
|
|
65
|
+
return typeof input === 'string' || input instanceof URL ? String(input) : input.url
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requestBody(input, init) {
|
|
69
|
+
if (typeof init?.body === 'string') return init.body
|
|
70
|
+
if (input instanceof Request) return input.clone().text()
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function withBody(input, init, body) {
|
|
75
|
+
const headers = new Headers(input instanceof Request ? input.headers : init?.headers)
|
|
76
|
+
headers.delete('content-length')
|
|
77
|
+
if (input instanceof Request) return [new Request(input, { body, headers }), undefined]
|
|
78
|
+
return [input, { ...init, body, headers }]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function isUuidCacheMiss(response) {
|
|
82
|
+
if (response.status !== 400) return false
|
|
83
|
+
return CACHE_MISS.test(await response.clone().text())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createFetchWrapper(nativeFetch, { endpoint, key, seen, remember }) {
|
|
87
|
+
const expected = new URL(endpoint).href
|
|
88
|
+
return async function vllmUuidFetch(input, init) {
|
|
89
|
+
if (new URL(requestUrl(input)).href !== expected) return nativeFetch(input, init)
|
|
90
|
+
|
|
91
|
+
const text = await requestBody(input, init)
|
|
92
|
+
if (text === undefined) return nativeFetch(input, init)
|
|
93
|
+
|
|
94
|
+
let payload
|
|
95
|
+
try {
|
|
96
|
+
payload = preparePayload(text, key, seen)
|
|
97
|
+
} catch {
|
|
98
|
+
return nativeFetch(input, init)
|
|
99
|
+
}
|
|
100
|
+
if (payload === undefined) return nativeFetch(input, init)
|
|
101
|
+
|
|
102
|
+
let request = withBody(input, init, payload.projected)
|
|
103
|
+
let response = await nativeFetch(...request)
|
|
104
|
+
if (await isUuidCacheMiss(response)) {
|
|
105
|
+
request = withBody(input, init, payload.raw)
|
|
106
|
+
response = await nativeFetch(...request)
|
|
107
|
+
}
|
|
108
|
+
if (response.ok) remember(payload.uuids)
|
|
109
|
+
return response
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function replaceMissingBlocks(blocks, missing) {
|
|
114
|
+
return blocks.map((block) => {
|
|
115
|
+
if (block.type === 'image' && missing.has(String(block.attachment.attachmentId))) {
|
|
116
|
+
return { type: 'text', text: PLACEHOLDER }
|
|
117
|
+
}
|
|
118
|
+
if (block.type === 'tool-result') return { ...block, content: replaceMissingBlocks(block.content, missing) }
|
|
119
|
+
return block
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function projectMissingMessages(messages, missing) {
|
|
124
|
+
return messages.map((message) => ({ ...message, content: replaceMissingBlocks(message.content, missing) }))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectImageRefs(blocks, refs) {
|
|
128
|
+
for (const block of blocks) {
|
|
129
|
+
if (block.type === 'image') refs.set(String(block.attachment.attachmentId), block.attachment)
|
|
130
|
+
if (block.type === 'tool-result') collectImageRefs(block.content, refs)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function unavailableAttachments(ctx, options) {
|
|
135
|
+
const refs = new Map()
|
|
136
|
+
for (const message of options.messages) collectImageRefs(message.content, refs)
|
|
137
|
+
const missing = new Set()
|
|
138
|
+
await Promise.all([...refs].map(async ([id, ref]) => {
|
|
139
|
+
try {
|
|
140
|
+
await ctx.attachments.readImage(ref, options.signal)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (options.signal?.aborted) throw error
|
|
143
|
+
missing.add(id)
|
|
144
|
+
}
|
|
145
|
+
}))
|
|
146
|
+
return missing
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function attachmentFailure(chunk) {
|
|
150
|
+
return chunk.type === 'finish'
|
|
151
|
+
&& chunk.reason.kind === 'error'
|
|
152
|
+
&& ATTACHMENT_FAILURES.has(chunk.reason.failure.code)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function retryStream(ctx, options, messages) {
|
|
156
|
+
const retry = { ...options, messages }
|
|
157
|
+
Object.defineProperty(retry, RECOVERY, { value: true })
|
|
158
|
+
return ctx.llm.stream(retry)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function recoverAttachmentFailures(ctx, options, next, knownMissing) {
|
|
162
|
+
return (async function* () {
|
|
163
|
+
const alreadyMissing = new Set()
|
|
164
|
+
for (const message of options.messages) {
|
|
165
|
+
const refs = new Map()
|
|
166
|
+
collectImageRefs(message.content, refs)
|
|
167
|
+
for (const id of refs.keys()) if (knownMissing.has(id)) alreadyMissing.add(id)
|
|
168
|
+
}
|
|
169
|
+
if (alreadyMissing.size > 0) {
|
|
170
|
+
yield* retryStream(ctx, options, projectMissingMessages(options.messages, alreadyMissing))
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let emitted = false
|
|
175
|
+
for await (const chunk of next()) {
|
|
176
|
+
if (!emitted && attachmentFailure(chunk)) {
|
|
177
|
+
const missing = await unavailableAttachments(ctx, options)
|
|
178
|
+
for (const id of missing) knownMissing.add(id)
|
|
179
|
+
yield* retryStream(
|
|
180
|
+
ctx,
|
|
181
|
+
options,
|
|
182
|
+
missing.size === 0 ? options.messages : projectMissingMessages(options.messages, missing),
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
emitted = true
|
|
187
|
+
yield chunk
|
|
188
|
+
}
|
|
189
|
+
})()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readState(config) {
|
|
193
|
+
const key = readFileSync(config.keyFile)
|
|
194
|
+
if (key.length < 32) throw new Error('vllm-mm-uuid: keyFile must contain at least 32 bytes')
|
|
195
|
+
const seen = new Set()
|
|
196
|
+
try {
|
|
197
|
+
for (const line of readFileSync(config.warmFile, 'utf8').split('\n')) if (line) seen.add(line)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'ENOENT') throw error
|
|
200
|
+
}
|
|
201
|
+
return { key, seen }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function apply(ctx, config) {
|
|
205
|
+
if (!config || typeof config.endpoint !== 'string' || !config.endpoint) {
|
|
206
|
+
throw new Error('vllm-mm-uuid: endpoint is required')
|
|
207
|
+
}
|
|
208
|
+
if (typeof config.keyFile !== 'string' || typeof config.warmFile !== 'string') {
|
|
209
|
+
throw new Error('vllm-mm-uuid: keyFile and warmFile are required')
|
|
210
|
+
}
|
|
211
|
+
const providers = new Set(config.providers ?? ['vllm'])
|
|
212
|
+
const { key, seen } = readState(config)
|
|
213
|
+
const knownMissing = new Set()
|
|
214
|
+
const remember = (uuids) => {
|
|
215
|
+
const added = uuids.filter((uuid) => !seen.has(uuid))
|
|
216
|
+
if (added.length === 0) return
|
|
217
|
+
for (const uuid of added) seen.add(uuid)
|
|
218
|
+
appendFileSync(config.warmFile, `${added.join('\n')}\n`, { mode: 0o600 })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ctx.effect(() => {
|
|
222
|
+
const nativeFetch = globalThis.fetch
|
|
223
|
+
const wrapped = createFetchWrapper(nativeFetch, {
|
|
224
|
+
endpoint: config.endpoint,
|
|
225
|
+
key,
|
|
226
|
+
seen,
|
|
227
|
+
remember,
|
|
228
|
+
})
|
|
229
|
+
globalThis.fetch = wrapped
|
|
230
|
+
return () => {
|
|
231
|
+
if (globalThis.fetch === wrapped) globalThis.fetch = nativeFetch
|
|
232
|
+
}
|
|
233
|
+
}, 'vllm-mm-uuid: fetch wrapper')
|
|
234
|
+
|
|
235
|
+
ctx.on('llm/stream', (options, next) => {
|
|
236
|
+
if (!providers.has(options.provider) || options[RECOVERY]) return next()
|
|
237
|
+
return recoverAttachmentFailures(ctx, options, next, knownMissing)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const EMPTY_RESPONSE = {
|
|
2
|
+
message: 'vllm returned reasoning without text or tool calls',
|
|
3
|
+
code: 'EMPTY_RESPONSE',
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const name = 'vllm-reasoning-only-retry'
|
|
7
|
+
export const inject = ['llm']
|
|
8
|
+
|
|
9
|
+
function hasText(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function* classifyReasoningOnly(stream) {
|
|
14
|
+
let reasoning = false
|
|
15
|
+
let output = false
|
|
16
|
+
|
|
17
|
+
for await (const chunk of stream) {
|
|
18
|
+
if (chunk.type === 'reasoning-delta' && hasText(chunk.text)) reasoning = true
|
|
19
|
+
if (chunk.type === 'text-delta' && hasText(chunk.text)) output = true
|
|
20
|
+
if (chunk.type === 'tool-call-delta') output = true
|
|
21
|
+
if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') output = true
|
|
22
|
+
if (chunk.type === 'block-end') {
|
|
23
|
+
if (chunk.block.type === 'reasoning' && hasText(chunk.block.text)) reasoning = true
|
|
24
|
+
if (chunk.block.type === 'text' && hasText(chunk.block.text)) output = true
|
|
25
|
+
if (chunk.block.type === 'tool-call') output = true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (chunk.type === 'finish' && chunk.reason.kind === 'stop' && reasoning && !output) {
|
|
29
|
+
yield { ...chunk, reason: { kind: 'error', failure: EMPTY_RESPONSE } }
|
|
30
|
+
} else {
|
|
31
|
+
yield chunk
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function apply(ctx) {
|
|
37
|
+
ctx.on('llm/stream', (options, next) => {
|
|
38
|
+
if (options.provider !== 'vllm') return next()
|
|
39
|
+
return classifyReasoningOnly(next())
|
|
40
|
+
})
|
|
41
|
+
}
|