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