@ouraihub/aether-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,329 @@
1
+ // Editable form of a provider and its conversion to save/probe requests (docs/11 §3, §7); key helpers (docs/34 §5).
2
+ import type { KitI18n } from '../i18n'
3
+ import { allowsAuto, dialectFits } from './reasoning'
4
+ import {
5
+ isKitClientError,
6
+ type Endpoint,
7
+ type EndpointKey,
8
+ type KeyStrategy,
9
+ type EndpointSave,
10
+ type Model,
11
+ type ProbeResult,
12
+ type ModelSave,
13
+ type ModelType,
14
+ type ProbeTarget,
15
+ type Protocol,
16
+ type Provider,
17
+ type ProviderSave,
18
+ } from '../ports'
19
+
20
+ export interface EndpointDraft {
21
+ /** Stored endpoint, `null` for a new one. Its `id` must go back on save (docs/11 陷阱 1). */
22
+ saved: Endpoint | null
23
+ slug: string
24
+ baseUrl: string
25
+ protocol: Protocol
26
+ keyStrategy: KeyStrategy
27
+ /** `null` = paint at `baseUrl` (docs/36 §3.4). */
28
+ imageBaseUrl: string | null
29
+ /** `null` = edit at `baseUrl` (docs/37 §2). */
30
+ imageEditBaseUrl: string | null
31
+ headers: Endpoint['headers']
32
+ models: Model[]
33
+ }
34
+
35
+ export interface ProviderDraft {
36
+ id: string | null
37
+ name: string
38
+ enabled: boolean
39
+ notes: string
40
+ endpoints: EndpointDraft[]
41
+ }
42
+
43
+ export function toDraft(provider: Provider): ProviderDraft {
44
+ return {
45
+ id: provider.id,
46
+ name: provider.name,
47
+ enabled: provider.enabled,
48
+ notes: provider.notes,
49
+ endpoints: provider.endpoints.map((saved) => ({
50
+ saved,
51
+ slug: saved.slug,
52
+ baseUrl: saved.baseUrl,
53
+ protocol: saved.protocol,
54
+ keyStrategy: saved.keyStrategy,
55
+ imageBaseUrl: saved.imageBaseUrl,
56
+ imageEditBaseUrl: saved.imageEditBaseUrl,
57
+ headers: saved.headers,
58
+ models: saved.models,
59
+ })),
60
+ }
61
+ }
62
+
63
+ export function newEndpoint(taken: readonly EndpointDraft[]): EndpointDraft {
64
+ return {
65
+ saved: null,
66
+ slug: nextSlug(taken),
67
+ baseUrl: '',
68
+ protocol: 'openai-chat',
69
+ keyStrategy: 'round_robin',
70
+ imageBaseUrl: null,
71
+ imageEditBaseUrl: null,
72
+ headers: {},
73
+ models: [],
74
+ }
75
+ }
76
+
77
+ /** First free `ep-N`; slugs are unique per provider and hidden from users. */
78
+ export function nextSlug(taken: readonly EndpointDraft[]): string {
79
+ const used = new Set(taken.map((endpoint) => endpoint.slug))
80
+ let n = taken.length + 1
81
+ while (used.has(`ep-${n}`)) n++
82
+ return `ep-${n}`
83
+ }
84
+
85
+ export const validUrl = (url: string): boolean => /^https?:\/\/\S+$/.test(url.trim())
86
+
87
+ /** Endpoints are stored even without an address (keys hang on them); only a malformed address stays local. */
88
+ export function savable(draft: ProviderDraft): ProviderDraft {
89
+ return {
90
+ ...draft,
91
+ endpoints: draft.endpoints.filter((e) => e.saved !== null || !e.baseUrl.trim() || validUrl(e.baseUrl)),
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Takes the stored provider after a save without discarding what the user typed meanwhile:
97
+ * sent endpoints get their stored record (new ones matched by slug); edits stay in the draft.
98
+ */
99
+ export function adopt(current: ProviderDraft, sent: ProviderDraft, stored: Provider): ProviderDraft {
100
+ const sentEndpoints = new Map(sent.endpoints.map((endpoint) => [endpoint.slug, endpoint]))
101
+ return {
102
+ ...current,
103
+ id: stored.id,
104
+ endpoints: current.endpoints.map((endpoint) => {
105
+ const original = sentEndpoints.get(endpoint.slug)
106
+ const record =
107
+ original &&
108
+ stored.endpoints.find((item) => (original.saved ? item.id === original.saved.id : item.slug === original.slug))
109
+ return record ? { ...endpoint, saved: record } : endpoint
110
+ }),
111
+ }
112
+ }
113
+
114
+ /** Probe selection preserves every existing user field; only new models take probe defaults. */
115
+ export function mergeModels(
116
+ current: readonly Model[],
117
+ probed: ProbeResult['models'],
118
+ checked: Readonly<Record<string, boolean>>,
119
+ types: Readonly<Record<string, ModelType>> = {},
120
+ ): Model[] {
121
+ const fetched = new Set(probed.map((model) => model.id))
122
+ const existing = new Map(current.map((model) => [model.id, model]))
123
+ const merged = new Map(current.filter((model) => !fetched.has(model.id)).map((model) => [model.id, model]))
124
+ for (const model of probed) {
125
+ if (!checked[model.id] || merged.has(model.id)) continue
126
+ const previous = existing.get(model.id)
127
+ const base: Model = previous ?? {
128
+ id: model.id,
129
+ name: model.name,
130
+ groupName: model.group,
131
+ modelType: model.modelType,
132
+ typeSource: model.typeSource,
133
+ contextWindow: model.contextWindow,
134
+ maxOutput: model.maxOutput,
135
+ reasoning: model.reasoning,
136
+ nativeWebSearch: model.nativeWebSearch,
137
+ pricing: model.pricing,
138
+ canVision: model.canVision,
139
+ supportsTools: model.supportsTools,
140
+ }
141
+ merged.set(model.id, withType(base, types[model.id] ?? base.modelType))
142
+ }
143
+ return [...merged.values()]
144
+ }
145
+
146
+ export function toSave(draft: ProviderDraft): ProviderSave {
147
+ const endpoints = draft.endpoints.map((e): EndpointSave => {
148
+ const save: EndpointSave = {
149
+ slug: e.slug.trim(),
150
+ baseUrl: e.baseUrl.trim(),
151
+ protocol: e.protocol,
152
+ keyStrategy: e.keyStrategy,
153
+ imageBaseUrl: e.imageBaseUrl?.trim() || null,
154
+ imageEditBaseUrl: e.imageEditBaseUrl?.trim() || null,
155
+ headers: e.headers,
156
+ models: e.models.map((model) => {
157
+ const save: ModelSave = {
158
+ ...model,
159
+ nativeWebSearch: model.modelType === 'chat' && e.protocol !== 'openai-chat' && model.nativeWebSearch,
160
+ }
161
+ // Contract §2: omitted reasoning keeps the stored value, so an untouched (possibly inferred) value is not frozen.
162
+ // Key order matches: edits spread the stored config and append `budgetTokens` last, like serde.
163
+ const before = e.saved?.models.find((item) => item.id === model.id)
164
+ const unchanged = before
165
+ ? JSON.stringify(before.reasoning) === JSON.stringify(model.reasoning)
166
+ : model.reasoning === null
167
+ if (unchanged) delete save.reasoning
168
+ return save
169
+ }),
170
+ }
171
+ if (e.saved) save.id = e.saved.id
172
+ return save
173
+ })
174
+ const save = { name: draft.name.trim(), enabled: draft.enabled, notes: draft.notes, endpoints }
175
+ return draft.id === null ? save : { id: draft.id, ...save }
176
+ }
177
+
178
+ /** Model list group: the user's group name, else the id prefix (`gpt-4o` → `gpt`). */
179
+ export const idPrefix = (id: string): string => id.split(/[-/:]/)[0] || ''
180
+ export const groupKey = (model: Model): string => model.groupName ?? idPrefix(model.id)
181
+
182
+ /** Purpose maps onto existing fields (docs/41 §3.3): edit = image + canVision. */
183
+ export type Purpose = 'chat' | 'image' | 'edit'
184
+ export const purposeOf = (model: Model): Purpose | null =>
185
+ model.modelType === 'chat' ? 'chat' : model.modelType === 'image' ? (model.canVision ? 'edit' : 'image') : null
186
+ export function withPurpose(model: Model, purpose: Purpose): Model {
187
+ const typed = withType(model, purpose === 'chat' ? 'chat' : 'image')
188
+ return purpose === 'chat' ? typed : { ...typed, canVision: purpose === 'edit' }
189
+ }
190
+ /** A user-chosen type; leaving chat drops chat-only settings the backend would reject. */
191
+ export function withType(model: Model, modelType: ModelType): Model {
192
+ if (modelType === model.modelType) return model
193
+ const next: Model = { ...model, modelType, typeSource: 'user' }
194
+ return modelType === 'chat' ? next : { ...next, reasoning: null, nativeWebSearch: false, pricing: null }
195
+ }
196
+
197
+ export type ModelField = 'name' | 'groupName' | 'contextWindow' | 'maxOutput' | 'reasoning' | 'pricing'
198
+ export interface ModelError {
199
+ field: ModelField
200
+ reason: string
201
+ }
202
+
203
+ /** Front-end mirror of the backend model rules (contract §3); reason codes match `bad_request(field, reason)`. */
204
+ export function validateModel(model: Model, protocol: Protocol): ModelError[] {
205
+ const errors: ModelError[] = []
206
+ const add = (field: ModelField, reason: string) => errors.push({ field, reason })
207
+ for (const field of ['name', 'groupName'] as const)
208
+ if ([...(model[field]?.trim() ?? '')].length > 80) add(field, 'too_long')
209
+ const { contextWindow, maxOutput, reasoning, pricing } = model
210
+ const chat = model.modelType === 'chat'
211
+ // Non-integers also fail the backend's integer schema; doc §4.2 shows them as the same `invalid`.
212
+ if (contextWindow !== null && !(Number.isInteger(contextWindow) && contextWindow >= 1))
213
+ add('contextWindow', 'invalid')
214
+ if (maxOutput !== null && !(Number.isInteger(maxOutput) && maxOutput >= 1)) add('maxOutput', 'invalid')
215
+ else if (chat && contextWindow !== null && maxOutput !== null && maxOutput >= contextWindow)
216
+ add('maxOutput', 'exceeds_context')
217
+ if (reasoning) {
218
+ const { efforts, dialect, budgetTokens } = reasoning
219
+ if (
220
+ !chat ||
221
+ !efforts.length ||
222
+ efforts.includes('default') ||
223
+ (efforts.includes('auto') && !allowsAuto(dialect)) ||
224
+ new Set(efforts).size !== efforts.length
225
+ )
226
+ add('reasoning', 'invalid_efforts')
227
+ else if (!dialectFits(dialect, protocol)) add('reasoning', 'protocol_mismatch')
228
+ else if (dialect === 'anthropic-budget') {
229
+ if (budgetTokens == null || budgetTokens < 1024 || budgetTokens >= (maxOutput ?? 4096))
230
+ add('reasoning', 'invalid_budget')
231
+ } else if (dialect === 'qwen') {
232
+ if (budgetTokens === 0) add('reasoning', 'invalid_budget')
233
+ } else if (budgetTokens != null) add('reasoning', 'unexpected_budget')
234
+ }
235
+ if (pricing) {
236
+ const { tiers } = pricing
237
+ const price = (value: number | null) => value === null || (Number.isFinite(value) && value >= 0 && value <= 1e6)
238
+ if (
239
+ !chat ||
240
+ !tiers.length ||
241
+ tiers.length > 8 ||
242
+ tiers[0]?.minInputTokens !== 0 ||
243
+ tiers.some(
244
+ (tier, index) =>
245
+ !Number.isSafeInteger(tier.minInputTokens) ||
246
+ (index > 0 && tier.minInputTokens <= (tiers[index - 1]?.minInputTokens ?? 0)) ||
247
+ ![tier.input, tier.output, tier.cacheRead, tier.cacheWrite].every(price),
248
+ )
249
+ )
250
+ add('pricing', 'invalid')
251
+ }
252
+ return errors
253
+ }
254
+
255
+ /** Always a draft target: with endpointId the backend uses the stored keys on an edited, unsaved address. */
256
+ export function probeTarget(e: EndpointDraft, keyId?: string): ProbeTarget {
257
+ const target: ProbeTarget = { kind: 'draft', baseUrl: e.baseUrl.trim(), protocol: e.protocol }
258
+ if (e.saved) target.endpointId = e.saved.id
259
+ if (keyId) target.keyId = keyId
260
+ return target
261
+ }
262
+
263
+ const apiBase = (baseUrl: string): string => {
264
+ const trimmed = baseUrl.trim().replace(/\/+$/, '')
265
+ return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
266
+ }
267
+
268
+ /** Mirrors `aether_providers::api_base` + `/models` for the `→ GET` line before saving. */
269
+ export const probeUrl = (baseUrl: string): string => `${apiBase(baseUrl)}/models`
270
+
271
+ const CHAT_PATH: Record<Protocol, string> = {
272
+ 'openai-chat': 'chat/completions',
273
+ 'openai-responses': 'responses',
274
+ 'anthropic-messages': 'messages',
275
+ }
276
+ /** Mirrors `aether_chat::protocol` request paths. */
277
+ export const chatUrl = (baseUrl: string, protocol: Protocol): string => `${apiBase(baseUrl)}/${CHAT_PATH[protocol]}`
278
+
279
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}$/
280
+ const RESERVED_HEADERS: Record<string, true> = {
281
+ authorization: true,
282
+ 'x-api-key': true,
283
+ 'anthropic-version': true,
284
+ host: true,
285
+ 'content-type': true,
286
+ 'content-length': true,
287
+ accept: true,
288
+ connection: true,
289
+ 'transfer-encoding': true,
290
+ cookie: true,
291
+ }
292
+ export type HeadersError = { key: 'headers_too_many' | 'headers_invalid' } | { key: 'headers_reserved'; name: string }
293
+
294
+ /** Front-end mirror of the backend header rules (docs/36 §3.3); blank rows are dropped. */
295
+ export function checkHeaders(
296
+ rows: readonly { name: string; value: string }[],
297
+ ): { headers: Record<string, string> } | { error: HeadersError } {
298
+ const kept = rows.map((r) => ({ name: r.name.trim(), value: r.value })).filter((r) => r.name || r.value)
299
+ if (kept.length > 20) return { error: { key: 'headers_too_many' } }
300
+ const headers: Record<string, string> = {}
301
+ const seen = new Set<string>()
302
+ for (const { name, value } of kept) {
303
+ const lower = name.toLowerCase()
304
+ if (Object.hasOwn(RESERVED_HEADERS, lower)) return { error: { key: 'headers_reserved', name } }
305
+ if (!HEADER_NAME.test(name) || seen.has(lower) || value.length > 2048 || /[\r\n]/.test(value))
306
+ return { error: { key: 'headers_invalid' } }
307
+ seen.add(lower)
308
+ headers[name] = value
309
+ }
310
+ return { headers }
311
+ }
312
+
313
+ /** Primary key: the first enabled one, else the first. */
314
+ export const primaryKey = (keys: readonly EndpointKey[]): EndpointKey | null =>
315
+ keys.find((k) => k.enabled) ?? keys[0] ?? null
316
+
317
+ export const maskedText = (key: EndpointKey, t: KitI18n['t']): string =>
318
+ key.readable && key.masked ? key.masked.replace(/\*{4,}/, '****') : t('providers.key_unreadable')
319
+
320
+ /** Maps key save errors (docs/34 §3.2) to their texts. */
321
+ export function keyError(error: unknown, t: KitI18n['t']): string {
322
+ if (!isKitClientError(error)) return t('providers.keys_save_failed')
323
+ const { field, reason } = error.details
324
+ if (reason === 'duplicate') return t('providers.keys_err_duplicate')
325
+ if (reason === 'too_many') return t('providers.keys_err_too_many')
326
+ if (field === 'label' && reason === 'too_long') return t('providers.keys_err_label')
327
+ if (field === 'apiKey' && reason === 'empty') return t('providers.keys_err_empty')
328
+ return error.code === 'bad_request' ? t('providers.keys_save_failed') : t(`errors.${error.code}`)
329
+ }
@@ -0,0 +1,146 @@
1
+ import { KitClientError, type CallOptions, type ProbeResult, type Provider, type ProvidersPort } from '../ports'
2
+ import { adopt, probeTarget, savable, toSave, type ProviderDraft } from './draft'
3
+
4
+ export interface ProviderProbeBatch {
5
+ results: { keyId: string | null; result: ProbeResult }[]
6
+ usable: ProbeResult | null
7
+ }
8
+ export interface ProviderEditSession {
9
+ commit(): Promise<void>
10
+ probe(endpointSlug: string, options?: CallOptions): Promise<ProviderProbeBatch>
11
+ invalidate(): void
12
+ }
13
+
14
+ const cancelled = () =>
15
+ new KitClientError({ code: 'cancelled', message: 'Provider editing session cancelled', details: {} })
16
+ const identityError = () =>
17
+ new KitClientError({ code: 'internal', message: 'Saved provider identity is missing or ambiguous', details: {} })
18
+ // Copy plain DTO data without losing invalid numeric edits or retaining Svelte proxies.
19
+ function snapshot<T>(value: T): T {
20
+ if (Array.isArray(value)) return value.map((item) => snapshot(item)) as T
21
+ if (value !== null && typeof value === 'object')
22
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item)])) as T
23
+ return value
24
+ }
25
+ export function createProviderEditSession(
26
+ port: ProvidersPort,
27
+ state: {
28
+ readDraft(): ProviderDraft
29
+ writeDraft(next: ProviderDraft): void
30
+ readProviders(): readonly Provider[]
31
+ onSaved(next: Provider[]): void
32
+ },
33
+ ): ProviderEditSession {
34
+ let valid = true
35
+ let saving = false
36
+ let dirty = false
37
+ let waiters: { resolve(): void; reject(error: unknown): void }[] = []
38
+ let activeProbe: { cancel(): void } | null = null
39
+
40
+ function settle(error?: unknown) {
41
+ const pending = waiters
42
+ waiters = []
43
+ for (const waiter of pending) {
44
+ if (error !== undefined) waiter.reject(error)
45
+ else waiter.resolve()
46
+ }
47
+ }
48
+ async function drain() {
49
+ try {
50
+ while (valid && dirty) {
51
+ dirty = false
52
+ const sent = snapshot(savable(state.readDraft()))
53
+ const before = new Set(state.readProviders().map((provider) => provider.id))
54
+ const next = await port.save(toSave(sent))
55
+ if (!valid) return
56
+ const matches = next.filter((provider) =>
57
+ sent.id === null ? !before.has(provider.id) : provider.id === sent.id,
58
+ )
59
+ const stored = matches[0]
60
+ if (matches.length !== 1 || !stored) throw identityError()
61
+ for (const endpoint of sent.endpoints) {
62
+ const records = stored.endpoints.filter((record) =>
63
+ endpoint.saved ? record.id === endpoint.saved.id : record.slug === endpoint.slug,
64
+ )
65
+ if (records.length !== 1) throw identityError()
66
+ }
67
+ state.writeDraft(adopt(state.readDraft(), sent, stored))
68
+ if (!valid) return
69
+ state.onSaved(next)
70
+ }
71
+ if (valid) settle()
72
+ } catch (error) {
73
+ if (valid) {
74
+ dirty = true
75
+ settle(error)
76
+ }
77
+ } finally {
78
+ saving = false
79
+ }
80
+ }
81
+ function commit(): Promise<void> {
82
+ if (!valid) return Promise.reject(cancelled())
83
+ dirty = true
84
+ const pending = Promise.withResolvers<void>()
85
+ waiters.push(pending)
86
+ if (!saving) {
87
+ saving = true
88
+ void drain()
89
+ }
90
+ return pending.promise
91
+ }
92
+ function probe(slug: string, options?: CallOptions): Promise<ProviderProbeBatch> {
93
+ activeProbe?.cancel()
94
+ if (!valid || options?.signal?.aborted) return Promise.reject(cancelled())
95
+ const controller = new AbortController()
96
+ let stopped = false
97
+ const cancellation = Promise.withResolvers<never>()
98
+ const request = {
99
+ cancel() {
100
+ if (stopped) return
101
+ stopped = true
102
+ controller.abort()
103
+ cancellation.reject(cancelled())
104
+ },
105
+ }
106
+ activeProbe = request
107
+ const ensureCurrent = () => {
108
+ if (!valid || stopped || options?.signal?.aborted) throw cancelled()
109
+ }
110
+ options?.signal?.addEventListener('abort', request.cancel, { once: true })
111
+ const run = async (): Promise<ProviderProbeBatch> => {
112
+ await commit()
113
+ ensureCurrent()
114
+ const endpoint = snapshot(state.readDraft().endpoints.find((item) => item.slug === slug) ?? null)
115
+ if (!endpoint)
116
+ throw new KitClientError({ code: 'not_found', message: 'Provider endpoint no longer exists', details: {} })
117
+ const enabled = endpoint.saved?.keys.filter((key) => key.enabled) ?? []
118
+ const keyIds = enabled.length >= 2 ? enabled.map((key) => key.id) : [null]
119
+ const batch: ProviderProbeBatch = { results: [], usable: null }
120
+ for (const keyId of keyIds) {
121
+ ensureCurrent()
122
+ const result = await port.probe(probeTarget(endpoint, keyId ?? undefined), { signal: controller.signal })
123
+ ensureCurrent()
124
+ batch.results.push({ keyId, result })
125
+ if (result.error === null) batch.usable ??= result
126
+ }
127
+ return batch
128
+ }
129
+ return Promise.race([run(), cancellation.promise]).finally(() => {
130
+ options?.signal?.removeEventListener('abort', request.cancel)
131
+ if (activeProbe === request) activeProbe = null
132
+ })
133
+ }
134
+ return {
135
+ commit,
136
+ probe,
137
+ invalidate() {
138
+ if (!valid) return
139
+ valid = false
140
+ dirty = false
141
+ activeProbe?.cancel()
142
+ activeProbe = null
143
+ settle(cancelled())
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,5 @@
1
+ // ProvidersSettings and ModelSelect (docs/16 §4.1).
2
+ export { default as ModelSelect } from './ModelSelect.svelte'
3
+ export { default as ProvidersSettings } from './ProvidersSettings.svelte'
4
+ export { providersRevision } from './revision.svelte'
5
+ export { REASONING_EFFORTS } from './reasoning'
@@ -0,0 +1,41 @@
1
+ import type { Protocol, ReasoningEffort, ReasoningDialect, ReasoningConfig } from '../ports'
2
+
3
+ export const REASONING_EFFORTS: ReasoningEffort[] = ['none', 'auto', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
4
+ export const REASONING_DIALECTS: ReasoningDialect[] = [
5
+ 'openai',
6
+ 'anthropic-budget',
7
+ 'anthropic-adaptive',
8
+ 'thinking-type',
9
+ 'qwen',
10
+ ]
11
+
12
+ /** Whether `dialect` is written on `protocol` (same rule as the backends). */
13
+ export function dialectFits(dialect: ReasoningDialect, protocol: Protocol): boolean {
14
+ if (dialect === 'anthropic-budget' || dialect === 'anthropic-adaptive') return protocol === 'anthropic-messages'
15
+ if (dialect === 'openai') return protocol !== 'anthropic-messages'
16
+ return protocol === 'openai-chat'
17
+ }
18
+
19
+ /** `auto` (on, depth left to the provider) exists only for switch-style dialects. */
20
+ export const allowsAuto = (dialect: ReasoningDialect): boolean => dialect === 'thinking-type' || dialect === 'qwen'
21
+
22
+ export function emptyReasoning(dialect: ReasoningDialect = 'openai'): ReasoningConfig {
23
+ return {
24
+ dialect,
25
+ efforts: allowsAuto(dialect) ? ['none', 'auto'] : ['low', 'medium', 'high'],
26
+ ...(dialect === 'anthropic-budget' ? { budgetTokens: 1024 } : {}),
27
+ }
28
+ }
29
+
30
+ /** The saved choice if allowed, else the nearest allowed effort (ties go higher); `default` without one. */
31
+ export function nearestEffort(chosen: ReasoningEffort, allowed: readonly ReasoningEffort[]): ReasoningEffort {
32
+ if (chosen === 'default' || allowed.includes(chosen)) return allowed.length ? chosen : 'default'
33
+ const at = REASONING_EFFORTS.indexOf(chosen)
34
+ let best: ReasoningEffort = 'default'
35
+ let bestDistance = Infinity
36
+ for (const effort of allowed) {
37
+ const distance = Math.abs(REASONING_EFFORTS.indexOf(effort) - at)
38
+ if (distance <= bestDistance) [best, bestDistance] = [effort, distance]
39
+ }
40
+ return best
41
+ }
@@ -0,0 +1,4 @@
1
+ // Bumped after a provider save/remove so open pickers and chat panels reload model capabilities.
2
+ export const providersRevision = $state({ value: 0 })
3
+ /** Model the next `ProvidersSettings` mount opens; set by a chat "open settings" action, cleared once used. */
4
+ export const settingsTarget = $state<{ value: { endpointId: string; modelId: string } | null }>({ value: null })