byterover-cli 2.2.0 → 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/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/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/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 +132 -116
- package/package.json +1 -1
|
@@ -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 };
|
|
@@ -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;
|
|
@@ -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,
|
|
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
|
-
|
|
111
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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') ||
|
|
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')) {
|