@lynn123411/dsh-model-submenu 0.2.5 → 0.3.1

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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * What the settings document knows about one model, for the picker's metadata
3
+ * line.
4
+ *
5
+ * The Host's model catalog carries only a name and a reasoning list; context
6
+ * window and accepted modalities live in each provider's settings profile,
7
+ * which the settings mirror already holds in the browser. This module joins
8
+ * the two by the identity both agree on: the catalog's provider route key and
9
+ * model id are exactly the settings profile's provider key and model id.
10
+ *
11
+ * Every field is answered independently. A provider whose profile lists no
12
+ * models (an OAuth route such as the Kimi subscription, or the account
13
+ * provider) answers `unknown` rather than guessing the route default, because
14
+ * the line's rule is to omit what cannot be asserted.
15
+ */
16
+
17
+ /** Accepted-modality answer for one model. */
18
+ export type ImageAnswer = 'yes' | 'no' | 'unknown'
19
+
20
+ /**
21
+ * The model knowledge a shipped adapter carries for its own account route.
22
+ *
23
+ * The account route has no `models` list on purpose: the account listing
24
+ * supplies the catalog and settings only carry per-model overrides. The same
25
+ * adapter family ships a default catalog for its API-key route, and the account
26
+ * route serves the same models, so those entries answer what the account
27
+ * profile cannot. Ids and facts mirror the adapter's own table.
28
+ */
29
+ const FAMILY_DEFAULTS: Readonly<Record<string, readonly {
30
+ readonly id: string
31
+ readonly contextWindow: number
32
+ readonly images: boolean
33
+ }[]>> = {
34
+ 'deepseek-account': [
35
+ { id: 'deepseek-flash', contextWindow: 1_000_000, images: true },
36
+ { id: 'deepseek-v4-pro', contextWindow: 1_000_000, images: false },
37
+ ],
38
+ }
39
+
40
+ /** The metadata the settings document can answer for one model. */
41
+ export interface ModelFacts {
42
+ /** Exact advertised capacity, or undefined when the model declares none. */
43
+ readonly contextWindow: number | undefined
44
+ /** Whether the model accepts images; `unknown` means the document is silent. */
45
+ readonly images: ImageAnswer
46
+ }
47
+
48
+ /** One provider directory row, as the Host's LLM directory reports it. */
49
+ export interface ConfigurableProviderRow {
50
+ readonly provider: string
51
+ readonly settingsNs: string
52
+ readonly settingsPath: readonly string[]
53
+ }
54
+
55
+ /** One settings namespace view, as the describe answer carries it. */
56
+ export interface NamespaceRow {
57
+ readonly ns: string
58
+ readonly value: unknown
59
+ }
60
+
61
+ /** What one provider's profile may carry, across both adapter families. */
62
+ interface ProfileView {
63
+ readonly models?: readonly Record<string, unknown>[]
64
+ readonly defaultContextWindow?: unknown
65
+ readonly defaultInput?: unknown
66
+ }
67
+
68
+ type Record_ = Record<string, unknown>
69
+
70
+ /**
71
+ * Build the fact lookup from the settings answer.
72
+ * @param providers - provider directory rows giving each route's settings address.
73
+ * @param namespaces - the describe answer's namespace views.
74
+ * @returns a lookup answering both fields for one provider and model.
75
+ */
76
+ export function buildModelFacts(
77
+ providers: readonly ConfigurableProviderRow[],
78
+ namespaces: readonly NamespaceRow[],
79
+ ): (provider: string, model: string) => ModelFacts {
80
+ const byName = new Map(namespaces.map((row) => [row.ns, row.value]))
81
+ const profiles = new Map<string, ProfileView>()
82
+ for (const row of providers) {
83
+ const section = byName.get(row.settingsNs)
84
+ const profile = readPath(section, row.settingsPath)
85
+ if (isRecord(profile)) profiles.set(row.provider, profile as ProfileView)
86
+ }
87
+ return (provider, model) => {
88
+ const profile = profiles.get(provider)
89
+ const entry = profile?.models === undefined
90
+ ? undefined
91
+ : profile.models.find((candidate) => candidate.id === model)
92
+ if (profile !== undefined && entry !== undefined) {
93
+ const window = typeof entry.contextWindow === 'number' && entry.contextWindow > 0
94
+ ? entry.contextWindow
95
+ : undefined
96
+ return { contextWindow: window, images: imageAnswer(entry, profile) }
97
+ }
98
+ // A route with no model list of its own (the account route) still serves its
99
+ // adapter family's models; a declared override there wins over the default.
100
+ const override = readOverride(profile, model)
101
+ if (override !== undefined) {
102
+ const window = typeof override.contextWindow === 'number' && override.contextWindow > 0
103
+ ? override.contextWindow
104
+ : undefined
105
+ return {
106
+ contextWindow: window,
107
+ images: Array.isArray(override.inputModalities) ? containsImage(override.inputModalities) : 'unknown',
108
+ }
109
+ }
110
+ const family = FAMILY_DEFAULTS[provider]?.find((candidate) => candidate.id === model)
111
+ return family === undefined
112
+ ? { contextWindow: undefined, images: 'unknown' }
113
+ : { contextWindow: family.contextWindow, images: family.images ? 'yes' : 'no' }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Read one model entry's accepted image modalities.
119
+ *
120
+ * The two adapter families disagree about an omission. The Messages family
121
+ * names the field `inputModalities`, and leaving it out means text only. The
122
+ * pi-ai family names it `input`, and an empty or absent list defers to the
123
+ * route's `defaultInput`; with no route default the answer stays unknown.
124
+ */
125
+ function imageAnswer(entry: Record_, profile: ProfileView): ImageAnswer {
126
+ if (Array.isArray(entry.inputModalities)) return containsImage(entry.inputModalities)
127
+ const defaultInput = Array.isArray(profile.defaultInput) ? profile.defaultInput : undefined
128
+ if (Array.isArray(entry.input)) {
129
+ if (entry.input.length > 0) return containsImage(entry.input)
130
+ return defaultInput === undefined ? 'unknown' : containsImage(defaultInput)
131
+ }
132
+ return defaultInput === undefined ? 'no' : containsImage(defaultInput)
133
+ }
134
+
135
+ /** One model's declared override, when the route keeps a `modelOverrides` table. */
136
+ function readOverride(profile: ProfileView | undefined, model: string): Record_ | undefined {
137
+ const overrides = (profile as Record_ | undefined)?.modelOverrides
138
+ if (!isRecord(overrides)) return undefined
139
+ const entry = overrides[model]
140
+ return isRecord(entry) ? entry : undefined
141
+ }
142
+
143
+ function containsImage(modalities: readonly unknown[]): ImageAnswer {
144
+ return modalities.includes('image') ? 'yes' : 'no'
145
+ }
146
+
147
+ /**
148
+ * Abbreviate one capacity the way the metadata line shows it.
149
+ * @param tokens - exact advertised capacity in tokens.
150
+ * @returns e.g. `1M ctx`, `1.5M ctx`, `272k ctx`, or `512 ctx` below a thousand.
151
+ */
152
+ export function formatContextWindow(tokens: number): string {
153
+ if (tokens >= 1_000_000) return `${trimNumber(tokens / 1_000_000)}M ctx`
154
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k ctx`
155
+ return `${tokens} ctx`
156
+ }
157
+
158
+ /**
159
+ * Build the metadata line's segments.
160
+ * @param input.providerName - the provider's display name, omitted inside that provider's own tab.
161
+ * @param input.facts - what the settings document answered.
162
+ * @param input.thinks - whether the catalog advertises reasoning levels for this model.
163
+ * @param input.effortLabel - the localized word for the reasoning segment.
164
+ * @param input.imageLabel - the localized word for the image segment.
165
+ * @returns the segments to join; an empty list means show no line.
166
+ */
167
+ export function metaSegments(input: {
168
+ readonly providerName?: string
169
+ readonly facts: ModelFacts
170
+ readonly thinks: boolean
171
+ readonly effortLabel: string
172
+ readonly imageLabel: string
173
+ }): readonly string[] {
174
+ const segments: string[] = []
175
+ if (input.providerName !== undefined) segments.push(input.providerName)
176
+ if (input.facts.contextWindow !== undefined) segments.push(formatContextWindow(input.facts.contextWindow))
177
+ if (input.thinks) segments.push(input.effortLabel)
178
+ if (input.facts.images === 'yes') segments.push(input.imageLabel)
179
+ return segments
180
+ }
181
+
182
+ function trimNumber(value: number): string {
183
+ const rounded = Math.round(value * 10) / 10
184
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
185
+ }
186
+
187
+ function readPath(value: unknown, path: readonly string[]): unknown {
188
+ let cursor = value
189
+ for (const key of path) {
190
+ if (!isRecord(cursor)) return undefined
191
+ cursor = cursor[key]
192
+ }
193
+ return cursor
194
+ }
195
+
196
+ function isRecord(value: unknown): value is Record_ {
197
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
198
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Catalog flattening, tab derivation, and query matching for the picker.
3
+ *
4
+ * The model catalog groups models by provider; the picker shows one flat list
5
+ * with a tab per provider, so both surfaces read from these rows. Matching
6
+ * delegates to the shell's own ranker, which is why a query behaves the same
7
+ * here as in the built-in menu: case-insensitive, ordered subsequence, prefix
8
+ * hits first.
9
+ */
10
+
11
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
12
+
13
+ /** One provider group of the shared catalog. */
14
+ export type ProviderGroup = ModelDirectoryState['groups'][number]
15
+
16
+ /** One model inside a provider group. */
17
+ export type CatalogModel = ProviderGroup['models'][number]
18
+
19
+ /** One flat catalog row, carrying the provider identity beside the model. */
20
+ export interface ModelRow extends ModelRef {
21
+ readonly providerName: string
22
+ /** The model id inside its provider, the other half of a selection. */
23
+ readonly model: string
24
+ /** The catalog entry itself, for name and reasoning metadata. */
25
+ readonly entry: CatalogModel
26
+ }
27
+
28
+ /** A provider and one of its models: the pair every selection and favorite names. */
29
+ export interface ModelRef {
30
+ readonly provider: string
31
+ readonly model: string
32
+ }
33
+
34
+ /** One picker tab. */
35
+ export interface PickerTab {
36
+ readonly id: string
37
+ readonly label: string
38
+ }
39
+
40
+ /** The tab id standing for every provider. */
41
+ export const ALL_TAB = '*'
42
+
43
+ /**
44
+ * Flatten the catalog to one row per model.
45
+ * @param groups - the shared directory's provider groups, in display order.
46
+ * @returns the rows in catalog order.
47
+ */
48
+ export function flattenGroups(groups: readonly ProviderGroup[]): readonly ModelRow[] {
49
+ return groups.flatMap((group) => group.models.map((model) => ({
50
+ provider: group.id,
51
+ providerName: group.name,
52
+ model: model.id,
53
+ entry: model,
54
+ })))
55
+ }
56
+
57
+ /**
58
+ * Build the picker's tabs: the all-tab, then one per provider that has a model.
59
+ * @param groups - the shared directory's provider groups.
60
+ * @param allLabel - localized label for the all-tab.
61
+ * @returns the tabs in catalog order.
62
+ */
63
+ export function pickerTabs(groups: readonly ProviderGroup[], allLabel = 'All'): readonly PickerTab[] {
64
+ return [
65
+ { id: ALL_TAB, label: allLabel },
66
+ ...groups.filter((group) => group.models.length > 0).map((group) => ({ id: group.id, label: group.name })),
67
+ ]
68
+ }
69
+
70
+ /**
71
+ * @param rows - the flat catalog.
72
+ * @param tab - the tab id; the all-tab keeps every row.
73
+ * @returns the rows the tab shows, in their current order.
74
+ */
75
+ export function rowsForTab(rows: readonly ModelRow[], tab: string): readonly ModelRow[] {
76
+ return tab === ALL_TAB ? rows : rows.filter((row) => row.provider === tab)
77
+ }
78
+
79
+ /** One row handed to the shell's ranker, carrying both searchable keys. */
80
+ export interface SearchCandidate {
81
+ readonly name: string
82
+ readonly label: string
83
+ readonly row: ModelRow
84
+ }
85
+
86
+ /**
87
+ * Shape rows as the shell's ranker candidates: the model name is the name, the
88
+ * provider display name is the second key, so a query reaches either.
89
+ * @param rows - the rows to search.
90
+ * @returns candidates in row order.
91
+ */
92
+ export function searchCandidates(rows: readonly ModelRow[]): readonly SearchCandidate[] {
93
+ return rows.map((row) => ({ name: row.entry.name, label: row.providerName, row }))
94
+ }
95
+
96
+ /**
97
+ * Read the reasoning metadata the catalog advertises for one model.
98
+ * @param groups - the shared directory's provider groups.
99
+ * @returns the metadata, or undefined when that model advertises none.
100
+ */
101
+ export function reasoningOf(
102
+ groups: readonly ProviderGroup[],
103
+ ): (provider: string, model: string) => CatalogModel['reasoning'] {
104
+ return (provider, model) => groups
105
+ .find((group) => group.id === provider)
106
+ ?.models.find((candidate) => candidate.id === model)
107
+ ?.reasoning
108
+ }
109
+
110
+ /**
111
+ * Filter rows by a query over the model name and the provider name.
112
+ * @param rows - the rows to search.
113
+ * @param query - the typed text; an empty or blank query keeps every row.
114
+ * @param rank - the shell's own ranker, so this search behaves like every other one.
115
+ * @returns the matching rows.
116
+ */
117
+ export function searchRows(
118
+ rows: readonly ModelRow[],
119
+ query: string,
120
+ rank: (items: readonly SearchCandidate[], query: string) => readonly SearchCandidate[],
121
+ ): readonly ModelRow[] {
122
+ const trimmed = query.trim()
123
+ if (trimmed === '') return rows
124
+ return rank(searchCandidates(rows), trimmed).map((match) => match.row)
125
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * When the composer menu writes a selection.
3
+ *
4
+ * Both gestures are immediate: clicking a level submits it, clicking a model
5
+ * submits it. The only quiet path is clicking the model already in use — that
6
+ * closes the menu without a write. A model click also restores the level this
7
+ * browser last used for that model, so switching away and back never silently
8
+ * resets the effort.
9
+ */
10
+
11
+ import type { ModelReasoning } from '@deepseek-ai/dsh-api-session-controller/types'
12
+
13
+ /** One complete selection, as the directory's `select` takes it. */
14
+ export interface Selection {
15
+ readonly provider: string
16
+ readonly model: string
17
+ readonly reasoningEffort?: string
18
+ }
19
+
20
+ /** One segment of the reasoning row. */
21
+ export interface EffortChoice {
22
+ readonly key: string
23
+ readonly effort: string | undefined
24
+ readonly label: string
25
+ }
26
+
27
+ /**
28
+ * Build the segments for one model's reasoning row.
29
+ * @param reasoning - the catalog's reasoning metadata, undefined when the model has none.
30
+ * @param providerDefaultLabel - the localized label for the "follow the provider" segment.
31
+ * @returns the segments in catalog order; the provider-default one leads when the model declares no default.
32
+ */
33
+ export function effortChoices(
34
+ reasoning: ModelReasoning | undefined,
35
+ providerDefaultLabel: string,
36
+ ): readonly EffortChoice[] {
37
+ if (reasoning === undefined) return []
38
+ return [
39
+ ...reasoning.defaultEffort === undefined
40
+ ? [{ key: 'provider-default', effort: undefined, label: providerDefaultLabel }]
41
+ : [],
42
+ ...reasoning.efforts.map((level) => ({
43
+ key: level.id,
44
+ effort: level.id as string | undefined,
45
+ label: level.name,
46
+ })),
47
+ ]
48
+ }
49
+
50
+ /**
51
+ * The selection a level click submits.
52
+ * @param live - the selection in force right now.
53
+ * @param effort - the clicked level, undefined meaning the provider default.
54
+ * @returns the selection to submit, or null when the level already is the live one.
55
+ */
56
+ export function planEffortCommit(live: Selection, effort: string | undefined): Selection | null {
57
+ if (sameEffort(live.reasoningEffort, effort)) return null
58
+ return {
59
+ provider: live.provider,
60
+ model: live.model,
61
+ ...effort === undefined ? {} : { reasoningEffort: effort },
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The selection a model click submits.
67
+ * @param live - the selection in force right now, null before a first selection resolves.
68
+ * @param provider - the clicked model's provider.
69
+ * @param model - the clicked model id.
70
+ * @param reasoningOf - the catalog's reasoning metadata for one provider and model.
71
+ * @param remembered - the level this browser last used for the target model, if any.
72
+ * @returns the selection to submit, or null when the click changes nothing.
73
+ */
74
+ export function planModelCommit(
75
+ live: Selection | null,
76
+ provider: string,
77
+ model: string,
78
+ reasoningOf: (provider: string, model: string) => ModelReasoning | undefined,
79
+ remembered?: string | undefined,
80
+ ): Selection | null {
81
+ if (live !== null && sameModel(live, provider, model)) return null
82
+ const offered = reasoningOf(provider, model)
83
+ // The target model's own memory is restored, so switching away and back never
84
+ // silently resets the effort.
85
+ const carried = offered !== undefined
86
+ && remembered !== undefined
87
+ && offered.efforts.some((level) => level.id === remembered)
88
+ return {
89
+ provider,
90
+ model,
91
+ ...carried ? { reasoningEffort: remembered } : {},
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @param live - one selection.
97
+ * @param provider - the provider to compare.
98
+ * @param model - the model id to compare.
99
+ * @returns whether both name the same model.
100
+ */
101
+ export function sameModel(live: Selection, provider: string, model: string): boolean {
102
+ return live.provider === provider && live.model === model
103
+ }
104
+
105
+ /**
106
+ * @param liveEffort - the level in force, undefined meaning the provider default.
107
+ * @param effort - the level in question.
108
+ * @returns whether they name the same level.
109
+ */
110
+ export function sameEffort(liveEffort: string | undefined, effort: string | undefined): boolean {
111
+ return liveEffort === effort
112
+ }