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.
- package/CONTRIBUTING.en.md +57 -0
- package/CONTRIBUTING.md +57 -0
- package/INSTALL.en.md +62 -0
- package/INSTALL.md +62 -0
- package/LICENSE +21 -0
- package/README.en.md +64 -0
- package/README.md +64 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1237 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +6 -0
- package/lib/types/client/EffortDeclareSection.d.ts +24 -0
- package/lib/types/client/EffortDeclareSection.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/load-drafts.d.ts +20 -0
- package/lib/types/client/load-drafts.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/schema-ops.d.ts +13 -0
- package/lib/types/client/schema-ops.d.ts.map +1 -0
- package/lib/types/core/catalog.d.ts +31 -0
- package/lib/types/core/catalog.d.ts.map +1 -0
- package/lib/types/core/drafts.d.ts +53 -0
- package/lib/types/core/drafts.d.ts.map +1 -0
- package/lib/types/core/efforts.d.ts +32 -0
- package/lib/types/core/efforts.d.ts.map +1 -0
- package/lib/types/core/filter.d.ts +28 -0
- package/lib/types/core/filter.d.ts.map +1 -0
- package/lib/types/core/path-ops.d.ts +40 -0
- package/lib/types/core/path-ops.d.ts.map +1 -0
- package/lib/types/core/paths.d.ts +16 -0
- package/lib/types/core/paths.d.ts.map +1 -0
- package/lib/types/core/presets.d.ts +31 -0
- package/lib/types/core/presets.d.ts.map +1 -0
- package/lib/types/core/validate.d.ts +3 -0
- package/lib/types/core/validate.d.ts.map +1 -0
- package/lib/types/index.d.ts +10 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/README.en.md +23 -0
- package/src/README.md +23 -0
- package/src/client/EffortDeclareSection.tsx +496 -0
- package/src/client/README.en.md +27 -0
- package/src/client/README.md +27 -0
- package/src/client/effort-declare.module.css +278 -0
- package/src/client/index.ts +97 -0
- package/src/client/load-drafts.ts +83 -0
- package/src/client/locales.ts +133 -0
- package/src/client/schema-ops.ts +20 -0
- package/src/core/README.en.md +27 -0
- package/src/core/README.md +27 -0
- package/src/core/catalog.ts +61 -0
- package/src/core/drafts.ts +137 -0
- package/src/core/efforts.ts +119 -0
- package/src/core/filter.ts +82 -0
- package/src/core/path-ops.ts +65 -0
- package/src/core/paths.ts +45 -0
- package/src/core/presets.ts +104 -0
- package/src/core/validate.ts +7 -0
- package/src/css-modules.d.ts +6 -0
- 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
|
+
}
|
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 {}
|