dsh-plugin-effort-declare 0.1.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.
Files changed (62) hide show
  1. package/CONTRIBUTING.en.md +57 -0
  2. package/CONTRIBUTING.md +57 -0
  3. package/INSTALL.en.md +62 -0
  4. package/INSTALL.md +62 -0
  5. package/LICENSE +21 -0
  6. package/README.en.md +64 -0
  7. package/README.md +64 -0
  8. package/cordis.patch.yml +5 -0
  9. package/lib/client.js +1237 -0
  10. package/lib/client.js.map +1 -0
  11. package/lib/index.js +6 -0
  12. package/lib/types/client/EffortDeclareSection.d.ts +24 -0
  13. package/lib/types/client/EffortDeclareSection.d.ts.map +1 -0
  14. package/lib/types/client/index.d.ts +15 -0
  15. package/lib/types/client/index.d.ts.map +1 -0
  16. package/lib/types/client/load-drafts.d.ts +20 -0
  17. package/lib/types/client/load-drafts.d.ts.map +1 -0
  18. package/lib/types/client/locales.d.ts +8 -0
  19. package/lib/types/client/locales.d.ts.map +1 -0
  20. package/lib/types/client/schema-ops.d.ts +13 -0
  21. package/lib/types/client/schema-ops.d.ts.map +1 -0
  22. package/lib/types/core/catalog.d.ts +31 -0
  23. package/lib/types/core/catalog.d.ts.map +1 -0
  24. package/lib/types/core/drafts.d.ts +53 -0
  25. package/lib/types/core/drafts.d.ts.map +1 -0
  26. package/lib/types/core/efforts.d.ts +32 -0
  27. package/lib/types/core/efforts.d.ts.map +1 -0
  28. package/lib/types/core/filter.d.ts +28 -0
  29. package/lib/types/core/filter.d.ts.map +1 -0
  30. package/lib/types/core/path-ops.d.ts +40 -0
  31. package/lib/types/core/path-ops.d.ts.map +1 -0
  32. package/lib/types/core/paths.d.ts +16 -0
  33. package/lib/types/core/paths.d.ts.map +1 -0
  34. package/lib/types/core/presets.d.ts +31 -0
  35. package/lib/types/core/presets.d.ts.map +1 -0
  36. package/lib/types/core/validate.d.ts +3 -0
  37. package/lib/types/core/validate.d.ts.map +1 -0
  38. package/lib/types/index.d.ts +10 -0
  39. package/lib/types/index.d.ts.map +1 -0
  40. package/package.json +87 -0
  41. package/src/README.en.md +23 -0
  42. package/src/README.md +23 -0
  43. package/src/client/EffortDeclareSection.tsx +496 -0
  44. package/src/client/README.en.md +27 -0
  45. package/src/client/README.md +27 -0
  46. package/src/client/effort-declare.module.css +278 -0
  47. package/src/client/index.ts +97 -0
  48. package/src/client/load-drafts.ts +83 -0
  49. package/src/client/locales.ts +133 -0
  50. package/src/client/schema-ops.ts +20 -0
  51. package/src/core/README.en.md +27 -0
  52. package/src/core/README.md +27 -0
  53. package/src/core/catalog.ts +61 -0
  54. package/src/core/drafts.ts +137 -0
  55. package/src/core/efforts.ts +119 -0
  56. package/src/core/filter.ts +82 -0
  57. package/src/core/path-ops.ts +65 -0
  58. package/src/core/paths.ts +45 -0
  59. package/src/core/presets.ts +104 -0
  60. package/src/core/validate.ts +7 -0
  61. package/src/css-modules.d.ts +6 -0
  62. package/src/index.ts +11 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical thinking levels and openai-completions thinkingFormat names.
3
+ *
4
+ * Levels match `@deepseek-ai/dsh-llm-pi-ai` catalog.ts `THINKING_LEVELS`.
5
+ * Formats match `SUPPORTED_THINKING_FORMATS` in the same file (rc.8).
6
+ * Runtime UI prefers the live settings schema union; these lists are the
7
+ * fallback plus a test pin so a silent drift is a failing test, not a
8
+ * second hand-maintained copy nobody notices.
9
+ */
10
+
11
+ /** Selectable reasoning levels, in pi-ai escalation order. */
12
+ export const THINKING_LEVELS = [
13
+ 'off',
14
+ 'minimal',
15
+ 'low',
16
+ 'medium',
17
+ 'high',
18
+ 'xhigh',
19
+ 'max',
20
+ ] as const
21
+
22
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number]
23
+
24
+ /** Thinking levels other than Off; Off has its own tri-state control. */
25
+ export const THINKING_LEVELS_WITHOUT_OFF = THINKING_LEVELS.filter(
26
+ (level): level is Exclude<ThinkingLevel, 'off'> => level !== 'off',
27
+ )
28
+
29
+ /**
30
+ * openai-completions thinkingFormat values from llm-pi-ai catalog.ts rc.8.
31
+ * Tests pin this list; the settings page prefers schema union choices.
32
+ */
33
+ export const FALLBACK_THINKING_FORMATS = [
34
+ 'openai',
35
+ 'deepseek',
36
+ 'openrouter',
37
+ 'together',
38
+ 'zai',
39
+ 'qwen',
40
+ 'chat-template',
41
+ 'qwen-chat-template',
42
+ 'string-thinking',
43
+ 'ant-ling',
44
+ ] as const
45
+
46
+ export type ThinkingFormat = (typeof FALLBACK_THINKING_FORMATS)[number]
47
+
48
+ /** Wire protocol this plugin's v1 editor supports. */
49
+ export const OPENAI_COMPLETIONS = 'openai-completions'
50
+
51
+ /** Settings namespace this plugin writes. */
52
+ export const LLM_PI_AI_NS = 'llm-pi-ai'
53
+
54
+ /** Official DeepSeek adapter namespace — skip. */
55
+ export const LLM_DEEPSEEK_NS = 'llm-deepseek'
56
+
57
+ /** Official DeepSeek provider route — skip. */
58
+ export const DEEPSEEK_OFFICIAL = 'deepseek-official'
59
+
60
+ /** Any route key walks a dict schema to the same profile node. */
61
+ export const SCHEMA_PROBE_ROUTE = '\u0000probe'
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Route-card draft merge: user-layer slices, namespace revision, dirty preserve.
3
+ * No React — settings UI and tests share these helpers.
4
+ */
5
+ import { profileAt } from './filter.ts'
6
+ import { buildSaveOps } from './path-ops.ts'
7
+ import { cloneModels, cloneObject, isPlainObject } from './paths.ts'
8
+
9
+ /** Editable llm-pi-ai route as the settings section holds it. */
10
+ export interface RouteDraft {
11
+ provider: string
12
+ displayName: string
13
+ /** Path inside the llm-pi-ai section (`llm.providers[].settingsPath`). */
14
+ settingsPath: readonly string[]
15
+ /** Namespace revision (one value for the whole llm-pi-ai document). */
16
+ revision: number
17
+ models: Record<string, unknown>[]
18
+ originalModels: Record<string, unknown>[]
19
+ compat: Record<string, unknown>
20
+ originalCompat: Record<string, unknown>
21
+ /** Whether `compat` is present on the user profile (not the effective view). */
22
+ compatPresent: boolean
23
+ }
24
+
25
+ /** Build a draft from the stored user subtree (never from effective `value`). */
26
+ export function routeDraftFromUserProfile(args: {
27
+ provider: string
28
+ displayName: string
29
+ settingsPath: readonly string[]
30
+ revision: number
31
+ userProfile: unknown
32
+ }): RouteDraft {
33
+ const { provider, displayName, settingsPath, revision, userProfile } = args
34
+ const models = cloneModels(isPlainObject(userProfile) ? userProfile.models : [])
35
+ const compatPresent = isPlainObject(userProfile) && 'compat' in userProfile && isPlainObject(userProfile.compat)
36
+ const compat = cloneObject(isPlainObject(userProfile) ? userProfile.compat : {})
37
+ return {
38
+ provider,
39
+ displayName,
40
+ settingsPath: [...settingsPath],
41
+ revision,
42
+ models,
43
+ originalModels: cloneModels(models),
44
+ compat,
45
+ originalCompat: cloneObject(compat),
46
+ compatPresent,
47
+ }
48
+ }
49
+
50
+ /** Dirty iff pathOps would emit something (same comparison as save). */
51
+ export function draftDirty(draft: RouteDraft): boolean {
52
+ return buildSaveOps({
53
+ settingsPath: draft.settingsPath,
54
+ beforeModels: draft.originalModels,
55
+ afterModels: draft.models,
56
+ beforeCompat: draft.compatPresent ? draft.originalCompat : undefined,
57
+ afterCompat: draft.compat,
58
+ }).length > 0
59
+ }
60
+
61
+ /** Treat the current models/compat as the committed originals (no-op save). */
62
+ export function alignDraft(draft: RouteDraft): RouteDraft {
63
+ return {
64
+ ...draft,
65
+ originalModels: cloneModels(draft.models),
66
+ originalCompat: cloneObject(draft.compat),
67
+ compatPresent: Object.keys(draft.compat).length > 0 ? true : draft.compatPresent,
68
+ }
69
+ }
70
+
71
+ /** Fold a successful mutate view: saved card realigns; every card gets the new revision. */
72
+ export function applySaveSuccess(
73
+ drafts: readonly RouteDraft[],
74
+ savedProvider: string,
75
+ slice: { user: unknown; revision: number },
76
+ ): RouteDraft[] {
77
+ return drafts.map((draft) => {
78
+ if (draft.provider !== savedProvider) {
79
+ return { ...draft, revision: slice.revision }
80
+ }
81
+ const userProfile = profileAt(slice.user, draft.settingsPath, draft.provider)
82
+ return routeDraftFromUserProfile({
83
+ provider: draft.provider,
84
+ displayName: draft.displayName,
85
+ settingsPath: draft.settingsPath,
86
+ revision: slice.revision,
87
+ userProfile,
88
+ })
89
+ })
90
+ }
91
+
92
+ /**
93
+ * Apply a freshly loaded table. Dirty cards keep models/compat; originals and
94
+ * revision follow the incoming snapshot so a later save is against the new user layer.
95
+ */
96
+ export function mergeLoadedDrafts(
97
+ current: readonly RouteDraft[],
98
+ incoming: readonly RouteDraft[],
99
+ options: { preserveDirty: boolean },
100
+ ): { drafts: RouteDraft[]; conflicted: string[] } {
101
+ const currentByProvider = new Map(current.map(draft => [draft.provider, draft]))
102
+ const conflicted: string[] = []
103
+ const drafts = incoming.map((next) => {
104
+ const prev = currentByProvider.get(next.provider)
105
+ if (prev === undefined || !options.preserveDirty || !draftDirty(prev)) return next
106
+ conflicted.push(next.provider)
107
+ return {
108
+ ...prev,
109
+ displayName: next.displayName,
110
+ settingsPath: next.settingsPath,
111
+ revision: next.revision,
112
+ originalModels: cloneModels(next.originalModels),
113
+ originalCompat: cloneObject(next.originalCompat),
114
+ compatPresent: next.compatPresent,
115
+ }
116
+ })
117
+ return { drafts, conflicted }
118
+ }
119
+
120
+ /** Increment a generation counter; callers discard stale async settlements. */
121
+ export function nextGeneration(holder: { current: number }): number {
122
+ holder.current += 1
123
+ return holder.current
124
+ }
125
+
126
+ /** Whether `generation` is still the latest issued token. */
127
+ export function generationIsCurrent(holder: { current: number }, generation: number): boolean {
128
+ return holder.current === generation
129
+ }
130
+
131
+ /** Keep a stored thinkingFormat visible even if the schema union omitted it. */
132
+ export function thinkingFormatChoices(formats: readonly string[], current: unknown): string[] {
133
+ if (typeof current === 'string' && current.length > 0 && !formats.includes(current)) {
134
+ return [current, ...formats]
135
+ }
136
+ return [...formats]
137
+ }
@@ -0,0 +1,119 @@
1
+ import { THINKING_LEVELS, type ThinkingLevel } from './catalog.ts'
2
+
3
+ /** Per-model reasoningEfforts dict as stored in llm-pi-ai settings. */
4
+ export type ReasoningEfforts = Partial<Record<ThinkingLevel, string | null>>
5
+
6
+ /** Off tri-state. Must not be collapsed into a single checkbox. */
7
+ export type OffMode = 'absent' | 'empty' | 'value'
8
+
9
+ export type ReasoningEffortsField = ReasoningEfforts | false | undefined
10
+
11
+ /** Read Off's three states from a stored dict. */
12
+ export function readOff(efforts: ReasoningEfforts | undefined): { mode: OffMode; value: string } {
13
+ if (efforts === undefined || !('off' in efforts) || efforts.off === undefined) {
14
+ return { mode: 'absent', value: 'none' }
15
+ }
16
+ if (efforts.off === null) return { mode: 'empty', value: 'none' }
17
+ return { mode: 'value', value: efforts.off }
18
+ }
19
+
20
+ /** Write Off's three states onto a dict (does not mutate the input). */
21
+ export function writeOff(
22
+ efforts: ReasoningEfforts,
23
+ mode: OffMode,
24
+ value: string,
25
+ ): ReasoningEfforts {
26
+ const next: ReasoningEfforts = { ...efforts }
27
+ delete next.off
28
+ if (mode === 'empty') next.off = null
29
+ else if (mode === 'value') next.off = value.trim().length > 0 ? value : 'none'
30
+ return next
31
+ }
32
+
33
+ /** Whether a thinking level (other than Off) is currently declared. */
34
+ export function hasLevel(efforts: ReasoningEfforts | undefined, level: Exclude<ThinkingLevel, 'off'>): boolean {
35
+ return efforts !== undefined && efforts[level] !== undefined && efforts[level] !== null
36
+ }
37
+
38
+ /** Toggle a non-off level. New levels default the wire spelling to the key. */
39
+ export function toggleLevel(
40
+ efforts: ReasoningEfforts,
41
+ level: Exclude<ThinkingLevel, 'off'>,
42
+ enabled: boolean,
43
+ wire?: string,
44
+ ): ReasoningEfforts {
45
+ const next: ReasoningEfforts = { ...efforts }
46
+ if (enabled) next[level] = wire !== undefined && wire.length > 0 ? wire : level
47
+ else delete next[level]
48
+ return next
49
+ }
50
+
51
+ /** Set the wire spelling for a declared non-off level. */
52
+ export function setWireSpelling(
53
+ efforts: ReasoningEfforts,
54
+ level: Exclude<ThinkingLevel, 'off'>,
55
+ wire: string,
56
+ ): ReasoningEfforts {
57
+ if (!hasLevel(efforts, level)) return efforts
58
+ return { ...efforts, [level]: wire }
59
+ }
60
+
61
+ /**
62
+ * Validate a reasoningEfforts field. Returns an error code; never throws.
63
+ * Absence / `false` are valid (default-off / catalog strip). Empty dict and
64
+ * off-only are rejected by the official resolver.
65
+ */
66
+ export function validateReasoningEfforts(efforts: unknown): 'empty' | 'off-only' | 'bad-wire' | undefined {
67
+ if (efforts === undefined || efforts === false) return undefined
68
+ if (efforts === null || (typeof efforts === 'object' && !Array.isArray(efforts) && Object.keys(efforts).length === 0)) {
69
+ return 'empty'
70
+ }
71
+ if (typeof efforts !== 'object' || Array.isArray(efforts)) return 'empty'
72
+ const record = efforts as Record<string, unknown>
73
+ const known = new Set<string>(THINKING_LEVELS)
74
+ for (const key of Object.keys(record)) {
75
+ if (!known.has(key)) return 'bad-wire'
76
+ }
77
+ const declared = THINKING_LEVELS.flatMap((level) => {
78
+ if (!(level in record) || record[level] === undefined) return []
79
+ return [[level, record[level]] as const]
80
+ })
81
+ for (const [level, wire] of declared) {
82
+ if (wire === null) {
83
+ if (level !== 'off') return 'bad-wire'
84
+ } else if (typeof wire !== 'string' || wire.length === 0) {
85
+ return 'bad-wire'
86
+ }
87
+ }
88
+ if (!declared.some(([level]) => level !== 'off')) return 'off-only'
89
+ return undefined
90
+ }
91
+
92
+ /** Drop reasoningEfforts from a model row; keep every other field. */
93
+ export function clearReasoningEfforts(row: Record<string, unknown>): Record<string, unknown> {
94
+ const next = { ...row }
95
+ delete next.reasoningEfforts
96
+ return next
97
+ }
98
+
99
+ /** Read reasoningEfforts from a model row. `false` is treated as absent for the editor. */
100
+ export function readEfforts(row: Record<string, unknown>): ReasoningEfforts | undefined {
101
+ const value = row.reasoningEfforts
102
+ if (value === undefined || value === false || value === null) return undefined
103
+ if (typeof value !== 'object' || Array.isArray(value)) return undefined
104
+ return value as ReasoningEfforts
105
+ }
106
+
107
+ /** Put a dict (or omit the field) onto a spread row. */
108
+ export function writeEfforts(
109
+ row: Record<string, unknown>,
110
+ efforts: ReasoningEfforts | undefined,
111
+ ): Record<string, unknown> {
112
+ const next = { ...row }
113
+ if (efforts === undefined || Object.keys(efforts).length === 0) {
114
+ delete next.reasoningEfforts
115
+ return next
116
+ }
117
+ next.reasoningEfforts = efforts
118
+ return next
119
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ DEEPSEEK_OFFICIAL,
3
+ LLM_DEEPSEEK_NS,
4
+ LLM_PI_AI_NS,
5
+ OPENAI_COMPLETIONS,
6
+ } from './catalog.ts'
7
+ import { getPath, isPlainObject } from './paths.ts'
8
+
9
+ /** Directory row from `llm.providers()` (the fields v1 filtering reads). */
10
+ export interface ProviderDirectoryEntry {
11
+ provider: string
12
+ displayName?: string
13
+ settingsNs?: string
14
+ settingsPath?: readonly string[]
15
+ declared?: boolean
16
+ active?: boolean
17
+ }
18
+
19
+ /**
20
+ * Resolve the wire protocol for a route.
21
+ * User/effective `api` wins; then schema default; then openai-completions.
22
+ */
23
+ export function resolveRouteApi(
24
+ profile: unknown,
25
+ schemaDefault: string | undefined,
26
+ ): string {
27
+ if (isPlainObject(profile) && typeof profile.api === 'string' && profile.api.length > 0) {
28
+ return profile.api
29
+ }
30
+ if (schemaDefault !== undefined && schemaDefault.length > 0) return schemaDefault
31
+ return OPENAI_COMPLETIONS
32
+ }
33
+
34
+ export interface EditableDecision {
35
+ editable: boolean
36
+ reason?: 'not-pi-ai' | 'official-deepseek' | 'catalog' | 'not-completions'
37
+ }
38
+
39
+ /**
40
+ * v1 editable routes: hand-declared llm-pi-ai openai-completions.
41
+ * Catalog routes are excluded (writing `models` replaces the whole catalog).
42
+ */
43
+ export function classifyRoute(
44
+ entry: ProviderDirectoryEntry,
45
+ profile: unknown,
46
+ schemaDefaultApi?: string,
47
+ ): EditableDecision {
48
+ if (entry.provider === DEEPSEEK_OFFICIAL || entry.settingsNs === LLM_DEEPSEEK_NS) {
49
+ return { editable: false, reason: 'official-deepseek' }
50
+ }
51
+ if (entry.settingsNs !== LLM_PI_AI_NS) {
52
+ return { editable: false, reason: 'not-pi-ai' }
53
+ }
54
+ if (entry.declared !== true) {
55
+ return { editable: false, reason: 'catalog' }
56
+ }
57
+ const api = resolveRouteApi(profile, schemaDefaultApi)
58
+ if (api !== OPENAI_COMPLETIONS) {
59
+ return { editable: false, reason: 'not-completions' }
60
+ }
61
+ return { editable: true }
62
+ }
63
+
64
+ /** Profile object at `providers.<route>` from an llm-pi-ai section value. */
65
+ export function profileAt(
66
+ section: unknown,
67
+ settingsPath: readonly string[] | undefined,
68
+ provider: string,
69
+ ): unknown {
70
+ const path = settingsPath !== undefined && settingsPath.length > 0
71
+ ? settingsPath
72
+ : ['providers', provider]
73
+ return getPath(section, path)
74
+ }
75
+
76
+ /** String choices from a schemastery union node (`type: 'union'`). */
77
+ export function unionStringChoices(node: unknown): string[] {
78
+ if (!isPlainObject(node) || node.type !== 'union' || !Array.isArray(node.list)) return []
79
+ return node.list
80
+ .map((entry) => (isPlainObject(entry) ? entry.value : undefined))
81
+ .filter((value): value is string => typeof value === 'string')
82
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Minimal path ops, same one-level-key algorithm as the official
3
+ * ProviderEditor.pathOps: only keys that differ produce a set/unset.
4
+ */
5
+
6
+ export type PathOp =
7
+ | { op: 'set'; path: string[]; value: unknown }
8
+ | { op: 'unset'; path: string[] }
9
+
10
+ /**
11
+ * The minimal path ops carrying `after` over `before`.
12
+ * @param base - path of the edited subtree.
13
+ * @param before - subtree as loaded, or undefined when new.
14
+ * @param after - subtree as edited.
15
+ */
16
+ export function pathOps(
17
+ base: readonly string[],
18
+ before: unknown,
19
+ after: Record<string, unknown>,
20
+ ): PathOp[] {
21
+ const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
22
+ ? before as Record<string, unknown>
23
+ : {}
24
+ const ops: PathOp[] = []
25
+ for (const [key, value] of Object.entries(after)) {
26
+ if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
27
+ ops.push({ op: 'set', path: [...base, key], value })
28
+ }
29
+ for (const key of Object.keys(previous)) {
30
+ if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
31
+ }
32
+ return ops
33
+ }
34
+
35
+ export interface SaveSlices {
36
+ /** Profile path from `llm.providers[].settingsPath` (not a hardcoded route id). */
37
+ settingsPath: readonly string[]
38
+ /** Models array before the edit (user layer). */
39
+ beforeModels: unknown
40
+ /** Models array after the edit — every existing row, spread intact. */
41
+ afterModels: unknown[]
42
+ /** Route-level compat before the edit (`undefined` = field absent on user). */
43
+ beforeCompat: unknown
44
+ /**
45
+ * Route-level compat after the edit. `undefined` means leave the field
46
+ * untouched (no compat ops at all).
47
+ */
48
+ afterCompat: Record<string, unknown> | undefined
49
+ }
50
+
51
+ /**
52
+ * Ops for one route: whole-array `models` replace when the table changed,
53
+ * plus one-level compat path ops. Never replace the `llm-pi-ai` section.
54
+ */
55
+ export function buildSaveOps(slices: SaveSlices): PathOp[] {
56
+ const base = [...slices.settingsPath]
57
+ const ops: PathOp[] = []
58
+ if (JSON.stringify(slices.beforeModels) !== JSON.stringify(slices.afterModels)) {
59
+ ops.push({ op: 'set', path: [...base, 'models'], value: slices.afterModels })
60
+ }
61
+ if (slices.afterCompat !== undefined) {
62
+ ops.push(...pathOps([...base, 'compat'], slices.beforeCompat, slices.afterCompat))
63
+ }
64
+ return ops
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Nested get/has over plain JSON, matching ui-settings SettingsSchemaService
3
+ * for the paths this plugin needs. Kept in-repo so tests do not import a
4
+ * Cordis service.
5
+ */
6
+
7
+ /** Read a nested value by string keys / array indexes. */
8
+ export function getPath(value: unknown, path: readonly string[]): unknown {
9
+ let current: unknown = value
10
+ for (const key of path) {
11
+ if (Array.isArray(current)) {
12
+ current = current[Number(key)]
13
+ continue
14
+ }
15
+ if (typeof current !== 'object' || current === null) return undefined
16
+ current = (current as Record<string, unknown>)[key]
17
+ }
18
+ return current
19
+ }
20
+
21
+ /** Whether the final path key exists, independently of its value. */
22
+ export function hasPath(value: unknown, path: readonly string[]): boolean {
23
+ if (path.length === 0) return value !== undefined
24
+ const parent = getPath(value, path.slice(0, -1))
25
+ const key = path[path.length - 1] as string
26
+ if (Array.isArray(parent)) return Number(key) < parent.length
27
+ if (typeof parent !== 'object' || parent === null) return false
28
+ return key in parent
29
+ }
30
+
31
+ /** Plain object (not array, not null). */
32
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
34
+ }
35
+
36
+ /** Structured clone of a JSON object, or `{}` when the source is not one. */
37
+ export function cloneObject(value: unknown): Record<string, unknown> {
38
+ return isPlainObject(value) ? structuredClone(value) : {}
39
+ }
40
+
41
+ /** Structured clone of a models array; non-arrays and non-object rows are skipped. */
42
+ export function cloneModels(value: unknown): Record<string, unknown>[] {
43
+ if (!Array.isArray(value)) return []
44
+ return value.flatMap((row) => (isPlainObject(row) ? [structuredClone(row)] : []))
45
+ }
@@ -0,0 +1,104 @@
1
+ import type { ReasoningEfforts } from './efforts.ts'
2
+
3
+ export type PresetId = 'deepseek' | 'openai' | 'toggle'
4
+
5
+ export interface PresetCompatPatch {
6
+ /**
7
+ * Every preset states all three UI dialect keys. `set` writes the key;
8
+ * `unset` removes it. Unknown extra compat keys are left in place.
9
+ */
10
+ thinkingFormat: 'set' | 'unset'
11
+ thinkingFormatValue?: string
12
+ supportsDeveloperRole: 'set-false' | 'unset'
13
+ supportsReasoningEffort: 'set-false' | 'unset'
14
+ }
15
+
16
+ export interface EffortPreset {
17
+ id: PresetId
18
+ efforts: ReasoningEfforts
19
+ compat: PresetCompatPatch
20
+ /** True when selector levels do not change the on-wire request (except Off). */
21
+ warnSameWire: boolean
22
+ }
23
+
24
+ /** DeepSeek-compatible gateway: explicit thinking.disabled on Off. */
25
+ export const DEEPSEEK_PRESET: EffortPreset = {
26
+ id: 'deepseek',
27
+ efforts: {
28
+ off: null,
29
+ low: 'low',
30
+ high: 'high',
31
+ max: 'max',
32
+ },
33
+ compat: {
34
+ thinkingFormat: 'set',
35
+ thinkingFormatValue: 'deepseek',
36
+ supportsDeveloperRole: 'set-false',
37
+ supportsReasoningEffort: 'unset',
38
+ },
39
+ warnSameWire: false,
40
+ }
41
+
42
+ /** OpenAI-compatible gateway: default thinkingFormat (omit the key). */
43
+ export const OPENAI_PRESET: EffortPreset = {
44
+ id: 'openai',
45
+ efforts: {
46
+ minimal: 'minimal',
47
+ low: 'low',
48
+ medium: 'medium',
49
+ high: 'high',
50
+ },
51
+ compat: {
52
+ thinkingFormat: 'unset',
53
+ supportsDeveloperRole: 'unset',
54
+ supportsReasoningEffort: 'unset',
55
+ },
56
+ warnSameWire: false,
57
+ }
58
+
59
+ /** On/off only: selector may list levels that are identical on the wire. */
60
+ export const TOGGLE_PRESET: EffortPreset = {
61
+ id: 'toggle',
62
+ efforts: {
63
+ off: null,
64
+ high: 'high',
65
+ },
66
+ compat: {
67
+ thinkingFormat: 'unset',
68
+ supportsDeveloperRole: 'unset',
69
+ supportsReasoningEffort: 'set-false',
70
+ },
71
+ warnSameWire: true,
72
+ }
73
+
74
+ export const PRESETS: Record<PresetId, EffortPreset> = {
75
+ deepseek: DEEPSEEK_PRESET,
76
+ openai: OPENAI_PRESET,
77
+ toggle: TOGGLE_PRESET,
78
+ }
79
+
80
+ /** Apply a preset's efforts onto every model row (spread, keep other fields). */
81
+ export function applyPresetEfforts(
82
+ models: readonly Record<string, unknown>[],
83
+ preset: EffortPreset,
84
+ ): Record<string, unknown>[] {
85
+ return models.map(row => ({ ...row, reasoningEfforts: { ...preset.efforts } }))
86
+ }
87
+
88
+ /** Merge a preset's compat patch onto the current route-level compat object. */
89
+ export function applyPresetCompat(
90
+ compat: Record<string, unknown>,
91
+ preset: EffortPreset,
92
+ ): Record<string, unknown> {
93
+ const next = { ...compat }
94
+ const { compat: patch } = preset
95
+ if (patch.thinkingFormat === 'unset') delete next.thinkingFormat
96
+ else if (patch.thinkingFormat === 'set' && patch.thinkingFormatValue !== undefined) {
97
+ next.thinkingFormat = patch.thinkingFormatValue
98
+ }
99
+ if (patch.supportsDeveloperRole === 'unset') delete next.supportsDeveloperRole
100
+ else if (patch.supportsDeveloperRole === 'set-false') next.supportsDeveloperRole = false
101
+ if (patch.supportsReasoningEffort === 'unset') delete next.supportsReasoningEffort
102
+ else if (patch.supportsReasoningEffort === 'set-false') next.supportsReasoningEffort = false
103
+ return next
104
+ }
@@ -0,0 +1,7 @@
1
+ import { validateReasoningEfforts } from './efforts.ts'
2
+
3
+ /** Per-model client-side check used to show errors without throwing. */
4
+ export function modelEffortError(row: Record<string, unknown>): 'empty' | 'off-only' | 'bad-wire' | undefined {
5
+ if (!('reasoningEfforts' in row) || row.reasoningEfforts === undefined) return undefined
6
+ return validateReasoningEfforts(row.reasoningEfforts)
7
+ }
@@ -0,0 +1,6 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export const cssText: string
4
+ export const cssTagId: string
5
+ export default classes
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * dsh-plugin-effort-declare — host half.
3
+ * v1 contributes no host services, adapters, or events. The cordis row still
4
+ * has to exist so a later `dsh plugin add` can load the bundle.
5
+ */
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+
8
+ export const name = 'dsh-plugin-effort-declare'
9
+
10
+ /** Host apply is intentionally empty in v1. */
11
+ export function apply(_ctx: Context): void {}