@lynn123411/dsh-model-submenu 0.2.5 → 0.3.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/DETAILS.md +8 -2
- package/README.md +10 -8
- package/lib/client.js +1158 -326
- package/lib/client.js.map +1 -1
- package/locale/en.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +26 -20
- package/src/client/ModelPicker.tsx +305 -0
- package/src/client/ModelRow.tsx +127 -0
- package/src/client/ModelSubmenu.module.css +338 -97
- package/src/client/ModelSubmenu.tsx +237 -233
- package/src/client/effort-memory.ts +90 -0
- package/src/client/facts-source.ts +93 -0
- package/src/client/favorites.ts +103 -0
- package/src/client/icons.tsx +36 -0
- package/src/client/index.ts +29 -8
- package/src/client/locales.ts +66 -33
- package/src/client/model-facts.ts +198 -0
- package/src/client/rows.ts +125 -0
- package/src/client/selection.ts +112 -0
|
@@ -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
|
+
}
|