byterover-cli 2.1.5 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/agent/infra/llm/providers/openai.d.ts +12 -0
  2. package/dist/agent/infra/llm/providers/openai.js +52 -1
  3. package/dist/oclif/commands/curate/index.js +2 -2
  4. package/dist/oclif/commands/locations.d.ts +14 -0
  5. package/dist/oclif/commands/locations.js +68 -0
  6. package/dist/oclif/commands/model/switch.js +14 -3
  7. package/dist/oclif/commands/providers/connect.d.ts +9 -0
  8. package/dist/oclif/commands/providers/connect.js +110 -14
  9. package/dist/oclif/commands/providers/list.js +3 -5
  10. package/dist/oclif/commands/query.js +2 -2
  11. package/dist/oclif/commands/status.js +3 -3
  12. package/dist/oclif/lib/daemon-client.d.ts +4 -0
  13. package/dist/oclif/lib/daemon-client.js +13 -3
  14. package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
  15. package/dist/server/core/domain/entities/provider-config.js +4 -3
  16. package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
  17. package/dist/server/core/domain/entities/provider-registry.js +24 -1
  18. package/dist/server/core/domain/errors/task-error.d.ts +2 -0
  19. package/dist/server/core/domain/errors/task-error.js +6 -1
  20. package/dist/server/core/domain/transport/schemas.d.ts +2 -0
  21. package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
  22. package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
  23. package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
  24. package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
  25. package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
  26. package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
  27. package/dist/server/infra/daemon/agent-process.js +22 -4
  28. package/dist/server/infra/daemon/brv-server.js +15 -2
  29. package/dist/server/infra/http/models-dev-client.d.ts +29 -0
  30. package/dist/server/infra/http/models-dev-client.js +133 -0
  31. package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
  32. package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
  33. package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
  34. package/dist/server/infra/http/provider-model-fetchers.js +88 -10
  35. package/dist/server/infra/process/feature-handlers.d.ts +6 -1
  36. package/dist/server/infra/process/feature-handlers.js +11 -2
  37. package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
  38. package/dist/server/infra/provider/provider-config-resolver.js +59 -4
  39. package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
  40. package/dist/server/infra/provider-oauth/callback-server.js +203 -0
  41. package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
  42. package/dist/server/infra/provider-oauth/errors.js +76 -0
  43. package/dist/server/infra/provider-oauth/index.d.ts +9 -0
  44. package/dist/server/infra/provider-oauth/index.js +9 -0
  45. package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
  46. package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
  47. package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
  48. package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
  49. package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
  50. package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
  51. package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
  52. package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
  53. package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
  54. package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
  55. package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
  56. package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
  57. package/dist/server/infra/provider-oauth/types.d.ts +55 -0
  58. package/dist/server/infra/provider-oauth/types.js +22 -0
  59. package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
  60. package/dist/server/infra/storage/file-provider-config-store.js +1 -3
  61. package/dist/server/infra/transport/handlers/index.d.ts +2 -0
  62. package/dist/server/infra/transport/handlers/index.js +1 -0
  63. package/dist/server/infra/transport/handlers/locations-handler.d.ts +25 -0
  64. package/dist/server/infra/transport/handlers/locations-handler.js +64 -0
  65. package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
  66. package/dist/server/infra/transport/handlers/model-handler.js +53 -11
  67. package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
  68. package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
  69. package/dist/server/templates/skill/SKILL.md +19 -1
  70. package/dist/shared/constants/oauth.d.ts +14 -0
  71. package/dist/shared/constants/oauth.js +14 -0
  72. package/dist/shared/transport/events/index.d.ts +8 -0
  73. package/dist/shared/transport/events/index.js +3 -0
  74. package/dist/shared/transport/events/locations-events.d.ts +7 -0
  75. package/dist/shared/transport/events/locations-events.js +3 -0
  76. package/dist/shared/transport/events/model-events.d.ts +2 -0
  77. package/dist/shared/transport/events/provider-events.d.ts +36 -0
  78. package/dist/shared/transport/events/provider-events.js +5 -0
  79. package/dist/shared/transport/types/dto.d.ts +15 -0
  80. package/dist/tui/features/commands/definitions/index.js +2 -0
  81. package/dist/tui/features/commands/definitions/locations.d.ts +2 -0
  82. package/dist/tui/features/commands/definitions/locations.js +11 -0
  83. package/dist/tui/features/locations/api/get-locations.d.ts +16 -0
  84. package/dist/tui/features/locations/api/get-locations.js +17 -0
  85. package/dist/tui/features/locations/components/locations-view.d.ts +3 -0
  86. package/dist/tui/features/locations/components/locations-view.js +25 -0
  87. package/dist/tui/features/locations/utils/format-locations.d.ts +2 -0
  88. package/dist/tui/features/locations/utils/format-locations.js +26 -0
  89. package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
  90. package/dist/tui/features/model/api/set-active-model.js +12 -4
  91. package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
  92. package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
  93. package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
  94. package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
  95. package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
  96. package/dist/tui/features/provider/api/start-oauth.js +15 -0
  97. package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
  98. package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
  99. package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
  100. package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
  101. package/dist/tui/features/provider/components/provider-dialog.js +1 -1
  102. package/dist/tui/features/provider/components/provider-flow.js +54 -4
  103. package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
  104. package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
  105. package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
  106. package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
  107. package/dist/tui/providers/app-providers.js +2 -1
  108. package/dist/tui/utils/error-messages.js +6 -1
  109. package/oclif.manifest.json +56 -1
  110. package/package.json +1 -1
@@ -4,6 +4,44 @@
4
4
  * Defines available LLM providers that can be connected to byterover-cli.
5
5
  * Inspired by OpenCode's provider system.
6
6
  */
7
+ /**
8
+ * Configuration for a single OAuth authentication mode.
9
+ */
10
+ export interface OAuthModeConfig {
11
+ /** Auth URL for this mode */
12
+ readonly authUrl: string;
13
+ /** Mode identifier (e.g. 'default', 'pro-max') */
14
+ readonly id: string;
15
+ /** Display label (e.g. "Sign in with OpenAI") */
16
+ readonly label: string;
17
+ }
18
+ /**
19
+ * OAuth configuration for a provider.
20
+ */
21
+ export interface ProviderOAuthConfig {
22
+ /** How the callback is received: local server ('auto') or user pastes code ('code-paste') */
23
+ readonly callbackMode: 'auto' | 'code-paste';
24
+ /** Port for local callback server (auto mode only) */
25
+ readonly callbackPort?: number;
26
+ /** OAuth client ID */
27
+ readonly clientId: string;
28
+ /** Whether to add `code=true` query param to auth URL (code-paste mode only — tells server to display paste-able code) */
29
+ readonly codeDisplay?: boolean;
30
+ /** Default model when connected via OAuth (overrides ProviderDefinition.defaultModel) */
31
+ readonly defaultModel?: string;
32
+ /** Extra query params appended to the authorization URL (provider-specific) */
33
+ readonly extraParams?: Readonly<Record<string, string>>;
34
+ /** Supported OAuth modes (some providers have multiple) */
35
+ readonly modes: readonly OAuthModeConfig[];
36
+ /** OAuth redirect URI */
37
+ readonly redirectUri: string;
38
+ /** OAuth scopes */
39
+ readonly scopes: string;
40
+ /** Token endpoint content type: OpenAI = 'form', Anthropic = 'json' */
41
+ readonly tokenContentType: 'form' | 'json';
42
+ /** Token exchange endpoint */
43
+ readonly tokenUrl: string;
44
+ }
7
45
  /**
8
46
  * Definition for an LLM provider.
9
47
  */
@@ -28,6 +66,8 @@ export interface ProviderDefinition {
28
66
  readonly modelsEndpoint: string;
29
67
  /** Display name */
30
68
  readonly name: string;
69
+ /** OAuth configuration (only for OAuth-capable providers) */
70
+ readonly oauth?: ProviderOAuthConfig;
31
71
  /** Priority for display order (lower = higher priority) */
32
72
  readonly priority: number;
33
73
  }
@@ -54,4 +94,4 @@ export declare function getProviderById(id: string): ProviderDefinition | undefi
54
94
  /**
55
95
  * Check if a provider requires an API key.
56
96
  */
57
- export declare function providerRequiresApiKey(id: string): boolean;
97
+ export declare function providerRequiresApiKey(id: string, authMethod?: 'api-key' | 'oauth'): boolean;
@@ -4,6 +4,7 @@
4
4
  * Defines available LLM providers that can be connected to byterover-cli.
5
5
  * Inspired by OpenCode's provider system.
6
6
  */
7
+ import { CHATGPT_OAUTH_ORIGINATOR } from '../../../../shared/constants/oauth.js';
7
8
  /**
8
9
  * Registry of all available providers.
9
10
  * Order by priority for consistent display.
@@ -160,6 +161,26 @@ export const PROVIDER_REGISTRY = {
160
161
  id: 'openai',
161
162
  modelsEndpoint: '/models',
162
163
  name: 'OpenAI',
164
+ oauth: {
165
+ callbackMode: 'auto',
166
+ callbackPort: 1455,
167
+ // Public OAuth client ID (safe to commit — native app public client, no client secret)
168
+ clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
169
+ // OpenAI Codex model used for the ChatGPT OAuth (Codex CLI) flow
170
+ defaultModel: 'gpt-5.1-codex-mini',
171
+ /* eslint-disable camelcase -- OAuth query params follow RFC 6749 naming */
172
+ extraParams: {
173
+ codex_cli_simplified_flow: 'true',
174
+ id_token_add_organizations: 'true',
175
+ originator: CHATGPT_OAUTH_ORIGINATOR,
176
+ },
177
+ /* eslint-enable camelcase */
178
+ modes: [{ authUrl: 'https://auth.openai.com/oauth/authorize', id: 'default', label: 'Sign in with OpenAI' }],
179
+ redirectUri: 'http://localhost:1455/auth/callback',
180
+ scopes: 'openid profile email offline_access',
181
+ tokenContentType: 'form',
182
+ tokenUrl: 'https://auth.openai.com/oauth/token',
183
+ },
163
184
  priority: 3,
164
185
  },
165
186
  'openai-compatible': {
@@ -268,7 +289,9 @@ export function getProviderById(id) {
268
289
  /**
269
290
  * Check if a provider requires an API key.
270
291
  */
271
- export function providerRequiresApiKey(id) {
292
+ export function providerRequiresApiKey(id, authMethod) {
293
+ if (authMethod === 'oauth')
294
+ return false;
272
295
  const provider = getProviderById(id);
273
296
  if (!provider)
274
297
  return false;
@@ -11,6 +11,8 @@ export declare const TaskErrorCode: {
11
11
  readonly LLM_RATE_LIMIT: "ERR_LLM_RATE_LIMIT";
12
12
  readonly LOCAL_CHANGES_EXIST: "ERR_LOCAL_CHANGES_EXIST";
13
13
  readonly NOT_AUTHENTICATED: "ERR_NOT_AUTHENTICATED";
14
+ readonly OAUTH_REFRESH_FAILED: "ERR_OAUTH_REFRESH_FAILED";
15
+ readonly OAUTH_TOKEN_EXPIRED: "ERR_OAUTH_TOKEN_EXPIRED";
14
16
  readonly PROJECT_NOT_INIT: "ERR_PROJECT_NOT_INIT";
15
17
  readonly PROVIDER_NOT_CONFIGURED: "ERR_PROVIDER_NOT_CONFIGURED";
16
18
  readonly SPACE_NOT_CONFIGURED: "ERR_SPACE_NOT_CONFIGURED";
@@ -15,6 +15,9 @@ export const TaskErrorCode = {
15
15
  LOCAL_CHANGES_EXIST: 'ERR_LOCAL_CHANGES_EXIST',
16
16
  // Auth/Init errors
17
17
  NOT_AUTHENTICATED: 'ERR_NOT_AUTHENTICATED',
18
+ // OAuth errors
19
+ OAUTH_REFRESH_FAILED: 'ERR_OAUTH_REFRESH_FAILED',
20
+ OAUTH_TOKEN_EXPIRED: 'ERR_OAUTH_TOKEN_EXPIRED',
18
21
  // Execution errors
19
22
  PROJECT_NOT_INIT: 'ERR_PROJECT_NOT_INIT',
20
23
  PROVIDER_NOT_CONFIGURED: 'ERR_PROVIDER_NOT_CONFIGURED',
@@ -100,7 +103,9 @@ export class AgentDisconnectedError extends TaskError {
100
103
  }
101
104
  export class AgentNotInitializedError extends TaskError {
102
105
  constructor(reason) {
103
- super(reason ? `Agent failed to initialize: ${reason}` : "Agent failed to initialize. Run 'brv restart' to force a clean restart.", TaskErrorCode.AGENT_NOT_INITIALIZED);
106
+ super(reason
107
+ ? `Agent failed to initialize: ${reason}`
108
+ : "Agent failed to initialize. Run 'brv restart' to force a clean restart.", TaskErrorCode.AGENT_NOT_INITIALIZED);
104
109
  this.name = 'AgentNotInitializedError';
105
110
  }
106
111
  }
@@ -463,6 +463,8 @@ export declare const TransportDaemonEventNames: {
463
463
  export interface ProviderConfigResponse {
464
464
  activeModel?: string;
465
465
  activeProvider: string;
466
+ /** How the provider was authenticated ('api-key' | 'oauth'). Undefined for internal providers. */
467
+ authMethod?: 'api-key' | 'oauth';
466
468
  maxInputTokens?: number;
467
469
  openRouterApiKey?: string;
468
470
  provider?: string;
@@ -12,7 +12,9 @@ export interface IProviderConfigStore {
12
12
  */
13
13
  connectProvider: (providerId: string, options?: {
14
14
  activeModel?: string;
15
+ authMethod?: 'api-key' | 'oauth';
15
16
  baseUrl?: string;
17
+ oauthAccountId?: string;
16
18
  }) => Promise<void>;
17
19
  /**
18
20
  * Removes a provider connection.
@@ -33,6 +33,15 @@ export interface ProviderModelInfo {
33
33
  /** Provider name (e.g., 'Anthropic', 'OpenAI') */
34
34
  provider: string;
35
35
  }
36
+ /**
37
+ * Options for fetchModels().
38
+ */
39
+ export interface FetchModelsOptions {
40
+ /** How this provider was authenticated */
41
+ authMethod?: 'api-key' | 'oauth';
42
+ /** If true, bypass any cache */
43
+ forceRefresh?: boolean;
44
+ }
36
45
  /**
37
46
  * Interface for provider-specific model fetching.
38
47
  *
@@ -42,11 +51,11 @@ export interface ProviderModelInfo {
42
51
  export interface IProviderModelFetcher {
43
52
  /**
44
53
  * Fetch available models from the provider.
45
- * @param apiKey - API key for authentication
46
- * @param forceRefresh - If true, bypass any cache
54
+ * @param apiKey - API key or OAuth access token for authentication
55
+ * @param options - Fetch options (authMethod, forceRefresh)
47
56
  * @returns Array of normalized model info
48
57
  */
49
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
58
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
50
59
  /**
51
60
  * Validate an API key by attempting a lightweight API call.
52
61
  * @param apiKey - API key to validate
@@ -0,0 +1,24 @@
1
+ /**
2
+ * OAuth Token Record
3
+ *
4
+ * Stores the refresh token and expiry for a single OAuth-connected provider.
5
+ * Access tokens are stored separately in the provider keychain store.
6
+ */
7
+ export type OAuthTokenRecord = {
8
+ /** Token expiry as ISO 8601 timestamp */
9
+ readonly expiresAt: string;
10
+ /** OAuth refresh token (used to obtain new access tokens) */
11
+ readonly refreshToken: string;
12
+ };
13
+ /**
14
+ * Interface for securely storing OAuth token metadata per provider.
15
+ *
16
+ * Separate from the provider keychain (which stores access tokens as "API keys").
17
+ * Implementations must encrypt data at rest.
18
+ */
19
+ export interface IProviderOAuthTokenStore {
20
+ delete(providerId: string): Promise<void>;
21
+ get(providerId: string): Promise<OAuthTokenRecord | undefined>;
22
+ has(providerId: string): Promise<boolean>;
23
+ set(providerId: string, data: OAuthTokenRecord): Promise<void>;
24
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Interface for the token refresh manager.
3
+ * Used by resolveProviderConfig to transparently refresh OAuth tokens.
4
+ */
5
+ export interface ITokenRefreshManager {
6
+ refreshIfNeeded(providerId: string): Promise<boolean>;
7
+ }
@@ -111,6 +111,8 @@ let cachedTeamId = '';
111
111
  let cachedSpaceId = '';
112
112
  let cachedActiveProvider = '';
113
113
  let cachedActiveModel = '';
114
+ let cachedProviderApiKey;
115
+ let cachedProviderHeaders;
114
116
  // ============================================================================
115
117
  // Provider Config (resolved by daemon via state:getProviderConfig)
116
118
  // ============================================================================
@@ -182,6 +184,8 @@ async function start() {
182
184
  const { activeModel, activeProvider } = providerResult;
183
185
  cachedActiveProvider = activeProvider;
184
186
  cachedActiveModel = activeModel ?? DEFAULT_LLM_MODEL;
187
+ cachedProviderApiKey = providerResult.providerApiKey;
188
+ cachedProviderHeaders = providerResult.providerHeaders ? JSON.stringify(providerResult.providerHeaders) : undefined;
185
189
  agentLog(`Provider: ${activeProvider}, Model: ${activeModel ?? 'default'}`);
186
190
  // 5. Create CipherAgent with lazy providers + transport client
187
191
  const envConfig = getCurrentConfig();
@@ -311,7 +315,8 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
311
315
  }
312
316
  if (freshProviderConfig.providerKeyMissing) {
313
317
  const modelInfo = freshProviderConfig.activeModel ? ` (model: ${freshProviderConfig.activeModel})` : '';
314
- const errorMessage = `${freshProviderConfig.activeProvider} API key is missing${modelInfo}. Use /provider in the REPL to reconnect.`;
318
+ const credentialType = freshProviderConfig.authMethod === 'oauth' ? 'authentication has expired' : 'API key is missing';
319
+ const errorMessage = `${freshProviderConfig.activeProvider} ${credentialType}${modelInfo}. Use /provider in the REPL to reconnect.`;
315
320
  const error = serializeTaskError(new TaskError(errorMessage, TaskErrorCode.PROVIDER_NOT_CONFIGURED));
316
321
  transport.request(TransportTaskEventNames.ERROR, { clientId, error, taskId });
317
322
  return;
@@ -435,6 +440,9 @@ async function executeTask(task, curateExecutor, folderPackExecutor, queryExecut
435
440
  *
436
441
  * If only the model changed (same provider), the session ID is reused on the
437
442
  * fresh SessionManager for metadata continuity (in-memory history is not preserved).
443
+ * If only credentials changed (same provider and model), the session ID is reused
444
+ * and the SessionManager is rebuilt with the new credentials. This covers token refresh,
445
+ * auth method switches (API Key ↔ OAuth), and API key re-entry.
438
446
  * If the provider changed, a new session is created (history format is incompatible).
439
447
  */
440
448
  async function hotSwapProvider(currentAgent, transportClient) {
@@ -464,11 +472,18 @@ async function hotSwapProvider(currentAgent, transportClient) {
464
472
  const newModel = freshProvider.activeModel ?? DEFAULT_LLM_MODEL;
465
473
  const isProviderChange = ap !== cachedActiveProvider;
466
474
  const isModelChange = newModel !== cachedActiveModel;
475
+ const isCredentialChange = freshProvider.providerApiKey !== cachedProviderApiKey ||
476
+ (freshProvider.providerHeaders ? JSON.stringify(freshProvider.providerHeaders) : undefined) !==
477
+ cachedProviderHeaders;
467
478
  // Nothing actually changed (duplicate event) — skip
468
- if (!isProviderChange && !isModelChange) {
479
+ if (!isProviderChange && !isModelChange && !isCredentialChange) {
469
480
  providerConfigDirty = false;
470
481
  return {};
471
482
  }
483
+ // TODO: Credential-only changes (e.g., OAuth token refresh) currently rebuild the entire
484
+ // SessionManager, which destroys in-memory conversation history. A future
485
+ // SessionManager.updateCredentials() method could swap LLM config in-place,
486
+ // preserving sessions and avoiding history loss on hourly token refreshes.
472
487
  // Phase 2a: Replace SessionManager (if this throws, old SM remains intact)
473
488
  const previousSessionId = currentAgent.sessionId;
474
489
  try {
@@ -502,7 +517,7 @@ async function hotSwapProvider(currentAgent, transportClient) {
502
517
  await persistNewSession(newSessionId, ap);
503
518
  }
504
519
  else {
505
- // Model-only change: reuse session ID for metadata continuity.
520
+ // Model-only or credential-only change: reuse session ID for metadata continuity.
506
521
  // Note: in-memory conversation history is lost (new SessionManager has no sessions).
507
522
  // Only the session ID and persisted metadata are preserved.
508
523
  await currentAgent.createSession(previousSessionId);
@@ -530,7 +545,10 @@ async function hotSwapProvider(currentAgent, transportClient) {
530
545
  providerConfigDirty = false;
531
546
  cachedActiveProvider = ap;
532
547
  cachedActiveModel = newModel;
533
- agentLog(`Provider hot-switched: ${ap}, Model: ${newModel}`);
548
+ cachedProviderApiKey = freshProvider.providerApiKey;
549
+ cachedProviderHeaders = freshProvider.providerHeaders ? JSON.stringify(freshProvider.providerHeaders) : undefined;
550
+ const changeType = isProviderChange ? 'provider' : isModelChange ? 'model' : 'credentials';
551
+ agentLog(`Provider hot-swapped (${changeType}): ${ap}, Model: ${newModel}`);
534
552
  return {};
535
553
  }
536
554
  // ============================================================================
@@ -36,6 +36,8 @@ import { CurateLogHandler } from '../process/curate-log-handler.js';
36
36
  import { setupFeatureHandlers } from '../process/feature-handlers.js';
37
37
  import { TransportHandlers } from '../process/transport-handlers.js';
38
38
  import { ProjectRegistry } from '../project/project-registry.js';
39
+ import { createProviderOAuthTokenStore } from '../provider-oauth/provider-oauth-token-store.js';
40
+ import { TokenRefreshManager } from '../provider-oauth/token-refresh-manager.js';
39
41
  import { clearStaleProviderConfig, resolveProviderConfig } from '../provider/provider-config-resolver.js';
40
42
  import { ProjectRouter } from '../routing/project-router.js';
41
43
  import { AuthStateStore } from '../state/auth-state-store.js';
@@ -338,12 +340,20 @@ async function main() {
338
340
  // Provider config/keychain stores — shared between feature handlers and state endpoint
339
341
  const providerConfigStore = new FileProviderConfigStore();
340
342
  const providerKeychainStore = createProviderKeychainStore();
343
+ const providerOAuthTokenStore = createProviderOAuthTokenStore();
344
+ // Token refresh manager — transparently refreshes OAuth tokens before they expire
345
+ const tokenRefreshManager = new TokenRefreshManager({
346
+ providerConfigStore,
347
+ providerKeychainStore,
348
+ providerOAuthTokenStore,
349
+ transport: transportServer,
350
+ });
341
351
  // Clear stale provider config on startup (e.g. migration from v1 system keychain to v2 file keystore).
342
352
  // If a provider is configured but its API key is no longer accessible, disconnect it so the user
343
353
  // is returned to the onboarding flow rather than hitting a cryptic API key error mid-task.
344
- await clearStaleProviderConfig(providerConfigStore, providerKeychainStore);
354
+ await clearStaleProviderConfig(providerConfigStore, providerKeychainStore, providerOAuthTokenStore);
345
355
  // State endpoint: provider config — agents request this on startup and after provider:updated
346
- transportServer.onRequest(TransportStateEventNames.GET_PROVIDER_CONFIG, async () => resolveProviderConfig(providerConfigStore, providerKeychainStore));
356
+ transportServer.onRequest(TransportStateEventNames.GET_PROVIDER_CONFIG, async () => resolveProviderConfig(providerConfigStore, providerKeychainStore, tokenRefreshManager));
347
357
  // Feature handlers (auth, init, status, push, pull, etc.) require async OIDC discovery.
348
358
  // Placed after daemon:getState so the debug endpoint is available immediately,
349
359
  // without waiting for OIDC discovery (~400ms).
@@ -352,9 +362,12 @@ async function main() {
352
362
  broadcastToProject(projectPath, event, data) {
353
363
  broadcastToProjectRoom(projectRegistry, projectRouter, projectPath, event, data);
354
364
  },
365
+ getActiveProjectPaths: () => clientManager.getActiveProjects(),
355
366
  log,
367
+ projectRegistry,
356
368
  providerConfigStore,
357
369
  providerKeychainStore,
370
+ providerOAuthTokenStore,
358
371
  resolveProjectPath: (clientId) => clientManager.getClient(clientId)?.projectPath,
359
372
  transport: transportServer,
360
373
  });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Models.dev Client
3
+ *
4
+ * Fetches model metadata from https://models.dev/api.json — a centralized
5
+ * third-party model database. Caches to disk with 60-minute TTL.
6
+ * Used by OpenAIModelFetcher to get dynamic Codex model lists for OAuth.
7
+ */
8
+ import type { ProviderModelInfo } from '../../core/interfaces/i-provider-model-fetcher.js';
9
+ export declare class ModelsDevClient {
10
+ private readonly cachePath;
11
+ private inMemoryCache;
12
+ constructor(cachePath?: string);
13
+ /**
14
+ * Get models for a specific provider, transformed to ProviderModelInfo[].
15
+ */
16
+ getModelsForProvider(providerId: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
17
+ /**
18
+ * Force refresh the cache from models.dev.
19
+ */
20
+ refresh(): Promise<void>;
21
+ private fetchAndCache;
22
+ private getData;
23
+ private readDiskCache;
24
+ }
25
+ export declare function getModelsDevClient(): ModelsDevClient;
26
+ /**
27
+ * Reset the singleton (for testing).
28
+ */
29
+ export declare function resetModelsDevClient(): void;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Models.dev Client
3
+ *
4
+ * Fetches model metadata from https://models.dev/api.json — a centralized
5
+ * third-party model database. Caches to disk with 60-minute TTL.
6
+ * Used by OpenAIModelFetcher to get dynamic Codex model lists for OAuth.
7
+ */
8
+ import axios from 'axios';
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import { z } from 'zod';
12
+ import { getGlobalDataDir } from '../../utils/global-data-path.js';
13
+ const CacheEnvelopeSchema = z.object({
14
+ data: z.record(z.unknown()),
15
+ timestamp: z.number(),
16
+ });
17
+ // ============================================================================
18
+ // Constants
19
+ // ============================================================================
20
+ const MODELS_DEV_URL = 'https://models.dev/api.json';
21
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 60 minutes
22
+ const FETCH_TIMEOUT_MS = 10_000;
23
+ // ============================================================================
24
+ // Client
25
+ // ============================================================================
26
+ export class ModelsDevClient {
27
+ cachePath;
28
+ inMemoryCache;
29
+ constructor(cachePath) {
30
+ this.cachePath = cachePath ?? join(getGlobalDataDir(), 'models-dev.json');
31
+ }
32
+ /**
33
+ * Get models for a specific provider, transformed to ProviderModelInfo[].
34
+ */
35
+ async getModelsForProvider(providerId, forceRefresh = false) {
36
+ const data = await this.getData(forceRefresh);
37
+ const provider = data[providerId];
38
+ if (!provider)
39
+ return [];
40
+ return Object.values(provider.models).map((model) => ({
41
+ contextLength: model.limit.context,
42
+ id: model.id,
43
+ isFree: !model.cost,
44
+ name: model.name,
45
+ pricing: {
46
+ inputPerM: model.cost?.input ?? 0,
47
+ outputPerM: model.cost?.output ?? 0,
48
+ },
49
+ provider: provider.name,
50
+ }));
51
+ }
52
+ /**
53
+ * Force refresh the cache from models.dev.
54
+ */
55
+ async refresh() {
56
+ await this.fetchAndCache();
57
+ }
58
+ async fetchAndCache() {
59
+ const response = await axios.get(MODELS_DEV_URL, {
60
+ timeout: FETCH_TIMEOUT_MS,
61
+ });
62
+ const { data } = response;
63
+ const envelope = { data, timestamp: Date.now() };
64
+ this.inMemoryCache = envelope;
65
+ // Write to disk (best-effort, don't throw on failure)
66
+ try {
67
+ await mkdir(dirname(this.cachePath), { recursive: true });
68
+ await writeFile(this.cachePath, JSON.stringify(envelope), 'utf8');
69
+ }
70
+ catch {
71
+ // Cache write failure is non-fatal
72
+ }
73
+ return data;
74
+ }
75
+ async getData(forceRefresh) {
76
+ // Check in-memory cache
77
+ if (!forceRefresh && this.inMemoryCache && Date.now() - this.inMemoryCache.timestamp < CACHE_TTL_MS) {
78
+ return this.inMemoryCache.data;
79
+ }
80
+ // Try disk cache
81
+ if (!forceRefresh) {
82
+ const diskCache = await this.readDiskCache();
83
+ if (diskCache && Date.now() - diskCache.timestamp < CACHE_TTL_MS) {
84
+ this.inMemoryCache = diskCache;
85
+ return diskCache.data;
86
+ }
87
+ }
88
+ // Fetch from network
89
+ try {
90
+ return await this.fetchAndCache();
91
+ }
92
+ catch {
93
+ // Network failure: fall back to stale disk cache (any age)
94
+ const staleCache = this.inMemoryCache ?? (await this.readDiskCache());
95
+ if (staleCache) {
96
+ this.inMemoryCache = staleCache;
97
+ return staleCache.data;
98
+ }
99
+ // No cache at all — return empty
100
+ return {};
101
+ }
102
+ }
103
+ async readDiskCache() {
104
+ try {
105
+ const content = await readFile(this.cachePath, 'utf8');
106
+ const parsed = JSON.parse(content);
107
+ const result = CacheEnvelopeSchema.safeParse(parsed);
108
+ if (result.success) {
109
+ return result.data;
110
+ }
111
+ }
112
+ catch {
113
+ // Cache read failure is non-fatal
114
+ }
115
+ return undefined;
116
+ }
117
+ }
118
+ /**
119
+ * Singleton instance for the models.dev client.
120
+ */
121
+ let clientInstance;
122
+ export function getModelsDevClient() {
123
+ if (!clientInstance) {
124
+ clientInstance = new ModelsDevClient();
125
+ }
126
+ return clientInstance;
127
+ }
128
+ /**
129
+ * Reset the singleton (for testing).
130
+ */
131
+ export function resetModelsDevClient() {
132
+ clientInstance = undefined;
133
+ }
@@ -23,9 +23,10 @@ export declare function clearModelFetcherCache(): void;
23
23
  *
24
24
  * @param apiKey - API key to validate
25
25
  * @param providerId - Provider identifier
26
+ * @param authMethod - How this provider is authenticated (OAuth providers skip validation)
26
27
  * @returns Validation result, or {isValid: false} if no fetcher exists
27
28
  */
28
- export declare function validateApiKey(apiKey: string, providerId: string): Promise<{
29
+ export declare function validateApiKey(apiKey: string, providerId: string, authMethod?: 'api-key' | 'oauth'): Promise<{
29
30
  error?: string;
30
31
  isValid: boolean;
31
32
  }>;
@@ -106,9 +106,14 @@ export function clearModelFetcherCache() {
106
106
  *
107
107
  * @param apiKey - API key to validate
108
108
  * @param providerId - Provider identifier
109
+ * @param authMethod - How this provider is authenticated (OAuth providers skip validation)
109
110
  * @returns Validation result, or {isValid: false} if no fetcher exists
110
111
  */
111
- export async function validateApiKey(apiKey, providerId) {
112
+ export async function validateApiKey(apiKey, providerId, authMethod) {
113
+ // OAuth tokens are validated via the token exchange flow, not API key validation
114
+ if (authMethod === 'oauth') {
115
+ return { isValid: true };
116
+ }
112
117
  const fetcher = await getModelFetcher(providerId);
113
118
  if (!fetcher) {
114
119
  return { error: `No model fetcher available for provider: ${providerId}`, isValid: false };
@@ -8,7 +8,8 @@
8
8
  * - OpenAICompatibleModelFetcher: Generic for xAI/Groq/Mistral (REST API)
9
9
  * - OpenRouterModelFetcher: Wraps existing OpenRouterApiClient
10
10
  */
11
- import type { IProviderModelFetcher, ProviderModelInfo } from '../../core/interfaces/i-provider-model-fetcher.js';
11
+ import type { FetchModelsOptions, IProviderModelFetcher, ProviderModelInfo } from '../../core/interfaces/i-provider-model-fetcher.js';
12
+ import { type ModelsDevClient } from './models-dev-client.js';
12
13
  /**
13
14
  * Fetches models from Anthropic using the official SDK.
14
15
  */
@@ -16,26 +17,50 @@ export declare class AnthropicModelFetcher implements IProviderModelFetcher {
16
17
  private cache;
17
18
  private readonly cacheTtlMs;
18
19
  constructor(cacheTtlMs?: number);
19
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
20
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
20
21
  validateApiKey(apiKey: string): Promise<{
21
22
  error?: string;
22
23
  isValid: boolean;
23
24
  }>;
24
25
  }
26
+ /**
27
+ * Strict allowlist of model IDs permitted for OAuth-connected OpenAI (Codex).
28
+ * Only models verified to work with the ChatGPT Codex endpoint are included.
29
+ *
30
+ * NOT supported (Bad Request on chatgpt.com/backend-api/codex):
31
+ * - codex-mini-latest: standard API model, not a Codex endpoint model
32
+ * - o4-mini: reasoning model, not supported by the Codex endpoint
33
+ * - gpt-5.3-codex-spark: reported unsupported (GitHub openai/codex#13469)
34
+ */
35
+ export declare const CODEX_ALLOWED_MODELS: Set<string>;
36
+ /**
37
+ * Fallback Codex models used when models.dev is unreachable and no disk cache exists.
38
+ */
39
+ export declare const CODEX_FALLBACK_MODELS: readonly ProviderModelInfo[];
25
40
  /**
26
41
  * Fetches models from OpenAI using the official SDK.
42
+ * For OAuth-connected providers, fetches from models.dev and filters to Codex-allowed models.
27
43
  */
28
44
  export declare class OpenAIModelFetcher implements IProviderModelFetcher {
29
45
  private cache;
30
46
  private readonly cacheTtlMs;
31
- constructor(cacheTtlMs?: number);
32
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
47
+ private readonly getModelsDevClient;
48
+ constructor(options?: {
49
+ cacheTtlMs?: number;
50
+ modelsDevClient?: ModelsDevClient;
51
+ });
52
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
33
53
  validateApiKey(apiKey: string): Promise<{
34
54
  error?: string;
35
55
  isValid: boolean;
36
56
  }>;
37
57
  private estimateContextLength;
38
58
  private estimatePricing;
59
+ /**
60
+ * Fetch Codex models from models.dev, filtered by allowlist.
61
+ * Falls back to CODEX_FALLBACK_MODELS if models.dev is unavailable.
62
+ */
63
+ private fetchCodexModels;
39
64
  }
40
65
  /**
41
66
  * Fetches models from Google using the @google/genai SDK.
@@ -44,7 +69,7 @@ export declare class GoogleModelFetcher implements IProviderModelFetcher {
44
69
  private cache;
45
70
  private readonly cacheTtlMs;
46
71
  constructor(cacheTtlMs?: number);
47
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
72
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
48
73
  validateApiKey(apiKey: string): Promise<{
49
74
  error?: string;
50
75
  isValid: boolean;
@@ -60,7 +85,7 @@ export declare class OpenAICompatibleModelFetcher implements IProviderModelFetch
60
85
  private readonly cacheTtlMs;
61
86
  private readonly providerName;
62
87
  constructor(baseUrl: string, providerName: string, cacheTtlMs?: number);
63
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
88
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
64
89
  validateApiKey(apiKey: string): Promise<{
65
90
  error?: string;
66
91
  isValid: boolean;
@@ -75,7 +100,7 @@ export declare class ChatBasedModelFetcher implements IProviderModelFetcher {
75
100
  private readonly baseUrl;
76
101
  private readonly knownModels;
77
102
  constructor(baseUrl: string, providerName: string, knownModels: string[]);
78
- fetchModels(_apiKey: string, _forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
103
+ fetchModels(_apiKey: string, _options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
79
104
  validateApiKey(apiKey: string): Promise<{
80
105
  error?: string;
81
106
  isValid: boolean;
@@ -86,7 +111,7 @@ export declare class ChatBasedModelFetcher implements IProviderModelFetcher {
86
111
  * Adapts NormalizedModel to ProviderModelInfo.
87
112
  */
88
113
  export declare class OpenRouterModelFetcher implements IProviderModelFetcher {
89
- fetchModels(apiKey: string, forceRefresh?: boolean): Promise<ProviderModelInfo[]>;
114
+ fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
90
115
  validateApiKey(apiKey: string): Promise<{
91
116
  error?: string;
92
117
  isValid: boolean;