@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.
- package/LICENSE +789 -0
- package/fixtures/model-classify.json +42 -0
- package/fixtures/pricing-cost.json +56 -0
- package/fixtures/reasoning-infer.json +44 -0
- package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
- package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
- package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
- package/fixtures/sse/anthropic-messages/text.sse +27 -0
- package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
- package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
- package/fixtures/sse/expect.json +20 -0
- package/fixtures/sse/openai-chat/chinese.sse +14 -0
- package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
- package/fixtures/sse/openai-chat/single-tool.sse +22 -0
- package/fixtures/sse/openai-chat/text.sse +14 -0
- package/fixtures/sse/openai-chat/truncated.sse +12 -0
- package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
- package/fixtures/sse/openai-responses/chinese.sse +33 -0
- package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
- package/fixtures/sse/openai-responses/single-tool.sse +27 -0
- package/fixtures/sse/openai-responses/text.sse +33 -0
- package/fixtures/sse/openai-responses/truncated.sse +30 -0
- package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
- package/package.json +71 -3
- package/src/CopyInput.svelte +33 -0
- package/src/KitNotifier.svelte +33 -0
- package/src/chat/ApprovalCard.svelte +29 -0
- package/src/chat/ChatComposer.svelte +277 -0
- package/src/chat/ChatMessage.svelte +262 -0
- package/src/chat/ChatPanel.css +386 -0
- package/src/chat/ChatPanel.svelte +496 -0
- package/src/chat/ChatPanelApprovals.svelte +28 -0
- package/src/chat/ChatPanelComposer.svelte +317 -0
- package/src/chat/ChatPanelDialogs.svelte +242 -0
- package/src/chat/ChatPanelHeader.svelte +132 -0
- package/src/chat/ChatPanelMessages.svelte +166 -0
- package/src/chat/ChatPanelMode.svelte +190 -0
- package/src/chat/ChatPanelState.svelte.ts +1 -0
- package/src/chat/ChatPanelToolbar.svelte +425 -0
- package/src/chat/ChatSurface.svelte +168 -0
- package/src/chat/MessageItem.svelte +480 -0
- package/src/chat/ReasoningControl.svelte +286 -0
- package/src/chat/SkillPicker.svelte +133 -0
- package/src/chat/ToolCallItem.svelte +23 -0
- package/src/chat/UsageControl.svelte +216 -0
- package/src/chat/UsageDetails.svelte +76 -0
- package/src/chat/chat-attachments.svelte.ts +74 -0
- package/src/chat/chat-panel-history.svelte.ts +430 -0
- package/src/chat/chat-panel-props.ts +32 -0
- package/src/chat/index.ts +6 -0
- package/src/chat/run-session-history.ts +56 -0
- package/src/chat/run-session-types.ts +69 -0
- package/src/chat/run-session.svelte.ts +494 -0
- package/src/chat/test-support/chat-fixture.ts +178 -0
- package/src/chat/test-support/conversation-fixtures.ts +32 -0
- package/src/chat/test-support/run-session-fixture.ts +112 -0
- package/src/components/ui/alert/alert-action.svelte +20 -0
- package/src/components/ui/alert/alert-description.svelte +23 -0
- package/src/components/ui/alert/alert-title.svelte +23 -0
- package/src/components/ui/alert/alert.svelte +43 -0
- package/src/components/ui/alert/index.ts +17 -0
- package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
- package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
- package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
- package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
- package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
- package/src/components/ui/alert-dialog/index.ts +40 -0
- package/src/components/ui/button/button.svelte +81 -0
- package/src/components/ui/button/index.ts +17 -0
- package/src/components/ui/dialog/dialog-close.svelte +11 -0
- package/src/components/ui/dialog/dialog-content.svelte +48 -0
- package/src/components/ui/dialog/dialog-description.svelte +17 -0
- package/src/components/ui/dialog/dialog-footer.svelte +32 -0
- package/src/components/ui/dialog/dialog-header.svelte +20 -0
- package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
- package/src/components/ui/dialog/dialog-portal.svelte +7 -0
- package/src/components/ui/dialog/dialog-title.svelte +17 -0
- package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
- package/src/components/ui/dialog/dialog.svelte +7 -0
- package/src/components/ui/dialog/index.ts +34 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
- package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
- package/src/components/ui/dropdown-menu/index.ts +54 -0
- package/src/components/ui/empty/empty-content.svelte +23 -0
- package/src/components/ui/empty/empty-description.svelte +23 -0
- package/src/components/ui/empty/empty-header.svelte +20 -0
- package/src/components/ui/empty/empty-media.svelte +41 -0
- package/src/components/ui/empty/empty-title.svelte +20 -0
- package/src/components/ui/empty/empty.svelte +23 -0
- package/src/components/ui/empty/index.ts +22 -0
- package/src/components/ui/field/field-content.svelte +20 -0
- package/src/components/ui/field/field-description.svelte +25 -0
- package/src/components/ui/field/field-error.svelte +58 -0
- package/src/components/ui/field/field-group.svelte +23 -0
- package/src/components/ui/field/field-label.svelte +25 -0
- package/src/components/ui/field/field-legend.svelte +24 -0
- package/src/components/ui/field/field-separator.svelte +35 -0
- package/src/components/ui/field/field-set.svelte +20 -0
- package/src/components/ui/field/field-title.svelte +20 -0
- package/src/components/ui/field/field.svelte +47 -0
- package/src/components/ui/field/index.ts +33 -0
- package/src/components/ui/input/index.ts +7 -0
- package/src/components/ui/input/input.svelte +48 -0
- package/src/components/ui/label/index.ts +7 -0
- package/src/components/ui/label/label.svelte +20 -0
- package/src/components/ui/select/index.ts +40 -0
- package/src/components/ui/select/select-content.svelte +45 -0
- package/src/components/ui/select/select-group-heading.svelte +21 -0
- package/src/components/ui/select/select-group.svelte +17 -0
- package/src/components/ui/select/select-item.svelte +43 -0
- package/src/components/ui/select/select-label.svelte +20 -0
- package/src/components/ui/select/select-portal.svelte +7 -0
- package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
- package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
- package/src/components/ui/select/select-separator.svelte +18 -0
- package/src/components/ui/select/select-trigger.svelte +29 -0
- package/src/components/ui/select/select-value.svelte +7 -0
- package/src/components/ui/select/select.svelte +11 -0
- package/src/components/ui/separator/index.ts +7 -0
- package/src/components/ui/separator/separator.svelte +21 -0
- package/src/components/ui/sonner/index.ts +1 -0
- package/src/components/ui/sonner/sonner.svelte +40 -0
- package/src/components/ui/switch/index.ts +7 -0
- package/src/components/ui/switch/switch.svelte +34 -0
- package/src/components/ui/textarea/index.ts +7 -0
- package/src/components/ui/textarea/textarea.svelte +22 -0
- package/src/context.ts +34 -0
- package/src/dev/Demo.svelte +47 -0
- package/src/dev/main.ts +7 -0
- package/src/i18n/en.ts +645 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/zh-CN.ts +610 -0
- package/src/index.ts +10 -0
- package/src/logs/LogPanel.svelte +103 -0
- package/src/logs/LogSettings.svelte +58 -0
- package/src/logs/index.ts +5 -0
- package/src/logs/logger.ts +96 -0
- package/src/markdown.ts +8 -0
- package/src/mcp/McpClientEditor.svelte +204 -0
- package/src/mcp/McpClientSettings.svelte +418 -0
- package/src/mcp/McpServerSettings.svelte +363 -0
- package/src/mcp/config.ts +26 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp/input.ts +74 -0
- package/src/notify.svelte.ts +43 -0
- package/src/ports/contract.ts +174 -0
- package/src/ports/fake-optional.ts +81 -0
- package/src/ports/fake.ts +488 -0
- package/src/ports/index.ts +7 -0
- package/src/ports/search.ts +56 -0
- package/src/ports/skill-sources.ts +45 -0
- package/src/ports/test-support/fake-fixture.ts +63 -0
- package/src/providers/EndpointKeysDialog.svelte +357 -0
- package/src/providers/EndpointModels.svelte +311 -0
- package/src/providers/EndpointPanel.svelte +266 -0
- package/src/providers/ModelPickerDialog.svelte +230 -0
- package/src/providers/ModelPricing.svelte +125 -0
- package/src/providers/ModelSelect.svelte +448 -0
- package/src/providers/ModelSettingsDialog.svelte +388 -0
- package/src/providers/ProviderDialogs.svelte +120 -0
- package/src/providers/ProviderList.svelte +159 -0
- package/src/providers/ProvidersSettings.svelte +491 -0
- package/src/providers/RequestConfigDialog.svelte +278 -0
- package/src/providers/SecretInput.svelte +284 -0
- package/src/providers/draft.ts +355 -0
- package/src/providers/edit-session.ts +170 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/reasoning.ts +55 -0
- package/src/providers/revision.svelte.ts +6 -0
- package/src/providers/test-support/providers-fixture.ts +120 -0
- package/src/search/SearchAdvancedTools.svelte +182 -0
- package/src/search/SearchProviderCard.svelte +195 -0
- package/src/search/SearchSettings.svelte +500 -0
- package/src/search/index.ts +13 -0
- package/src/search/settings-flow.ts +67 -0
- package/src/session.ts +20 -0
- package/src/skills/SkillAdd.svelte +380 -0
- package/src/skills/SkillAuthoring.svelte +430 -0
- package/src/skills/SkillEditor.svelte +104 -0
- package/src/skills/SkillPreviewDialog.svelte +136 -0
- package/src/skills/SkillsSettings.svelte +147 -0
- package/src/skills/error.ts +32 -0
- package/src/skills/index.ts +4 -0
- package/src/skills/test-support/skills-fixture.ts +139 -0
- package/src/styles/kit.css +137 -0
- package/src/styles/tokens.css +22 -0
- package/src/tauri/index.ts +298 -0
- package/src/utils.ts +22 -0
- package/src/web/index.ts +441 -0
- 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
|
+
})
|