dsh-context-compression-improved 0.5.2 → 0.5.3

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 (28) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -119
  3. package/CHANGELOG.ko.md +143 -118
  4. package/CHANGELOG.md +278 -250
  5. package/CHANGELOG.zh.md +131 -109
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/src/client/EstimatorControls.tsx +277 -277
  11. package/packages/selector/src/client/locales.ts +196 -196
  12. package/packages/selector/src/index.ts +463 -463
  13. package/packages/selector/src/pruner/state.ts +50 -50
  14. package/packages/selector/src/pruner.ts +2402 -2402
  15. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  16. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -149
  17. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  18. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -200
  19. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  20. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  21. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -232
  22. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  23. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  24. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  25. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -96
  26. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -217
  27. package/packages/selector/tests/settings-seat.client.spec.ts +29 -4
  28. package/scripts/toolclass-corpus-replay.mjs +281 -281
@@ -1,277 +1,277 @@
1
- /**
2
- * TokenPilot-inspired estimator channel card components.
3
- *
4
- * Extracted from CompressionProfileSelector.tsx to reduce god-module size.
5
- *
6
- * @module dsh-context-compression-improved/client/EstimatorControls
7
- */
8
-
9
- import { useEffect, useState } from 'react'
10
- import type { ContextCompressionLocaleKey } from './locales.ts'
11
- import type { PresetOptionsPatch } from './preset-options.ts'
12
- import type { PresetOptionsSettings } from '../profiles.ts'
13
- import css from './CompressionProfileSelector.module.css'
14
-
15
- interface EstimatorControlsProps {
16
- options: PresetOptionsSettings
17
- disabled: boolean
18
- save: (options: PresetOptionsPatch) => Promise<void>
19
- settle: (operation: () => Promise<void>) => void
20
- t: (key: ContextCompressionLocaleKey) => string
21
- }
22
-
23
- interface EstimatorInactiveNoticeProps {
24
- /** Already-localized label of the profile that currently owns the selector. */
25
- profile: string
26
- t: (key: ContextCompressionLocaleKey) => string
27
- }
28
-
29
- /**
30
- * The estimator card is gated on the tokenpilot-inspired profile, because
31
- * `presetOptions` is merged into that profile alone. A card that merely
32
- * disappears reads as a missing feature — the first real-machine report was
33
- * exactly that — so keep the heading and its anchor id in place and spend them
34
- * on the reason plus the profile that unlocks the card. The gate itself is
35
- * unchanged: no estimator control exists outside tokenpilot-inspired.
36
- *
37
- * Exported from the estimator card's own module so the gated card and its
38
- * explanation cannot drift apart.
39
- */
40
- export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
41
- return (
42
- <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
43
- <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
44
- <p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
45
- </section>
46
- )
47
- }
48
-
49
- interface CatalogModelEntry { readonly id: string, readonly name: string }
50
- interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
51
- interface EstimatorCatalogBody {
52
- readonly ok?: boolean
53
- readonly providers?: readonly CatalogProviderEntry[]
54
- readonly selection?: { readonly provider: string, readonly model: string }
55
- }
56
-
57
- const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
58
-
59
- /**
60
- * TokenPilot-inspired estimator channel card. Shown only while the
61
- * tokenpilot-inspired profile is selected, and split by channel:
62
- *
63
- * - `host` reuses the providers and credentials the user already configured in
64
- * DSH through the Harness `llm` service, so this card names a provider and a
65
- * model and accepts NO API key — the key field belongs to the direct channel
66
- * alone.
67
- * - `direct` talks to a native OpenAI-compatible endpoint, which is the only
68
- * channel that carries its own base URL and write-only key.
69
- *
70
- * The whole card is advisory: an unconfigured or failing endpoint keeps every
71
- * consumer on its rule-only fallback.
72
- */
73
- export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
74
- const [keyDraft, setKeyDraft] = useState('')
75
- const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
76
- const [model, setModel] = useState(options.estimatorModel ?? '')
77
- const [provider, setProvider] = useState(options.estimatorProvider ?? '')
78
- const mode = options.estimatorMode ?? ''
79
- // Host-mode dropdown source: the live provider/model-group catalog served by
80
- // the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
81
- // pattern). The provider/model fields are always comboboxes: an empty
82
- // catalog (route missing / llm service absent) simply offers no suggestions
83
- // while custom typing keeps working, so nothing breaks on hosts without the
84
- // webServer.
85
- const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
86
- useEffect(() => {
87
- if (mode !== 'host') return
88
- let alive = true
89
- // The catalog route answers once the host webServer/llm services are up;
90
- // poll briefly so the dropdowns fill without reopening the panel.
91
- let attempts = 0
92
- const load = async (): Promise<EstimatorCatalogBody | undefined> => {
93
- try {
94
- const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
95
- return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
96
- } catch {
97
- return undefined
98
- }
99
- }
100
- const tick = (): void => {
101
- attempts += 1
102
- void load().then((body) => {
103
- if (!alive) return
104
- if (body !== undefined && (body.providers?.length ?? 0) > 0) {
105
- setCatalog(body)
106
- return
107
- }
108
- if (attempts < 10) setTimeout(tick, 3_000)
109
- })
110
- }
111
- tick()
112
- return () => { alive = false }
113
- }, [mode])
114
- const hostProviders = catalog?.providers ?? []
115
- // Model suggestions follow the provider draft exactly like dsh-perm-gate's
116
- // receiver card: an empty provider lists every group's models, a chosen
117
- // provider narrows to its own. The provider error (if any) rides along in
118
- // the option label so a broken group is visible without blocking typing.
119
- const providerDraft = provider
120
- const hostModels = hostProviders
121
- .filter(entry => providerDraft === '' || entry.id === providerDraft)
122
- .flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
123
- const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
124
- ?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
125
- const hasKey = (options.estimatorApiKey ?? '') !== ''
126
- const commit = (patch: PresetOptionsPatch) => {
127
- settle(() => save(patch))
128
- }
129
- // What the host channel would actually call right now: an explicit override
130
- // wins, otherwise the session default the catalog reports. Naming the
131
- // resolved route is the whole point of this channel — the user re-enters
132
- // nothing the Harness already knows.
133
- const overrideProvider = options.estimatorProvider ?? ''
134
- const overrideModel = options.estimatorModel ?? ''
135
- const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
136
- const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
137
- const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
138
- ? `${effectiveProvider} / ${effectiveModel}`
139
- : t('estimator.hostUnresolved')
140
- return (
141
- <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
142
- <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
143
- <p className={css.customNote}>{t('estimator.description')}</p>
144
- <label className={css.field}>
145
- <span>{t('estimator.mode')}</span>
146
- <select
147
- value={mode}
148
- disabled={disabled}
149
- onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
150
- >
151
- <option value="">{t('estimator.mode.off')}</option>
152
- <option value="host">{t('estimator.mode.host')}</option>
153
- <option value="direct">{t('estimator.mode.direct')}</option>
154
- </select>
155
- </label>
156
- {mode === '' ? null : mode === 'host' ? (
157
- <>
158
- <label className={css.field}>
159
- <span>{t('estimator.provider')}</span>
160
- <input
161
- type="text"
162
- list="estimator-provider-options"
163
- value={provider}
164
- disabled={disabled}
165
- placeholder={t('estimator.provider.placeholder')}
166
- onChange={(event) => {
167
- const next = event.currentTarget.value
168
- setProvider(next)
169
- // Picking from the datalist fires change without blur: commit
170
- // an exact catalog hit immediately; free typing waits for blur
171
- // so mid-edit keystrokes never disable the panel.
172
- if (next !== '' && hostProviders.some(entry => entry.id === next)) {
173
- commit({ estimatorProvider: next })
174
- }
175
- }}
176
- onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
177
- />
178
- </label>
179
- {/* Datalists ride outside the labels on purpose: option text inside a
180
- label would leak into its accessible name. */}
181
- <datalist id="estimator-provider-options">
182
- {hostProviders.map(entry => (
183
- <option key={entry.id} value={entry.id}>
184
- {entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
185
- </option>
186
- ))}
187
- </datalist>
188
- <label className={css.field}>
189
- <span>{t('estimator.model')}</span>
190
- <input
191
- type="text"
192
- list="estimator-model-options"
193
- value={model}
194
- disabled={disabled}
195
- placeholder={t('estimator.model.placeholder')}
196
- onChange={(event) => {
197
- const next = event.currentTarget.value
198
- setModel(next)
199
- if (next !== '' && hostModels.some(entry => entry.id === next)) {
200
- commit({ estimatorModel: next })
201
- }
202
- }}
203
- onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
204
- />
205
- </label>
206
- <datalist id="estimator-model-options">
207
- {hostModels.map(entry => (
208
- <option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
209
- ))}
210
- </datalist>
211
- {hostProvider?.error === undefined ? null : (
212
- <p className={css.customNote}>{String(hostProvider.error)}</p>
213
- )}
214
- <p className={css.customNote}>{t('estimator.hostReuse')}</p>
215
- <p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
216
- </>
217
- ) : (
218
- <>
219
- <label className={css.field}>
220
- <span>{t('estimator.baseUrl')}</span>
221
- <input
222
- type="text"
223
- value={baseUrl}
224
- disabled={disabled}
225
- placeholder="https://127.0.0.1:8000/v1"
226
- onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
227
- onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
228
- />
229
- </label>
230
- <label className={css.field}>
231
- <span>{t('estimator.model')}</span>
232
- <input
233
- type="text"
234
- value={model}
235
- disabled={disabled}
236
- onChange={(event) => { setModel(event.currentTarget.value) }}
237
- onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
238
- />
239
- </label>
240
- <label className={css.field}>
241
- <span>{t('estimator.apiKey')}</span>
242
- <div style={{ display: 'flex', gap: '6px' }}>
243
- <input
244
- type="password"
245
- autoComplete="off"
246
- spellCheck={false}
247
- value={keyDraft}
248
- disabled={disabled}
249
- placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
250
- onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
251
- onBlur={() => {
252
- const next = keyDraft.trim()
253
- if (next === '') return
254
- settle(() => save({ estimatorApiKey: next }))
255
- setKeyDraft('')
256
- }}
257
- onKeyDown={(event) => {
258
- if (event.key === 'Enter') event.currentTarget.blur()
259
- }}
260
- />
261
- {hasKey ? (
262
- <button
263
- type="button"
264
- disabled={disabled}
265
- onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
266
- >
267
- {t('estimator.apiKey.clear')}
268
- </button>
269
- ) : null}
270
- </div>
271
- {hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
272
- </label>
273
- </>
274
- )}
275
- </section>
276
- )
277
- }
1
+ /**
2
+ * TokenPilot-inspired estimator channel card components.
3
+ *
4
+ * Extracted from CompressionProfileSelector.tsx to reduce god-module size.
5
+ *
6
+ * @module dsh-context-compression-improved/client/EstimatorControls
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import type { ContextCompressionLocaleKey } from './locales.ts'
11
+ import type { PresetOptionsPatch } from './preset-options.ts'
12
+ import type { PresetOptionsSettings } from '../profiles.ts'
13
+ import css from './CompressionProfileSelector.module.css'
14
+
15
+ interface EstimatorControlsProps {
16
+ options: PresetOptionsSettings
17
+ disabled: boolean
18
+ save: (options: PresetOptionsPatch) => Promise<void>
19
+ settle: (operation: () => Promise<void>) => void
20
+ t: (key: ContextCompressionLocaleKey) => string
21
+ }
22
+
23
+ interface EstimatorInactiveNoticeProps {
24
+ /** Already-localized label of the profile that currently owns the selector. */
25
+ profile: string
26
+ t: (key: ContextCompressionLocaleKey) => string
27
+ }
28
+
29
+ /**
30
+ * The estimator card is gated on the tokenpilot-inspired profile, because
31
+ * `presetOptions` is merged into that profile alone. A card that merely
32
+ * disappears reads as a missing feature — the first real-machine report was
33
+ * exactly that — so keep the heading and its anchor id in place and spend them
34
+ * on the reason plus the profile that unlocks the card. The gate itself is
35
+ * unchanged: no estimator control exists outside tokenpilot-inspired.
36
+ *
37
+ * Exported from the estimator card's own module so the gated card and its
38
+ * explanation cannot drift apart.
39
+ */
40
+ export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
41
+ return (
42
+ <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
43
+ <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
44
+ <p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
45
+ </section>
46
+ )
47
+ }
48
+
49
+ interface CatalogModelEntry { readonly id: string, readonly name: string }
50
+ interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
51
+ interface EstimatorCatalogBody {
52
+ readonly ok?: boolean
53
+ readonly providers?: readonly CatalogProviderEntry[]
54
+ readonly selection?: { readonly provider: string, readonly model: string }
55
+ }
56
+
57
+ const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
58
+
59
+ /**
60
+ * TokenPilot-inspired estimator channel card. Shown only while the
61
+ * tokenpilot-inspired profile is selected, and split by channel:
62
+ *
63
+ * - `host` reuses the providers and credentials the user already configured in
64
+ * DSH through the Harness `llm` service, so this card names a provider and a
65
+ * model and accepts NO API key — the key field belongs to the direct channel
66
+ * alone.
67
+ * - `direct` talks to a native OpenAI-compatible endpoint, which is the only
68
+ * channel that carries its own base URL and write-only key.
69
+ *
70
+ * The whole card is advisory: an unconfigured or failing endpoint keeps every
71
+ * consumer on its rule-only fallback.
72
+ */
73
+ export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
74
+ const [keyDraft, setKeyDraft] = useState('')
75
+ const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
76
+ const [model, setModel] = useState(options.estimatorModel ?? '')
77
+ const [provider, setProvider] = useState(options.estimatorProvider ?? '')
78
+ const mode = options.estimatorMode ?? ''
79
+ // Host-mode dropdown source: the live provider/model-group catalog served by
80
+ // the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
81
+ // pattern). The provider/model fields are always comboboxes: an empty
82
+ // catalog (route missing / llm service absent) simply offers no suggestions
83
+ // while custom typing keeps working, so nothing breaks on hosts without the
84
+ // webServer.
85
+ const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
86
+ useEffect(() => {
87
+ if (mode !== 'host') return
88
+ let alive = true
89
+ // The catalog route answers once the host webServer/llm services are up;
90
+ // poll briefly so the dropdowns fill without reopening the panel.
91
+ let attempts = 0
92
+ const load = async (): Promise<EstimatorCatalogBody | undefined> => {
93
+ try {
94
+ const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
95
+ return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+ const tick = (): void => {
101
+ attempts += 1
102
+ void load().then((body) => {
103
+ if (!alive) return
104
+ if (body !== undefined && (body.providers?.length ?? 0) > 0) {
105
+ setCatalog(body)
106
+ return
107
+ }
108
+ if (attempts < 10) setTimeout(tick, 3_000)
109
+ })
110
+ }
111
+ tick()
112
+ return () => { alive = false }
113
+ }, [mode])
114
+ const hostProviders = catalog?.providers ?? []
115
+ // Model suggestions follow the provider draft exactly like dsh-perm-gate's
116
+ // receiver card: an empty provider lists every group's models, a chosen
117
+ // provider narrows to its own. The provider error (if any) rides along in
118
+ // the option label so a broken group is visible without blocking typing.
119
+ const providerDraft = provider
120
+ const hostModels = hostProviders
121
+ .filter(entry => providerDraft === '' || entry.id === providerDraft)
122
+ .flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
123
+ const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
124
+ ?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
125
+ const hasKey = (options.estimatorApiKey ?? '') !== ''
126
+ const commit = (patch: PresetOptionsPatch) => {
127
+ settle(() => save(patch))
128
+ }
129
+ // What the host channel would actually call right now: an explicit override
130
+ // wins, otherwise the session default the catalog reports. Naming the
131
+ // resolved route is the whole point of this channel — the user re-enters
132
+ // nothing the Harness already knows.
133
+ const overrideProvider = options.estimatorProvider ?? ''
134
+ const overrideModel = options.estimatorModel ?? ''
135
+ const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
136
+ const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
137
+ const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
138
+ ? `${effectiveProvider} / ${effectiveModel}`
139
+ : t('estimator.hostUnresolved')
140
+ return (
141
+ <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
142
+ <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
143
+ <p className={css.customNote}>{t('estimator.description')}</p>
144
+ <label className={css.field}>
145
+ <span>{t('estimator.mode')}</span>
146
+ <select
147
+ value={mode}
148
+ disabled={disabled}
149
+ onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
150
+ >
151
+ <option value="">{t('estimator.mode.off')}</option>
152
+ <option value="host">{t('estimator.mode.host')}</option>
153
+ <option value="direct">{t('estimator.mode.direct')}</option>
154
+ </select>
155
+ </label>
156
+ {mode === '' ? null : mode === 'host' ? (
157
+ <>
158
+ <label className={css.field}>
159
+ <span>{t('estimator.provider')}</span>
160
+ <input
161
+ type="text"
162
+ list="estimator-provider-options"
163
+ value={provider}
164
+ disabled={disabled}
165
+ placeholder={t('estimator.provider.placeholder')}
166
+ onChange={(event) => {
167
+ const next = event.currentTarget.value
168
+ setProvider(next)
169
+ // Picking from the datalist fires change without blur: commit
170
+ // an exact catalog hit immediately; free typing waits for blur
171
+ // so mid-edit keystrokes never disable the panel.
172
+ if (next !== '' && hostProviders.some(entry => entry.id === next)) {
173
+ commit({ estimatorProvider: next })
174
+ }
175
+ }}
176
+ onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
177
+ />
178
+ </label>
179
+ {/* Datalists ride outside the labels on purpose: option text inside a
180
+ label would leak into its accessible name. */}
181
+ <datalist id="estimator-provider-options">
182
+ {hostProviders.map(entry => (
183
+ <option key={entry.id} value={entry.id}>
184
+ {entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
185
+ </option>
186
+ ))}
187
+ </datalist>
188
+ <label className={css.field}>
189
+ <span>{t('estimator.model')}</span>
190
+ <input
191
+ type="text"
192
+ list="estimator-model-options"
193
+ value={model}
194
+ disabled={disabled}
195
+ placeholder={t('estimator.model.placeholder')}
196
+ onChange={(event) => {
197
+ const next = event.currentTarget.value
198
+ setModel(next)
199
+ if (next !== '' && hostModels.some(entry => entry.id === next)) {
200
+ commit({ estimatorModel: next })
201
+ }
202
+ }}
203
+ onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
204
+ />
205
+ </label>
206
+ <datalist id="estimator-model-options">
207
+ {hostModels.map(entry => (
208
+ <option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
209
+ ))}
210
+ </datalist>
211
+ {hostProvider?.error === undefined ? null : (
212
+ <p className={css.customNote}>{String(hostProvider.error)}</p>
213
+ )}
214
+ <p className={css.customNote}>{t('estimator.hostReuse')}</p>
215
+ <p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
216
+ </>
217
+ ) : (
218
+ <>
219
+ <label className={css.field}>
220
+ <span>{t('estimator.baseUrl')}</span>
221
+ <input
222
+ type="text"
223
+ value={baseUrl}
224
+ disabled={disabled}
225
+ placeholder="https://127.0.0.1:8000/v1"
226
+ onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
227
+ onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
228
+ />
229
+ </label>
230
+ <label className={css.field}>
231
+ <span>{t('estimator.model')}</span>
232
+ <input
233
+ type="text"
234
+ value={model}
235
+ disabled={disabled}
236
+ onChange={(event) => { setModel(event.currentTarget.value) }}
237
+ onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
238
+ />
239
+ </label>
240
+ <label className={css.field}>
241
+ <span>{t('estimator.apiKey')}</span>
242
+ <div style={{ display: 'flex', gap: '6px' }}>
243
+ <input
244
+ type="password"
245
+ autoComplete="off"
246
+ spellCheck={false}
247
+ value={keyDraft}
248
+ disabled={disabled}
249
+ placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
250
+ onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
251
+ onBlur={() => {
252
+ const next = keyDraft.trim()
253
+ if (next === '') return
254
+ settle(() => save({ estimatorApiKey: next }))
255
+ setKeyDraft('')
256
+ }}
257
+ onKeyDown={(event) => {
258
+ if (event.key === 'Enter') event.currentTarget.blur()
259
+ }}
260
+ />
261
+ {hasKey ? (
262
+ <button
263
+ type="button"
264
+ disabled={disabled}
265
+ onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
266
+ >
267
+ {t('estimator.apiKey.clear')}
268
+ </button>
269
+ ) : null}
270
+ </div>
271
+ {hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
272
+ </label>
273
+ </>
274
+ )}
275
+ </section>
276
+ )
277
+ }