ethagent 3.0.2 → 3.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 (69) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +15 -116
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/identity/continuity/challenges.ts +123 -0
  23. package/src/identity/continuity/envelope.ts +49 -1484
  24. package/src/identity/continuity/envelopeCreate.ts +322 -0
  25. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  26. package/src/identity/continuity/envelopeParse.ts +441 -0
  27. package/src/identity/continuity/envelopeTypes.ts +204 -0
  28. package/src/identity/continuity/envelopeVersion.ts +1 -0
  29. package/src/identity/continuity/payloadNormalization.ts +183 -0
  30. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  31. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  32. package/src/identity/continuity/skillsNormalization.ts +119 -0
  33. package/src/identity/continuity/snapshotToken.ts +28 -0
  34. package/src/identity/hub/continuity/completion.ts +67 -0
  35. package/src/identity/hub/continuity/effects.ts +5 -62
  36. package/src/identity/hub/profile/effects.ts +6 -170
  37. package/src/identity/hub/profile/operatorSave.ts +202 -0
  38. package/src/identity/wallet/browserWallet/html.ts +1 -57
  39. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  40. package/src/identity/wallet/page/controller.ts +1 -1
  41. package/src/identity/wallet/page/errorView.ts +122 -0
  42. package/src/identity/wallet/page/view.ts +3 -114
  43. package/src/mcp/manager.ts +8 -66
  44. package/src/mcp/managerHelpers.ts +70 -0
  45. package/src/models/ModelPicker.tsx +69 -889
  46. package/src/models/huggingface.ts +20 -137
  47. package/src/models/huggingfaceStorage.ts +136 -0
  48. package/src/models/llamacpp.ts +37 -303
  49. package/src/models/llamacppCommands.ts +44 -0
  50. package/src/models/llamacppConfig.ts +34 -0
  51. package/src/models/llamacppDiscovery.ts +176 -0
  52. package/src/models/llamacppOutput.ts +65 -0
  53. package/src/models/modelPickerCatalogFlow.ts +56 -0
  54. package/src/models/modelPickerCredentials.ts +166 -0
  55. package/src/models/modelPickerData.ts +41 -0
  56. package/src/models/modelPickerDisplay.tsx +132 -0
  57. package/src/models/modelPickerHfFlow.ts +192 -0
  58. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  59. package/src/models/modelPickerTypes.ts +69 -0
  60. package/src/models/modelPickerUninstallFlow.ts +48 -0
  61. package/src/models/modelPickerViewHelpers.ts +174 -0
  62. package/src/providers/openai-chat.ts +5 -124
  63. package/src/providers/openaiChatWire.ts +124 -0
  64. package/src/runtime/providerTurn.ts +38 -0
  65. package/src/runtime/textToolParser.ts +161 -0
  66. package/src/runtime/toolIntent.ts +1 -1
  67. package/src/runtime/turn.ts +43 -499
  68. package/src/runtime/turnNudges.ts +223 -0
  69. package/src/runtime/turnTypes.ts +86 -0
@@ -0,0 +1,115 @@
1
+ import type {
2
+ LlamaCppInstallProgress,
3
+ LlamaCppInstallResult,
4
+ LlamaCppStartResult,
5
+ } from './llamacpp.js'
6
+ import {
7
+ installLlamaCppRunner,
8
+ setLlamaCppServerPath,
9
+ startLlamaCppServer,
10
+ } from './llamacpp.js'
11
+ import type { LocalHfModel } from './huggingface.js'
12
+ import type { ModelPickerSelection, ModelPickerState as State } from './modelPickerTypes.js'
13
+ import { loadHfPickerModels, probeLlamaCpp } from './modelPickerData.js'
14
+ export function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
15
+ switch (result.code) {
16
+ case 'readiness-timeout':
17
+ return 'the local runner is still loading or did not answer in time'
18
+ case 'runner-exited':
19
+ return 'the local runner closed before becoming ready'
20
+ case 'spawn-failed':
21
+ return 'the local runner could not be started'
22
+ case 'different-model-running':
23
+ return result.message
24
+ case 'model-file-missing':
25
+ return result.message
26
+ case 'runner-not-installed':
27
+ return 'this machine still needs a local runner'
28
+ }
29
+ }
30
+
31
+ export async function startAndPickHfModel(
32
+ model: LocalHfModel,
33
+ state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' | 'mmprojOffer' | 'mmprojError' }>,
34
+ setState: (s: State) => void,
35
+ onPick: (sel: ModelPickerSelection) => void,
36
+ ): Promise<void> {
37
+ if (model.risk === 'high') {
38
+ setState({ kind: 'hfError', data: state.data, message: 'blocked high-risk model; choose a model from a more credible source' })
39
+ return
40
+ }
41
+ if (model.mmprojAvailable && !model.mmprojPath && state.kind !== 'mmprojOffer' && state.kind !== 'mmprojError') {
42
+ setState({ kind: 'mmprojOffer', data: state.data, model })
43
+ return
44
+ }
45
+ setState({ kind: 'localRunnerStarting', data: state.data, model, startedAt: Date.now() })
46
+ const result = await startLlamaCppServer({
47
+ modelPath: model.localPath,
48
+ modelAlias: model.id,
49
+ mmprojPath: model.mmprojPath,
50
+ })
51
+ const llamaCpp = await probeLlamaCpp()
52
+ const data = { ...state.data, llamaCpp }
53
+ if (!result.ok) {
54
+ if (result.code === 'runner-not-installed') {
55
+ setState({ kind: 'localRunnerSetup', data, model })
56
+ return
57
+ }
58
+ setState({ kind: 'localRunnerStartFail', data, model, result })
59
+ return
60
+ }
61
+ onPick({ kind: 'llamacpp', model: model.id, mmprojPath: model.mmprojPath })
62
+ }
63
+
64
+ export async function installRunnerAndStart(
65
+ state: Extract<State, { kind: 'localRunnerSetup' }>,
66
+ setState: (s: State) => void,
67
+ onPick: (sel: ModelPickerSelection) => void,
68
+ ): Promise<void> {
69
+ await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
70
+ }
71
+
72
+ export async function runRunnerSetup(
73
+ state: Extract<State, { kind: 'localRunnerSetup' }>,
74
+ setState: (s: State) => void,
75
+ onPick: (sel: ModelPickerSelection) => void,
76
+ setup: (onProgress?: (progress: LlamaCppInstallProgress) => void) => Promise<LlamaCppInstallResult>,
77
+ ): Promise<void> {
78
+ const startedAt = Date.now()
79
+ const initialProgress: LlamaCppInstallProgress = {
80
+ phase: 'checking',
81
+ label: 'preparing local runner...',
82
+ progress: 0.04,
83
+ }
84
+ const updateProgress = (progress: LlamaCppInstallProgress): void => {
85
+ setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress })
86
+ }
87
+
88
+ setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress: initialProgress })
89
+ const result = await setup(updateProgress)
90
+ if (!result.ok) {
91
+ setState({ kind: 'localRunnerInstallFail', data: state.data, model: state.model, result })
92
+ return
93
+ }
94
+ await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
95
+ }
96
+
97
+ export async function saveRunnerPathAndStart(
98
+ state: Extract<State, { kind: 'localRunnerPathEntry' }>,
99
+ value: string,
100
+ setState: (s: State) => void,
101
+ onPick: (sel: ModelPickerSelection) => void,
102
+ ): Promise<void> {
103
+ const runnerPath = value.trim().replace(/^"|"$/g, '')
104
+ if (!runnerPath) {
105
+ setState({ ...state, error: 'paste the full path to llama-server' })
106
+ return
107
+ }
108
+ setState({ ...state, submitting: true, error: undefined })
109
+ try {
110
+ await setLlamaCppServerPath(runnerPath)
111
+ await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
112
+ } catch (err: unknown) {
113
+ setState({ ...state, submitting: false, error: (err as Error).message })
114
+ }
115
+ }
@@ -0,0 +1,69 @@
1
+ import type { EthagentConfig, ProviderId } from '../storage/config.js'
2
+ import type {
3
+ LlamaCppInstallProgress,
4
+ LlamaCppInstallResult,
5
+ LlamaCppStartResult,
6
+ } from './llamacpp.js'
7
+ import type {
8
+ HfDownloadPlan,
9
+ HfDownloadProgress,
10
+ HuggingFaceRepoInfo,
11
+ HuggingFaceSibling,
12
+ LocalHfModel,
13
+ } from './huggingface.js'
14
+ import type {
15
+ CloudProviderId,
16
+ ModelPickerContextFit,
17
+ ModelPickerOptionsData,
18
+ } from './modelPickerOptions.js'
19
+
20
+ export type ModelPickerSelection =
21
+ | { kind: 'llamacpp'; model: string; mmprojPath?: string }
22
+ | { kind: 'cloud'; provider: CloudProviderId; model: string; keyJustSet: boolean }
23
+
24
+ export type ModelPickerProps = {
25
+ currentConfig: EthagentConfig
26
+ currentProvider: ProviderId
27
+ currentModel: string
28
+ contextFit?: ModelPickerContextFit | null
29
+ featuredHfRepo?: string
30
+ localOnly?: boolean
31
+ onPick: (selection: ModelPickerSelection) => void
32
+ onCancel: () => void
33
+ }
34
+
35
+ export type LoadedModelPickerData = ModelPickerOptionsData
36
+ export type LocalUninstallTarget = { kind: 'hf'; id: string; displayName: string; sizeBytes: number }
37
+
38
+ export type ModelPickerState =
39
+ | { kind: 'loading' }
40
+ | { kind: 'list'; data: LoadedModelPickerData }
41
+ | { kind: 'localCatalogLoading'; data: LoadedModelPickerData }
42
+ | { kind: 'localCatalog'; data: LoadedModelPickerData; catalog: import('./uncensoredCatalog.js').UncensoredCatalogEntry[] }
43
+ | { kind: 'localCatalogError'; data: LoadedModelPickerData; message: string }
44
+ | { kind: 'catalog'; provider: CloudProviderId; data: LoadedModelPickerData }
45
+ | { kind: 'keyEntry'; provider: CloudProviderId; action: 'set' | 'edit'; data: LoadedModelPickerData; submitting: boolean; error?: string }
46
+ | { kind: 'keyManage'; provider: CloudProviderId; data: LoadedModelPickerData; submitting: boolean; error?: string }
47
+ | { kind: 'oauthManage'; data: LoadedModelPickerData; submitting: boolean; error?: string }
48
+ | { kind: 'oauthLogin'; data: LoadedModelPickerData; phase: 'waiting' | 'exchanging' | 'error'; url?: string; message?: string }
49
+ | { kind: 'hfInput'; data: LoadedModelPickerData; error?: string }
50
+ | { kind: 'hfLoading'; data: LoadedModelPickerData; input: string }
51
+ | { kind: 'hfFilePick'; data: LoadedModelPickerData; input: string; repo: HuggingFaceRepoInfo; files: HuggingFaceSibling[] }
52
+ | { kind: 'hfReview'; data: LoadedModelPickerData; plan: HfDownloadPlan }
53
+ | { kind: 'hfDownloading'; data: LoadedModelPickerData; plan: HfDownloadPlan; progress: HfDownloadProgress }
54
+ | { kind: 'hfDone'; data: LoadedModelPickerData; model: LocalHfModel; alreadyInstalled?: boolean }
55
+ | { kind: 'hfError'; data: LoadedModelPickerData; message: string; input?: string }
56
+ | { kind: 'localUninstallPick'; data: LoadedModelPickerData }
57
+ | { kind: 'localUninstallConfirm'; data: LoadedModelPickerData; target: LocalUninstallTarget }
58
+ | { kind: 'localUninstalling'; data: LoadedModelPickerData; target: LocalUninstallTarget }
59
+ | { kind: 'localUninstallDone'; data: LoadedModelPickerData; modelName: string }
60
+ | { kind: 'localUninstallError'; data: LoadedModelPickerData; target: LocalUninstallTarget; message: string }
61
+ | { kind: 'localRunnerSetup'; data: LoadedModelPickerData; model: LocalHfModel }
62
+ | { kind: 'localRunnerInstalling'; data: LoadedModelPickerData; model: LocalHfModel; startedAt: number; progress: LlamaCppInstallProgress }
63
+ | { kind: 'localRunnerInstallFail'; data: LoadedModelPickerData; model: LocalHfModel; result: Extract<LlamaCppInstallResult, { ok: false }> }
64
+ | { kind: 'localRunnerPathEntry'; data: LoadedModelPickerData; model: LocalHfModel; submitting: boolean; error?: string }
65
+ | { kind: 'localRunnerStarting'; data: LoadedModelPickerData; model: LocalHfModel; startedAt: number }
66
+ | { kind: 'localRunnerStartFail'; data: LoadedModelPickerData; model: LocalHfModel; result: Extract<LlamaCppStartResult, { ok: false }> }
67
+ | { kind: 'mmprojOffer'; data: LoadedModelPickerData; model: LocalHfModel }
68
+ | { kind: 'mmprojDownloading'; data: LoadedModelPickerData; model: LocalHfModel; progress: HfDownloadProgress }
69
+ | { kind: 'mmprojError'; data: LoadedModelPickerData; model: LocalHfModel; message: string }
@@ -0,0 +1,48 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import { uninstallLocalHfModel } from './huggingface.js'
3
+ import { formatLocalHfModelDisplayName } from './modelDisplay.js'
4
+ import type { LoadedModelPickerData as LoadedData, LocalUninstallTarget, ModelPickerState as State } from './modelPickerTypes.js'
5
+ import { refreshLocalModelData } from './modelPickerData.js'
6
+ export function localUninstallTargets(data: LoadedData): LocalUninstallTarget[] {
7
+ return data.hfModels.map(model => ({
8
+ kind: 'hf' as const,
9
+ id: model.id,
10
+ displayName: formatLocalHfModelDisplayName(model.id, {
11
+ displayName: model.displayName,
12
+ maxLength: 64,
13
+ }),
14
+ sizeBytes: model.sizeBytes,
15
+ }))
16
+ }
17
+
18
+ export function isCurrentLocalUninstallTarget(
19
+ target: LocalUninstallTarget,
20
+ currentProvider: ProviderId,
21
+ currentModel: string,
22
+ ): boolean {
23
+ return target.kind === 'hf' && currentProvider === 'llamacpp' && target.id === currentModel
24
+ }
25
+
26
+ export function localUninstallBoundaryCopy(_target: LocalUninstallTarget): string {
27
+ return 'This removes only the downloaded GGUF file and metadata from this machine.'
28
+ }
29
+
30
+ export async function uninstallLocalModel(
31
+ state: Extract<State, { kind: 'localUninstallConfirm' }>,
32
+ setState: (s: State) => void,
33
+ ): Promise<void> {
34
+ setState({ kind: 'localUninstalling', data: state.data, target: state.target })
35
+ const modelName = state.target.displayName
36
+ try {
37
+ await uninstallLocalHfModel(state.target.id)
38
+ const data = await refreshLocalModelData(state.data)
39
+ setState({ kind: 'localUninstallDone', data, modelName })
40
+ } catch (err: unknown) {
41
+ setState({
42
+ kind: 'localUninstallError',
43
+ data: state.data,
44
+ target: state.target,
45
+ message: (err as Error).message,
46
+ })
47
+ }
48
+ }
@@ -0,0 +1,174 @@
1
+ import type { SelectOption } from '../ui/Select.js'
2
+ import type { ProviderId } from '../storage/config.js'
3
+ import { defaultModelFor, type EthagentConfig } from '../storage/config.js'
4
+ import type { LlamaCppInstallResult } from './llamacpp.js'
5
+ import { orderGgufFilesForSpec, type GgufMachineFit } from './modelRecommendation.js'
6
+ import {
7
+ localModelId,
8
+ type HuggingFaceRepoInfo,
9
+ type HuggingFaceSibling,
10
+ } from './huggingface.js'
11
+ import {
12
+ MODEL_PICKER_CLOUD_PROVIDERS,
13
+ orderModelsForContextFit,
14
+ type CloudProviderId,
15
+ type ModelPickerContextFit,
16
+ } from './modelPickerOptions.js'
17
+ import { formatModelDisplayName } from './modelDisplay.js'
18
+ import { contextFitLabel, formatBytes, modelMetadataSubtext } from './modelPickerDisplay.js'
19
+ import type { LoadedModelPickerData as LoadedData, ModelPickerSelection } from './modelPickerTypes.js'
20
+ import type { ModelCatalogResult } from './catalog.js'
21
+ import type { SpecSnapshot } from './runtimeDetection.js'
22
+
23
+ export function buildCatalogOptions(
24
+ provider: CloudProviderId,
25
+ catalog: ModelCatalogResult | undefined,
26
+ currentProvider: ProviderId,
27
+ currentModel: string,
28
+ contextFit?: ModelPickerContextFit | null,
29
+ ): SelectOption<string>[] {
30
+ if (!catalog || catalog.entries.length === 0) {
31
+ return [{
32
+ value: `hdr:catalog-empty:${provider}`,
33
+ label: 'No Models Found',
34
+ disabled: true,
35
+ role: 'notice',
36
+ prefix: 'note',
37
+ }]
38
+ }
39
+ const sourceById = new Map(catalog.entries.map(entry => [entry.id, entry.source]))
40
+ return orderModelsForContextFit(provider, catalog.entries.map(entry => entry.id), contextFit).map(id => {
41
+ const active = currentProvider === provider && currentModel === id
42
+ const suffix = sourceById.get(id) === 'fallback' ? ' fallback' : ''
43
+ const displayName = formatModelDisplayName(provider, id, { maxLength: 64 })
44
+ return {
45
+ value: `full:${provider}:${id}`,
46
+ label: contextFitLabel(provider, id, `${displayName}${active ? ' *' : ''}${suffix}`, contextFit),
47
+ role: 'option',
48
+ }
49
+ })
50
+ }
51
+
52
+ export function parseCloudValue(value: string): { provider: CloudProviderId; model: string } | null {
53
+ if (!value.startsWith('c:')) return null
54
+ const rest = value.slice(2)
55
+ const sep = rest.indexOf(':')
56
+ if (sep === -1) return null
57
+ const provider = rest.slice(0, sep)
58
+ const model = rest.slice(sep + 1)
59
+ if (!isCloudProvider(provider) || !model) return null
60
+ return { provider, model }
61
+ }
62
+
63
+ export function localModelOptionIndex(
64
+ options: SelectOption<string>[],
65
+ currentProvider: ProviderId,
66
+ currentModel: string,
67
+ ): number {
68
+ return options.findIndex(opt => {
69
+ if (opt.disabled) return false
70
+ if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
71
+ if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
72
+ return false
73
+ })
74
+ }
75
+
76
+ export function localOrCloudOptionIndex(
77
+ options: SelectOption<string>[],
78
+ currentProvider: ProviderId,
79
+ currentModel: string,
80
+ ): number {
81
+ return options.findIndex(opt => {
82
+ if (opt.disabled) return false
83
+ if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
84
+ if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
85
+ const cloud = parseCloudValue(opt.value)
86
+ return cloud?.provider === currentProvider && cloud.model === currentModel
87
+ })
88
+ }
89
+
90
+ export function parseFullCatalogValue(value: string): { provider: CloudProviderId; model: string } | null {
91
+ if (!value.startsWith('full:')) return null
92
+ const rest = value.slice(5)
93
+ const sep = rest.indexOf(':')
94
+ if (sep === -1) return null
95
+ const provider = rest.slice(0, sep)
96
+ const model = rest.slice(sep + 1)
97
+ if (!isCloudProvider(provider) || !model) return null
98
+ return { provider, model }
99
+ }
100
+
101
+ export function parseKeyValue(value: string): { action: 'set' | 'edit' | 'manage'; provider: CloudProviderId } | null {
102
+ if (!value.startsWith('key:')) return null
103
+ const parts = value.split(':')
104
+ if (parts.length !== 3) return null
105
+ const action = parts[1]
106
+ const provider = parts[2]
107
+ if (action !== 'set' && action !== 'edit' && action !== 'manage') return null
108
+ if (!provider) return null
109
+ if (!isCloudProvider(provider)) return null
110
+ return { action, provider }
111
+ }
112
+
113
+ export function pickFallbackSelection(data: LoadedData, removed: ProviderId): ModelPickerSelection | null {
114
+ for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
115
+ if (provider === removed) continue
116
+ if (data.cloudKeys[provider] !== true) continue
117
+ const catalogModel = data.cloudCatalogs[provider]?.entries[0]?.id
118
+ const model = catalogModel ?? defaultModelFor(provider)
119
+ return { kind: 'cloud', provider, model, keyJustSet: false }
120
+ }
121
+ if (data.hfModels.length > 0) {
122
+ return { kind: 'llamacpp', model: data.hfModels[0]!.id }
123
+ }
124
+ return null
125
+ }
126
+
127
+ export function configForProvider(config: EthagentConfig, provider: CloudProviderId): EthagentConfig {
128
+ return {
129
+ ...config,
130
+ provider,
131
+ model: config.provider === provider ? config.model : defaultModelFor(provider),
132
+ baseUrl: provider === 'openai' && config.provider === 'openai' ? config.baseUrl : undefined,
133
+ }
134
+ }
135
+
136
+ export function buildHfFileOptions(
137
+ repo: HuggingFaceRepoInfo,
138
+ files: HuggingFaceSibling[],
139
+ spec: SpecSnapshot | undefined,
140
+ installedModelIds: string[] = [],
141
+ ): SelectOption<string>[] {
142
+ const ordered = spec
143
+ ? orderGgufFilesForSpec(repo, files, spec)
144
+ : files.map(file => ({ file, fit: 'unknown' as GgufMachineFit, score: 0, budgetBytes: 0 }))
145
+ const recommended = spec ? ordered[0]?.file.filename : undefined
146
+ const installed = new Set(installedModelIds)
147
+ return ordered.map(item => {
148
+ const size = item.file.sizeBytes ? formatBytes(item.file.sizeBytes) : ''
149
+ const indicators = [
150
+ item.file.filename === recommended ? 'Recommended' : '',
151
+ installed.has(localModelId(repo.repoId, item.file.filename)) ? 'Installed' : '',
152
+ ]
153
+ return {
154
+ value: item.file.filename,
155
+ label: item.file.filename,
156
+ subtext: modelMetadataSubtext(size, indicators),
157
+ role: 'option' as const,
158
+ }
159
+ })
160
+ }
161
+
162
+ export function buildRunnerRecoveryOptions(
163
+ _result: Extract<LlamaCppInstallResult, { ok: false }>,
164
+ ): SelectOption<'stop-and-retry' | 'path' | 'back'>[] {
165
+ return [
166
+ { value: 'stop-and-retry', label: 'Stop and Retry', hint: 'Stop background runners and try the install again' },
167
+ { value: 'path', label: 'Use Existing Runner Path' },
168
+ { value: 'back', label: 'Back To Picker' },
169
+ ]
170
+ }
171
+
172
+ function isCloudProvider(value: string): value is CloudProviderId {
173
+ return MODEL_PICKER_CLOUD_PROVIDERS.includes(value as CloudProviderId)
174
+ }
@@ -1,12 +1,14 @@
1
1
  import type { ProviderId } from '../storage/config.js'
2
- import type { Message, MessageContentBlock, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
2
+ import type { Message, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
3
  import { ProviderError } from './contracts.js'
4
4
  import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseFrames } from './sse.js'
7
- import { messageTextContent } from '../utils/messages.js'
8
- import { hasImageBlocks, ImageLoadError, loadImageBlock } from '../utils/images.js'
7
+ import { hasImageBlocks, ImageLoadError } from '../utils/images.js'
9
8
  import { providerDisplayName } from '../models/providerDisplay.js'
9
+ import { toWireMessages } from './openaiChatWire.js'
10
+
11
+ export { toWireMessages } from './openaiChatWire.js'
10
12
 
11
13
  export type OpenAIToolDefinition = {
12
14
  type: 'function'
@@ -270,91 +272,6 @@ export class OpenAIChatProvider implements Provider {
270
272
 
271
273
  }
272
274
 
273
- export async function toWireMessages(messages: Message[]): Promise<Array<Record<string, unknown>>> {
274
- const out: Array<Record<string, unknown>> = []
275
-
276
- for (const message of messages) {
277
- if (typeof message.content === 'string') {
278
- out.push({ role: message.role, content: message.content })
279
- continue
280
- }
281
-
282
- if (message.role === 'user') {
283
- const toolResults = message.content.filter(isToolResultBlock)
284
- if (toolResults.length > 0) {
285
- for (const block of toolResults) {
286
- out.push({
287
- role: 'tool',
288
- tool_call_id: block.toolUseId,
289
- content: block.content,
290
- })
291
- }
292
- const nonToolBlocks = message.content.filter(block => block.type !== 'tool_result')
293
- if (nonToolBlocks.length > 0) {
294
- out.push({ role: 'user', content: await toOpenAIUserContent(nonToolBlocks) })
295
- }
296
- continue
297
- }
298
- out.push({ role: 'user', content: await toOpenAIUserContent(message.content) })
299
- continue
300
- }
301
-
302
- if (message.role === 'assistant') {
303
- const textParts = message.content.filter(isTextBlock).map(block => block.text)
304
- const toolCalls = message.content.filter(isToolUseBlock).map(block => ({
305
- id: block.id,
306
- type: 'function',
307
- function: {
308
- name: block.name,
309
- arguments: JSON.stringify(block.input),
310
- },
311
- }))
312
- out.push({
313
- role: 'assistant',
314
- content: textParts.join(''),
315
- tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
316
- })
317
- continue
318
- }
319
-
320
- const toolResults = message.content.filter(isToolResultBlock)
321
- if (toolResults.length > 0) {
322
- for (const block of toolResults) {
323
- out.push({
324
- role: 'tool',
325
- tool_call_id: block.toolUseId,
326
- content: block.content,
327
- })
328
- }
329
- continue
330
- }
331
-
332
- out.push({ role: message.role, content: messageTextContent(message) })
333
- }
334
-
335
- return normalizeSystemMessages(out)
336
- }
337
-
338
- async function toOpenAIUserContent(blocks: MessageContentBlock[]): Promise<Array<Record<string, unknown>>> {
339
- const parts: Array<Record<string, unknown>> = []
340
- for (const block of blocks) {
341
- if (block.type === 'text') {
342
- if (block.text.length > 0) parts.push({ type: 'text', text: block.text })
343
- continue
344
- }
345
- if (block.type === 'image') {
346
- const loaded = await loadImageBlock(block)
347
- if (loaded.url) {
348
- parts.push({ type: 'image_url', image_url: { url: loaded.url } })
349
- } else if (loaded.dataBase64 && loaded.mimeType) {
350
- parts.push({ type: 'image_url', image_url: { url: `data:${loaded.mimeType};base64,${loaded.dataBase64}` } })
351
- }
352
- continue
353
- }
354
- }
355
- return parts.length > 0 ? parts : [{ type: 'text', text: '' }]
356
- }
357
-
358
275
  export function supportsOpenAIImages(model: string): boolean {
359
276
  const normalized = model.toLowerCase()
360
277
  if (normalized.includes('gpt-3.5')) return false
@@ -366,42 +283,6 @@ export function localModelNameHintsVision(model: string): boolean {
366
283
  return /llava|bakllava|qwen[-_.]?vl|qwen2[-_.]?vl|qwen2\.5[-_.]?vl|minicpm-?v|llama-3\.2.*vision|mllama|cogvlm|internvl|moondream|pixtral|phi-?3[\.-]?vision|phi-?3\.5[\.-]?vision|smolvlm/.test(normalized)
367
284
  }
368
285
 
369
- function normalizeSystemMessages(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
370
- const systemContents: string[] = []
371
- const nonSystem: Array<Record<string, unknown>> = []
372
-
373
- for (const message of messages) {
374
- if (message.role === 'system') {
375
- if (typeof message.content === 'string' && message.content.length > 0) {
376
- systemContents.push(message.content)
377
- }
378
- continue
379
- }
380
- nonSystem.push(message)
381
- }
382
-
383
- if (systemContents.length === 0) return nonSystem
384
- return [
385
- {
386
- role: 'system',
387
- content: systemContents.join('\n\n'),
388
- },
389
- ...nonSystem,
390
- ]
391
- }
392
-
393
- function isTextBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'text' }> {
394
- return block.type === 'text'
395
- }
396
-
397
- function isToolUseBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_use' }> {
398
- return block.type === 'tool_use'
399
- }
400
-
401
- function isToolResultBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_result' }> {
402
- return block.type === 'tool_result'
403
- }
404
-
405
286
  function parseToolArguments(inputJson: string): Record<string, unknown> {
406
287
  if (!inputJson.trim()) return {}
407
288
  const direct = tryParseJsonOnce(inputJson)
@@ -0,0 +1,124 @@
1
+ import type { Message, MessageContentBlock } from './contracts.js'
2
+ import { messageTextContent } from '../utils/messages.js'
3
+ import { loadImageBlock } from '../utils/images.js'
4
+
5
+ export async function toWireMessages(messages: Message[]): Promise<Array<Record<string, unknown>>> {
6
+ const out: Array<Record<string, unknown>> = []
7
+
8
+ for (const message of messages) {
9
+ if (typeof message.content === 'string') {
10
+ out.push({ role: message.role, content: message.content })
11
+ continue
12
+ }
13
+
14
+ if (message.role === 'user') {
15
+ const toolResults = message.content.filter(isToolResultBlock)
16
+ if (toolResults.length > 0) {
17
+ for (const block of toolResults) {
18
+ out.push({
19
+ role: 'tool',
20
+ tool_call_id: block.toolUseId,
21
+ content: block.content,
22
+ })
23
+ }
24
+ const nonToolBlocks = message.content.filter(block => block.type !== 'tool_result')
25
+ if (nonToolBlocks.length > 0) {
26
+ out.push({ role: 'user', content: await toOpenAIUserContent(nonToolBlocks) })
27
+ }
28
+ continue
29
+ }
30
+ out.push({ role: 'user', content: await toOpenAIUserContent(message.content) })
31
+ continue
32
+ }
33
+
34
+ if (message.role === 'assistant') {
35
+ const textParts = message.content.filter(isTextBlock).map(block => block.text)
36
+ const toolCalls = message.content.filter(isToolUseBlock).map(block => ({
37
+ id: block.id,
38
+ type: 'function',
39
+ function: {
40
+ name: block.name,
41
+ arguments: JSON.stringify(block.input),
42
+ },
43
+ }))
44
+ out.push({
45
+ role: 'assistant',
46
+ content: textParts.join(''),
47
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
48
+ })
49
+ continue
50
+ }
51
+
52
+ const toolResults = message.content.filter(isToolResultBlock)
53
+ if (toolResults.length > 0) {
54
+ for (const block of toolResults) {
55
+ out.push({
56
+ role: 'tool',
57
+ tool_call_id: block.toolUseId,
58
+ content: block.content,
59
+ })
60
+ }
61
+ continue
62
+ }
63
+
64
+ out.push({ role: message.role, content: messageTextContent(message) })
65
+ }
66
+
67
+ return normalizeSystemMessages(out)
68
+ }
69
+
70
+ async function toOpenAIUserContent(blocks: MessageContentBlock[]): Promise<Array<Record<string, unknown>>> {
71
+ const parts: Array<Record<string, unknown>> = []
72
+ for (const block of blocks) {
73
+ if (block.type === 'text') {
74
+ if (block.text.length > 0) parts.push({ type: 'text', text: block.text })
75
+ continue
76
+ }
77
+ if (block.type === 'image') {
78
+ const loaded = await loadImageBlock(block)
79
+ if (loaded.url) {
80
+ parts.push({ type: 'image_url', image_url: { url: loaded.url } })
81
+ } else if (loaded.dataBase64 && loaded.mimeType) {
82
+ parts.push({ type: 'image_url', image_url: { url: `data:${loaded.mimeType};base64,${loaded.dataBase64}` } })
83
+ }
84
+ continue
85
+ }
86
+ }
87
+ return parts.length > 0 ? parts : [{ type: 'text', text: '' }]
88
+ }
89
+
90
+ function normalizeSystemMessages(messages: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
91
+ const systemContents: string[] = []
92
+ const nonSystem: Array<Record<string, unknown>> = []
93
+
94
+ for (const message of messages) {
95
+ if (message.role === 'system') {
96
+ if (typeof message.content === 'string' && message.content.length > 0) {
97
+ systemContents.push(message.content)
98
+ }
99
+ continue
100
+ }
101
+ nonSystem.push(message)
102
+ }
103
+
104
+ if (systemContents.length === 0) return nonSystem
105
+ return [
106
+ {
107
+ role: 'system',
108
+ content: systemContents.join('\n\n'),
109
+ },
110
+ ...nonSystem,
111
+ ]
112
+ }
113
+
114
+ function isTextBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'text' }> {
115
+ return block.type === 'text'
116
+ }
117
+
118
+ function isToolUseBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_use' }> {
119
+ return block.type === 'tool_use'
120
+ }
121
+
122
+ function isToolResultBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_result' }> {
123
+ return block.type === 'tool_result'
124
+ }