dsh-context-compression-improved 0.1.1 → 0.2.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/.gitattributes +1 -0
- package/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.ja.md +39 -0
- package/CHANGELOG.ko.md +39 -0
- package/CHANGELOG.md +135 -0
- package/CHANGELOG.zh.md +39 -0
- package/CONTRIBUTING.md +22 -0
- package/README.ja.md +104 -0
- package/README.ko.md +103 -0
- package/README.md +89 -12
- package/README.zh.md +87 -12
- package/SECURITY.md +18 -0
- package/THIRD_PARTY_NOTICES.md +7 -31
- package/docs/installation.ja.md +76 -0
- package/docs/installation.ko.md +76 -0
- package/docs/installation.md +76 -0
- package/docs/installation.zh.md +76 -0
- package/docs/repair-log.md +582 -0
- package/eslint.config.js +30 -0
- package/package.json +84 -81
- package/packages/selector/LICENSE +21 -0
- package/packages/selector/README.md +26 -0
- package/packages/selector/README.zh.md +26 -0
- package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
- package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
- package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
- package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
- package/packages/selector/lib/style.css +308 -0
- package/packages/selector/package.json +115 -0
- package/{screenshots.json → packages/selector/screenshots.json} +6 -6
- package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
- package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
- package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
- package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
- package/packages/selector/src/client/EstimatorControls.tsx +281 -0
- package/packages/selector/src/client/decode.ts +49 -0
- package/packages/selector/src/client/index.ts +111 -0
- package/packages/selector/src/client/locales.ts +198 -0
- package/packages/selector/src/client/preset-options.ts +70 -0
- package/packages/selector/src/client/settings-section.tsx +126 -0
- package/packages/selector/src/css-modules.d.ts +6 -0
- package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
- package/packages/selector/src/estimator-catalog.ts +104 -0
- package/packages/selector/src/index.ts +327 -0
- package/packages/selector/src/invariant.ts +113 -0
- package/packages/selector/src/preset-overlay.ts +567 -0
- package/packages/selector/src/profiles.ts +342 -0
- package/packages/selector/src/pruner/content.ts +188 -0
- package/packages/selector/src/pruner/session.ts +94 -0
- package/packages/selector/src/pruner/state.ts +43 -0
- package/packages/selector/src/pruner/tuning.ts +23 -0
- package/packages/selector/src/pruner/types.ts +60 -0
- package/packages/selector/src/pruner.ts +2144 -0
- package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
- package/packages/selector/src/runtime/audit.ts +215 -0
- package/packages/selector/src/runtime/config.ts +613 -0
- package/packages/selector/src/runtime/custom-policy.ts +278 -0
- package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
- package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
- package/packages/selector/src/runtime/measurement.ts +403 -0
- package/packages/selector/src/runtime/reducers.ts +656 -0
- package/packages/selector/src/runtime/retrieve.ts +457 -0
- package/packages/selector/src/runtime/session-events.ts +17 -0
- package/packages/selector/src/runtime/tail-trim.ts +166 -0
- package/packages/selector/src/runtime/token-count.ts +72 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
- package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
- package/packages/selector/src/runtime/types.ts +309 -0
- package/packages/selector/src/runtime/value.ts +48 -0
- package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
- package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
- package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
- package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
- package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
- package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
- package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
- package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
- package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
- package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
- package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
- package/packages/selector/tests/profiles.client.spec.tsx +434 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
- package/packages/selector/tests/runtime/audit.spec.ts +129 -0
- package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
- package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
- package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
- package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
- package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
- package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
- package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
- package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
- package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
- package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
- package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
- package/packages/selector/tests/runtime/value.spec.ts +23 -0
- package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
- package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
- package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
- package/packages/selector/tests/support/mock-adapter.ts +37 -0
- package/packages/selector/tests/support/ui-primitives.tsx +34 -0
- package/packages/selector/tsconfig.json +11 -0
- package/packages/selector/tsdown.client.config.ts +102 -0
- package/packages/selector/tsdown.config.ts +20 -0
- package/pnpm-workspace.yaml +19 -0
- package/scripts/capture-profile-baseline.ts +80 -0
- package/scripts/generate-tokenizer-fixtures.py +81 -0
- package/scripts/generate-vision-fixtures.py +208 -0
- package/scripts/packed-components-smoke.ts +713 -0
- package/scripts/packed-install-e2e.ts +1072 -0
- package/scripts/verify-release.ts +300 -0
- package/tests/TEST_INVENTORY.md +42 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +7 -0
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tests.json +15 -0
- package/vitest.built.config.ts +9 -0
- package/vitest.config.ts +43 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
- /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
- /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
- /package/{lib → packages/selector/lib}/client.d.ts +0 -0
- /package/{lib → packages/selector/lib}/client.js +0 -0
- /package/{lib → packages/selector/lib}/config.js +0 -0
- /package/{lib → packages/selector/lib}/index.d.ts +0 -0
- /package/{lib → packages/selector/lib}/index.js +0 -0
- /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
- /package/{lib → packages/selector/lib}/invariant.js +0 -0
- /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
- /package/{lib → packages/selector/lib}/pruner.js +0 -0
- /package/{lib → packages/selector/lib}/tail-trim.js +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-wise writes for the `presetOptions` section of the
|
|
3
|
+
* context-compression settings namespace.
|
|
4
|
+
*
|
|
5
|
+
* `settingsScope.set(field, value)` replaces the value AT that field, so
|
|
6
|
+
* writing the section root with a patch deleted every sibling: choosing a
|
|
7
|
+
* provider after choosing a channel erased `estimatorMode` and silently turned
|
|
8
|
+
* the estimator back off, and the stored document kept exactly the last field
|
|
9
|
+
* the user touched. The client reads the complete section (this namespace
|
|
10
|
+
* declares no secret fields), so a patch is applied over the current document
|
|
11
|
+
* and the merged result is what gets written.
|
|
12
|
+
*
|
|
13
|
+
* A patch member set to `undefined` means "clear this field" (it then
|
|
14
|
+
* re-inherits the preset default); absent members stay untouched.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PresetOptionsSettings } from '../profiles.ts'
|
|
18
|
+
|
|
19
|
+
/** Every field a patch may address, in the schema's own order. */
|
|
20
|
+
const PRESET_OPTION_KEYS = [
|
|
21
|
+
'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
|
|
22
|
+
'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
/** One partial edit of `presetOptions`; `undefined` clears the named field. */
|
|
26
|
+
export type PresetOptionsPatch = {
|
|
27
|
+
readonly [K in keyof PresetOptionsSettings]?: PresetOptionsSettings[K] | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Apply one patch over the stored section.
|
|
32
|
+
*
|
|
33
|
+
* @param current - the decoded `presetOptions` section, when one is stored.
|
|
34
|
+
* @param patch - the fields to write or clear.
|
|
35
|
+
* @returns the complete section to store.
|
|
36
|
+
*/
|
|
37
|
+
export function mergePresetOptionsPatch(
|
|
38
|
+
current: PresetOptionsSettings | undefined,
|
|
39
|
+
patch: PresetOptionsPatch,
|
|
40
|
+
): PresetOptionsSettings {
|
|
41
|
+
const source = current as Record<string, unknown> | undefined
|
|
42
|
+
const merged: Record<string, unknown> = {}
|
|
43
|
+
for (const key of PRESET_OPTION_KEYS) {
|
|
44
|
+
const stored = source?.[key]
|
|
45
|
+
if (stored !== undefined) merged[key] = stored
|
|
46
|
+
}
|
|
47
|
+
for (const key of Object.keys(patch)) {
|
|
48
|
+
const value = (patch as Record<string, unknown>)[key]
|
|
49
|
+
if (value === undefined) delete merged[key]
|
|
50
|
+
else merged[key] = value
|
|
51
|
+
}
|
|
52
|
+
return merged as PresetOptionsSettings
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compare two sections field by field, so an unchanged patch neither rewrites
|
|
57
|
+
* the document nor reports a save the Host never committed.
|
|
58
|
+
*
|
|
59
|
+
* @param left - one section (or none).
|
|
60
|
+
* @param right - the other section.
|
|
61
|
+
* @returns whether every known field holds the same value.
|
|
62
|
+
*/
|
|
63
|
+
export function presetOptionsEqual(
|
|
64
|
+
left: PresetOptionsSettings | undefined,
|
|
65
|
+
right: PresetOptionsSettings | undefined,
|
|
66
|
+
): boolean {
|
|
67
|
+
const a = left as Record<string, unknown> | undefined
|
|
68
|
+
const b = right as Record<string, unknown> | undefined
|
|
69
|
+
return PRESET_OPTION_KEYS.every(key => a?.[key] === b?.[key])
|
|
70
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-page Settings surface backed by the same durable selector state.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from CompressionProfileSelector.tsx to reduce god-module size.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-context-compression-improved/client/settings-section
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import css from './CompressionProfileSelector.module.css'
|
|
11
|
+
import {
|
|
12
|
+
AUTO_COMPACT_THRESHOLD_LIMITS,
|
|
13
|
+
COMPRESSION_PROFILES,
|
|
14
|
+
type CompressionProfile,
|
|
15
|
+
type CustomCompressionPolicyV3,
|
|
16
|
+
} from '../profiles.ts'
|
|
17
|
+
import type { CompressionProfileSelectorProps } from './CompressionProfileSelector.tsx'
|
|
18
|
+
import { AutoCompactThresholdControls, CodeSkeletonControls } from './CompressionProfileControls.tsx'
|
|
19
|
+
import { EstimatorControls, EstimatorInactiveNotice } from './EstimatorControls.tsx'
|
|
20
|
+
import { CustomPolicyEditor, editableCustom } from './CustomPolicyEditor.tsx'
|
|
21
|
+
|
|
22
|
+
/** Full-page Settings surface backed by the same durable selector state. */
|
|
23
|
+
export function ContextCompressionSettingsSection(props: CompressionProfileSelectorProps) {
|
|
24
|
+
return <SettingsCompressionProfileControls {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SettingsCompressionProfileControls({
|
|
28
|
+
useCompression, useSessions, select, saveCustom, resetCustom, saveAutoCompact, saveCodeSkeleton,
|
|
29
|
+
savePresetOptions, t,
|
|
30
|
+
}: CompressionProfileSelectorProps) {
|
|
31
|
+
const state = useCompression(snapshot => snapshot)
|
|
32
|
+
const currentPreset = useSessions((sessions) => {
|
|
33
|
+
const current = sessions.current
|
|
34
|
+
return current === undefined ? undefined : sessions.byId[current]?.agentPreset
|
|
35
|
+
})
|
|
36
|
+
const selectorAvailable = currentPreset !== 'minimal'
|
|
37
|
+
const [saving, setSaving] = useState(false)
|
|
38
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
39
|
+
const [draft, setDraft] = useState<CustomCompressionPolicyV3 | null>(null)
|
|
40
|
+
const current = state.value?.profile ?? 'balanced'
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const custom = state.value?.custom
|
|
43
|
+
setDraft(current === 'custom' && custom !== undefined ? editableCustom(custom) : null)
|
|
44
|
+
}, [current, state.value?.custom])
|
|
45
|
+
if (state.status === 'unavailable') return null
|
|
46
|
+
|
|
47
|
+
const busy = state.status === 'loading' || saving
|
|
48
|
+
const selectProfile = (profile: CompressionProfile): void => {
|
|
49
|
+
if (!selectorAvailable || !state.writable || profile === current) return
|
|
50
|
+
setSaveError(null)
|
|
51
|
+
setSaving(true)
|
|
52
|
+
void select(profile).then(
|
|
53
|
+
() => { setSaving(false) },
|
|
54
|
+
(error: unknown) => {
|
|
55
|
+
setSaving(false)
|
|
56
|
+
setSaveError(error instanceof Error && error.message !== '' ? error.message : t('status.saveFailed'))
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const settle = (operation: () => Promise<void>): void => {
|
|
61
|
+
setSaveError(null)
|
|
62
|
+
setSaving(true)
|
|
63
|
+
void operation().then(
|
|
64
|
+
() => { setSaving(false) },
|
|
65
|
+
(error: unknown) => {
|
|
66
|
+
setSaving(false)
|
|
67
|
+
setSaveError(error instanceof Error && error.message !== '' ? error.message : t('status.saveFailed'))
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<section className={css.settingsSection}>
|
|
74
|
+
<h2 className={css.settingsTitle}>{t('settings.title')}</h2>
|
|
75
|
+
<p className={css.settingsDescription}>{t('settings.description')}</p>
|
|
76
|
+
{selectorAvailable ? (
|
|
77
|
+
<div className={css.profileGrid} aria-label={t('label')}>
|
|
78
|
+
{COMPRESSION_PROFILES.map((profile) => {
|
|
79
|
+
const selected = profile === current
|
|
80
|
+
return (
|
|
81
|
+
<button key={profile} type="button" className={css.profileCard} aria-pressed={selected}
|
|
82
|
+
disabled={busy || !state.writable} onClick={() => { selectProfile(profile) }}>
|
|
83
|
+
<span className={css.profileCardTop}>
|
|
84
|
+
<span className={css.profileCardTitle}>{t(`profile.${profile}`)}</span>
|
|
85
|
+
{selected ? <span className={css.profileCurrent}>{t('profile.current')}</span> : null}
|
|
86
|
+
</span>
|
|
87
|
+
<span className={css.profileCardDetail}>{t(`detail.${profile}`)}</span>
|
|
88
|
+
</button>
|
|
89
|
+
)
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
) : <div className={css.unavailable} role="status">{t('status.minimalUnavailable')}</div>}
|
|
93
|
+
<AutoCompactThresholdControls
|
|
94
|
+
value={state.value?.autoCompact?.thresholdPercent ?? AUTO_COMPACT_THRESHOLD_LIMITS.default}
|
|
95
|
+
disabled={busy || !state.writable || !selectorAvailable}
|
|
96
|
+
save={saveAutoCompact}
|
|
97
|
+
settle={settle}
|
|
98
|
+
t={t}
|
|
99
|
+
/>
|
|
100
|
+
<CodeSkeletonControls
|
|
101
|
+
value={state.value?.codeSkeleton?.enabled ?? false}
|
|
102
|
+
disabled={busy || !state.writable || !selectorAvailable}
|
|
103
|
+
save={saveCodeSkeleton}
|
|
104
|
+
settle={settle}
|
|
105
|
+
t={t}
|
|
106
|
+
/>
|
|
107
|
+
{current !== 'tokenpilot-inspired' ? (
|
|
108
|
+
<EstimatorInactiveNotice profile={t(`profile.${current}`)} t={t} />
|
|
109
|
+
) : (
|
|
110
|
+
<EstimatorControls
|
|
111
|
+
options={state.value?.presetOptions ?? {}}
|
|
112
|
+
disabled={busy || !state.writable || !selectorAvailable}
|
|
113
|
+
save={savePresetOptions}
|
|
114
|
+
settle={settle}
|
|
115
|
+
t={t}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
<div className={css.pricing}>{t('pricing.disclosure')}</div>
|
|
119
|
+
{current !== 'custom' || draft === null || !selectorAvailable ? null : (
|
|
120
|
+
<CustomPolicyEditor value={draft} disabled={busy || !state.writable} setValue={setDraft}
|
|
121
|
+
save={() => saveCustom(structuredClone(draft))} reset={resetCustom} settle={settle} t={t} />
|
|
122
|
+
)}
|
|
123
|
+
{saveError === null ? null : <div className={css.error} role="alert">{saveError}</div>}
|
|
124
|
+
</section>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Offline DeepSeek tokenizers backed by pinned official Hugging Face assets. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { Tokenizer } from '@huggingface/tokenizers'
|
|
6
|
+
import type { ExactTokenizerTokenCount } from './runtime/token-count.ts'
|
|
7
|
+
|
|
8
|
+
const TOKENIZER_ID = 'deepseek-ai/DeepSeek-V4-Pro'
|
|
9
|
+
const TOKENIZER_REVISION = '0e1a0e5e52aea73055f50fef6f2423db370265b6'
|
|
10
|
+
const TOKENIZER_SHA256 = '8f9f37ca37fdc4f5fd36d5cf4d3b0e8392edb4e894fd10cc0d70b4957c8633cf'
|
|
11
|
+
const CONFIG_SHA256 = '6ac8c8dc065ed118161d02dd532749ae3f52c243deac27872134fae2f50d8547'
|
|
12
|
+
const VISION_TOKENIZER_ID = 'deepseek-ai/DeepSeek-V4-Flash-Vision-Exp'
|
|
13
|
+
const VISION_TOKENIZER_REVISION = '6821d6ad3681a4b137b066b76094fa82ebd0a380'
|
|
14
|
+
const VISION_TOKENIZER_SHA256 = 'c90dfa01249db1be4245780a052ede752e1361c612ac6d08e2bdada7d599476b'
|
|
15
|
+
const VISION_CONFIG_SHA256 = '6ac8c8dc065ed118161d02dd532749ae3f52c243deac27872134fae2f50d8547'
|
|
16
|
+
|
|
17
|
+
/** Auditable origin and compatibility mapping for one bundled tokenizer. */
|
|
18
|
+
export interface DeepSeekTokenizerArtifactOrigin {
|
|
19
|
+
/** Hugging Face repository the assets were pinned from. */
|
|
20
|
+
readonly repository: string
|
|
21
|
+
/** Immutable commit revision the assets were pinned from. */
|
|
22
|
+
readonly revision: string
|
|
23
|
+
/** Upstream license of the repository. */
|
|
24
|
+
readonly license: string
|
|
25
|
+
/** Exact API wire model ids served by this artifact. */
|
|
26
|
+
readonly modelIds: readonly string[]
|
|
27
|
+
readonly tokenizerSha256: string
|
|
28
|
+
readonly tokenizerConfigSha256: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Auditable origin and compatibility mapping for the bundled V4 Pro tokenizer. */
|
|
32
|
+
export const DEEPSEEK_V4_TOKENIZER_ARTIFACT: DeepSeekTokenizerArtifactOrigin = Object.freeze({
|
|
33
|
+
repository: TOKENIZER_ID,
|
|
34
|
+
revision: TOKENIZER_REVISION,
|
|
35
|
+
license: 'MIT',
|
|
36
|
+
tokenizerSha256: TOKENIZER_SHA256,
|
|
37
|
+
tokenizerConfigSha256: CONFIG_SHA256,
|
|
38
|
+
modelIds: Object.freeze(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Auditable origin and compatibility mapping for the bundled V4 Flash Vision
|
|
43
|
+
* tokenizer. The vision repository ships a distinct `tokenizer.json` (it adds
|
|
44
|
+
* the `<|deepseek_image|>` special token), so the vision model must never be
|
|
45
|
+
* mapped onto the V4 Pro tokenizer as an alias.
|
|
46
|
+
*/
|
|
47
|
+
export const DEEPSEEK_VISION_TOKENIZER_ARTIFACT: DeepSeekTokenizerArtifactOrigin = Object.freeze({
|
|
48
|
+
repository: VISION_TOKENIZER_ID,
|
|
49
|
+
revision: VISION_TOKENIZER_REVISION,
|
|
50
|
+
license: 'MIT',
|
|
51
|
+
tokenizerSha256: VISION_TOKENIZER_SHA256,
|
|
52
|
+
tokenizerConfigSha256: VISION_CONFIG_SHA256,
|
|
53
|
+
modelIds: Object.freeze(['deepseek-v4-flash-vision-exp']),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Synchronous exact counter backed only by the verified local V4 artifacts. */
|
|
57
|
+
export interface DeepSeekV4TextTokenizer {
|
|
58
|
+
/** Count one fully determined text value with the official tokenizer. */
|
|
59
|
+
countText(text: string): ExactTokenizerTokenCount
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Integrity tuple for one tokenizer JSON asset. @internal */
|
|
63
|
+
export interface DeepSeekV4TokenizerAssetDescriptor {
|
|
64
|
+
readonly bytes: number
|
|
65
|
+
readonly sha256: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Injectable integrity manifest used by negative loader tests. @internal */
|
|
69
|
+
export interface DeepSeekV4TokenizerAssetIntegrity {
|
|
70
|
+
readonly tokenizer: DeepSeekV4TokenizerAssetDescriptor
|
|
71
|
+
readonly config: DeepSeekV4TokenizerAssetDescriptor
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface TokenizerArtifact {
|
|
75
|
+
readonly origin: DeepSeekTokenizerArtifactOrigin
|
|
76
|
+
readonly assetRoot: URL
|
|
77
|
+
readonly integrity: DeepSeekV4TokenizerAssetIntegrity
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every bundled artifact is registered with independent asset roots, integrity
|
|
82
|
+
* manifests, and cache entries: one corrupted artifact must never disable the
|
|
83
|
+
* tokenizer serving the other model family.
|
|
84
|
+
*
|
|
85
|
+
* This module deliberately sits at the package's `src/` root rather than in
|
|
86
|
+
* `src/runtime/`: the build flattens the runtime into `lib/*.js`, so only a
|
|
87
|
+
* module one level below the package root resolves `../assets/` identically
|
|
88
|
+
* from source and from the published artifact.
|
|
89
|
+
*/
|
|
90
|
+
const ARTIFACTS: readonly TokenizerArtifact[] = Object.freeze([
|
|
91
|
+
Object.freeze({
|
|
92
|
+
origin: DEEPSEEK_V4_TOKENIZER_ARTIFACT,
|
|
93
|
+
assetRoot: new URL('../assets/deepseek-v4/', import.meta.url),
|
|
94
|
+
integrity: Object.freeze({
|
|
95
|
+
tokenizer: Object.freeze({ bytes: 6_367_146, sha256: TOKENIZER_SHA256 }),
|
|
96
|
+
config: Object.freeze({ bytes: 801, sha256: CONFIG_SHA256 }),
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
Object.freeze({
|
|
100
|
+
origin: DEEPSEEK_VISION_TOKENIZER_ARTIFACT,
|
|
101
|
+
assetRoot: new URL('../assets/deepseek-v4-vision-exp/', import.meta.url),
|
|
102
|
+
integrity: Object.freeze({
|
|
103
|
+
tokenizer: Object.freeze({ bytes: 6_367_257, sha256: VISION_TOKENIZER_SHA256 }),
|
|
104
|
+
config: Object.freeze({ bytes: 801, sha256: VISION_CONFIG_SHA256 }),
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
interface ArtifactCacheEntry {
|
|
110
|
+
readonly tokenizer?: DeepSeekV4TextTokenizer
|
|
111
|
+
readonly failure?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const registryCache = new Map<DeepSeekTokenizerArtifactOrigin, ArtifactCacheEntry>()
|
|
115
|
+
|
|
116
|
+
function artifactForModel(modelId: string): TokenizerArtifact | undefined {
|
|
117
|
+
return ARTIFACTS.find(artifact => (artifact.origin.modelIds as readonly string[]).includes(modelId))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the shared offline tokenizer for one compatible API model.
|
|
122
|
+
* Unknown models and a cached asset/runtime failure return `undefined`; callers
|
|
123
|
+
* must report unavailable instead of manufacturing a character estimate.
|
|
124
|
+
* @param modelId - exact DeepSeek API wire model id.
|
|
125
|
+
* @returns the shared verified tokenizer, or undefined when unsupported/unavailable.
|
|
126
|
+
*/
|
|
127
|
+
export function deepSeekV4TokenizerForModel(modelId: string): DeepSeekV4TextTokenizer | undefined {
|
|
128
|
+
const artifact = artifactForModel(modelId)
|
|
129
|
+
if (artifact === undefined) return undefined
|
|
130
|
+
const cached = registryCache.get(artifact.origin)
|
|
131
|
+
if (cached !== undefined) return cached.tokenizer
|
|
132
|
+
let entry: ArtifactCacheEntry
|
|
133
|
+
try {
|
|
134
|
+
const tokenizer = createDeepSeekV4TokenizerFromAssets(artifact.assetRoot, artifact.integrity, artifact.origin)
|
|
135
|
+
entry = { tokenizer }
|
|
136
|
+
} catch (error: unknown) {
|
|
137
|
+
entry = { failure: error instanceof Error ? error.message : String(error) }
|
|
138
|
+
}
|
|
139
|
+
registryCache.set(artifact.origin, entry)
|
|
140
|
+
return entry.tokenizer
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read the cached initialization diagnostic for provider registration.
|
|
145
|
+
* @param modelId - optional exact model id; defaults to the V4 Pro artifact.
|
|
146
|
+
* @returns the first initialization failure for that artifact, or undefined.
|
|
147
|
+
*/
|
|
148
|
+
export function deepSeekV4TokenizerFailureReason(modelId?: string): string | undefined {
|
|
149
|
+
const artifact = modelId === undefined ? ARTIFACTS[0] : artifactForModel(modelId)
|
|
150
|
+
if (artifact === undefined) return undefined
|
|
151
|
+
return registryCache.get(artifact.origin)?.failure
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Complete pinned artifact inventory, in stable registration order. */
|
|
155
|
+
export function deepSeekTokenizerArtifacts(): readonly DeepSeekTokenizerArtifactOrigin[] {
|
|
156
|
+
return ARTIFACTS.map(artifact => artifact.origin)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a tokenizer from one local asset directory after byte/hash validation.
|
|
161
|
+
* This provider-private seam exists so tests can prove every failure branch
|
|
162
|
+
* without mutating the committed artifact.
|
|
163
|
+
* @param assetRoot - local URL containing tokenizer.json and tokenizer_config.json.
|
|
164
|
+
* @param integrity - expected byte length and SHA-256 for both files.
|
|
165
|
+
* @param origin - auditable identity recorded on every returned count.
|
|
166
|
+
* @returns a synchronous exact text counter.
|
|
167
|
+
* @internal
|
|
168
|
+
*/
|
|
169
|
+
export function createDeepSeekV4TokenizerFromAssets(
|
|
170
|
+
assetRoot: URL,
|
|
171
|
+
integrity: DeepSeekV4TokenizerAssetIntegrity,
|
|
172
|
+
origin: DeepSeekTokenizerArtifactOrigin = DEEPSEEK_V4_TOKENIZER_ARTIFACT,
|
|
173
|
+
): DeepSeekV4TextTokenizer {
|
|
174
|
+
const tokenizerJson = readVerifiedJson(assetRoot, 'tokenizer.json', integrity.tokenizer)
|
|
175
|
+
const tokenizerConfig = readVerifiedJson(assetRoot, 'tokenizer_config.json', integrity.config)
|
|
176
|
+
const runtime = new Tokenizer(tokenizerJson, tokenizerConfig)
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
countText(text: string): ExactTokenizerTokenCount {
|
|
179
|
+
const tokens = runtime.encode(text, { add_special_tokens: false }).ids.length
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
kind: 'exact-tokenizer',
|
|
182
|
+
tokens,
|
|
183
|
+
tokenizerId: origin.repository,
|
|
184
|
+
tokenizerRevision: origin.revision,
|
|
185
|
+
})
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readVerifiedJson(
|
|
191
|
+
assetRoot: URL,
|
|
192
|
+
name: string,
|
|
193
|
+
descriptor: DeepSeekV4TokenizerAssetDescriptor,
|
|
194
|
+
): Record<string, unknown> {
|
|
195
|
+
const bytes = readFileSync(new URL(name, assetRoot))
|
|
196
|
+
if (bytes.byteLength !== descriptor.bytes) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`DeepSeek tokenizer asset ${name} has ${String(bytes.byteLength)} bytes; expected ${String(descriptor.bytes)}`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
const actualSha256 = createHash('sha256').update(bytes).digest('hex')
|
|
202
|
+
if (actualSha256 !== descriptor.sha256) {
|
|
203
|
+
throw new Error(`DeepSeek tokenizer asset ${name} failed SHA-256 verification`)
|
|
204
|
+
}
|
|
205
|
+
const parsed: unknown = JSON.parse(bytes.toString('utf8'))
|
|
206
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
207
|
+
throw new Error(`DeepSeek tokenizer asset ${name} must contain a JSON object`)
|
|
208
|
+
}
|
|
209
|
+
return parsed as Record<string, unknown>
|
|
210
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimator model-catalog projection for the settings card: the live
|
|
3
|
+
* provider/model-group catalog from the DSH `llm` service — including
|
|
4
|
+
* user-configured custom groups — plus the effective host selection
|
|
5
|
+
* (override → agentDefaultModel.currentSelection). The same projection seam
|
|
6
|
+
* dsh-perm-gate's receiver-info and dsh-prime-memory's llm-providers view use.
|
|
7
|
+
*
|
|
8
|
+
* Pure projection: never throws; per-provider failures are reported inline so
|
|
9
|
+
* one broken group never blanks the whole list.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One model entry as offered by a provider group. */
|
|
13
|
+
export interface CatalogModel {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly name: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One provider group and its models (or the reason it failed to enumerate). */
|
|
19
|
+
export interface CatalogProvider {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly models: readonly CatalogModel[]
|
|
23
|
+
readonly error?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the settings card needs to render the host-route dropdowns. */
|
|
27
|
+
export interface EstimatorCatalog {
|
|
28
|
+
/** Live provider groups; empty when the `llm` service is unavailable. */
|
|
29
|
+
readonly providers: readonly CatalogProvider[]
|
|
30
|
+
/** The provider/model the host channel would use right now (undefined when undetermined). */
|
|
31
|
+
readonly selection?: { readonly provider: string, readonly model: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The minimal faces of the host services this projection reads. */
|
|
35
|
+
export interface EstimatorCatalogDeps {
|
|
36
|
+
llm?: {
|
|
37
|
+
listProviders?: () => readonly { id: string, name: string }[]
|
|
38
|
+
listModels?: (providerId: string) => Promise<readonly { id: string, name: string }[]>
|
|
39
|
+
/** Fallback enumeration source for builds whose `listModels` is absent or dormant. */
|
|
40
|
+
listConfigurableProviders?: () => readonly { provider: string, settingsNs: string }[]
|
|
41
|
+
/** Discovery for a configured route answers from the adapter's own knowledge — no network call. */
|
|
42
|
+
discoverModels?: (settingsNs: string, request: { provider?: string }) => Promise<readonly { id: string, name?: string }[]>
|
|
43
|
+
}
|
|
44
|
+
/** Live default-model selection (optional `agentDefaultModel` service). */
|
|
45
|
+
currentSelection?: () => { provider?: unknown, model?: unknown } | undefined
|
|
46
|
+
/** Explicit estimator overrides (empty string = follow the host selection). */
|
|
47
|
+
overrideProvider?: string
|
|
48
|
+
overrideModel?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function str(value: unknown): string {
|
|
52
|
+
return typeof value === 'string' ? value : ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the effective host model route (override → session default). */
|
|
56
|
+
export function resolveHostRoute(deps: EstimatorCatalogDeps): { provider: string, model: string } | undefined {
|
|
57
|
+
const overrideProvider = str(deps.overrideProvider)
|
|
58
|
+
const overrideModel = str(deps.overrideModel)
|
|
59
|
+
const selected = deps.currentSelection?.()
|
|
60
|
+
const selectedProvider = str(selected?.provider)
|
|
61
|
+
const selectedModel = str(selected?.model)
|
|
62
|
+
const provider = overrideProvider !== '' ? overrideProvider : selectedProvider
|
|
63
|
+
const model = overrideModel !== '' ? overrideModel : selectedModel
|
|
64
|
+
if (provider === '' || model === '') return undefined
|
|
65
|
+
return { provider, model }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build the catalog projection. Never throws. */
|
|
69
|
+
export async function buildEstimatorCatalog(deps: EstimatorCatalogDeps): Promise<EstimatorCatalog> {
|
|
70
|
+
if (deps.llm?.listProviders === undefined) {
|
|
71
|
+
const selection = resolveHostRoute(deps)
|
|
72
|
+
return selection === undefined ? { providers: [] } : { providers: [], selection }
|
|
73
|
+
}
|
|
74
|
+
const selection = resolveHostRoute(deps)
|
|
75
|
+
const raw = deps.llm.listProviders()
|
|
76
|
+
const providers = await Promise.all(raw.map(async (p): Promise<CatalogProvider> => {
|
|
77
|
+
let models: CatalogModel[] = []
|
|
78
|
+
let error: string | undefined
|
|
79
|
+
try {
|
|
80
|
+
models = (await deps.llm?.listModels?.(p.id) ?? []).map(m => ({ id: m.id, name: m.name }))
|
|
81
|
+
} catch (e) {
|
|
82
|
+
error = String((e as Error)?.message ?? e)
|
|
83
|
+
}
|
|
84
|
+
if (models.length === 0) {
|
|
85
|
+
// Fallback: route discovery. For an already-configured route the
|
|
86
|
+
// adapter answers from its own stored knowledge (no network call).
|
|
87
|
+
try {
|
|
88
|
+
const entry = deps.llm?.listConfigurableProviders?.().find(c => c.provider === p.id)
|
|
89
|
+
if (entry !== undefined && deps.llm?.discoverModels !== undefined) {
|
|
90
|
+
const discovered = await deps.llm.discoverModels(entry.settingsNs, { provider: p.id })
|
|
91
|
+
if (discovered.length > 0) {
|
|
92
|
+
models = discovered.map(m => ({ id: m.id, name: m.name ?? m.id }))
|
|
93
|
+
error = undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// keep the primary error / empty state
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (models.length === 0 && error === undefined) error = 'no models advertised'
|
|
101
|
+
return { id: p.id, name: p.name, models, ...(error === undefined ? {} : { error }) }
|
|
102
|
+
}))
|
|
103
|
+
return { providers, ...(selection === undefined ? {} : { selection }) }
|
|
104
|
+
}
|