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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mumucc",
3
- "version": "0.1.6",
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,7 +4,7 @@
4
4
  */
5
5
 
6
6
  ;(globalThis as any).MACRO = {
7
- VERSION: '0.1.6-mumucc',
7
+ VERSION: '0.1.8-mumucc',
8
8
  VERSION_CHANGELOG: '{}',
9
9
  BUILD_TIME: new Date().toISOString(),
10
10
  PACKAGE_URL: 'https://github.com/mumuxsy/mumucc',
@@ -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 Key
8
- * 4. API Format (Anthropic or OpenAI)
9
- * 5. Model ID(s)
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 handleModelsSubmit = React.useCallback(
92
- (value: string) => {
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: modelIds.map(id => ({
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 = modelIds.join(', ')
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}:${modelIds[0]}`,
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
- <Dialog title={dialogTitle} onCancel={handleCancel} color="permission">
263
- <Box flexDirection="column">
264
- <Text bold>Provider: {providerName}</Text>
265
- <Text>URL: {baseUrl}</Text>
266
- <Text>Format: {apiFormat}</Text>
267
- <Text>Key: {apiKey ? '***' : '(none)'}</Text>
268
- <Text> </Text>
269
- <Text>Enter model ID(s), comma-separated:</Text>
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 → Validate & save to settings.
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 done immediately
38
+ // If provider already has a valid key, skip to model selection
37
39
  if (provider && getResolvedProvider(provider.id)) {
38
- return 'done'
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
- // Handle the "already configured" case on mount
50
+ // Resolve existing API key for already-configured providers
48
51
  React.useEffect(() => {
49
- if (step === 'done' && provider) {
50
- onDone(
51
- true,
52
- `${provider.name} already configured (API key found). Models: ${provider.models.map(m => m.id).join(', ')}`,
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 the API key to user settings
81
+ // Save provider with API key + selected models
69
82
  const providerConfig: ThirdPartyProviderConfig = {
70
83
  ...provider,
71
- apiKey: trimmedKey,
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('enter-api-key')
91
+ setStep('select-models')
78
92
  return
79
93
  }
80
94
 
81
95
  setStep('done')
82
- const modelNames = provider.models.map(m => m.id).join(', ')
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
- Models:{' '}
112
- {provider.models.map(m => m.id).join(', ')}
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}`,