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
@@ -15,6 +15,7 @@ import { GoogleGenAI } from '@google/genai';
15
15
  import { APICallError, generateText } from 'ai';
16
16
  import axios, { isAxiosError } from 'axios';
17
17
  import OpenAI from 'openai';
18
+ import { getModelsDevClient as getModelsDevClientDefault } from './models-dev-client.js';
18
19
  const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour
19
20
  const ANTHROPIC_KNOWN_MODELS = {
20
21
  'claude-3-5-haiku-20241022': { contextLength: 200_000, inputPerM: 0.8, outputPerM: 4 },
@@ -60,7 +61,8 @@ export class AnthropicModelFetcher {
60
61
  constructor(cacheTtlMs = DEFAULT_CACHE_TTL) {
61
62
  this.cacheTtlMs = cacheTtlMs;
62
63
  }
63
- async fetchModels(apiKey, forceRefresh = false) {
64
+ async fetchModels(apiKey, options) {
65
+ const forceRefresh = options?.forceRefresh ?? false;
64
66
  if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
65
67
  return this.cache.models;
66
68
  }
@@ -101,16 +103,64 @@ export class AnthropicModelFetcher {
101
103
  // ============================================================================
102
104
  // OpenAI Model Fetcher
103
105
  // ============================================================================
106
+ /**
107
+ * Strict allowlist of model IDs permitted for OAuth-connected OpenAI (Codex).
108
+ * Only models verified to work with the ChatGPT Codex endpoint are included.
109
+ *
110
+ * NOT supported (Bad Request on chatgpt.com/backend-api/codex):
111
+ * - codex-mini-latest: standard API model, not a Codex endpoint model
112
+ * - o4-mini: reasoning model, not supported by the Codex endpoint
113
+ * - gpt-5.3-codex-spark: reported unsupported (GitHub openai/codex#13469)
114
+ */
115
+ export const CODEX_ALLOWED_MODELS = new Set([
116
+ 'gpt-5.1-codex',
117
+ 'gpt-5.1-codex-max',
118
+ 'gpt-5.1-codex-mini',
119
+ 'gpt-5.2',
120
+ 'gpt-5.2-codex',
121
+ 'gpt-5.3-codex',
122
+ 'gpt-5.4',
123
+ ]);
124
+ /**
125
+ * Fallback Codex models used when models.dev is unreachable and no disk cache exists.
126
+ */
127
+ export const CODEX_FALLBACK_MODELS = [
128
+ {
129
+ contextLength: 400_000,
130
+ id: 'gpt-5.3-codex',
131
+ isFree: false,
132
+ name: 'GPT-5.3 Codex',
133
+ pricing: { inputPerM: 0, outputPerM: 0 },
134
+ provider: 'OpenAI',
135
+ },
136
+ {
137
+ contextLength: 200_000,
138
+ id: 'gpt-5.1-codex-mini',
139
+ isFree: false,
140
+ name: 'GPT-5.1 Codex Mini',
141
+ pricing: { inputPerM: 0, outputPerM: 0 },
142
+ provider: 'OpenAI',
143
+ },
144
+ ];
104
145
  /**
105
146
  * Fetches models from OpenAI using the official SDK.
147
+ * For OAuth-connected providers, fetches from models.dev and filters to Codex-allowed models.
106
148
  */
107
149
  export class OpenAIModelFetcher {
108
150
  cache;
109
151
  cacheTtlMs;
110
- constructor(cacheTtlMs = DEFAULT_CACHE_TTL) {
111
- this.cacheTtlMs = cacheTtlMs;
152
+ getModelsDevClient;
153
+ constructor(options) {
154
+ this.cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL;
155
+ const injectedClient = options?.modelsDevClient;
156
+ this.getModelsDevClient = injectedClient ? () => injectedClient : () => getModelsDevClientDefault();
112
157
  }
113
- async fetchModels(apiKey, forceRefresh = false) {
158
+ async fetchModels(apiKey, options) {
159
+ // OAuth-connected OpenAI: fetch from models.dev, filter to Codex models
160
+ if (options?.authMethod === 'oauth') {
161
+ return this.fetchCodexModels(options.forceRefresh ?? false);
162
+ }
163
+ const forceRefresh = options?.forceRefresh ?? false;
114
164
  if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
115
165
  return this.cache.models;
116
166
  }
@@ -198,6 +248,29 @@ export class OpenAIModelFetcher {
198
248
  return { inputPerM: 15, outputPerM: 60 };
199
249
  return { inputPerM: 0, outputPerM: 0 };
200
250
  }
251
+ /**
252
+ * Fetch Codex models from models.dev, filtered by allowlist.
253
+ * Falls back to CODEX_FALLBACK_MODELS if models.dev is unavailable.
254
+ */
255
+ async fetchCodexModels(forceRefresh) {
256
+ const client = this.getModelsDevClient();
257
+ const allModels = await client.getModelsForProvider('openai', forceRefresh);
258
+ if (allModels.length === 0) {
259
+ return [...CODEX_FALLBACK_MODELS];
260
+ }
261
+ // Strict allowlist only — dynamic "codex in name" matching is too broad
262
+ // (e.g. codex-mini-latest, gpt-5.3-codex-spark cause Bad Request)
263
+ const codexModels = allModels.filter((m) => CODEX_ALLOWED_MODELS.has(m.id));
264
+ if (codexModels.length === 0) {
265
+ return [...CODEX_FALLBACK_MODELS];
266
+ }
267
+ // Zero out costs (included in ChatGPT subscription)
268
+ return codexModels.map((m) => ({
269
+ ...m,
270
+ isFree: false,
271
+ pricing: { inputPerM: 0, outputPerM: 0 },
272
+ }));
273
+ }
201
274
  }
202
275
  // ============================================================================
203
276
  // Google Model Fetcher
@@ -211,7 +284,8 @@ export class GoogleModelFetcher {
211
284
  constructor(cacheTtlMs = DEFAULT_CACHE_TTL) {
212
285
  this.cacheTtlMs = cacheTtlMs;
213
286
  }
214
- async fetchModels(apiKey, forceRefresh = false) {
287
+ async fetchModels(apiKey, options) {
288
+ const forceRefresh = options?.forceRefresh ?? false;
215
289
  if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
216
290
  return this.cache.models;
217
291
  }
@@ -265,7 +339,8 @@ export class OpenAICompatibleModelFetcher {
265
339
  this.providerName = providerName;
266
340
  this.cacheTtlMs = cacheTtlMs;
267
341
  }
268
- async fetchModels(apiKey, forceRefresh = false) {
342
+ async fetchModels(apiKey, options) {
343
+ const forceRefresh = options?.forceRefresh ?? false;
269
344
  if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
270
345
  return this.cache.models;
271
346
  }
@@ -342,7 +417,7 @@ export class ChatBasedModelFetcher {
342
417
  provider: providerName,
343
418
  }));
344
419
  }
345
- async fetchModels(_apiKey, _forceRefresh = false) {
420
+ async fetchModels(_apiKey, _options) {
346
421
  return this.knownModels;
347
422
  }
348
423
  async validateApiKey(apiKey) {
@@ -384,9 +459,9 @@ import { getOpenRouterApiClient } from './openrouter-api-client.js';
384
459
  * Adapts NormalizedModel to ProviderModelInfo.
385
460
  */
386
461
  export class OpenRouterModelFetcher {
387
- async fetchModels(apiKey, forceRefresh = false) {
462
+ async fetchModels(apiKey, options) {
388
463
  const client = getOpenRouterApiClient();
389
- const models = await client.fetchModels(apiKey, forceRefresh);
464
+ const models = await client.fetchModels(apiKey, options?.forceRefresh ?? false);
390
465
  return models.map((m) => ({
391
466
  contextLength: m.contextLength,
392
467
  description: m.description,
@@ -438,7 +513,10 @@ function handleAiSdkValidationError(error) {
438
513
  function handleSdkValidationError(error) {
439
514
  if (error instanceof Error) {
440
515
  const message = error.message.toLowerCase();
441
- if (message.includes('401') || message.includes('unauthorized') || message.includes('invalid api key') || message.includes('authentication')) {
516
+ if (message.includes('401') ||
517
+ message.includes('unauthorized') ||
518
+ message.includes('invalid api key') ||
519
+ message.includes('authentication')) {
442
520
  return { error: `Authentication failed: ${error.message}`, isValid: false };
443
521
  }
444
522
  if (message.includes('403') || message.includes('forbidden') || message.includes('permission')) {
@@ -6,15 +6,20 @@
6
6
  */
7
7
  import type { IProviderConfigStore } from '../../core/interfaces/i-provider-config-store.js';
8
8
  import type { IProviderKeychainStore } from '../../core/interfaces/i-provider-keychain-store.js';
9
+ import type { IProviderOAuthTokenStore } from '../../core/interfaces/i-provider-oauth-token-store.js';
10
+ import type { IProjectRegistry } from '../../core/interfaces/project/i-project-registry.js';
9
11
  import type { IAuthStateStore } from '../../core/interfaces/state/i-auth-state-store.js';
10
12
  import type { ITransportServer } from '../../core/interfaces/transport/i-transport-server.js';
11
13
  import type { ProjectBroadcaster, ProjectPathResolver } from '../transport/handlers/handler-types.js';
12
14
  export interface FeatureHandlersOptions {
13
15
  authStateStore: IAuthStateStore;
14
16
  broadcastToProject: ProjectBroadcaster;
17
+ getActiveProjectPaths: () => string[];
15
18
  log: (msg: string) => void;
19
+ projectRegistry: IProjectRegistry;
16
20
  providerConfigStore: IProviderConfigStore;
17
21
  providerKeychainStore: IProviderKeychainStore;
22
+ providerOAuthTokenStore: IProviderOAuthTokenStore;
18
23
  resolveProjectPath: ProjectPathResolver;
19
24
  transport: ITransportServer;
20
25
  }
@@ -22,4 +27,4 @@ export interface FeatureHandlersOptions {
22
27
  * Setup all feature handlers on the transport server.
23
28
  * These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.).
24
29
  */
25
- export declare function setupFeatureHandlers({ authStateStore, broadcastToProject, log, providerConfigStore, providerKeychainStore, resolveProjectPath, transport, }: FeatureHandlersOptions): Promise<void>;
30
+ export declare function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, providerOAuthTokenStore, resolveProjectPath, transport, }: FeatureHandlersOptions): Promise<void>;
@@ -29,13 +29,13 @@ import { HttpSpaceService } from '../space/http-space-service.js';
29
29
  import { createTokenStore } from '../storage/token-store.js';
30
30
  import { HttpTeamService } from '../team/http-team-service.js';
31
31
  import { FsTemplateLoader } from '../template/fs-template-loader.js';
32
- import { AuthHandler, ConfigHandler, ConnectorsHandler, HubHandler, InitHandler, ModelHandler, ProviderHandler, PullHandler, PushHandler, ResetHandler, SpaceHandler, StatusHandler, } from '../transport/handlers/index.js';
32
+ import { AuthHandler, ConfigHandler, ConnectorsHandler, HubHandler, InitHandler, LocationsHandler, ModelHandler, ProviderHandler, PullHandler, PushHandler, ResetHandler, SpaceHandler, StatusHandler, } from '../transport/handlers/index.js';
33
33
  import { HttpUserService } from '../user/http-user-service.js';
34
34
  /**
35
35
  * Setup all feature handlers on the transport server.
36
36
  * These handlers implement the TUI ↔ Server event contract (auth:*, config:*, status:*, etc.).
37
37
  */
38
- export async function setupFeatureHandlers({ authStateStore, broadcastToProject, log, providerConfigStore, providerKeychainStore, resolveProjectPath, transport, }) {
38
+ export async function setupFeatureHandlers({ authStateStore, broadcastToProject, getActiveProjectPaths, log, projectRegistry, providerConfigStore, providerKeychainStore, providerOAuthTokenStore, resolveProjectPath, transport, }) {
39
39
  const envConfig = getCurrentConfig();
40
40
  const tokenStore = createTokenStore();
41
41
  const projectConfigStore = new ProjectConfigStore();
@@ -59,8 +59,10 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
59
59
  userService,
60
60
  }).setup();
61
61
  new ProviderHandler({
62
+ browserLauncher: new SystemBrowserLauncher(),
62
63
  providerConfigStore,
63
64
  providerKeychainStore,
65
+ providerOAuthTokenStore,
64
66
  transport,
65
67
  }).setup();
66
68
  new ModelHandler({
@@ -90,6 +92,13 @@ export async function setupFeatureHandlers({ authStateStore, broadcastToProject,
90
92
  tokenStore,
91
93
  transport,
92
94
  }).setup();
95
+ new LocationsHandler({
96
+ contextTreeService,
97
+ getActiveProjectPaths,
98
+ projectRegistry,
99
+ resolveProjectPath,
100
+ transport,
101
+ }).setup();
93
102
  new PushHandler({
94
103
  broadcastToProject,
95
104
  cogitPushService,
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import type { IProviderConfigStore } from '../../core/interfaces/i-provider-config-store.js';
9
9
  import type { IProviderKeychainStore } from '../../core/interfaces/i-provider-keychain-store.js';
10
+ import type { IProviderOAuthTokenStore } from '../../core/interfaces/i-provider-oauth-token-store.js';
11
+ import type { ITokenRefreshManager } from '../../core/interfaces/i-token-refresh-manager.js';
10
12
  import { type ProviderConfigResponse } from '../../core/domain/transport/schemas.js';
11
13
  /**
12
14
  * Validate provider config integrity at startup.
@@ -19,7 +21,7 @@ import { type ProviderConfigResponse } from '../../core/domain/transport/schemas
19
21
  * empty string so the TUI routes to the provider setup flow — bypassing the
20
22
  * 'byterover' fallback that withProviderDisconnected() would otherwise apply.
21
23
  */
22
- export declare function clearStaleProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore): Promise<void>;
24
+ export declare function clearStaleProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore, providerOAuthTokenStore?: IProviderOAuthTokenStore): Promise<void>;
23
25
  /**
24
26
  * Resolve the active provider's full configuration.
25
27
  *
@@ -27,4 +29,4 @@ export declare function clearStaleProviderConfig(providerConfigStore: IProviderC
27
29
  * the API key (keychain → env fallback), and maps provider-specific
28
30
  * fields (base URL, headers, location, etc.).
29
31
  */
30
- export declare function resolveProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore): Promise<ProviderConfigResponse>;
32
+ export declare function resolveProviderConfig(providerConfigStore: IProviderConfigStore, providerKeychainStore: IProviderKeychainStore, tokenRefreshManager?: ITokenRefreshManager): Promise<ProviderConfigResponse>;
@@ -5,8 +5,18 @@
5
5
  * for the currently active provider. Used by the daemon state server to serve
6
6
  * agent child processes on startup and after provider hot-swap.
7
7
  */
8
+ import { CHATGPT_OAUTH_BASE_URL, CHATGPT_OAUTH_ORIGINATOR } from '../../../shared/constants/oauth.js';
8
9
  import { getProviderById, providerRequiresApiKey } from '../../core/domain/entities/provider-registry.js';
9
10
  import { getProviderApiKeyFromEnv } from './env-provider-detector.js';
11
+ /**
12
+ * Check if a provider's credential (API key or OAuth access token) is accessible.
13
+ *
14
+ * Note: authMethod is intentionally NOT passed to providerRequiresApiKey() here.
15
+ * OAuth access tokens are stored in the keychain (as the provider's "API key"),
16
+ * so the keychain check on the next line correctly handles both auth methods.
17
+ * If an OAuth token expires and the refresh manager (Issue 5) deletes it from
18
+ * keychain, this function returns false — correctly marking the provider as stale.
19
+ */
10
20
  async function isProviderCredentialAccessible(providerId, providerKeychainStore) {
11
21
  if (!providerRequiresApiKey(providerId))
12
22
  return true;
@@ -23,7 +33,7 @@ async function isProviderCredentialAccessible(providerId, providerKeychainStore)
23
33
  * empty string so the TUI routes to the provider setup flow — bypassing the
24
34
  * 'byterover' fallback that withProviderDisconnected() would otherwise apply.
25
35
  */
26
- export async function clearStaleProviderConfig(providerConfigStore, providerKeychainStore) {
36
+ export async function clearStaleProviderConfig(providerConfigStore, providerKeychainStore, providerOAuthTokenStore) {
27
37
  try {
28
38
  const config = await providerConfigStore.read();
29
39
  const results = await Promise.all(Object.keys(config.providers).map(async (providerId) => ({
@@ -46,6 +56,11 @@ export async function clearStaleProviderConfig(providerConfigStore, providerKeyc
46
56
  newConfig = newConfig.withActiveProvider('');
47
57
  }
48
58
  await providerConfigStore.write(newConfig);
59
+ // Clean up orphaned OAuth tokens for stale providers (consistent with
60
+ // the 3-store cleanup in TokenRefreshManager and ProviderHandler.setupDisconnect)
61
+ if (providerOAuthTokenStore) {
62
+ await Promise.all(staleProviderIds.map((id) => providerOAuthTokenStore.delete(id).catch(() => { })));
63
+ }
49
64
  }
50
65
  catch {
51
66
  // Non-critical: if validation fails, daemon continues normally.
@@ -59,7 +74,7 @@ export async function clearStaleProviderConfig(providerConfigStore, providerKeyc
59
74
  * the API key (keychain → env fallback), and maps provider-specific
60
75
  * fields (base URL, headers, location, etc.).
61
76
  */
62
- export async function resolveProviderConfig(providerConfigStore, providerKeychainStore) {
77
+ export async function resolveProviderConfig(providerConfigStore, providerKeychainStore, tokenRefreshManager) {
63
78
  const config = await providerConfigStore.read();
64
79
  const { activeProvider } = config;
65
80
  const activeModel = config.getActiveModel(activeProvider);
@@ -96,16 +111,56 @@ export async function resolveProviderConfig(providerConfigStore, providerKeychai
96
111
  }
97
112
  default: {
98
113
  const providerDef = getProviderById(activeProvider);
114
+ const providerConfig = config.providers[activeProvider];
115
+ if (!providerConfig) {
116
+ return { activeModel, activeProvider, maxInputTokens };
117
+ }
118
+ const { authMethod } = providerConfig;
119
+ // Attempt OAuth token refresh if provider is OAuth-connected
120
+ if (authMethod === 'oauth' && tokenRefreshManager) {
121
+ try {
122
+ const refreshed = await tokenRefreshManager.refreshIfNeeded(activeProvider);
123
+ if (!refreshed) {
124
+ return { activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true };
125
+ }
126
+ // Re-read API key after potential refresh
127
+ apiKey = (await providerKeychainStore.getApiKey(activeProvider)) ?? apiKey;
128
+ }
129
+ catch {
130
+ return { activeModel, activeProvider, authMethod, maxInputTokens, providerKeyMissing: true };
131
+ }
132
+ }
133
+ // OAuth-connected OpenAI: use Codex endpoint + required headers
134
+ if (activeProvider === 'openai' && authMethod === 'oauth') {
135
+ const codexHeaders = {
136
+ originator: CHATGPT_OAUTH_ORIGINATOR,
137
+ };
138
+ if (providerConfig.oauthAccountId) {
139
+ codexHeaders['ChatGPT-Account-Id'] = providerConfig.oauthAccountId;
140
+ }
141
+ return {
142
+ activeModel,
143
+ activeProvider,
144
+ authMethod,
145
+ maxInputTokens,
146
+ provider: activeProvider,
147
+ providerApiKey: apiKey || undefined,
148
+ providerBaseUrl: CHATGPT_OAUTH_BASE_URL,
149
+ providerHeaders: codexHeaders,
150
+ providerKeyMissing: providerRequiresApiKey(activeProvider, authMethod) && !apiKey,
151
+ };
152
+ }
99
153
  const headers = providerDef?.headers;
100
154
  return {
101
155
  activeModel,
102
156
  activeProvider,
157
+ authMethod,
103
158
  maxInputTokens,
104
159
  provider: activeProvider,
105
160
  providerApiKey: apiKey || undefined,
106
- providerBaseUrl: providerDef?.baseUrl || undefined,
161
+ providerBaseUrl: config.getBaseUrl(activeProvider) || providerDef?.baseUrl || undefined,
107
162
  providerHeaders: headers && Object.keys(headers).length > 0 ? { ...headers } : undefined,
108
- providerKeyMissing: providerRequiresApiKey(activeProvider) && !apiKey,
163
+ providerKeyMissing: providerRequiresApiKey(activeProvider, authMethod) && !apiKey,
109
164
  };
110
165
  }
111
166
  }
@@ -0,0 +1,24 @@
1
+ import type { ProviderCallbackResult } from './types.js';
2
+ type ProviderCallbackServerOptions = {
3
+ callbackPath?: string;
4
+ port: number;
5
+ };
6
+ export declare class ProviderCallbackServer {
7
+ private readonly callbackPath;
8
+ private readonly connections;
9
+ private isStopping;
10
+ private onCallback;
11
+ private onError;
12
+ private pendingTimeout;
13
+ private readonly port;
14
+ private server;
15
+ constructor(options: ProviderCallbackServerOptions);
16
+ getAddress(): undefined | {
17
+ port: number;
18
+ };
19
+ start(): Promise<number>;
20
+ stop(): Promise<void>;
21
+ waitForCallback(expectedState: string, timeoutMs?: number): Promise<ProviderCallbackResult>;
22
+ private handleRequest;
23
+ }
24
+ export {};
@@ -0,0 +1,203 @@
1
+ import http from 'node:http';
2
+ import { OAUTH_CALLBACK_TIMEOUT_MS } from '../../../shared/constants/oauth.js';
3
+ import { ProviderCallbackOAuthError, ProviderCallbackStateError, ProviderCallbackTimeoutError, ProviderOAuthError, } from './errors.js';
4
+ const DEFAULT_CALLBACK_PATH = '/auth/callback';
5
+ const SUCCESS_HTML = `<!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <title>ByteRover - Authorization Successful</title>
9
+ <style>
10
+ body { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e5e5e5; }
11
+ .container { text-align: center; padding: 2rem; }
12
+ h1 { color: #17b26a; margin-bottom: 1rem; }
13
+ p { color: #a3a3a3; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div class="container">
18
+ <h1>Authorization Successful</h1>
19
+ <p>You can close this window and return to ByteRover.</p>
20
+ </div>
21
+ <script>setTimeout(() => window.close(), 2000);</script>
22
+ </body>
23
+ </html>`;
24
+ function errorHtml(message) {
25
+ return `<!DOCTYPE html>
26
+ <html>
27
+ <head>
28
+ <title>ByteRover - Authorization Failed</title>
29
+ <style>
30
+ body { font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e5e5e5; }
31
+ .container { text-align: center; padding: 2rem; }
32
+ h1 { color: #f87171; margin-bottom: 1rem; }
33
+ p { color: #a3a3a3; }
34
+ .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="container">
39
+ <h1>Authorization Failed</h1>
40
+ <p>An error occurred during authorization.</p>
41
+ <div class="error">${escapeHtml(message)}</div>
42
+ </div>
43
+ </body>
44
+ </html>`;
45
+ }
46
+ function escapeHtml(text) {
47
+ return text
48
+ .replaceAll('&', '&amp;')
49
+ .replaceAll('<', '&lt;')
50
+ .replaceAll('>', '&gt;')
51
+ .replaceAll('"', '&quot;')
52
+ .replaceAll("'", '&#39;');
53
+ }
54
+ export class ProviderCallbackServer {
55
+ callbackPath;
56
+ connections = new Set();
57
+ isStopping = false;
58
+ onCallback;
59
+ onError;
60
+ pendingTimeout;
61
+ port;
62
+ server = undefined;
63
+ constructor(options) {
64
+ this.port = options.port;
65
+ this.callbackPath = options.callbackPath ?? DEFAULT_CALLBACK_PATH;
66
+ }
67
+ getAddress() {
68
+ const address = this.server?.address();
69
+ if (address !== null && address !== undefined && typeof address !== 'string') {
70
+ return { port: address.port };
71
+ }
72
+ return undefined;
73
+ }
74
+ async start() {
75
+ const address = this.getAddress();
76
+ if (address !== undefined) {
77
+ return address.port;
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ this.server = http.createServer((req, res) => {
81
+ this.handleRequest(req, res);
82
+ });
83
+ this.server.on('connection', (conn) => {
84
+ this.connections.add(conn);
85
+ conn.on('close', () => {
86
+ this.connections.delete(conn);
87
+ });
88
+ });
89
+ this.server.on('error', reject);
90
+ this.server.listen(this.port, '127.0.0.1', () => {
91
+ const address = this.server?.address();
92
+ if (address !== null && address !== undefined && typeof address !== 'string') {
93
+ resolve(address.port);
94
+ }
95
+ else {
96
+ reject(new Error('Failed to start provider callback server'));
97
+ }
98
+ });
99
+ });
100
+ }
101
+ async stop() {
102
+ if (this.isStopping)
103
+ return;
104
+ this.isStopping = true;
105
+ // Reject any pending waitForCallback promise so consumers don't hang
106
+ this.onError?.(new ProviderOAuthError('Callback server was stopped'));
107
+ this.onCallback = undefined;
108
+ this.onError = undefined;
109
+ if (this.pendingTimeout !== undefined) {
110
+ clearTimeout(this.pendingTimeout);
111
+ this.pendingTimeout = undefined;
112
+ }
113
+ return new Promise((resolve) => {
114
+ if (this.server === undefined) {
115
+ this.isStopping = false;
116
+ resolve();
117
+ return;
118
+ }
119
+ for (const conn of this.connections) {
120
+ conn.destroy();
121
+ }
122
+ this.connections.clear();
123
+ this.server.close(() => {
124
+ this.server = undefined;
125
+ this.isStopping = false;
126
+ resolve();
127
+ });
128
+ });
129
+ }
130
+ waitForCallback(expectedState, timeoutMs = OAUTH_CALLBACK_TIMEOUT_MS) {
131
+ if (this.onCallback !== undefined) {
132
+ return Promise.reject(new ProviderOAuthError('A callback is already pending'));
133
+ }
134
+ const promise = new Promise((resolve, reject) => {
135
+ let settled = false;
136
+ this.pendingTimeout = setTimeout(() => {
137
+ if (!settled) {
138
+ settled = true;
139
+ this.pendingTimeout = undefined;
140
+ reject(new ProviderCallbackTimeoutError(timeoutMs));
141
+ }
142
+ }, timeoutMs);
143
+ this.onCallback = (code, state) => {
144
+ if (settled)
145
+ return;
146
+ clearTimeout(this.pendingTimeout);
147
+ this.pendingTimeout = undefined;
148
+ if (state !== expectedState) {
149
+ settled = true;
150
+ reject(new ProviderCallbackStateError());
151
+ return;
152
+ }
153
+ settled = true;
154
+ resolve({ code, state });
155
+ };
156
+ this.onError = (error) => {
157
+ if (settled)
158
+ return;
159
+ clearTimeout(this.pendingTimeout);
160
+ this.pendingTimeout = undefined;
161
+ settled = true;
162
+ reject(error);
163
+ };
164
+ });
165
+ // Auto-close server after callback or timeout (ticket requirement)
166
+ const autoClose = () => this.stop();
167
+ promise.then(autoClose, autoClose);
168
+ return promise;
169
+ }
170
+ handleRequest(req, res) {
171
+ const url = new URL(req.url ?? '/', `http://localhost:${this.port}`);
172
+ if (url.pathname !== this.callbackPath) {
173
+ res.writeHead(404);
174
+ res.end('Not found');
175
+ return;
176
+ }
177
+ const error = url.searchParams.get('error');
178
+ const errorDescription = url.searchParams.get('error_description');
179
+ const code = url.searchParams.get('code');
180
+ const state = url.searchParams.get('state');
181
+ if (error !== null) {
182
+ res.writeHead(400, { 'Content-Type': 'text/html' });
183
+ res.end(errorHtml(errorDescription ?? error));
184
+ this.onError?.(new ProviderCallbackOAuthError(error, errorDescription ?? undefined));
185
+ this.onCallback = undefined;
186
+ this.onError = undefined;
187
+ return;
188
+ }
189
+ if (code === null || state === null) {
190
+ res.writeHead(400, { 'Content-Type': 'text/html' });
191
+ res.end(errorHtml('Missing code or state parameter'));
192
+ this.onError?.(new ProviderOAuthError('Missing code or state parameter'));
193
+ this.onCallback = undefined;
194
+ this.onError = undefined;
195
+ return;
196
+ }
197
+ res.writeHead(200, { 'Content-Type': 'text/html' });
198
+ res.end(SUCCESS_HTML);
199
+ this.onCallback?.(code, state);
200
+ this.onCallback = undefined;
201
+ this.onError = undefined;
202
+ }
203
+ }
@@ -0,0 +1,39 @@
1
+ export declare class ProviderOAuthError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class ProviderCallbackTimeoutError extends ProviderOAuthError {
5
+ readonly timeoutMs: number;
6
+ constructor(timeoutMs: number);
7
+ }
8
+ export declare class ProviderCallbackStateError extends ProviderOAuthError {
9
+ constructor();
10
+ }
11
+ export declare class ProviderCallbackOAuthError extends ProviderOAuthError {
12
+ readonly errorCode: string;
13
+ constructor(errorCode: string, errorDescription?: string);
14
+ }
15
+ export declare class ProviderTokenExchangeError extends ProviderOAuthError {
16
+ readonly errorCode?: string;
17
+ readonly statusCode?: number;
18
+ constructor(params: {
19
+ errorCode?: string;
20
+ message: string;
21
+ statusCode?: number;
22
+ });
23
+ }
24
+ /**
25
+ * Extracts OAuth error fields from an unknown error response body.
26
+ * Shared by token-exchange and refresh-token-exchange.
27
+ */
28
+ export declare function extractOAuthErrorFields(data: unknown): {
29
+ error?: string;
30
+ error_description?: string;
31
+ };
32
+ /**
33
+ * Checks whether an OAuth token refresh error is permanent (token revoked, client invalid)
34
+ * vs. transient (network timeout, server error).
35
+ *
36
+ * Permanent errors require disconnecting the provider and re-authenticating.
37
+ * Transient errors should preserve credentials so the existing access token can still be used.
38
+ */
39
+ export declare function isPermanentOAuthError(error: unknown): boolean;