chenbhao commited on
Commit
7d9eccd
·
1 Parent(s): c94dcb2

feat: /login local model

Browse files
src/components/LocalLoginFlow.tsx CHANGED
@@ -1,14 +1,11 @@
1
  import * as React from 'react'
2
  import { useState } from 'react'
3
- import type { LocalJSXCommandOnDone } from '../../types/command.js'
4
  import { saveLocalModelConfig } from '../utils/auth.js'
5
  import { Box, Text } from '../ink.js'
6
  import TextInput from './TextInput.js'
7
- import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'
8
- import Dialog from './design-system/Dialog.js'
9
 
10
  interface Props {
11
- onDone: LocalJSXCommandOnDone
12
  startingMessage?: string
13
  }
14
 
@@ -47,15 +44,15 @@ export function LocalLoginFlow({ onDone, startingMessage }: Props) {
47
  try {
48
  // 保存本地模型配置
49
  await saveLocalModelConfig(finalUrl, finalModel)
50
- onDone(true)
51
  } catch (error) {
52
  console.error('Failed to save local model config:', error)
53
- onDone(false)
54
  }
55
  }
56
 
57
  const handleCancel = () => {
58
- onDone(false)
59
  }
60
 
61
  return (
 
1
  import * as React from 'react'
2
  import { useState } from 'react'
 
3
  import { saveLocalModelConfig } from '../utils/auth.js'
4
  import { Box, Text } from '../ink.js'
5
  import TextInput from './TextInput.js'
 
 
6
 
7
  interface Props {
8
+ onDone: () => void
9
  startingMessage?: string
10
  }
11
 
 
44
  try {
45
  // 保存本地模型配置
46
  await saveLocalModelConfig(finalUrl, finalModel)
47
+ onDone()
48
  } catch (error) {
49
  console.error('Failed to save local model config:', error)
50
+ onDone()
51
  }
52
  }
53
 
54
  const handleCancel = () => {
55
+ onDone()
56
  }
57
 
58
  return (
src/hooks/useApiKeyVerification.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'react'
2
  import { getIsNonInteractiveSession } from '../bootstrap/state.js'
3
  import { verifyApiKey } from '../services/api/claude.js'
4
  import { getGlobalConfig } from '../utils/config.js'
@@ -8,8 +8,12 @@ import {
8
  isAnthropicAuthEnabled,
9
  isClaudeAISubscriber,
10
  getConfiguredAuthProvider,
 
11
  getOpenAIAuthTokens,
 
 
12
  } from '../utils/auth.js'
 
13
 
14
  export type VerificationStatus =
15
  | 'loading'
@@ -26,7 +30,17 @@ export type ApiKeyVerificationResult = {
26
 
27
  export function useApiKeyVerification(): ApiKeyVerificationResult {
28
  const [status, setStatus] = useState<VerificationStatus>(() => {
29
- const authProvider = getConfiguredAuthProvider()
 
 
 
 
 
 
 
 
 
 
30
 
31
  // Check OpenAI authentication
32
  if (authProvider === 'openai') {
@@ -67,8 +81,109 @@ export function useApiKeyVerification(): ApiKeyVerificationResult {
67
  })
68
  const [error, setError] = useState<Error | null>(null)
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  const verify = useCallback(async (): Promise<void> => {
71
- const authProvider = getConfiguredAuthProvider()
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  // Check OpenAI authentication
74
  if (authProvider === 'openai') {
 
1
+ import { useCallback, useEffect, useState } from 'react'
2
  import { getIsNonInteractiveSession } from '../bootstrap/state.js'
3
  import { verifyApiKey } from '../services/api/claude.js'
4
  import { getGlobalConfig } from '../utils/config.js'
 
8
  isAnthropicAuthEnabled,
9
  isClaudeAISubscriber,
10
  getConfiguredAuthProvider,
11
+ getConfiguredAuthProviderFromFile,
12
  getOpenAIAuthTokens,
13
+ getLocalBaseUrl,
14
+ getLocalModelName,
15
  } from '../utils/auth.js'
16
+ import { useAppState } from '../state/AppState.js'
17
 
18
  export type VerificationStatus =
19
  | 'loading'
 
30
 
31
  export function useApiKeyVerification(): ApiKeyVerificationResult {
32
  const [status, setStatus] = useState<VerificationStatus>(() => {
33
+ const authProvider = getConfiguredAuthProviderFromFile()
34
+
35
+ // Check Local authentication
36
+ if (authProvider === 'local') {
37
+ const baseUrl = getLocalBaseUrl()
38
+ const modelName = getLocalModelName()
39
+ if (baseUrl && modelName) {
40
+ return 'valid'
41
+ }
42
+ return 'missing'
43
+ }
44
 
45
  // Check OpenAI authentication
46
  if (authProvider === 'openai') {
 
81
  })
82
  const [error, setError] = useState<Error | null>(null)
83
 
84
+ // Watch authVersion changes to re-verify API key
85
+ const authVersion = useAppState(s => s.authVersion)
86
+
87
+ useEffect(() => {
88
+ // When authVersion changes, re-verify the API key
89
+ const reverify = async (): Promise<void> => {
90
+ const authProvider = getConfiguredAuthProviderFromFile()
91
+ console.log('[useApiKeyVerification] authVersion changed, authProvider:', authProvider)
92
+
93
+ // Check Local authentication
94
+ if (authProvider === 'local') {
95
+ // Force re-read config by reading directly from file
96
+ const baseUrl = getLocalBaseUrl()
97
+ const modelName = getLocalModelName()
98
+ console.log('[useApiKeyVerification] Local - baseUrl:', baseUrl, 'modelName:', modelName)
99
+ if (baseUrl && modelName) {
100
+ setStatus('valid')
101
+ } else {
102
+ setStatus('missing')
103
+ }
104
+ return
105
+ }
106
+
107
+ // Check OpenAI authentication
108
+ if (authProvider === 'openai') {
109
+ const { getGlobalConfig: getConfig } = await import('../utils/config.js')
110
+ const freshConfig = getConfig()
111
+ const hasApiKey = !!freshConfig.openAiApiKey
112
+ const hasAccessToken = !!freshConfig.openAiAccessToken
113
+ if (hasApiKey || hasAccessToken) {
114
+ setStatus('valid')
115
+ } else {
116
+ setStatus('missing')
117
+ }
118
+ return
119
+ }
120
+
121
+ // Check OpenRouter authentication
122
+ if (authProvider === 'openrouter') {
123
+ const { getGlobalConfig: getConfig } = await import('../utils/config.js')
124
+ const freshConfig = getConfig()
125
+ const hasApiKey = !!freshConfig.openRouterApiKey
126
+ if (hasApiKey) {
127
+ setStatus('valid')
128
+ } else {
129
+ setStatus('missing')
130
+ }
131
+ return
132
+ }
133
+
134
+ // Anthropic authentication
135
+ if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
136
+ setStatus('valid')
137
+ return
138
+ }
139
+ // Warm the apiKeyHelper cache (no-op if not configured), then read from
140
+ // all sources. getAnthropicApiKeyWithSource() reads the now-warm cache.
141
+ await getApiKeyFromApiKeyHelper(getIsNonInteractiveSession())
142
+ const { key: apiKey, source } = getAnthropicApiKeyWithSource()
143
+ if (!apiKey) {
144
+ if (source === 'apiKeyHelper') {
145
+ setStatus('error')
146
+ setError(new Error('API key helper did not return a valid key'))
147
+ return
148
+ }
149
+ const newStatus = 'missing'
150
+ setStatus(newStatus)
151
+ return
152
+ }
153
+
154
+ try {
155
+ const isValid = await verifyApiKey(apiKey, false)
156
+ const newStatus = isValid ? 'valid' : 'invalid'
157
+ setStatus(newStatus)
158
+ return
159
+ } catch (error) {
160
+ // This happens when there an error response from the API but it's not an invalid API key error
161
+ // In this case, we still mark the API key as invalid - but we also log the error so we can
162
+ // display it to the user to be more helpful
163
+ setError(error as Error)
164
+ const newStatus = 'error'
165
+ setStatus(newStatus)
166
+ return
167
+ }
168
+ }
169
+
170
+ void reverify()
171
+ }, [authVersion])
172
+
173
  const verify = useCallback(async (): Promise<void> => {
174
+ const authProvider = getConfiguredAuthProviderFromFile()
175
+
176
+ // Check Local authentication
177
+ if (authProvider === 'local') {
178
+ const baseUrl = getLocalBaseUrl()
179
+ const modelName = getLocalModelName()
180
+ if (baseUrl && modelName) {
181
+ setStatus('valid')
182
+ } else {
183
+ setStatus('missing')
184
+ }
185
+ return
186
+ }
187
 
188
  // Check OpenAI authentication
189
  if (authProvider === 'openai') {
src/services/api/client.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
  refreshGcpCredentialsIfNeeded,
12
  getOpenRouterApiKey,
13
  getOpenAIApiKey,
 
14
  } from 'src/utils/auth.js'
15
  import { getUserAgent } from 'src/utils/http.js'
16
  import { getSmallFastModel } from 'src/utils/model/model.js'
@@ -335,6 +336,15 @@ export async function getAnthropicClient({
335
  }
336
  }
337
 
 
 
 
 
 
 
 
 
 
338
  return new Anthropic(clientConfig)
339
  }
340
 
@@ -344,8 +354,8 @@ async function configureApiKeyHeaders(
344
  ): Promise<void> {
345
  const provider = getAPIProvider()
346
 
347
- // Skip for OpenRouter and OpenAI - they use apiKey parameter instead
348
- if (provider === 'openrouter' || provider === 'openai') {
349
  return
350
  }
351
 
 
11
  refreshGcpCredentialsIfNeeded,
12
  getOpenRouterApiKey,
13
  getOpenAIApiKey,
14
+ getLocalBaseUrl,
15
  } from 'src/utils/auth.js'
16
  import { getUserAgent } from 'src/utils/http.js'
17
  import { getSmallFastModel } from 'src/utils/model/model.js'
 
336
  }
337
  }
338
 
339
+ // Handle Local
340
+ if (provider === 'local') {
341
+ const localBaseUrl = await getLocalBaseUrl()
342
+ if (localBaseUrl) {
343
+ clientConfig.baseURL = localBaseUrl
344
+ clientConfig.apiKey = 'local-model' // Use a dummy API key for local models
345
+ }
346
+ }
347
+
348
  return new Anthropic(clientConfig)
349
  }
350
 
 
354
  ): Promise<void> {
355
  const provider = getAPIProvider()
356
 
357
+ // Skip for OpenRouter, OpenAI, and Local - they use apiKey parameter instead
358
+ if (provider === 'openrouter' || provider === 'openai' || provider === 'local') {
359
  return
360
  }
361
 
src/utils/auth.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
  saveGlobalConfig,
50
  } from './config.js'
51
  import { logAntError, logForDebugging } from './debug.js'
 
52
  import {
53
  getClaudeConfigHomeDir,
54
  isBareMode,
@@ -295,10 +296,8 @@ export function getOpenRouterApiKeyWithSource(): {
295
  : { key: null, source: 'none' }
296
  }
297
 
298
- export function getConfiguredAuthProvider():
299
- | 'anthropic'
300
- | 'openrouter'
301
- | 'openai' {
302
  const storedProvider = getGlobalConfig().authProvider
303
  if (storedProvider) {
304
  return storedProvider
@@ -315,6 +314,38 @@ export function getConfiguredAuthProvider():
315
  }
316
  }
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  export type OpenAIAuthTokens = {
319
  accessToken: string
320
  refreshToken?: string | null
@@ -394,6 +425,7 @@ export async function saveOpenRouterApiKey(apiKey: string): Promise<void> {
394
  }
395
 
396
  export async function saveLocalModelConfig(baseUrl: string, modelName: string): Promise<void> {
 
397
  if (!baseUrl.trim()) {
398
  throw new Error('Base URL cannot be empty')
399
  }
@@ -405,16 +437,63 @@ export async function saveLocalModelConfig(baseUrl: string, modelName: string):
405
  throw new Error('Invalid URL format')
406
  }
407
 
408
- saveGlobalConfig(current => ({
409
- ...current,
410
- authProvider: 'local',
411
- localBaseUrl: baseUrl,
412
- localModelName: modelName,
413
- }))
 
 
 
 
414
 
415
  // Clear provider cache so it will be re-read on next access
416
  const { clearStoredProviderCache } = await import('./model/providers.js')
417
  clearStoredProviderCache()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  }
419
 
420
  function getCodexHomeDir(): string {
 
49
  saveGlobalConfig,
50
  } from './config.js'
51
  import { logAntError, logForDebugging } from './debug.js'
52
+ import { getGlobalClaudeFile } from './env.js'
53
  import {
54
  getClaudeConfigHomeDir,
55
  isBareMode,
 
296
  : { key: null, source: 'none' }
297
  }
298
 
299
+ export function getConfiguredAuthProvider(): 'anthropic' | 'openrouter' | 'openai' | 'local' {
300
+ // First try to get from cache for performance
 
 
301
  const storedProvider = getGlobalConfig().authProvider
302
  if (storedProvider) {
303
  return storedProvider
 
314
  }
315
  }
316
 
317
+ // Read authProvider directly from file to bypass cache
318
+ export function getConfiguredAuthProviderFromFile(): 'anthropic' | 'openrouter' | 'openai' | 'local' {
319
+ try {
320
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
321
+ const { readFileSync } = require('fs') as typeof import('fs')
322
+ const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
323
+ const config = JSON.parse(raw) as {
324
+ authProvider?: 'anthropic' | 'openrouter' | 'openai' | 'local'
325
+ }
326
+ console.log('[getConfiguredAuthProviderFromFile] Config from file:', config.authProvider)
327
+
328
+ if (config.authProvider) {
329
+ return config.authProvider
330
+ }
331
+ } catch (error) {
332
+ console.log('[getConfiguredAuthProviderFromFile] Error reading file:', error)
333
+ // Ignore errors, fall through to default
334
+ }
335
+
336
+ // Fallback to API provider
337
+ const provider = getAPIProvider()
338
+ console.log('[getConfiguredAuthProviderFromFile] Fallback to API provider:', provider)
339
+ switch (provider) {
340
+ case 'openrouter':
341
+ return 'openrouter'
342
+ case 'openai':
343
+ return 'openai'
344
+ default:
345
+ return 'anthropic'
346
+ }
347
+ }
348
+
349
  export type OpenAIAuthTokens = {
350
  accessToken: string
351
  refreshToken?: string | null
 
425
  }
426
 
427
  export async function saveLocalModelConfig(baseUrl: string, modelName: string): Promise<void> {
428
+ console.log('[saveLocalModelConfig] Saving local model config:', baseUrl, modelName)
429
  if (!baseUrl.trim()) {
430
  throw new Error('Base URL cannot be empty')
431
  }
 
437
  throw new Error('Invalid URL format')
438
  }
439
 
440
+ saveGlobalConfig(current => {
441
+ const newConfig = {
442
+ ...current,
443
+ authProvider: 'local',
444
+ localBaseUrl: baseUrl,
445
+ localModelName: modelName,
446
+ }
447
+ console.log('[saveLocalModelConfig] New config to save:', newConfig)
448
+ return newConfig
449
+ })
450
 
451
  // Clear provider cache so it will be re-read on next access
452
  const { clearStoredProviderCache } = await import('./model/providers.js')
453
  clearStoredProviderCache()
454
+ console.log('[saveLocalModelConfig] Config saved successfully')
455
+ }
456
+
457
+ export function getLocalModelName(): string | null {
458
+ try {
459
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
460
+ const { readFileSync } = require('fs') as typeof import('fs')
461
+ const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
462
+ const config = JSON.parse(raw) as {
463
+ authProvider?: string
464
+ localModelName?: string
465
+ }
466
+ console.log('[getLocalModelName] Config from file:', config.authProvider, config.localModelName)
467
+
468
+ if (config.authProvider === 'local' && config.localModelName) {
469
+ return config.localModelName
470
+ }
471
+ return null
472
+ } catch (error) {
473
+ console.log('[getLocalModelName] Error reading file:', error)
474
+ return null
475
+ }
476
+ }
477
+
478
+ export function getLocalBaseUrl(): string | null {
479
+ try {
480
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
481
+ const { readFileSync } = require('fs') as typeof import('fs')
482
+ const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
483
+ const config = JSON.parse(raw) as {
484
+ authProvider?: string
485
+ localBaseUrl?: string
486
+ }
487
+ console.log('[getLocalBaseUrl] Config from file:', config.authProvider, config.localBaseUrl)
488
+
489
+ if (config.authProvider === 'local' && config.localBaseUrl) {
490
+ return config.localBaseUrl
491
+ }
492
+ return null
493
+ } catch (error) {
494
+ console.log('[getLocalBaseUrl] Error reading file:', error)
495
+ return null
496
+ }
497
  }
498
 
499
  function getCodexHomeDir(): string {
src/utils/config.ts CHANGED
@@ -185,6 +185,21 @@ export type GlobalConfig = {
185
  * @deprecated Use settings.apiKeyHelper instead.
186
  */
187
  apiKeyHelper?: string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  projects?: Record<string, ProjectConfig>
189
  numStartups: number
190
  installMethod?: InstallMethod
@@ -626,6 +641,12 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = createDefaultGlobalConfig()
626
 
627
  export const GLOBAL_CONFIG_KEYS = [
628
  'apiKeyHelper',
 
 
 
 
 
 
629
  'installMethod',
630
  'autoUpdates',
631
  'autoUpdatesProtectedForNative',
 
185
  * @deprecated Use settings.apiKeyHelper instead.
186
  */
187
  apiKeyHelper?: string
188
+
189
+ // Authentication provider configuration
190
+ authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local'
191
+
192
+ // OpenRouter API key
193
+ openRouterApiKey?: string
194
+
195
+ // OpenAI API configuration
196
+ openAiApiKey?: string
197
+ openAiAccessToken?: string
198
+
199
+ // Local model configuration
200
+ localBaseUrl?: string
201
+ localModelName?: string
202
+
203
  projects?: Record<string, ProjectConfig>
204
  numStartups: number
205
  installMethod?: InstallMethod
 
641
 
642
  export const GLOBAL_CONFIG_KEYS = [
643
  'apiKeyHelper',
644
+ 'authProvider',
645
+ 'openRouterApiKey',
646
+ 'openAiApiKey',
647
+ 'openAiAccessToken',
648
+ 'localBaseUrl',
649
+ 'localModelName',
650
  'installMethod',
651
  'autoUpdates',
652
  'autoUpdatesProtectedForNative',
src/utils/model/model.ts CHANGED
@@ -7,6 +7,7 @@
7
  */
8
  import { getMainLoopModelOverride } from '../../bootstrap/state.js'
9
  import {
 
10
  getSubscriptionType,
11
  isClaudeAISubscriber,
12
  isMaxSubscriber,
@@ -170,12 +171,23 @@ export function getRuntimeMainLoopModel(params: {
170
  * Get the default main loop model setting.
171
  *
172
  * This handles the built-in default:
 
173
  * - Opus for Max and Team Premium users
174
  * - Sonnet 4.6 for all other users (including Team Standard, Pro, Enterprise)
175
  *
176
  * @returns The default model setting to use
177
  */
178
  export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
 
 
 
 
 
 
 
 
 
 
179
  // Ants default to defaultModel from flag config, or Opus 1M if not configured
180
  if (process.env.USER_TYPE === 'ant') {
181
  return (
 
7
  */
8
  import { getMainLoopModelOverride } from '../../bootstrap/state.js'
9
  import {
10
+ getLocalModelName,
11
  getSubscriptionType,
12
  isClaudeAISubscriber,
13
  isMaxSubscriber,
 
171
  * Get the default main loop model setting.
172
  *
173
  * This handles the built-in default:
174
+ * - Local model name if configured as Local provider
175
  * - Opus for Max and Team Premium users
176
  * - Sonnet 4.6 for all other users (including Team Standard, Pro, Enterprise)
177
  *
178
  * @returns The default model setting to use
179
  */
180
  export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
181
+ // Check if using Local provider
182
+ if (getAPIProvider() === 'local') {
183
+ const localModelName = getLocalModelName()
184
+ if (localModelName) {
185
+ return localModelName
186
+ }
187
+ // Fallback to 'default' if no local model name is configured
188
+ return 'default'
189
+ }
190
+
191
  // Ants default to defaultModel from flag config, or Opus 1M if not configured
192
  if (process.env.USER_TYPE === 'ant') {
193
  return (