@anionex/dsh-vision-toolkit 0.1.45 → 0.1.46
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 +25 -1
- package/README.i18n.yaml +2 -2
- package/README.md +27 -4
- package/README.zh.md +27 -4
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +4 -4
- package/docs/requirements-traceability/README.zh.md +4 -4
- package/lib/client.js +20 -1
- package/lib/client.js.map +1 -1
- package/lib/config.js +147 -5
- package/lib/config.js.map +1 -1
- package/lib/evidence-cache.js +3 -0
- package/lib/evidence-cache.js.map +1 -1
- package/lib/image-input-variants.js +12 -3
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +4 -7
- package/lib/index.js.map +1 -1
- package/lib/runtime.js +40 -5
- package/lib/runtime.js.map +1 -1
- package/lib/settings-compat.js +38 -0
- package/lib/settings-compat.js.map +1 -0
- package/lib/types/client/index.d.ts +7 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +19 -4
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +4 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/settings-compat.d.ts +12 -0
- package/lib/types/settings-compat.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +4 -1
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/upstream.js +88 -29
- package/lib/upstream.js.map +1 -1
- package/package.json +23 -22
- package/src/client/index.tsx +29 -3
- package/src/config.ts +171 -9
- package/src/evidence-cache.ts +3 -0
- package/src/image-input-variants.ts +12 -3
- package/src/index.ts +4 -8
- package/src/runtime.ts +45 -5
- package/src/settings-compat.ts +56 -0
- package/src/upstream.ts +88 -25
package/src/client/index.tsx
CHANGED
|
@@ -43,6 +43,8 @@ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKi
|
|
|
43
43
|
const BUILT_IN_FREE_VISION_BASE_URL = 'https://vision.anionex.me/v1'
|
|
44
44
|
const BUILT_IN_FREE_VISION_CREDENTIAL = 'ANIONEX_FREE_VISION'
|
|
45
45
|
const BUILT_IN_FREE_VISION_MODEL = 'gemini-3.7-flash'
|
|
46
|
+
const MAX_REASONING_EFFORT_LENGTH = 64
|
|
47
|
+
const REASONING_EFFORT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
|
|
46
48
|
const AIHUBMIX_TUTORIAL_URL_EN = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.md'
|
|
47
49
|
const AIHUBMIX_TUTORIAL_URL_ZH = 'https://github.com/Anionex/dsh-vision-toolkit/blob/main/docs/aihubmix-gemini-vision.zh.md'
|
|
48
50
|
|
|
@@ -66,6 +68,9 @@ const en = {
|
|
|
66
68
|
credentialHint: 'The built-in free provider needs no user key. For a custom provider, this is the DSH credential reference used to store its key.',
|
|
67
69
|
model: 'Model',
|
|
68
70
|
protocol: 'API protocol',
|
|
71
|
+
reasoningEffort: 'Vision service reasoning effort',
|
|
72
|
+
reasoningEffortHint: 'Optional. Common values: none, minimal, low, medium, high, xhigh. Supported values and billing depend on the model or proxy; higher effort may increase tokens, latency, and cost. Requests send store:false, but you should still verify the provider\'s data-retention policy.',
|
|
73
|
+
reasoningEffortInvalid: 'Vision service reasoning effort must be at most 64 ASCII letters, digits, dots, underscores, or hyphens.',
|
|
69
74
|
anthropicThinking: 'Anthropic thinking',
|
|
70
75
|
anthropicThinkingHint: 'omit has the broadest compatibility. Use disabled or adaptive only when the selected model documents that mode; restore omit first after HTTP 400.',
|
|
71
76
|
userAgent: 'User-Agent',
|
|
@@ -257,6 +262,9 @@ const zh: Record<LocaleKey, string> = {
|
|
|
257
262
|
credentialHint: '内置免费视觉服务无需用户密钥;切换到自定义服务时,此处是保存其密钥的 DSH 凭据名称。',
|
|
258
263
|
model: '模型名称',
|
|
259
264
|
protocol: 'API 协议',
|
|
265
|
+
reasoningEffort: '视觉服务推理强度',
|
|
266
|
+
reasoningEffortHint: '可选。常见值:none、minimal、low、medium、high、xhigh。实际支持值和计费由模型或代理决定;较高强度可能增加 token、延迟和费用。请求会发送 store:false,但仍应核查服务商的数据保留政策。',
|
|
267
|
+
reasoningEffortInvalid: '视觉服务推理强度最多 64 位,只能包含 ASCII 字母、数字、点、下划线或连字符。',
|
|
260
268
|
anthropicThinking: 'Anthropic thinking',
|
|
261
269
|
anthropicThinkingHint: 'omit 兼容性最好。仅当所选模型明确支持时使用 disabled 或 adaptive;遇到 HTTP 400 时先恢复 omit。',
|
|
262
270
|
userAgent: 'User-Agent',
|
|
@@ -486,9 +494,12 @@ interface SettingsValue {
|
|
|
486
494
|
baseUrl?: string
|
|
487
495
|
credential?: string
|
|
488
496
|
model?: string
|
|
489
|
-
protocol?: 'openai' | 'anthropic'
|
|
497
|
+
protocol?: 'openai' | 'responses' | 'anthropic'
|
|
498
|
+
reasoningEffort?: string
|
|
490
499
|
anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
|
|
491
500
|
userAgent?: string
|
|
501
|
+
headers?: Record<string, string>
|
|
502
|
+
sessionHeaders?: string[]
|
|
492
503
|
}
|
|
493
504
|
language?: 'zh' | 'en'
|
|
494
505
|
timeoutMs?: number
|
|
@@ -1094,9 +1105,12 @@ interface Draft {
|
|
|
1094
1105
|
baseUrl: string
|
|
1095
1106
|
credential: string
|
|
1096
1107
|
model: string
|
|
1097
|
-
protocol: 'openai' | 'anthropic'
|
|
1108
|
+
protocol: 'openai' | 'responses' | 'anthropic'
|
|
1109
|
+
reasoningEffort: string
|
|
1098
1110
|
anthropicThinking: 'omit' | 'disabled' | 'adaptive'
|
|
1099
1111
|
userAgent: string
|
|
1112
|
+
providerHeaders: Record<string, string>
|
|
1113
|
+
providerSessionHeaders: string[]
|
|
1100
1114
|
language: 'zh' | 'en'
|
|
1101
1115
|
timeoutMs: string
|
|
1102
1116
|
maxImageBytes: string
|
|
@@ -1119,8 +1133,11 @@ function draftOf(value: SettingsValue): Draft {
|
|
|
1119
1133
|
credential: value.provider?.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL,
|
|
1120
1134
|
model: value.provider?.model ?? BUILT_IN_FREE_VISION_MODEL,
|
|
1121
1135
|
protocol: value.provider?.protocol ?? 'openai',
|
|
1136
|
+
reasoningEffort: value.provider?.protocol === 'responses' ? value.provider.reasoningEffort ?? '' : '',
|
|
1122
1137
|
anthropicThinking: value.provider?.anthropicThinking ?? 'omit',
|
|
1123
1138
|
userAgent: value.provider?.userAgent ?? DEFAULT_USER_AGENT,
|
|
1139
|
+
providerHeaders: { ...(value.provider?.headers ?? {}) },
|
|
1140
|
+
providerSessionHeaders: [...(value.provider?.sessionHeaders ?? [])],
|
|
1124
1141
|
language: value.language ?? 'zh',
|
|
1125
1142
|
timeoutMs: String(value.timeoutMs ?? 30000),
|
|
1126
1143
|
maxImageBytes: String(value.maxImageBytes ?? 4194304),
|
|
@@ -1155,14 +1172,22 @@ function apiKeyFailure(value: string, t: Translate): string | undefined {
|
|
|
1155
1172
|
}
|
|
1156
1173
|
|
|
1157
1174
|
function valueOf(draft: Draft, t: Translate): SettingsValue {
|
|
1175
|
+
const reasoningEffort = draft.reasoningEffort.trim()
|
|
1176
|
+
if (draft.protocol === 'responses' && reasoningEffort.length > 0
|
|
1177
|
+
&& (reasoningEffort.length > MAX_REASONING_EFFORT_LENGTH || !REASONING_EFFORT_PATTERN.test(reasoningEffort))) {
|
|
1178
|
+
throw new Error(t('reasoningEffortInvalid'))
|
|
1179
|
+
}
|
|
1158
1180
|
return {
|
|
1159
1181
|
provider: {
|
|
1160
1182
|
baseUrl: draft.baseUrl.trim(),
|
|
1161
1183
|
credential: draft.credential.trim(),
|
|
1162
1184
|
model: draft.model.trim(),
|
|
1163
1185
|
protocol: draft.protocol,
|
|
1186
|
+
...(draft.protocol === 'responses' && reasoningEffort.length > 0 ? { reasoningEffort } : {}),
|
|
1164
1187
|
anthropicThinking: draft.anthropicThinking,
|
|
1165
1188
|
userAgent: draft.userAgent.trim(),
|
|
1189
|
+
...(Object.keys(draft.providerHeaders).length === 0 ? {} : { headers: { ...draft.providerHeaders } }),
|
|
1190
|
+
...(draft.providerSessionHeaders.length === 0 ? {} : { sessionHeaders: [...draft.providerSessionHeaders] }),
|
|
1166
1191
|
},
|
|
1167
1192
|
language: draft.language,
|
|
1168
1193
|
timeoutMs: positiveInteger(draft.timeoutMs, t('timeout'), t),
|
|
@@ -1426,7 +1451,8 @@ function LoadedSettings({ controller, t }: SettingsInjected) {
|
|
|
1426
1451
|
<section className="dvt-panel dvt-essential"><div className="dvt-panel-title"><div><h3>{t('provider')}</h3><p>{t('providerHint')}</p></div><span className={`dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`}>{snapshot.credential.configured ? t('configured') : t('missing')}</span></div>
|
|
1427
1452
|
<p className="dvt-tutorial-link"><a href={aihubmixTutorialUrl} target="_blank" rel="noreferrer">{t('aihubmixTutorial')}</a></p>
|
|
1428
1453
|
<div className="dvt-form-grid">
|
|
1429
|
-
<Field label={t('protocol')}><select disabled={!snapshot.writable || busy} value={draft.protocol} onChange={(event) => { update('protocol', event.target.value as 'openai' | 'anthropic') }}><option value="openai">OpenAI Chat Completions</option><option value="anthropic">Anthropic Messages</option></select></Field>
|
|
1454
|
+
<Field label={t('protocol')}><select disabled={!snapshot.writable || busy} value={draft.protocol} onChange={(event) => { update('protocol', event.target.value as 'openai' | 'responses' | 'anthropic') }}><option value="openai">OpenAI Chat Completions</option><option value="responses">OpenAI Responses</option><option value="anthropic">Anthropic Messages</option></select></Field>
|
|
1455
|
+
{draft.protocol === 'responses' ? <Field label={t('reasoningEffort')} hint={t('reasoningEffortHint')}><Input aria-label={t('reasoningEffort')} disabled={!snapshot.writable || busy} placeholder="none / minimal / low / medium / high / xhigh" value={draft.reasoningEffort} onChange={(event) => { update('reasoningEffort', event.target.value); setDraftError(undefined) }} /></Field> : null}
|
|
1430
1456
|
<Field label={t('baseUrl')}><Input disabled={!snapshot.writable || busy} value={draft.baseUrl} onChange={(event) => { update('baseUrl', event.target.value) }} /></Field>
|
|
1431
1457
|
<Field label={t('model')}><Input disabled={!snapshot.writable || busy} value={draft.model} onChange={(event) => { update('model', event.target.value) }} /></Field>
|
|
1432
1458
|
<Field label={t('apiKey')} hint={keyLocked ? t('apiKeyLocked') : snapshot.credential.source === undefined ? t('apiKeyHint') : `${t('apiKeyHint')} ${t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) })}`}><Input aria-label={t('apiKey')} type="password" autoComplete="new-password" disabled={busy || keyLocked} placeholder={snapshot.credential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing')} value={apiKey} onChange={(event) => { setApiKey(event.target.value); setDraftError(undefined) }} /></Field>
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import z from '@deepseek-ai/schemastery'
|
|
10
10
|
import type Schema from '@deepseek-ai/schemastery'
|
|
11
11
|
import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials'
|
|
12
|
-
import
|
|
12
|
+
import SettingsService, { type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
13
13
|
import { VisionToolkitError } from './errors.ts'
|
|
14
14
|
import {
|
|
15
15
|
BUILT_IN_FREE_VISION_BASE_URL,
|
|
@@ -66,12 +66,18 @@ export interface VisionToolkitConfig {
|
|
|
66
66
|
credential?: string
|
|
67
67
|
/** Multimodal model name. */
|
|
68
68
|
model?: string
|
|
69
|
-
/** Vision request protocol: OpenAI Chat Completions or Anthropic Messages. */
|
|
70
|
-
protocol?: 'openai' | 'anthropic'
|
|
69
|
+
/** Vision request protocol: OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages. */
|
|
70
|
+
protocol?: 'openai' | 'responses' | 'anthropic'
|
|
71
|
+
/** Optional provider-specific reasoning effort for OpenAI Responses requests. */
|
|
72
|
+
reasoningEffort?: string
|
|
71
73
|
/** Anthropic thinking field behavior; `omit` leaves model defaults untouched. */
|
|
72
74
|
anthropicThinking?: 'omit' | 'disabled' | 'adaptive'
|
|
73
75
|
/** Outbound User-Agent for provider requests and connection tests. */
|
|
74
76
|
userAgent?: string
|
|
77
|
+
/** Non-secret deployment metadata sent with provider requests. */
|
|
78
|
+
headers?: Record<string, string>
|
|
79
|
+
/** Header names whose values are derived from the current operation identity. */
|
|
80
|
+
sessionHeaders?: string[]
|
|
75
81
|
}
|
|
76
82
|
/** Vision output language (`zh` or `en`). */
|
|
77
83
|
language?: 'zh' | 'en'
|
|
@@ -134,14 +140,17 @@ export interface VisionToolkitConfig {
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
/** Configuration schema with the documented P0 defaults. */
|
|
137
|
-
export const
|
|
143
|
+
export const LegacyConfig: Schema<VisionToolkitConfig> = z.object({
|
|
138
144
|
provider: z.object({
|
|
139
145
|
baseUrl: z.string().default(BUILT_IN_FREE_VISION_BASE_URL),
|
|
140
146
|
credential: z.string().default(BUILT_IN_FREE_VISION_CREDENTIAL),
|
|
141
147
|
model: z.string().default(BUILT_IN_FREE_VISION_MODEL),
|
|
142
|
-
protocol: z.union(['openai', 'anthropic'] as const).default('openai'),
|
|
148
|
+
protocol: z.union(['openai', 'responses', 'anthropic'] as const).default('openai'),
|
|
149
|
+
reasoningEffort: z.string(),
|
|
143
150
|
anthropicThinking: z.union(['omit', 'disabled', 'adaptive'] as const).default('omit'),
|
|
144
151
|
userAgent: z.string().default(DEFAULT_VISION_USER_AGENT),
|
|
152
|
+
headers: z.dict(z.string()).default({}),
|
|
153
|
+
sessionHeaders: z.array(z.string()).default([]),
|
|
145
154
|
}),
|
|
146
155
|
language: z.union(['zh', 'en'] as const).default('zh'),
|
|
147
156
|
timeoutMs: z.number().default(30000),
|
|
@@ -164,15 +173,54 @@ export const Config: Schema<VisionToolkitConfig> = z.object({
|
|
|
164
173
|
}),
|
|
165
174
|
})
|
|
166
175
|
|
|
176
|
+
/** New Settings reads this metadata; older Schemastery has no .volatile() method. */
|
|
177
|
+
export const VolatileConfig: Schema<VisionToolkitConfig> = (() => {
|
|
178
|
+
const schema = new z(LegacyConfig.toJSON()) as Schema<VisionToolkitConfig>
|
|
179
|
+
const markFields = (node: Schema): void => {
|
|
180
|
+
if (node.type === 'object') {
|
|
181
|
+
for (const field of Object.values(node.dict ?? {})) markFields(field as Schema)
|
|
182
|
+
} else {
|
|
183
|
+
node.meta.volatile = true
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
markFields(schema)
|
|
187
|
+
return schema
|
|
188
|
+
})()
|
|
189
|
+
|
|
190
|
+
/** Cordis resolves this export before apply(); select the host's schema dialect here. */
|
|
191
|
+
export const Config: Schema<VisionToolkitConfig> = typeof (SettingsService.prototype as { register?: unknown }).register === 'function'
|
|
192
|
+
? LegacyConfig
|
|
193
|
+
: VolatileConfig
|
|
194
|
+
|
|
195
|
+
/** Resolve Schemastery's live field wrappers into ordinary config data. */
|
|
196
|
+
export function plainVisionConfig(value: VisionToolkitConfig): VisionToolkitConfig {
|
|
197
|
+
const visit = (current: unknown): unknown => {
|
|
198
|
+
if (current !== null && typeof current === 'object'
|
|
199
|
+
&& typeof (current as { get?: unknown }).get === 'function'
|
|
200
|
+
&& Symbol.for('cosmokit.volatile.write') in current) {
|
|
201
|
+
return visit((current as { get(): unknown }).get())
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(current)) return current.map(visit)
|
|
204
|
+
if (current !== null && typeof current === 'object') {
|
|
205
|
+
return Object.fromEntries(Object.entries(current).map(([key, child]) => [key, visit(child)]))
|
|
206
|
+
}
|
|
207
|
+
return current
|
|
208
|
+
}
|
|
209
|
+
return visit(value) as VisionToolkitConfig
|
|
210
|
+
}
|
|
211
|
+
|
|
167
212
|
/** Configuration after static validation, with every default materialized. */
|
|
168
213
|
export interface ResolvedVisionToolkitConfig {
|
|
169
214
|
provider: {
|
|
170
215
|
baseUrl: string
|
|
171
216
|
credential: CredentialRef
|
|
172
217
|
model: string
|
|
173
|
-
protocol: 'openai' | 'anthropic'
|
|
218
|
+
protocol: 'openai' | 'responses' | 'anthropic'
|
|
219
|
+
reasoningEffort?: string
|
|
174
220
|
anthropicThinking: 'omit' | 'disabled' | 'adaptive'
|
|
175
221
|
userAgent: string
|
|
222
|
+
headers: Record<string, string>
|
|
223
|
+
sessionHeaders: string[]
|
|
176
224
|
}
|
|
177
225
|
language: 'zh' | 'en'
|
|
178
226
|
timeoutMs: number
|
|
@@ -199,6 +247,107 @@ const MAX_TIMEOUT_MS = 600000
|
|
|
199
247
|
const MAX_IMAGE_BYTES = 268435456
|
|
200
248
|
const MAX_IMAGE_PIXELS = 268435456
|
|
201
249
|
const MAX_CONCURRENCY = 16
|
|
250
|
+
const MAX_REASONING_EFFORT_LENGTH = 64
|
|
251
|
+
const REASONING_EFFORT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
|
|
252
|
+
const MAX_PROVIDER_HEADER_COUNT = 32
|
|
253
|
+
const MAX_PROVIDER_HEADER_NAME_BYTES = 128
|
|
254
|
+
const MAX_PROVIDER_HEADER_VALUE_BYTES = 4096
|
|
255
|
+
const MAX_PROVIDER_HEADER_BYTES = 16 * 1024
|
|
256
|
+
|
|
257
|
+
/** Headers owned by the client or HTTP transport cannot be taken over by configuration. */
|
|
258
|
+
const RESERVED_PROVIDER_HEADER_NAMES = new Set([
|
|
259
|
+
'authorization',
|
|
260
|
+
'connection',
|
|
261
|
+
'content-length',
|
|
262
|
+
'content-type',
|
|
263
|
+
'cookie',
|
|
264
|
+
'expect',
|
|
265
|
+
'host',
|
|
266
|
+
'keep-alive',
|
|
267
|
+
'proxy-authenticate',
|
|
268
|
+
'proxy-authorization',
|
|
269
|
+
'proxy-connection',
|
|
270
|
+
'set-cookie',
|
|
271
|
+
'te',
|
|
272
|
+
'trailer',
|
|
273
|
+
'transfer-encoding',
|
|
274
|
+
'upgrade',
|
|
275
|
+
'user-agent',
|
|
276
|
+
'www-authenticate',
|
|
277
|
+
'x-api-key',
|
|
278
|
+
'anthropic-version',
|
|
279
|
+
])
|
|
280
|
+
|
|
281
|
+
function headerBytes(value: string): number {
|
|
282
|
+
return Buffer.byteLength(value, 'utf8')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Validate one header without echoing its potentially sensitive value. */
|
|
286
|
+
function assertSendableHeader(field: string, name: string, value: string): void {
|
|
287
|
+
if (headerBytes(name) > MAX_PROVIDER_HEADER_NAME_BYTES) {
|
|
288
|
+
throw new VisionToolkitError('config', `${field} entry "${name}" exceeds the ${MAX_PROVIDER_HEADER_NAME_BYTES}-byte name limit`)
|
|
289
|
+
}
|
|
290
|
+
if (headerBytes(value) > MAX_PROVIDER_HEADER_VALUE_BYTES) {
|
|
291
|
+
throw new VisionToolkitError('config', `${field} entry "${name}" exceeds the ${MAX_PROVIDER_HEADER_VALUE_BYTES}-byte value limit`)
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
new Headers([[name, value]])
|
|
295
|
+
} catch {
|
|
296
|
+
throw new VisionToolkitError(
|
|
297
|
+
'config',
|
|
298
|
+
`${field} entry "${name}" is not a valid HTTP header; use a valid field name and a single-line value`,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeProviderHeaders(provider: NonNullable<VisionToolkitConfig['provider']>): {
|
|
304
|
+
headers: Record<string, string>
|
|
305
|
+
sessionHeaders: string[]
|
|
306
|
+
} {
|
|
307
|
+
const normalized = new Map<string, string>()
|
|
308
|
+
for (const [rawName, value] of Object.entries(provider.headers ?? {})) {
|
|
309
|
+
const name = rawName.trim().toLowerCase()
|
|
310
|
+
if (name.length === 0) throw new VisionToolkitError('config', 'provider.headers has an entry with an empty name')
|
|
311
|
+
if (RESERVED_PROVIDER_HEADER_NAMES.has(name)) {
|
|
312
|
+
throw new VisionToolkitError('config', `provider.headers must not set "${name}"; the client or HTTP transport owns it`)
|
|
313
|
+
}
|
|
314
|
+
if (normalized.has(name)) {
|
|
315
|
+
throw new VisionToolkitError('config', `provider.headers contains the duplicate name "${name}"`)
|
|
316
|
+
}
|
|
317
|
+
assertSendableHeader('provider.headers', name, value)
|
|
318
|
+
normalized.set(name, value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sessions = new Set<string>()
|
|
322
|
+
for (const rawName of provider.sessionHeaders ?? []) {
|
|
323
|
+
const name = rawName.trim().toLowerCase()
|
|
324
|
+
if (name.length === 0) throw new VisionToolkitError('config', 'provider.sessionHeaders has an empty entry')
|
|
325
|
+
if (RESERVED_PROVIDER_HEADER_NAMES.has(name)) {
|
|
326
|
+
throw new VisionToolkitError('config', `provider.sessionHeaders must not name "${name}"; the client or HTTP transport owns it`)
|
|
327
|
+
}
|
|
328
|
+
if (sessions.has(name)) {
|
|
329
|
+
throw new VisionToolkitError('config', `provider.sessionHeaders contains the duplicate name "${name}"`)
|
|
330
|
+
}
|
|
331
|
+
if (normalized.has(name)) {
|
|
332
|
+
throw new VisionToolkitError('config', `provider.headers and provider.sessionHeaders both name "${name}"`)
|
|
333
|
+
}
|
|
334
|
+
assertSendableHeader('provider.sessionHeaders', name, '0'.repeat(32))
|
|
335
|
+
sessions.add(name)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (normalized.size + sessions.size > MAX_PROVIDER_HEADER_COUNT) {
|
|
339
|
+
throw new VisionToolkitError('config', `provider headers must contain at most ${MAX_PROVIDER_HEADER_COUNT} entries in total`)
|
|
340
|
+
}
|
|
341
|
+
const compareNames = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
|
|
342
|
+
const entries = [...normalized.entries()].sort(([left], [right]) => compareNames(left, right))
|
|
343
|
+
const sessionHeaders = [...sessions].sort(compareNames)
|
|
344
|
+
const totalBytes = entries.reduce((total, [name, value]) => total + headerBytes(name) + headerBytes(value) + 4, 0)
|
|
345
|
+
+ sessionHeaders.reduce((total, name) => total + headerBytes(name) + 32 + 4, 0)
|
|
346
|
+
if (totalBytes > MAX_PROVIDER_HEADER_BYTES) {
|
|
347
|
+
throw new VisionToolkitError('config', `provider headers exceed the ${MAX_PROVIDER_HEADER_BYTES}-byte total limit`)
|
|
348
|
+
}
|
|
349
|
+
return { headers: Object.fromEntries(entries), sessionHeaders }
|
|
350
|
+
}
|
|
202
351
|
|
|
203
352
|
/**
|
|
204
353
|
* Validate and normalize a config object (partial inputs receive the same
|
|
@@ -230,8 +379,16 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
|
|
|
230
379
|
throw new VisionToolkitError('config', 'provider.model must not be empty')
|
|
231
380
|
}
|
|
232
381
|
const protocol = provider.protocol ?? 'openai'
|
|
233
|
-
if (protocol !== 'openai' && protocol !== 'anthropic') {
|
|
234
|
-
throw new VisionToolkitError('config', 'provider.protocol must be "openai" or "anthropic"')
|
|
382
|
+
if (protocol !== 'openai' && protocol !== 'responses' && protocol !== 'anthropic') {
|
|
383
|
+
throw new VisionToolkitError('config', 'provider.protocol must be "openai", "responses", or "anthropic"')
|
|
384
|
+
}
|
|
385
|
+
const reasoningEffort = protocol === 'responses' ? provider.reasoningEffort?.trim() : undefined
|
|
386
|
+
if (reasoningEffort !== undefined && reasoningEffort.length > 0
|
|
387
|
+
&& (reasoningEffort.length > MAX_REASONING_EFFORT_LENGTH || !REASONING_EFFORT_PATTERN.test(reasoningEffort))) {
|
|
388
|
+
throw new VisionToolkitError(
|
|
389
|
+
'config',
|
|
390
|
+
`provider.reasoningEffort must be 1-${MAX_REASONING_EFFORT_LENGTH} ASCII letters, digits, dots, underscores, or hyphens`,
|
|
391
|
+
)
|
|
235
392
|
}
|
|
236
393
|
const anthropicThinking = provider.anthropicThinking ?? 'omit'
|
|
237
394
|
if (anthropicThinking !== 'omit' && anthropicThinking !== 'disabled' && anthropicThinking !== 'adaptive') {
|
|
@@ -241,6 +398,7 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
|
|
|
241
398
|
if (userAgent.length === 0) {
|
|
242
399
|
throw new VisionToolkitError('config', 'provider.userAgent must not be empty')
|
|
243
400
|
}
|
|
401
|
+
const { headers, sessionHeaders } = normalizeProviderHeaders(provider)
|
|
244
402
|
const language = config.language ?? 'zh'
|
|
245
403
|
if (language !== 'zh' && language !== 'en') {
|
|
246
404
|
throw new VisionToolkitError('config', 'language must be "zh" or "en"')
|
|
@@ -289,7 +447,11 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
|
|
|
289
447
|
.map(provider => provider.trim())
|
|
290
448
|
.filter(provider => provider.length > 0)
|
|
291
449
|
return {
|
|
292
|
-
provider: {
|
|
450
|
+
provider: {
|
|
451
|
+
baseUrl, credential, model, protocol,
|
|
452
|
+
...(reasoningEffort === undefined || reasoningEffort.length === 0 ? {} : { reasoningEffort }),
|
|
453
|
+
anthropicThinking, userAgent, headers, sessionHeaders,
|
|
454
|
+
},
|
|
293
455
|
language,
|
|
294
456
|
timeoutMs,
|
|
295
457
|
maxImageBytes,
|
package/src/evidence-cache.ts
CHANGED
|
@@ -89,7 +89,10 @@ export function evidenceRuntimeFingerprint(
|
|
|
89
89
|
},
|
|
90
90
|
model: config.provider.model,
|
|
91
91
|
protocol: config.provider.protocol,
|
|
92
|
+
reasoningEffort: config.provider.reasoningEffort ?? null,
|
|
92
93
|
anthropicThinking: config.provider.anthropicThinking,
|
|
94
|
+
headersSha256: hash(JSON.stringify(config.provider.headers)),
|
|
95
|
+
sessionHeaders: config.provider.sessionHeaders,
|
|
93
96
|
sslVerify: sslVerify ?? null,
|
|
94
97
|
userAgent: config.provider.userAgent,
|
|
95
98
|
},
|
|
@@ -391,7 +391,11 @@ async function readImageBlock(
|
|
|
391
391
|
// deadline still bounds it.
|
|
392
392
|
const result = await current.glance(
|
|
393
393
|
{ images: [materialized.file], query },
|
|
394
|
-
{
|
|
394
|
+
{
|
|
395
|
+
signal: new AbortController().signal,
|
|
396
|
+
workspace: materialized.workspace,
|
|
397
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
398
|
+
},
|
|
395
399
|
)
|
|
396
400
|
const answer = result.answer.trim()
|
|
397
401
|
if (answer.length === 0) throw new Error('the Vision Toolkit returned an empty description')
|
|
@@ -445,7 +449,10 @@ export async function convertImagesToEvidence(
|
|
|
445
449
|
for (const message of messages) {
|
|
446
450
|
let hint = ''
|
|
447
451
|
let hintSource: VisionHintSource = 'user'
|
|
448
|
-
|
|
452
|
+
// `source` is declared required but is optional at runtime: the official
|
|
453
|
+
// createUserMessage() never writes one. A user-role message that does not
|
|
454
|
+
// declare its producer is still the current user turn.
|
|
455
|
+
if (message.role === 'user' && (message.source?.kind ?? 'user') === 'user') {
|
|
449
456
|
const itemUserText = userMessageText(message)
|
|
450
457
|
if (itemUserText.length > 0) {
|
|
451
458
|
lastUserText = itemUserText
|
|
@@ -543,7 +550,9 @@ function presentUnderUpstreamRoute(
|
|
|
543
550
|
let presented: Message[] | undefined
|
|
544
551
|
messages.forEach((message, index) => {
|
|
545
552
|
const source = message.source
|
|
546
|
-
|
|
553
|
+
// Without provenance there is no route to prove and nothing to restore;
|
|
554
|
+
// skip it exactly like history produced under another route.
|
|
555
|
+
if (message.role !== 'assistant' || source?.kind !== 'model') return
|
|
547
556
|
if (source.provider !== upstream && source.provider !== variant) return
|
|
548
557
|
let replayState = source.replayState
|
|
549
558
|
if (replayState === undefined) {
|
package/src/index.ts
CHANGED
|
@@ -16,9 +16,8 @@ import type {} from '@deepseek-ai/dsh-session'
|
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-settings'
|
|
17
17
|
import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-access.ts'
|
|
18
18
|
import {
|
|
19
|
-
Config,
|
|
20
|
-
VISION_TOOLKIT_SETTINGS_NAMESPACE,
|
|
21
19
|
prepareWatchedSettingsGeneration,
|
|
20
|
+
plainVisionConfig,
|
|
22
21
|
resolveConfig,
|
|
23
22
|
type ResolvedVisionToolkitConfig,
|
|
24
23
|
type VisionToolkitConfig,
|
|
@@ -28,6 +27,7 @@ import { createPasteTakeoverResolver, installImageInputVariants } from './image-
|
|
|
28
27
|
import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
|
|
29
28
|
import { VISION_SKILLS_SKILL } from './skill.ts'
|
|
30
29
|
import { StorageHistoryStore } from './storage-history.ts'
|
|
30
|
+
import { bindVisionSettings } from './settings-compat.ts'
|
|
31
31
|
import { createVisionTools } from './tools.ts'
|
|
32
32
|
import { PLUGIN_VERSION } from './version.ts'
|
|
33
33
|
import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
|
|
@@ -35,7 +35,7 @@ import { MAX_PASTE_IMAGE_BYTES, PastedImageBackend } from './paste-images.ts'
|
|
|
35
35
|
|
|
36
36
|
export const name = '@anionex/dsh-vision-toolkit'
|
|
37
37
|
|
|
38
|
-
export { Config }
|
|
38
|
+
export { Config } from './config.ts'
|
|
39
39
|
|
|
40
40
|
export const inject = ['tools', 'credentials', 'skills', 'subprocess', 'settings', 'agents', 'sessions']
|
|
41
41
|
|
|
@@ -45,11 +45,7 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
|
|
|
45
45
|
// or Tool becomes visible. The custom Web editor preflights runtime changes
|
|
46
46
|
// before persistence; hand-edited settings still fail loud here or retain
|
|
47
47
|
// the last serving generation when changed live.
|
|
48
|
-
const settings = ctx
|
|
49
|
-
base: config,
|
|
50
|
-
applies: 'live',
|
|
51
|
-
validate: (value) => { resolveConfig(value) },
|
|
52
|
-
})
|
|
48
|
+
const settings = bindVisionSettings(ctx, plainVisionConfig(config))
|
|
53
49
|
const manager = new VisionToolkitRuntimeManager(ctx)
|
|
54
50
|
const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
|
|
55
51
|
const lifecycle = new AbortController()
|
package/src/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @module dsh-vision-toolkit/runtime
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
9
|
+
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'
|
|
10
10
|
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
11
11
|
import { basename, extname, join } from 'node:path'
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
@@ -53,6 +53,26 @@ import {
|
|
|
53
53
|
import { PLUGIN_VERSION } from './version.ts'
|
|
54
54
|
|
|
55
55
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
|
56
|
+
|
|
57
|
+
/** Build one process-scoped provider-header resolver with a fresh, non-exported routing key. */
|
|
58
|
+
export function createVisionProviderHeaderResolver(
|
|
59
|
+
hmacKey: Uint8Array = randomBytes(32),
|
|
60
|
+
): (provider: ResolvedVisionToolkitConfig['provider'], operationKey: string) => Record<string, string> {
|
|
61
|
+
if (hmacKey.byteLength < 32) throw new TypeError('vision provider session HMAC key must contain at least 32 bytes')
|
|
62
|
+
const key = Buffer.from(hmacKey)
|
|
63
|
+
return (provider, operationKey) => {
|
|
64
|
+
if (provider.sessionHeaders.length === 0) return { ...provider.headers }
|
|
65
|
+
const sessionRoutingId = createHmac('sha256', key).update(operationKey).digest('hex').slice(0, 32)
|
|
66
|
+
return {
|
|
67
|
+
...provider.headers,
|
|
68
|
+
...Object.fromEntries(provider.sessionHeaders.map(name => [name, sessionRoutingId])),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Same operation key is stable within this process; a restart creates a fresh routing identity. */
|
|
74
|
+
export const visionProviderHeaders = createVisionProviderHeaderResolver()
|
|
75
|
+
|
|
56
76
|
const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
|
|
57
77
|
const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
|
|
58
78
|
|
|
@@ -506,6 +526,8 @@ interface OperationMetrics {
|
|
|
506
526
|
interface OperationContext {
|
|
507
527
|
signal: AbortSignal
|
|
508
528
|
metrics: OperationMetrics
|
|
529
|
+
/** Session id when available, otherwise the workspace key used by the concurrency gate. */
|
|
530
|
+
operationKey: string
|
|
509
531
|
}
|
|
510
532
|
|
|
511
533
|
const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/
|
|
@@ -820,7 +842,7 @@ export class VisionToolkitRuntime {
|
|
|
820
842
|
const executionDeadline = createDeadline(options.signal, timeoutMs)
|
|
821
843
|
try {
|
|
822
844
|
if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
|
|
823
|
-
const value = await action({ signal: executionDeadline.signal, metrics })
|
|
845
|
+
const value = await action({ signal: executionDeadline.signal, metrics, operationKey: semaphore.key })
|
|
824
846
|
if (executionDeadline.signal.aborted) throw this.operationError(tool, undefined, executionDeadline)
|
|
825
847
|
this.ctx.logger.info(
|
|
826
848
|
'dsh-vision-toolkit tool=%s outcome=ok totalMs=%d queueMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s',
|
|
@@ -877,7 +899,14 @@ export class VisionToolkitRuntime {
|
|
|
877
899
|
VISION_API_KEY: resolved.value,
|
|
878
900
|
VISION_BASE_URL: this.config.provider.baseUrl,
|
|
879
901
|
VISION_MODEL: this.config.provider.model,
|
|
880
|
-
VISION_API_PROTOCOL: this.config.provider.protocol === 'anthropic'
|
|
902
|
+
VISION_API_PROTOCOL: this.config.provider.protocol === 'anthropic'
|
|
903
|
+
? 'anthropic'
|
|
904
|
+
: this.config.provider.protocol === 'responses'
|
|
905
|
+
? 'responses'
|
|
906
|
+
: 'chat_completions',
|
|
907
|
+
...(this.config.provider.protocol === 'responses' && this.config.provider.reasoningEffort !== undefined
|
|
908
|
+
? { VISION_REASONING_EFFORT: this.config.provider.reasoningEffort }
|
|
909
|
+
: {}),
|
|
881
910
|
VISION_ANTHROPIC_THINKING: this.config.provider.anthropicThinking,
|
|
882
911
|
...(sslVerify === undefined ? {} : { VISION_SSL_VERIFY: sslVerify }),
|
|
883
912
|
VISION_USER_AGENT: this.config.provider.userAgent,
|
|
@@ -1159,6 +1188,7 @@ export class VisionToolkitRuntime {
|
|
|
1159
1188
|
baseUrl: env.VISION_BASE_URL,
|
|
1160
1189
|
model: env.VISION_MODEL,
|
|
1161
1190
|
protocol: env.VISION_API_PROTOCOL,
|
|
1191
|
+
reasoningEffort: env.VISION_REASONING_EFFORT ?? null,
|
|
1162
1192
|
anthropicThinking: env.VISION_ANTHROPIC_THINKING,
|
|
1163
1193
|
sslVerify: env.VISION_SSL_VERIFY ?? null,
|
|
1164
1194
|
userAgent: env.VISION_USER_AGENT,
|
|
@@ -1176,16 +1206,25 @@ export class VisionToolkitRuntime {
|
|
|
1176
1206
|
): Promise<UpstreamRunResult> {
|
|
1177
1207
|
const started = Date.now()
|
|
1178
1208
|
if (env !== undefined) operation.metrics.usedVisionService = true
|
|
1209
|
+
const headers = env === undefined ? undefined : visionProviderHeaders(this.config.provider, operation.operationKey)
|
|
1179
1210
|
const result = await this.adapter.run(tool, args, {
|
|
1180
1211
|
signal: operation.signal,
|
|
1181
|
-
...(env === undefined
|
|
1212
|
+
...(env === undefined
|
|
1213
|
+
? {}
|
|
1214
|
+
: {
|
|
1215
|
+
env: headers !== undefined && Object.keys(headers).length > 0
|
|
1216
|
+
? { ...env, DSH_VISION_EXTRA_HEADERS: JSON.stringify(headers) }
|
|
1217
|
+
: env,
|
|
1218
|
+
}),
|
|
1182
1219
|
})
|
|
1183
1220
|
operation.metrics.upstreamMs += Date.now() - started
|
|
1184
1221
|
if (result.outcome.exitCode !== 0) {
|
|
1185
1222
|
throw this.adapter.classifyFailure(tool, result, {
|
|
1186
1223
|
timedOut: false,
|
|
1187
1224
|
cancelled: operation.signal.aborted,
|
|
1188
|
-
...(env === undefined
|
|
1225
|
+
...(env === undefined
|
|
1226
|
+
? {}
|
|
1227
|
+
: { secrets: [env.VISION_API_KEY, ...Object.values(headers ?? {})] }),
|
|
1189
1228
|
})
|
|
1190
1229
|
}
|
|
1191
1230
|
if (result.stdoutTruncated || result.stderrTruncated) {
|
|
@@ -2109,6 +2148,7 @@ export class VisionToolkitRuntime {
|
|
|
2109
2148
|
const headers: Record<string, string> = {
|
|
2110
2149
|
Accept: 'application/json',
|
|
2111
2150
|
'User-Agent': this.config.provider.userAgent,
|
|
2151
|
+
...visionProviderHeaders(this.config.provider, operation.operationKey),
|
|
2112
2152
|
}
|
|
2113
2153
|
if (this.config.provider.protocol === 'anthropic') {
|
|
2114
2154
|
headers['x-api-key'] = resolvedCredential.value
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { LegacyConfig, VISION_TOOLKIT_SETTINGS_NAMESPACE, resolveConfig, type VisionToolkitConfig } from './config.ts'
|
|
3
|
+
|
|
4
|
+
type ChangeListener = (next: VisionToolkitConfig, previous: VisionToolkitConfig) => void | Promise<void>
|
|
5
|
+
|
|
6
|
+
export interface VisionSettingsBinding {
|
|
7
|
+
get(): VisionToolkitConfig
|
|
8
|
+
update(patch: Partial<VisionToolkitConfig>): Promise<void>
|
|
9
|
+
watch(listener: ChangeListener): () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bridge the registration API through 0.1.6 and the Config projection in 0.1.7. */
|
|
13
|
+
export function bindVisionSettings(ctx: Context, base: VisionToolkitConfig): VisionSettingsBinding {
|
|
14
|
+
const service = ctx.settings as unknown as {
|
|
15
|
+
register?: (
|
|
16
|
+
ns: string,
|
|
17
|
+
schema: typeof LegacyConfig,
|
|
18
|
+
options: { base: VisionToolkitConfig; applies: 'live'; validate: (value: VisionToolkitConfig) => void },
|
|
19
|
+
) => VisionSettingsBinding
|
|
20
|
+
describe(): Array<{ ns: string; value: unknown }>
|
|
21
|
+
update(ns: string, patch: object): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
if (typeof service.register === 'function') {
|
|
24
|
+
return service.register(VISION_TOOLKIT_SETTINGS_NAMESPACE, LegacyConfig, {
|
|
25
|
+
base,
|
|
26
|
+
applies: 'live',
|
|
27
|
+
validate: value => { resolveConfig(value) },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// New Settings reads the Loader entry's exported Config. While apply() is
|
|
32
|
+
// running the entry is not yet active, so describe() cannot see it; the
|
|
33
|
+
// Loader-provided config is the initial value in that interval.
|
|
34
|
+
let latest = base
|
|
35
|
+
const read = (): VisionToolkitConfig => {
|
|
36
|
+
const row = service.describe().find(item => item.ns === VISION_TOOLKIT_SETTINGS_NAMESPACE)
|
|
37
|
+
return (row?.value as VisionToolkitConfig | undefined) ?? latest
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
get: read,
|
|
41
|
+
update: patch => service.update(VISION_TOOLKIT_SETTINGS_NAMESPACE, patch),
|
|
42
|
+
watch(listener) {
|
|
43
|
+
const events = ctx as unknown as {
|
|
44
|
+
on(event: 'settings/document-updated', handler: (ns: string) => void): () => void
|
|
45
|
+
}
|
|
46
|
+
return events.on('settings/document-updated', ns => {
|
|
47
|
+
if (ns !== VISION_TOOLKIT_SETTINGS_NAMESPACE) return
|
|
48
|
+
const next = read()
|
|
49
|
+
if (JSON.stringify(next) === JSON.stringify(latest)) return
|
|
50
|
+
const previous = latest
|
|
51
|
+
latest = next
|
|
52
|
+
void listener(next, previous)
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|