byterover-cli 2.2.0 → 2.3.1
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/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/restart.d.ts +34 -50
- package/dist/oclif/commands/restart.js +122 -209
- package/dist/oclif/hooks/init/block-command-update-npm.d.ts +11 -0
- package/dist/oclif/hooks/init/block-command-update-npm.js +15 -0
- package/dist/oclif/hooks/init/update-notifier.d.ts +3 -0
- package/dist/oclif/hooks/init/update-notifier.js +17 -4
- package/dist/oclif/hooks/postrun/restart-after-update.d.ts +22 -0
- package/dist/oclif/hooks/postrun/restart-after-update.js +40 -0
- 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 +13 -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/openrouter-api-client.js +1 -1
- 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 +3 -1
- package/dist/server/infra/process/feature-handlers.js +3 -1
- 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/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/shared/constants/oauth.d.ts +14 -0
- package/dist/shared/constants/oauth.js +14 -0
- package/dist/shared/transport/events/index.d.ts +5 -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 +4 -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 +191 -168
- package/package.json +7 -5
|
@@ -12,12 +12,16 @@ export interface ConnectedProviderConfig {
|
|
|
12
12
|
readonly activeModel?: string;
|
|
13
13
|
/** Context window size of the active model (from provider API, e.g. OpenRouter) */
|
|
14
14
|
readonly activeModelContextLength?: number;
|
|
15
|
+
/** How this provider was authenticated */
|
|
16
|
+
readonly authMethod?: 'api-key' | 'oauth';
|
|
15
17
|
/** Custom API base URL (for openai-compatible provider) */
|
|
16
18
|
readonly baseUrl?: string;
|
|
17
19
|
/** When the provider was connected */
|
|
18
20
|
readonly connectedAt: string;
|
|
19
21
|
/** User's favorite models (for quick access) */
|
|
20
22
|
readonly favoriteModels: readonly string[];
|
|
23
|
+
/** OAuth account ID (e.g. ChatGPT-Account-Id for OpenAI) */
|
|
24
|
+
readonly oauthAccountId?: string;
|
|
21
25
|
/** Recently used models (last 10) */
|
|
22
26
|
readonly recentModels: readonly string[];
|
|
23
27
|
}
|
|
@@ -96,7 +100,9 @@ export declare class ProviderConfig {
|
|
|
96
100
|
*/
|
|
97
101
|
withProviderConnected(providerId: string, options?: {
|
|
98
102
|
activeModel?: string;
|
|
103
|
+
authMethod?: 'api-key' | 'oauth';
|
|
99
104
|
baseUrl?: string;
|
|
105
|
+
oauthAccountId?: string;
|
|
100
106
|
}): ProviderConfig;
|
|
101
107
|
/**
|
|
102
108
|
* Create a new config with a provider disconnected.
|
|
@@ -10,10 +10,9 @@
|
|
|
10
10
|
const isProviderConfigJson = (json) => {
|
|
11
11
|
if (typeof json !== 'object' || json === null)
|
|
12
12
|
return false;
|
|
13
|
-
|
|
14
|
-
if (typeof obj.activeProvider !== 'string')
|
|
13
|
+
if (!('activeProvider' in json) || typeof json.activeProvider !== 'string')
|
|
15
14
|
return false;
|
|
16
|
-
if (typeof
|
|
15
|
+
if (!('providers' in json) || typeof json.providers !== 'object' || json.providers === null)
|
|
17
16
|
return false;
|
|
18
17
|
return true;
|
|
19
18
|
};
|
|
@@ -164,9 +163,11 @@ export class ProviderConfig {
|
|
|
164
163
|
const existingConfig = this.providers[providerId];
|
|
165
164
|
const newProviderConfig = {
|
|
166
165
|
activeModel: options?.activeModel ?? existingConfig?.activeModel,
|
|
166
|
+
authMethod: options?.authMethod ?? existingConfig?.authMethod,
|
|
167
167
|
baseUrl: options?.baseUrl ?? existingConfig?.baseUrl,
|
|
168
168
|
connectedAt: existingConfig?.connectedAt ?? new Date().toISOString(),
|
|
169
169
|
favoriteModels: existingConfig?.favoriteModels ?? [],
|
|
170
|
+
oauthAccountId: options?.oauthAccountId ?? existingConfig?.oauthAccountId,
|
|
170
171
|
recentModels: existingConfig?.recentModels ?? [],
|
|
171
172
|
};
|
|
172
173
|
return new ProviderConfig({
|
|
@@ -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).
|
|
@@ -357,6 +367,7 @@ async function main() {
|
|
|
357
367
|
projectRegistry,
|
|
358
368
|
providerConfigStore,
|
|
359
369
|
providerKeychainStore,
|
|
370
|
+
providerOAuthTokenStore,
|
|
360
371
|
resolveProjectPath: (clientId) => clientManager.getClient(clientId)?.projectPath,
|
|
361
372
|
transport: transportServer,
|
|
362
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 };
|