mumucc 0.1.6 → 0.1.8
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/package.json +1 -1
- package/shims/globals.ts +1 -1
- package/src/commands/login/CustomProviderLogin.tsx +23 -53
- package/src/commands/login/ThirdPartyLogin.tsx +48 -18
- package/src/components/ProviderModelSelector.tsx +321 -0
- package/src/utils/model/fetchModels.ts +363 -0
- package/src/utils/model/modelOptions.ts +13 -0
- package/src/utils/model/thirdPartyProviders.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mumucc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Open-source AI coding assistant CLI with multi-model support (Anthropic, OpenAI/GPT, DeepSeek, GLM, Ollama, etc.), MCP integration, agent swarms, and out-of-the-box developer experience.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/shims/globals.ts
CHANGED
|
@@ -4,22 +4,24 @@
|
|
|
4
4
|
* Allows users to configure any OpenAI-compatible API endpoint by providing:
|
|
5
5
|
* 1. Provider name (for identification)
|
|
6
6
|
* 2. Base URL (e.g., https://api.openai.com/v1)
|
|
7
|
-
* 3. API
|
|
8
|
-
* 4. API
|
|
9
|
-
* 5. Model
|
|
7
|
+
* 3. API Format (Anthropic or OpenAI)
|
|
8
|
+
* 4. API Key
|
|
9
|
+
* 5. Model selection (auto-fetch from API + manual input)
|
|
10
10
|
*/
|
|
11
11
|
import * as React from 'react'
|
|
12
12
|
import { Box, Text } from '../../ink.js'
|
|
13
13
|
import { Dialog } from '../../components/design-system/Dialog.js'
|
|
14
14
|
import TextInput from '../../components/TextInput.js'
|
|
15
15
|
import { Select } from '../../components/CustomSelect/index.js'
|
|
16
|
+
import { ProviderModelSelector } from '../../components/ProviderModelSelector.js'
|
|
16
17
|
import {
|
|
17
18
|
saveProviderToSettings,
|
|
18
19
|
type ThirdPartyProviderConfig,
|
|
20
|
+
type ThirdPartyModelConfig,
|
|
19
21
|
type ApiFormat,
|
|
20
22
|
} from '../../utils/model/thirdPartyProviders.js'
|
|
21
23
|
|
|
22
|
-
type Step = 'name' | 'url' | 'format' | 'api-key' | 'models' | 'saving' | 'done'
|
|
24
|
+
type Step = 'name' | 'url' | 'format' | 'api-key' | 'select-models' | 'saving' | 'done'
|
|
23
25
|
|
|
24
26
|
interface CustomProviderLoginProps {
|
|
25
27
|
onDone: (success: boolean, message: string) => void
|
|
@@ -33,14 +35,12 @@ export function CustomProviderLogin({
|
|
|
33
35
|
const [baseUrl, setBaseUrl] = React.useState('')
|
|
34
36
|
const [apiFormat, setApiFormat] = React.useState<ApiFormat>('openai')
|
|
35
37
|
const [apiKey, setApiKey] = React.useState('')
|
|
36
|
-
const [modelsInput, setModelsInput] = React.useState('')
|
|
37
38
|
const [error, setError] = React.useState<string | null>(null)
|
|
38
39
|
|
|
39
40
|
// Cursor offset states for TextInput
|
|
40
41
|
const [nameCursor, setNameCursor] = React.useState(0)
|
|
41
42
|
const [urlCursor, setUrlCursor] = React.useState(0)
|
|
42
43
|
const [keyCursor, setKeyCursor] = React.useState(0)
|
|
43
|
-
const [modelsCursor, setModelsCursor] = React.useState(0)
|
|
44
44
|
|
|
45
45
|
const handleCancel = React.useCallback(() => {
|
|
46
46
|
onDone(false, 'Custom provider setup cancelled')
|
|
@@ -85,25 +85,13 @@ export function CustomProviderLogin({
|
|
|
85
85
|
// Allow empty key for local providers (Ollama, LM Studio)
|
|
86
86
|
setError(null)
|
|
87
87
|
setApiKey(trimmed)
|
|
88
|
-
setStep('models')
|
|
88
|
+
setStep('select-models')
|
|
89
89
|
}, [])
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
(
|
|
93
|
-
const trimmed = value.trim()
|
|
94
|
-
if (!trimmed) {
|
|
95
|
-
setError('At least one model ID is required')
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
91
|
+
const handleModelsSelected = React.useCallback(
|
|
92
|
+
(models: ThirdPartyModelConfig[]) => {
|
|
99
93
|
setStep('saving')
|
|
100
94
|
|
|
101
|
-
// Parse model IDs from comma-separated input
|
|
102
|
-
const modelIds = trimmed
|
|
103
|
-
.split(',')
|
|
104
|
-
.map(m => m.trim())
|
|
105
|
-
.filter(Boolean)
|
|
106
|
-
|
|
107
95
|
// Generate a safe provider ID from the name
|
|
108
96
|
const providerId = providerName
|
|
109
97
|
.toLowerCase()
|
|
@@ -116,28 +104,25 @@ export function CustomProviderLogin({
|
|
|
116
104
|
baseUrl: baseUrl,
|
|
117
105
|
apiFormat: apiFormat,
|
|
118
106
|
apiKey: apiKey || undefined,
|
|
119
|
-
models:
|
|
120
|
-
id,
|
|
121
|
-
displayName: id,
|
|
122
|
-
})),
|
|
107
|
+
models: models,
|
|
123
108
|
}
|
|
124
109
|
|
|
125
110
|
const result = saveProviderToSettings(providerConfig)
|
|
126
111
|
if (result.error) {
|
|
127
112
|
setError(`Failed to save: ${result.error.message}`)
|
|
128
|
-
setStep('models')
|
|
113
|
+
setStep('select-models')
|
|
129
114
|
return
|
|
130
115
|
}
|
|
131
116
|
|
|
132
117
|
setStep('done')
|
|
133
|
-
const modelNames =
|
|
118
|
+
const modelNames = models.map(m => m.id).join(', ')
|
|
134
119
|
onDone(
|
|
135
120
|
true,
|
|
136
121
|
`Custom provider "${providerName}" configured!\n` +
|
|
137
122
|
` URL: ${baseUrl}\n` +
|
|
138
123
|
` Format: ${apiFormat}\n` +
|
|
139
124
|
` Models: ${modelNames}\n` +
|
|
140
|
-
` Use with: /model ${providerId}:${
|
|
125
|
+
` Use with: /model ${providerId}:${models[0]?.id ?? 'model-id'}`,
|
|
141
126
|
)
|
|
142
127
|
},
|
|
143
128
|
[providerName, baseUrl, apiFormat, apiKey, onDone],
|
|
@@ -238,6 +223,7 @@ export function CustomProviderLogin({
|
|
|
238
223
|
<Text>Format: {apiFormat}</Text>
|
|
239
224
|
<Text> </Text>
|
|
240
225
|
<Text>Enter your API key (leave empty for local models):</Text>
|
|
226
|
+
<Text dimColor> After this step, available models will be fetched automatically.</Text>
|
|
241
227
|
<Text> </Text>
|
|
242
228
|
{error && <Text color="red">{error}</Text>}
|
|
243
229
|
<TextInput
|
|
@@ -257,32 +243,16 @@ export function CustomProviderLogin({
|
|
|
257
243
|
)
|
|
258
244
|
}
|
|
259
245
|
|
|
260
|
-
if (step === 'models') {
|
|
246
|
+
if (step === 'select-models') {
|
|
261
247
|
return (
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<Text dimColor> (e.g., "gpt-4o, gpt-4o-mini" or "deepseek-chat")</Text>
|
|
271
|
-
<Text> </Text>
|
|
272
|
-
{error && <Text color="red">{error}</Text>}
|
|
273
|
-
<TextInput
|
|
274
|
-
value={modelsInput}
|
|
275
|
-
onChange={setModelsInput}
|
|
276
|
-
onSubmit={handleModelsSubmit}
|
|
277
|
-
placeholder="model-id-1, model-id-2"
|
|
278
|
-
columns={80}
|
|
279
|
-
cursorOffset={modelsCursor}
|
|
280
|
-
onChangeCursorOffset={setModelsCursor}
|
|
281
|
-
focus={true}
|
|
282
|
-
showCursor={true}
|
|
283
|
-
/>
|
|
284
|
-
</Box>
|
|
285
|
-
</Dialog>
|
|
248
|
+
<ProviderModelSelector
|
|
249
|
+
providerName={providerName}
|
|
250
|
+
baseUrl={baseUrl}
|
|
251
|
+
apiKey={apiKey}
|
|
252
|
+
apiFormat={apiFormat}
|
|
253
|
+
onDone={handleModelsSelected}
|
|
254
|
+
onCancel={handleCancel}
|
|
255
|
+
/>
|
|
286
256
|
)
|
|
287
257
|
}
|
|
288
258
|
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interactive login flow for built-in third-party Anthropic-compatible providers.
|
|
3
3
|
*
|
|
4
|
-
* Flow: API key input →
|
|
4
|
+
* Flow: API key input → Auto-fetch models → Interactive model selection → Save.
|
|
5
5
|
* The provider is pre-selected by the LoginRouter in login.tsx.
|
|
6
6
|
*/
|
|
7
7
|
import * as React from 'react'
|
|
8
8
|
import { Box, Text } from '../../ink.js'
|
|
9
9
|
import { Dialog } from '../../components/design-system/Dialog.js'
|
|
10
10
|
import TextInput from '../../components/TextInput.js'
|
|
11
|
+
import { ProviderModelSelector } from '../../components/ProviderModelSelector.js'
|
|
11
12
|
import {
|
|
12
13
|
BUILTIN_PROVIDERS,
|
|
13
14
|
saveProviderToSettings,
|
|
14
15
|
getResolvedProvider,
|
|
15
16
|
type ThirdPartyProviderConfig,
|
|
17
|
+
type ThirdPartyModelConfig,
|
|
16
18
|
} from '../../utils/model/thirdPartyProviders.js'
|
|
17
19
|
|
|
18
|
-
type Step = 'enter-api-key' | 'saving' | 'done'
|
|
20
|
+
type Step = 'enter-api-key' | 'select-models' | 'saving' | 'done'
|
|
19
21
|
|
|
20
22
|
interface ThirdPartyLoginProps {
|
|
21
23
|
/** Provider ID pre-selected from the login router */
|
|
@@ -33,9 +35,9 @@ export function ThirdPartyLogin({
|
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
const [step, setStep] = React.useState<Step>(() => {
|
|
36
|
-
// If provider already has a valid key, skip to
|
|
38
|
+
// If provider already has a valid key, skip to model selection
|
|
37
39
|
if (provider && getResolvedProvider(provider.id)) {
|
|
38
|
-
return '
|
|
40
|
+
return 'select-models'
|
|
39
41
|
}
|
|
40
42
|
return 'enter-api-key'
|
|
41
43
|
})
|
|
@@ -43,14 +45,15 @@ export function ThirdPartyLogin({
|
|
|
43
45
|
const [apiKey, setApiKey] = React.useState('')
|
|
44
46
|
const [cursorOffset, setCursorOffset] = React.useState(0)
|
|
45
47
|
const [error, setError] = React.useState<string | null>(null)
|
|
48
|
+
const [savedApiKey, setSavedApiKey] = React.useState<string>('')
|
|
46
49
|
|
|
47
|
-
//
|
|
50
|
+
// Resolve existing API key for already-configured providers
|
|
48
51
|
React.useEffect(() => {
|
|
49
|
-
if (step === '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
if (step === 'select-models' && provider) {
|
|
53
|
+
const resolved = getResolvedProvider(provider.id)
|
|
54
|
+
if (resolved) {
|
|
55
|
+
setSavedApiKey(resolved.resolvedApiKey)
|
|
56
|
+
}
|
|
54
57
|
}
|
|
55
58
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
56
59
|
|
|
@@ -63,29 +66,40 @@ export function ThirdPartyLogin({
|
|
|
63
66
|
}
|
|
64
67
|
if (!provider) return
|
|
65
68
|
|
|
69
|
+
setSavedApiKey(trimmedKey)
|
|
70
|
+
setStep('select-models')
|
|
71
|
+
},
|
|
72
|
+
[provider],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const handleModelsSelected = React.useCallback(
|
|
76
|
+
(models: ThirdPartyModelConfig[]) => {
|
|
77
|
+
if (!provider) return
|
|
78
|
+
|
|
66
79
|
setStep('saving')
|
|
67
80
|
|
|
68
|
-
// Save provider with
|
|
81
|
+
// Save provider with API key + selected models
|
|
69
82
|
const providerConfig: ThirdPartyProviderConfig = {
|
|
70
83
|
...provider,
|
|
71
|
-
apiKey:
|
|
84
|
+
apiKey: savedApiKey || apiKey.trim(),
|
|
85
|
+
models: models.length > 0 ? models : provider.models,
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
const result = saveProviderToSettings(providerConfig)
|
|
75
89
|
if (result.error) {
|
|
76
90
|
setError(`Failed to save: ${result.error.message}`)
|
|
77
|
-
setStep('
|
|
91
|
+
setStep('select-models')
|
|
78
92
|
return
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
setStep('done')
|
|
82
|
-
const modelNames =
|
|
96
|
+
const modelNames = providerConfig.models.map(m => m.id).join(', ')
|
|
83
97
|
onDone(
|
|
84
98
|
true,
|
|
85
99
|
`${provider.name} configured successfully! Available models: ${modelNames}`,
|
|
86
100
|
)
|
|
87
101
|
},
|
|
88
|
-
[provider, onDone],
|
|
102
|
+
[provider, savedApiKey, apiKey, onDone],
|
|
89
103
|
)
|
|
90
104
|
|
|
91
105
|
const handleCancel = React.useCallback(() => {
|
|
@@ -107,9 +121,11 @@ export function ThirdPartyLogin({
|
|
|
107
121
|
<Box flexDirection="column">
|
|
108
122
|
<Text>Endpoint: {provider.baseUrl}</Text>
|
|
109
123
|
<Text>Format: {provider.apiFormat === 'openai' ? 'OpenAI Chat Completions' : 'Anthropic Messages'}</Text>
|
|
110
|
-
<Text>
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
<Text dimColor>
|
|
125
|
+
Default models: {provider.models.map(m => m.id).join(', ')}
|
|
126
|
+
</Text>
|
|
127
|
+
<Text dimColor>
|
|
128
|
+
(After entering API key, you can select models from the provider)
|
|
113
129
|
</Text>
|
|
114
130
|
<Text> </Text>
|
|
115
131
|
{error && (
|
|
@@ -133,6 +149,20 @@ export function ThirdPartyLogin({
|
|
|
133
149
|
)
|
|
134
150
|
}
|
|
135
151
|
|
|
152
|
+
if (step === 'select-models') {
|
|
153
|
+
return (
|
|
154
|
+
<ProviderModelSelector
|
|
155
|
+
providerName={provider.name}
|
|
156
|
+
baseUrl={provider.baseUrl}
|
|
157
|
+
apiKey={savedApiKey || apiKey.trim()}
|
|
158
|
+
apiFormat={provider.apiFormat ?? 'anthropic'}
|
|
159
|
+
presetModels={provider.models}
|
|
160
|
+
onDone={handleModelsSelected}
|
|
161
|
+
onCancel={handleCancel}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
136
166
|
if (step === 'saving') {
|
|
137
167
|
return (
|
|
138
168
|
<Dialog
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive model selector component for third-party provider login flows.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Auto-fetch available models from provider's API endpoint
|
|
6
|
+
* - Search/filter models by typing keywords
|
|
7
|
+
* - Multi-select models with checkboxes (Space to toggle, Enter to confirm)
|
|
8
|
+
* - Manual input for additional model IDs not in the list
|
|
9
|
+
*
|
|
10
|
+
* Used by ThirdPartyLogin and CustomProviderLogin after API key configuration.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import { Box, Text } from '../ink.js'
|
|
14
|
+
import { Dialog } from './design-system/Dialog.js'
|
|
15
|
+
import TextInput from './TextInput.js'
|
|
16
|
+
import { SelectMulti } from './CustomSelect/index.js'
|
|
17
|
+
import type { OptionWithDescription } from './CustomSelect/select.js'
|
|
18
|
+
import {
|
|
19
|
+
fetchProviderModels,
|
|
20
|
+
filterModels,
|
|
21
|
+
type FetchedModel,
|
|
22
|
+
} from '../utils/model/fetchModels.js'
|
|
23
|
+
import type { ApiFormat, ThirdPartyModelConfig } from '../utils/model/thirdPartyProviders.js'
|
|
24
|
+
|
|
25
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface ModelSelectorProps {
|
|
28
|
+
/** Provider display name */
|
|
29
|
+
providerName: string
|
|
30
|
+
/** Provider's base URL */
|
|
31
|
+
baseUrl: string
|
|
32
|
+
/** API key for fetching models */
|
|
33
|
+
apiKey: string
|
|
34
|
+
/** API format */
|
|
35
|
+
apiFormat: ApiFormat
|
|
36
|
+
/** Pre-configured models (from BUILTIN_PROVIDERS) */
|
|
37
|
+
presetModels?: ThirdPartyModelConfig[]
|
|
38
|
+
/** Callback when model selection is complete */
|
|
39
|
+
onDone: (models: ThirdPartyModelConfig[]) => void
|
|
40
|
+
/** Callback when user cancels */
|
|
41
|
+
onCancel: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Step = 'fetching' | 'select' | 'manual-input' | 'done'
|
|
45
|
+
|
|
46
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function ProviderModelSelector({
|
|
49
|
+
providerName,
|
|
50
|
+
baseUrl,
|
|
51
|
+
apiKey,
|
|
52
|
+
apiFormat,
|
|
53
|
+
presetModels,
|
|
54
|
+
onDone,
|
|
55
|
+
onCancel,
|
|
56
|
+
}: ModelSelectorProps): React.ReactNode {
|
|
57
|
+
const [step, setStep] = React.useState<Step>('fetching')
|
|
58
|
+
const [fetchedModels, setFetchedModels] = React.useState<FetchedModel[]>([])
|
|
59
|
+
const [fetchError, setFetchError] = React.useState<string | null>(null)
|
|
60
|
+
const [searchQuery, setSearchQuery] = React.useState('')
|
|
61
|
+
const [searchCursor, setSearchCursor] = React.useState(0)
|
|
62
|
+
const [selectedModelIds, setSelectedModelIds] = React.useState<string[]>([])
|
|
63
|
+
const [manualInput, setManualInput] = React.useState('')
|
|
64
|
+
const [manualCursor, setManualCursor] = React.useState(0)
|
|
65
|
+
|
|
66
|
+
// Auto-fetch models on mount
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
let cancelled = false
|
|
69
|
+
|
|
70
|
+
const doFetch = async () => {
|
|
71
|
+
const result = await fetchProviderModels(baseUrl, apiKey, apiFormat)
|
|
72
|
+
|
|
73
|
+
if (cancelled) return
|
|
74
|
+
|
|
75
|
+
if (result.models.length > 0) {
|
|
76
|
+
setFetchedModels(result.models)
|
|
77
|
+
|
|
78
|
+
// Pre-select preset models if they match
|
|
79
|
+
if (presetModels && presetModels.length > 0) {
|
|
80
|
+
const presetIds = new Set(presetModels.map(m => m.id))
|
|
81
|
+
const matched = result.models
|
|
82
|
+
.filter(m => presetIds.has(m.id))
|
|
83
|
+
.map(m => m.id)
|
|
84
|
+
setSelectedModelIds(matched)
|
|
85
|
+
}
|
|
86
|
+
} else if (result.error) {
|
|
87
|
+
setFetchError(result.error)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setStep('select')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
doFetch()
|
|
94
|
+
return () => { cancelled = true }
|
|
95
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
96
|
+
|
|
97
|
+
// Merge fetched models with preset models (presets that weren't fetched)
|
|
98
|
+
const allModels = React.useMemo(() => {
|
|
99
|
+
const fetchedIds = new Set(fetchedModels.map(m => m.id))
|
|
100
|
+
const merged = [...fetchedModels]
|
|
101
|
+
|
|
102
|
+
// Add preset models not found in fetched list
|
|
103
|
+
if (presetModels) {
|
|
104
|
+
for (const pm of presetModels) {
|
|
105
|
+
if (!fetchedIds.has(pm.id)) {
|
|
106
|
+
merged.push({
|
|
107
|
+
id: pm.id,
|
|
108
|
+
displayName: pm.displayName || pm.id,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return merged
|
|
115
|
+
}, [fetchedModels, presetModels])
|
|
116
|
+
|
|
117
|
+
// Filter models by search query
|
|
118
|
+
const filteredModels = React.useMemo(
|
|
119
|
+
() => filterModels(allModels, searchQuery),
|
|
120
|
+
[allModels, searchQuery],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// Build options for SelectMulti
|
|
124
|
+
const selectOptions: OptionWithDescription<string>[] = React.useMemo(
|
|
125
|
+
() =>
|
|
126
|
+
filteredModels.map(m => ({
|
|
127
|
+
label: m.displayName || m.id,
|
|
128
|
+
value: m.id,
|
|
129
|
+
description: m.id !== m.displayName ? m.id : undefined,
|
|
130
|
+
})),
|
|
131
|
+
[filteredModels],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Handle search input submit — switch to manual input mode
|
|
135
|
+
const handleSearchSubmit = React.useCallback(() => {
|
|
136
|
+
// If user presses Enter in search, treat it as wanting to go to manual input
|
|
137
|
+
if (searchQuery.trim() && filteredModels.length === 0) {
|
|
138
|
+
setManualInput(searchQuery)
|
|
139
|
+
setStep('manual-input')
|
|
140
|
+
}
|
|
141
|
+
}, [searchQuery, filteredModels])
|
|
142
|
+
|
|
143
|
+
// Handle multi-select submit
|
|
144
|
+
const handleSelectSubmit = React.useCallback(
|
|
145
|
+
(values: string[]) => {
|
|
146
|
+
setSelectedModelIds(values)
|
|
147
|
+
// After selection, ask if user wants to add manual models
|
|
148
|
+
setStep('manual-input')
|
|
149
|
+
},
|
|
150
|
+
[],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// Handle manual input submit — finalize model list
|
|
154
|
+
const handleManualSubmit = React.useCallback(
|
|
155
|
+
(value: string) => {
|
|
156
|
+
const trimmed = value.trim()
|
|
157
|
+
const manualIds = trimmed
|
|
158
|
+
? trimmed.split(',').map(m => m.trim()).filter(Boolean)
|
|
159
|
+
: []
|
|
160
|
+
|
|
161
|
+
// Combine selected + manual models
|
|
162
|
+
const allIds = new Set([...selectedModelIds, ...manualIds])
|
|
163
|
+
|
|
164
|
+
if (allIds.size === 0) {
|
|
165
|
+
// If nothing selected and no manual input, use presets or require at least one
|
|
166
|
+
if (presetModels && presetModels.length > 0) {
|
|
167
|
+
onDone(presetModels)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
// Try again
|
|
171
|
+
setStep('select')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build final model configs
|
|
176
|
+
const models: ThirdPartyModelConfig[] = []
|
|
177
|
+
for (const id of allIds) {
|
|
178
|
+
const fetched = allModels.find(m => m.id === id)
|
|
179
|
+
models.push({
|
|
180
|
+
id,
|
|
181
|
+
displayName: fetched?.displayName || id,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onDone(models)
|
|
186
|
+
},
|
|
187
|
+
[selectedModelIds, allModels, presetModels, onDone],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// Skip manual step — finalize with current selection
|
|
191
|
+
const handleSkipManual = React.useCallback(() => {
|
|
192
|
+
if (selectedModelIds.length === 0 && presetModels && presetModels.length > 0) {
|
|
193
|
+
onDone(presetModels)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const models: ThirdPartyModelConfig[] = selectedModelIds.map(id => {
|
|
198
|
+
const fetched = allModels.find(m => m.id === id)
|
|
199
|
+
return {
|
|
200
|
+
id,
|
|
201
|
+
displayName: fetched?.displayName || id,
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
onDone(models.length > 0 ? models : (presetModels ?? []))
|
|
205
|
+
}, [selectedModelIds, allModels, presetModels, onDone])
|
|
206
|
+
|
|
207
|
+
// ─── Render ────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
if (step === 'fetching') {
|
|
210
|
+
return (
|
|
211
|
+
<Dialog title={`${providerName} - Fetching Models`} onCancel={onCancel} color="permission">
|
|
212
|
+
<Box flexDirection="column">
|
|
213
|
+
<Text>Fetching available models from {baseUrl}...</Text>
|
|
214
|
+
<Text dimColor>This may take a few seconds.</Text>
|
|
215
|
+
</Box>
|
|
216
|
+
</Dialog>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (step === 'select') {
|
|
221
|
+
const hasModels = selectOptions.length > 0
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<Dialog
|
|
225
|
+
title={`${providerName} - Select Models`}
|
|
226
|
+
onCancel={onCancel}
|
|
227
|
+
color="permission"
|
|
228
|
+
>
|
|
229
|
+
<Box flexDirection="column">
|
|
230
|
+
{fetchError && (
|
|
231
|
+
<Text color="yellow">Note: {fetchError}</Text>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{/* Search filter */}
|
|
235
|
+
<Box>
|
|
236
|
+
<Text>Search: </Text>
|
|
237
|
+
<TextInput
|
|
238
|
+
value={searchQuery}
|
|
239
|
+
onChange={setSearchQuery}
|
|
240
|
+
onSubmit={handleSearchSubmit}
|
|
241
|
+
placeholder="Type to filter models... (Enter to add manually)"
|
|
242
|
+
columns={60}
|
|
243
|
+
cursorOffset={searchCursor}
|
|
244
|
+
onChangeCursorOffset={setSearchCursor}
|
|
245
|
+
focus={!hasModels}
|
|
246
|
+
showCursor={true}
|
|
247
|
+
/>
|
|
248
|
+
</Box>
|
|
249
|
+
|
|
250
|
+
<Text dimColor>
|
|
251
|
+
{hasModels
|
|
252
|
+
? `Found ${allModels.length} models. Space=toggle, Enter=confirm selected.`
|
|
253
|
+
: 'No models found. Type model ID and press Enter to add manually.'}
|
|
254
|
+
</Text>
|
|
255
|
+
|
|
256
|
+
{hasModels && (
|
|
257
|
+
<Box flexDirection="column" marginTop={1}>
|
|
258
|
+
<SelectMulti
|
|
259
|
+
options={selectOptions}
|
|
260
|
+
defaultValue={selectedModelIds}
|
|
261
|
+
visibleOptionCount={10}
|
|
262
|
+
submitButtonText="Confirm Selection"
|
|
263
|
+
onSubmit={handleSelectSubmit}
|
|
264
|
+
onCancel={onCancel}
|
|
265
|
+
hideIndexes={false}
|
|
266
|
+
/>
|
|
267
|
+
</Box>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{!hasModels && (
|
|
271
|
+
<Box marginTop={1}>
|
|
272
|
+
<Text dimColor>
|
|
273
|
+
Press Escape to cancel, or type model IDs and press Enter.
|
|
274
|
+
</Text>
|
|
275
|
+
</Box>
|
|
276
|
+
)}
|
|
277
|
+
</Box>
|
|
278
|
+
</Dialog>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (step === 'manual-input') {
|
|
283
|
+
return (
|
|
284
|
+
<Dialog
|
|
285
|
+
title={`${providerName} - Add Extra Models`}
|
|
286
|
+
onCancel={handleSkipManual}
|
|
287
|
+
color="permission"
|
|
288
|
+
>
|
|
289
|
+
<Box flexDirection="column">
|
|
290
|
+
{selectedModelIds.length > 0 && (
|
|
291
|
+
<>
|
|
292
|
+
<Text bold>Selected ({selectedModelIds.length}):</Text>
|
|
293
|
+
<Text color="green">
|
|
294
|
+
{selectedModelIds.join(', ')}
|
|
295
|
+
</Text>
|
|
296
|
+
<Text> </Text>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
<Text>Add extra model IDs (comma-separated), or press Enter to finish:</Text>
|
|
300
|
+
<Text dimColor>
|
|
301
|
+
For models not in the list, e.g.: custom-model-v1, another-model
|
|
302
|
+
</Text>
|
|
303
|
+
<Text> </Text>
|
|
304
|
+
<TextInput
|
|
305
|
+
value={manualInput}
|
|
306
|
+
onChange={setManualInput}
|
|
307
|
+
onSubmit={handleManualSubmit}
|
|
308
|
+
placeholder="(press Enter to skip)"
|
|
309
|
+
columns={80}
|
|
310
|
+
cursorOffset={manualCursor}
|
|
311
|
+
onChangeCursorOffset={setManualCursor}
|
|
312
|
+
focus={true}
|
|
313
|
+
showCursor={true}
|
|
314
|
+
/>
|
|
315
|
+
</Box>
|
|
316
|
+
</Dialog>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic model discovery from provider API endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Supports fetching available models from:
|
|
5
|
+
* - OpenAI-compatible `/v1/models` endpoints (OpenAI, DeepSeek, Ollama, etc.)
|
|
6
|
+
* - Anthropic-compatible endpoints (custom model listing)
|
|
7
|
+
*
|
|
8
|
+
* Used by login flows to auto-discover models after API key configuration.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logForDebugging } from '../debug.js'
|
|
12
|
+
import type { ApiFormat } from './thirdPartyProviders.js'
|
|
13
|
+
|
|
14
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface FetchedModel {
|
|
17
|
+
/** Model ID as recognized by the provider's API */
|
|
18
|
+
id: string
|
|
19
|
+
/** Human-readable name (from the API or derived from ID) */
|
|
20
|
+
displayName: string
|
|
21
|
+
/** Owner/organization (if available) */
|
|
22
|
+
ownedBy?: string
|
|
23
|
+
/** Creation timestamp (if available) */
|
|
24
|
+
created?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FetchModelsResult {
|
|
28
|
+
/** Successfully fetched models */
|
|
29
|
+
models: FetchedModel[]
|
|
30
|
+
/** Error message if fetch failed (models may still have partial results) */
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Model ID formatting ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive a human-readable display name from a model ID.
|
|
38
|
+
* Examples:
|
|
39
|
+
* "gpt-4o-mini" → "GPT 4o Mini"
|
|
40
|
+
* "deepseek-chat" → "Deepseek Chat"
|
|
41
|
+
* "meta-llama/Llama-3.3-70B" → "Llama 3.3 70B"
|
|
42
|
+
*/
|
|
43
|
+
function formatModelDisplayName(modelId: string): string {
|
|
44
|
+
// Strip org prefix (e.g., "meta-llama/Llama-3.3-70B" → "Llama-3.3-70B")
|
|
45
|
+
const baseName = modelId.includes('/') ? modelId.split('/').pop()! : modelId
|
|
46
|
+
return baseName
|
|
47
|
+
.replace(/[-_]/g, ' ')
|
|
48
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
49
|
+
.trim()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Known chat model patterns ──────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Filter patterns for identifying chat/completion models vs embedding/moderation/etc.
|
|
56
|
+
* Models matching these patterns are considered usable for chat completions.
|
|
57
|
+
*/
|
|
58
|
+
const CHAT_MODEL_PATTERNS = [
|
|
59
|
+
/gpt-/i,
|
|
60
|
+
/o[1-9]/i,
|
|
61
|
+
/claude/i,
|
|
62
|
+
/deepseek/i,
|
|
63
|
+
/llama/i,
|
|
64
|
+
/qwen/i,
|
|
65
|
+
/gemini/i,
|
|
66
|
+
/mixtral/i,
|
|
67
|
+
/mistral/i,
|
|
68
|
+
/glm/i,
|
|
69
|
+
/minimax/i,
|
|
70
|
+
/kimi/i,
|
|
71
|
+
/yi-/i,
|
|
72
|
+
/command/i,
|
|
73
|
+
/phi-/i,
|
|
74
|
+
/codestral/i,
|
|
75
|
+
/dbrx/i,
|
|
76
|
+
/chat/i,
|
|
77
|
+
/instruct/i,
|
|
78
|
+
/turbo/i,
|
|
79
|
+
/preview/i,
|
|
80
|
+
/coding/i,
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Patterns for models we should exclude (embeddings, moderation, tts, etc.)
|
|
85
|
+
*/
|
|
86
|
+
const EXCLUDE_PATTERNS = [
|
|
87
|
+
/^text-embedding/i,
|
|
88
|
+
/^text-moderation/i,
|
|
89
|
+
/^text-search/i,
|
|
90
|
+
/^tts-/i,
|
|
91
|
+
/^whisper/i,
|
|
92
|
+
/^dall-e/i,
|
|
93
|
+
/^davinci/i,
|
|
94
|
+
/^babbage/i,
|
|
95
|
+
/^ada$/i,
|
|
96
|
+
/^curie$/i,
|
|
97
|
+
/-embed/i,
|
|
98
|
+
/-embedding/i,
|
|
99
|
+
/^embedding/i,
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a model ID likely represents a chat/completion model.
|
|
104
|
+
*/
|
|
105
|
+
function isChatModel(modelId: string): boolean {
|
|
106
|
+
// First check exclusions
|
|
107
|
+
if (EXCLUDE_PATTERNS.some(p => p.test(modelId))) return false
|
|
108
|
+
// Then check inclusions — if none match, still include it (could be a custom model)
|
|
109
|
+
// Only filter out if we're confident it's not a chat model
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Fetch implementations ──────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fetch models from an OpenAI-compatible `/v1/models` endpoint.
|
|
117
|
+
*/
|
|
118
|
+
async function fetchOpenAIModels(
|
|
119
|
+
baseUrl: string,
|
|
120
|
+
apiKey: string,
|
|
121
|
+
timeoutMs = 10000,
|
|
122
|
+
): Promise<FetchModelsResult> {
|
|
123
|
+
// Normalize base URL
|
|
124
|
+
const url = baseUrl.replace(/\/+$/, '')
|
|
125
|
+
const modelsUrl = `${url}/models`
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const controller = new AbortController()
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
130
|
+
|
|
131
|
+
const headers: Record<string, string> = {
|
|
132
|
+
'Accept': 'application/json',
|
|
133
|
+
}
|
|
134
|
+
if (apiKey) {
|
|
135
|
+
headers['Authorization'] = `Bearer ${apiKey}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const response = await fetch(modelsUrl, {
|
|
139
|
+
method: 'GET',
|
|
140
|
+
headers,
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
})
|
|
143
|
+
clearTimeout(timer)
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const text = await response.text().catch(() => '')
|
|
147
|
+
return {
|
|
148
|
+
models: [],
|
|
149
|
+
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = await response.json() as {
|
|
154
|
+
data?: Array<{ id: string; owned_by?: string; created?: number }>
|
|
155
|
+
object?: string
|
|
156
|
+
models?: Array<{ name?: string; model?: string; details?: unknown }>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let models: FetchedModel[] = []
|
|
160
|
+
|
|
161
|
+
// Standard OpenAI format: { data: [{ id, owned_by, created }] }
|
|
162
|
+
if (data.data && Array.isArray(data.data)) {
|
|
163
|
+
models = data.data
|
|
164
|
+
.filter(m => m.id && isChatModel(m.id))
|
|
165
|
+
.map(m => ({
|
|
166
|
+
id: m.id,
|
|
167
|
+
displayName: formatModelDisplayName(m.id),
|
|
168
|
+
ownedBy: m.owned_by,
|
|
169
|
+
created: m.created,
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
// Ollama format: { models: [{ name, model, details }] }
|
|
173
|
+
else if (data.models && Array.isArray(data.models)) {
|
|
174
|
+
models = data.models
|
|
175
|
+
.filter(m => m.name || m.model)
|
|
176
|
+
.map(m => {
|
|
177
|
+
const id = (m.model || m.name)!
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
displayName: formatModelDisplayName(id),
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sort by name for better UX
|
|
186
|
+
models.sort((a, b) => a.id.localeCompare(b.id))
|
|
187
|
+
|
|
188
|
+
logForDebugging(`[FetchModels] Fetched ${models.length} models from ${modelsUrl}`)
|
|
189
|
+
return { models }
|
|
190
|
+
} catch (e) {
|
|
191
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
192
|
+
logForDebugging(`[FetchModels] Failed to fetch from ${modelsUrl}: ${msg}`)
|
|
193
|
+
return {
|
|
194
|
+
models: [],
|
|
195
|
+
error: msg.includes('abort')
|
|
196
|
+
? `Request timed out after ${timeoutMs / 1000}s`
|
|
197
|
+
: `Fetch failed: ${msg}`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fetch models from an Anthropic-compatible endpoint.
|
|
204
|
+
* Most Anthropic-compatible providers don't have a standard models listing endpoint,
|
|
205
|
+
* so this is best-effort. We try `/v1/models` first, then fall back.
|
|
206
|
+
*/
|
|
207
|
+
async function fetchAnthropicModels(
|
|
208
|
+
baseUrl: string,
|
|
209
|
+
apiKey: string,
|
|
210
|
+
timeoutMs = 10000,
|
|
211
|
+
): Promise<FetchModelsResult> {
|
|
212
|
+
// Try the standard endpoint first (some providers support it)
|
|
213
|
+
const url = baseUrl.replace(/\/+$/, '')
|
|
214
|
+
|
|
215
|
+
// Try /models endpoint
|
|
216
|
+
for (const endpoint of ['/models', '/v1/models']) {
|
|
217
|
+
try {
|
|
218
|
+
const controller = new AbortController()
|
|
219
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
220
|
+
|
|
221
|
+
const response = await fetch(`${url}${endpoint}`, {
|
|
222
|
+
method: 'GET',
|
|
223
|
+
headers: {
|
|
224
|
+
'Accept': 'application/json',
|
|
225
|
+
'x-api-key': apiKey,
|
|
226
|
+
'anthropic-version': '2023-06-01',
|
|
227
|
+
},
|
|
228
|
+
signal: controller.signal,
|
|
229
|
+
})
|
|
230
|
+
clearTimeout(timer)
|
|
231
|
+
|
|
232
|
+
if (response.ok) {
|
|
233
|
+
const data = await response.json() as {
|
|
234
|
+
data?: Array<{ id: string; display_name?: string; type?: string }>
|
|
235
|
+
}
|
|
236
|
+
if (data.data && Array.isArray(data.data)) {
|
|
237
|
+
const models = data.data
|
|
238
|
+
.filter(m => m.id && m.type !== 'embedding')
|
|
239
|
+
.map(m => ({
|
|
240
|
+
id: m.id,
|
|
241
|
+
displayName: m.display_name || formatModelDisplayName(m.id),
|
|
242
|
+
}))
|
|
243
|
+
if (models.length > 0) {
|
|
244
|
+
logForDebugging(`[FetchModels] Fetched ${models.length} Anthropic models from ${url}${endpoint}`)
|
|
245
|
+
return { models }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Try next endpoint
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
models: [],
|
|
256
|
+
error: 'This provider does not support model listing. You can add models manually.',
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fetch available models from a provider's API endpoint.
|
|
264
|
+
*
|
|
265
|
+
* @param baseUrl - The provider's base URL
|
|
266
|
+
* @param apiKey - API key for authentication
|
|
267
|
+
* @param apiFormat - API format ('openai' or 'anthropic')
|
|
268
|
+
* @param timeoutMs - Request timeout in milliseconds (default: 10s)
|
|
269
|
+
*/
|
|
270
|
+
export async function fetchProviderModels(
|
|
271
|
+
baseUrl: string,
|
|
272
|
+
apiKey: string,
|
|
273
|
+
apiFormat: ApiFormat = 'openai',
|
|
274
|
+
timeoutMs = 10000,
|
|
275
|
+
): Promise<FetchModelsResult> {
|
|
276
|
+
if (!baseUrl) {
|
|
277
|
+
return { models: [], error: 'No base URL provided' }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
logForDebugging(`[FetchModels] Fetching models from ${baseUrl} (format: ${apiFormat})`)
|
|
281
|
+
|
|
282
|
+
if (apiFormat === 'openai') {
|
|
283
|
+
// For OpenAI format, also try Ollama's native endpoint
|
|
284
|
+
const result = await fetchOpenAIModels(baseUrl, apiKey, timeoutMs)
|
|
285
|
+
|
|
286
|
+
// If standard /models failed and URL looks like Ollama, try /api/tags
|
|
287
|
+
if (result.models.length === 0 && baseUrl.includes('localhost')) {
|
|
288
|
+
const ollamaResult = await fetchOllamaModels(baseUrl, timeoutMs)
|
|
289
|
+
if (ollamaResult.models.length > 0) {
|
|
290
|
+
return ollamaResult
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return fetchAnthropicModels(baseUrl, apiKey, timeoutMs)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Fetch models from Ollama's native `/api/tags` endpoint.
|
|
302
|
+
*/
|
|
303
|
+
async function fetchOllamaModels(
|
|
304
|
+
baseUrl: string,
|
|
305
|
+
timeoutMs = 10000,
|
|
306
|
+
): Promise<FetchModelsResult> {
|
|
307
|
+
// Ollama's native API is on port 11434, extract host
|
|
308
|
+
const url = new URL(baseUrl)
|
|
309
|
+
const ollamaUrl = `${url.protocol}//${url.hostname}:11434/api/tags`
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const controller = new AbortController()
|
|
313
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
314
|
+
|
|
315
|
+
const response = await fetch(ollamaUrl, {
|
|
316
|
+
method: 'GET',
|
|
317
|
+
headers: { 'Accept': 'application/json' },
|
|
318
|
+
signal: controller.signal,
|
|
319
|
+
})
|
|
320
|
+
clearTimeout(timer)
|
|
321
|
+
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
return { models: [], error: `Ollama API returned ${response.status}` }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const data = await response.json() as {
|
|
327
|
+
models?: Array<{ name: string; model?: string; size?: number; details?: unknown }>
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!data.models || !Array.isArray(data.models)) {
|
|
331
|
+
return { models: [], error: 'Unexpected Ollama response format' }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const models = data.models.map(m => ({
|
|
335
|
+
id: m.name,
|
|
336
|
+
displayName: formatModelDisplayName(m.name),
|
|
337
|
+
}))
|
|
338
|
+
|
|
339
|
+
logForDebugging(`[FetchModels] Fetched ${models.length} models from Ollama`)
|
|
340
|
+
return { models }
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return {
|
|
343
|
+
models: [],
|
|
344
|
+
error: `Ollama fetch failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Filter models by a search query (fuzzy matching on ID and display name).
|
|
351
|
+
*/
|
|
352
|
+
export function filterModels(
|
|
353
|
+
models: FetchedModel[],
|
|
354
|
+
query: string,
|
|
355
|
+
): FetchedModel[] {
|
|
356
|
+
if (!query.trim()) return models
|
|
357
|
+
|
|
358
|
+
const terms = query.toLowerCase().split(/\s+/)
|
|
359
|
+
return models.filter(m => {
|
|
360
|
+
const searchable = `${m.id} ${m.displayName}`.toLowerCase()
|
|
361
|
+
return terms.every(term => searchable.includes(term))
|
|
362
|
+
})
|
|
363
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
|
2
2
|
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
|
3
3
|
import {
|
|
4
|
+
getAnthropicApiKey,
|
|
4
5
|
isClaudeAISubscriber,
|
|
5
6
|
isMaxSubscriber,
|
|
6
7
|
isTeamPremiumSubscriber,
|
|
@@ -270,6 +271,18 @@ function getOpusPlanOption(): ModelOption {
|
|
|
270
271
|
// @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
|
|
271
272
|
// Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
|
|
272
273
|
function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|
274
|
+
// If no Anthropic auth is configured (no API key, no OAuth token),
|
|
275
|
+
// skip Anthropic models entirely — user only sees third-party models
|
|
276
|
+
// from providers they've configured via /login.
|
|
277
|
+
const hasAnthropicAuth =
|
|
278
|
+
!!getAnthropicApiKey() ||
|
|
279
|
+
!!process.env.CLAUDE_CODE_USE_BEDROCK ||
|
|
280
|
+
!!process.env.CLAUDE_CODE_USE_VERTEX ||
|
|
281
|
+
!!process.env.CLAUDE_CODE_USE_FOUNDRY
|
|
282
|
+
if (!hasAnthropicAuth) {
|
|
283
|
+
return []
|
|
284
|
+
}
|
|
285
|
+
|
|
273
286
|
if (process.env.USER_TYPE === 'ant') {
|
|
274
287
|
// Build options from antModels config
|
|
275
288
|
const antModelOptions: ModelOption[] = getAntModels().map(m => ({
|
|
@@ -245,6 +245,11 @@ export function getAllThirdPartyModels(): Array<{
|
|
|
245
245
|
}> = []
|
|
246
246
|
|
|
247
247
|
for (const provider of providers) {
|
|
248
|
+
// Only show models from providers that have a configured API key.
|
|
249
|
+
// This filters /model to only display models the user can actually use.
|
|
250
|
+
const resolved = getResolvedProvider(provider.id)
|
|
251
|
+
if (!resolved) continue
|
|
252
|
+
|
|
248
253
|
for (const model of provider.models) {
|
|
249
254
|
models.push({
|
|
250
255
|
qualifiedId: `${provider.id}${PROVIDER_MODEL_SEPARATOR}${model.id}`,
|