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.
- package/dist/agent/infra/llm/providers/openai.d.ts +12 -0
- package/dist/agent/infra/llm/providers/openai.js +52 -1
- package/dist/oclif/commands/curate/index.js +2 -2
- package/dist/oclif/commands/locations.d.ts +14 -0
- package/dist/oclif/commands/locations.js +68 -0
- package/dist/oclif/commands/model/switch.js +14 -3
- package/dist/oclif/commands/providers/connect.d.ts +9 -0
- package/dist/oclif/commands/providers/connect.js +110 -14
- package/dist/oclif/commands/providers/list.js +3 -5
- package/dist/oclif/commands/query.js +2 -2
- package/dist/oclif/commands/status.js +3 -3
- package/dist/oclif/lib/daemon-client.d.ts +4 -0
- package/dist/oclif/lib/daemon-client.js +13 -3
- package/dist/server/core/domain/entities/provider-config.d.ts +6 -0
- package/dist/server/core/domain/entities/provider-config.js +4 -3
- package/dist/server/core/domain/entities/provider-registry.d.ts +41 -1
- package/dist/server/core/domain/entities/provider-registry.js +24 -1
- package/dist/server/core/domain/errors/task-error.d.ts +2 -0
- package/dist/server/core/domain/errors/task-error.js +6 -1
- package/dist/server/core/domain/transport/schemas.d.ts +2 -0
- package/dist/server/core/interfaces/i-provider-config-store.d.ts +2 -0
- package/dist/server/core/interfaces/i-provider-model-fetcher.d.ts +12 -3
- package/dist/server/core/interfaces/i-provider-oauth-token-store.d.ts +24 -0
- package/dist/server/core/interfaces/i-provider-oauth-token-store.js +1 -0
- package/dist/server/core/interfaces/i-token-refresh-manager.d.ts +7 -0
- package/dist/server/core/interfaces/i-token-refresh-manager.js +1 -0
- package/dist/server/infra/daemon/agent-process.js +22 -4
- package/dist/server/infra/daemon/brv-server.js +15 -2
- package/dist/server/infra/http/models-dev-client.d.ts +29 -0
- package/dist/server/infra/http/models-dev-client.js +133 -0
- package/dist/server/infra/http/provider-model-fetcher-registry.d.ts +2 -1
- package/dist/server/infra/http/provider-model-fetcher-registry.js +6 -1
- package/dist/server/infra/http/provider-model-fetchers.d.ts +33 -8
- package/dist/server/infra/http/provider-model-fetchers.js +88 -10
- package/dist/server/infra/process/feature-handlers.d.ts +6 -1
- package/dist/server/infra/process/feature-handlers.js +11 -2
- package/dist/server/infra/provider/provider-config-resolver.d.ts +4 -2
- package/dist/server/infra/provider/provider-config-resolver.js +59 -4
- package/dist/server/infra/provider-oauth/callback-server.d.ts +24 -0
- package/dist/server/infra/provider-oauth/callback-server.js +203 -0
- package/dist/server/infra/provider-oauth/errors.d.ts +39 -0
- package/dist/server/infra/provider-oauth/errors.js +76 -0
- package/dist/server/infra/provider-oauth/index.d.ts +9 -0
- package/dist/server/infra/provider-oauth/index.js +9 -0
- package/dist/server/infra/provider-oauth/jwt-utils.d.ts +17 -0
- package/dist/server/infra/provider-oauth/jwt-utils.js +51 -0
- package/dist/server/infra/provider-oauth/pkce-service.d.ts +22 -0
- package/dist/server/infra/provider-oauth/pkce-service.js +33 -0
- package/dist/server/infra/provider-oauth/provider-oauth-token-store.d.ts +48 -0
- package/dist/server/infra/provider-oauth/provider-oauth-token-store.js +155 -0
- package/dist/server/infra/provider-oauth/refresh-token-exchange.d.ts +8 -0
- package/dist/server/infra/provider-oauth/refresh-token-exchange.js +39 -0
- package/dist/server/infra/provider-oauth/token-exchange.d.ts +8 -0
- package/dist/server/infra/provider-oauth/token-exchange.js +44 -0
- package/dist/server/infra/provider-oauth/token-refresh-manager.d.ts +32 -0
- package/dist/server/infra/provider-oauth/token-refresh-manager.js +96 -0
- package/dist/server/infra/provider-oauth/types.d.ts +55 -0
- package/dist/server/infra/provider-oauth/types.js +22 -0
- package/dist/server/infra/storage/file-provider-config-store.d.ts +2 -0
- package/dist/server/infra/storage/file-provider-config-store.js +1 -3
- package/dist/server/infra/transport/handlers/index.d.ts +2 -0
- package/dist/server/infra/transport/handlers/index.js +1 -0
- package/dist/server/infra/transport/handlers/locations-handler.d.ts +25 -0
- package/dist/server/infra/transport/handlers/locations-handler.js +64 -0
- package/dist/server/infra/transport/handlers/model-handler.d.ts +3 -0
- package/dist/server/infra/transport/handlers/model-handler.js +53 -11
- package/dist/server/infra/transport/handlers/provider-handler.d.ts +26 -0
- package/dist/server/infra/transport/handlers/provider-handler.js +215 -13
- package/dist/server/templates/skill/SKILL.md +19 -1
- package/dist/shared/constants/oauth.d.ts +14 -0
- package/dist/shared/constants/oauth.js +14 -0
- package/dist/shared/transport/events/index.d.ts +8 -0
- package/dist/shared/transport/events/index.js +3 -0
- package/dist/shared/transport/events/locations-events.d.ts +7 -0
- package/dist/shared/transport/events/locations-events.js +3 -0
- package/dist/shared/transport/events/model-events.d.ts +2 -0
- package/dist/shared/transport/events/provider-events.d.ts +36 -0
- package/dist/shared/transport/events/provider-events.js +5 -0
- package/dist/shared/transport/types/dto.d.ts +15 -0
- package/dist/tui/features/commands/definitions/index.js +2 -0
- package/dist/tui/features/commands/definitions/locations.d.ts +2 -0
- package/dist/tui/features/commands/definitions/locations.js +11 -0
- package/dist/tui/features/locations/api/get-locations.d.ts +16 -0
- package/dist/tui/features/locations/api/get-locations.js +17 -0
- package/dist/tui/features/locations/components/locations-view.d.ts +3 -0
- package/dist/tui/features/locations/components/locations-view.js +25 -0
- package/dist/tui/features/locations/utils/format-locations.d.ts +2 -0
- package/dist/tui/features/locations/utils/format-locations.js +26 -0
- package/dist/tui/features/model/api/set-active-model.d.ts +1 -1
- package/dist/tui/features/model/api/set-active-model.js +12 -4
- package/dist/tui/features/provider/api/await-oauth-callback.d.ts +11 -0
- package/dist/tui/features/provider/api/await-oauth-callback.js +25 -0
- package/dist/tui/features/provider/api/cancel-oauth.d.ts +5 -0
- package/dist/tui/features/provider/api/cancel-oauth.js +10 -0
- package/dist/tui/features/provider/api/start-oauth.d.ts +11 -0
- package/dist/tui/features/provider/api/start-oauth.js +15 -0
- package/dist/tui/features/provider/components/auth-method-dialog.d.ts +9 -0
- package/dist/tui/features/provider/components/auth-method-dialog.js +20 -0
- package/dist/tui/features/provider/components/oauth-dialog.d.ts +9 -0
- package/dist/tui/features/provider/components/oauth-dialog.js +96 -0
- package/dist/tui/features/provider/components/provider-dialog.js +1 -1
- package/dist/tui/features/provider/components/provider-flow.js +54 -4
- package/dist/tui/features/provider/components/provider-subscription-initializer.d.ts +1 -0
- package/dist/tui/features/provider/components/provider-subscription-initializer.js +5 -0
- package/dist/tui/features/provider/hooks/use-provider-subscriptions.d.ts +6 -0
- package/dist/tui/features/provider/hooks/use-provider-subscriptions.js +24 -0
- package/dist/tui/providers/app-providers.js +2 -1
- package/dist/tui/utils/error-messages.js +6 -1
- package/oclif.manifest.json +56 -1
- 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
|
|
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
|
|
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,
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
32
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
114
|
+
fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]>;
|
|
90
115
|
validateApiKey(apiKey: string): Promise<{
|
|
91
116
|
error?: string;
|
|
92
117
|
isValid: boolean;
|