diff --git a/src/extension/completions-core/vscode-node/lib/src/openai/feimaFetch.ts b/src/extension/completions-core/vscode-node/lib/src/openai/feimaFetch.ts index 7d7d6efe76..7a1d315a00 100644 --- a/src/extension/completions-core/vscode-node/lib/src/openai/feimaFetch.ts +++ b/src/extension/completions-core/vscode-node/lib/src/openai/feimaFetch.ts @@ -8,7 +8,9 @@ import { IFeimaAuthenticationService } from '../../../../../../platform/authenti import { IFeimaModelMetadataFetcher } from '../../../../../../platform/endpoint/node/feimaModelMetadataFetcher'; import { IEnvService } from '../../../../../../platform/env/common/envService'; import { ILogService } from '../../../../../../platform/log/common/logService'; +import { IFetcherService } from '../../../../../../platform/networking/common/fetcherService'; import { ICompletionsFetchService } from '../../../../../../platform/nesFetch/common/completionsFetchService'; +import { CancellationToken } from '../../../../../../util/vs/base/common/cancellation'; import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; import { CancellationToken as ICancellationToken } from '../../../types/src'; import { ICompletionsCopilotTokenManager } from '../auth/copilotTokenManager'; @@ -71,6 +73,7 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { @IFeimaConfigService private readonly feimaConfig: IFeimaConfigService, @IFeimaModelMetadataFetcher private readonly feimaModelFetcher: IFeimaModelMetadataFetcher, @ILogService private readonly feimaLogService: ILogService, + @IFetcherService private readonly feimaFetcherService: IFetcherService, // Parent class parameters (LiveOpenAIFetcher) @IInstantiationService instantiationService: IInstantiationService, @ICompletionsRuntimeModeService runtimeModeService: ICompletionsRuntimeModeService, @@ -81,6 +84,7 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { @ICompletionsFetchService fetchService: ICompletionsFetchService, @IEnvService envService: IEnvService, ) { + feimaLogService.trace(`[FeimaOpenAIFetcher] Constructor called`); super(instantiationService, runtimeModeService, logTargetService, copilotTokenManager, statusReporter, authenticationService, fetchService, envService); this._instantiationService = instantiationService; } @@ -101,11 +105,14 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { cancel?: ICancellationToken ): Promise { + const mappedModelId = this._mapModelForFeimaPreference(params.engineModelId); + this.feimaLogService.trace(`[FeimaOpenAIFetcher] fetchAndStreamCompletions called for model ${mappedModelId} (original: ${params.engineModelId})`); + // Check if model is from Feima (via cached model list) - const isFeima = this.feimaModelFetcher.isFeimaModel(params.engineModelId); + const isFeima = this.feimaModelFetcher.isFeimaModel(mappedModelId); if (isFeima) { - this.feimaLogService.trace(`[FeimaOpenAIFetcher] Model ${params.engineModelId} identified as Feima model`); + this.feimaLogService.trace(`[FeimaOpenAIFetcher] Model ${mappedModelId} identified as Feima model`); // Check Feima authentication const isAuthenticated = await this.feimaAuth.isAuthenticated(); @@ -115,7 +122,7 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { } else { // Try Feima API try { - return await this.fetchFromFeima(params, baseTelemetryData, finishedCb, cancel); + return await this.fetchFromFeima(params, baseTelemetryData, finishedCb, cancel, mappedModelId); } catch (error) { this.feimaLogService.error(`[FeimaOpenAIFetcher] Feima API call failed: ${error instanceof Error ? error.message : String(error)}`); // Fall through to GitHub fallback @@ -124,22 +131,66 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { } // Use GitHub API (parent logic) - this.feimaLogService.trace(`[FeimaOpenAIFetcher] Using GitHub API for model ${params.engineModelId}`); + this.feimaLogService.trace(`[FeimaOpenAIFetcher] Using GitHub API for model ${mappedModelId}`); return super.fetchAndStreamCompletions(params, baseTelemetryData, finishedCb, cancel); } + /** + * Override to route requests between Feima and GitHub APIs for fetchAndStreamCompletions2 + */ + override async fetchAndStreamCompletions2( + params: CompletionParams, + baseTelemetryData: TelemetryWithExp, + finishedCb: FinishedCallback, + cancel: CancellationToken + ): Promise { + + const mappedModelId = this._mapModelForFeimaPreference(params.engineModelId); + this.feimaLogService.trace(`[FeimaOpenAIFetcher] fetchAndStreamCompletions2 called for model ${mappedModelId} (original: ${params.engineModelId})`); + + // Check if model is from Feima (via cached model list) + const isFeima = this.feimaModelFetcher.isFeimaModel(mappedModelId); + + if (isFeima) { + this.feimaLogService.trace(`[FeimaOpenAIFetcher] Model ${mappedModelId} identified as Feima model`); + + // Check Feima authentication + const isAuthenticated = await this.feimaAuth.isAuthenticated(); + if (!isAuthenticated) { + this.feimaLogService.warn(`[FeimaOpenAIFetcher] Feima model requested but not authenticated, falling back to GitHub`); + // Fall through to GitHub + } else { + // Try Feima API + try { + return await this.fetchFromFeima(params, baseTelemetryData, finishedCb, cancel, mappedModelId); + } catch (error) { + this.feimaLogService.error(`[FeimaOpenAIFetcher] Feima API call failed: ${error instanceof Error ? error.message : String(error)}`); + // Fall through to GitHub fallback + } + } + } + + // Use GitHub API (parent logic) + this.feimaLogService.trace(`[FeimaOpenAIFetcher] Using GitHub API for model ${mappedModelId}`); + return super.fetchAndStreamCompletions2(params, baseTelemetryData, finishedCb, cancel); + } + /** * Fetch completions from Feima API * * Calls Feima's OpenAI-compatible completion endpoint with SSE streaming. * The Feima API returns responses in OpenAI format, allowing direct integration * with the existing SSE processing infrastructure. + * + * Uses the platform's IFetcherService to ensure proper Response type with + * DestroyableStream that supports both pipeThrough() and destroy() methods. */ private async fetchFromFeima( params: CompletionParams, baseTelemetryData: TelemetryWithExp, finishedCb: FinishedCallback, - cancel?: ICancellationToken + cancel?: CancellationToken, + mappedModelId?: string ): Promise { // 1. Validate JWT token @@ -154,13 +205,14 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { // 2. Build request and call Feima API const config = this.feimaConfig.getConfig(); - const feimaRequest = this.buildFeimaCompletionRequest(params); + const feimaRequest = this.buildFeimaCompletionRequest(params, mappedModelId); const url = `${config.apiBaseUrl}/completions`; - this.feimaLogService.info(`[FeimaOpenAIFetcher] Calling Feima API: ${url} for model ${params.engineModelId}`); + this.feimaLogService.info(`[FeimaOpenAIFetcher] Calling Feima API: ${url} for model ${mappedModelId || params.engineModelId}`); try { - const response = await fetch(url, { + // Use IFetcherService to get proper Response with DestroyableStream + const response = await this.feimaFetcherService.fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -181,18 +233,22 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { // Check for cancellation if (cancel?.isCancellationRequested) { - if (response.body) { - void response.body.cancel(); + this.feimaLogService.trace('[FeimaOpenAIFetcher] Request cancelled before processing'); + // Clean up stream + try { + await response.body.destroy(); + } catch (e) { + this.feimaLogService.warn(`[FeimaOpenAIFetcher] Error destroying stream on cancellation: ${e}`); } - return { type: 'canceled', reason: 'after fetch request' }; + return { type: 'canceled', reason: 'before stream processing' }; } - // Convert fetch Response to networking Response format and use existing SSEProcessor - const adaptedResponse = this.adaptFetchResponse(response); + // Response.body is now a DestroyableStream with pipeThrough() and destroy() + this.feimaLogService.trace('[FeimaOpenAIFetcher] Creating SSE processor with response'); const processor = await this._instantiationService.invokeFunction( SSEProcessor.create, params.count, - adaptedResponse, + response as unknown as NetworkingResponse, baseTelemetryData, [], cancel @@ -221,25 +277,37 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { /** * Adapt fetch Response to networking Response format * - * SSEProcessor expects a Response object with specific methods. - * This adapter wraps the fetch Response to match the expected interface. + * NOTE: This method is currently unused. Modern Node.js (18+) with undici provides + * a fetch() implementation where response.body is already a Web ReadableStream + * compatible with pipeThrough() and other Web Streams API methods. * - * CRITICAL: SSEProcessor expects a Node.js ReadableStream with setEncoding(), - * but fetch() returns a Web ReadableStream. We need to convert it. + * Keeping this for reference in case we need special handling for older Node versions + * or different environments. */ + /* private adaptFetchResponse(response: Response): NetworkingResponse { + this.feimaLogService.trace('[FeimaOpenAIFetcher] Adapting fetch response to networking format'); + // Import Readable from Node.js stream module const { Readable } = require('stream'); - // Convert Web ReadableStream to Node.js Readable - const webStream = response.body; - if (!webStream) { + // Convert response body to Node.js Readable stream + const body = response.body; + if (!body) { throw new Error('Response body is null'); } - // Create a Node.js Readable that reads from the Web ReadableStream - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nodeStream = Readable.fromWeb(webStream as any); + // In Node.js, undici's fetch may return different stream types + // Readable.fromWeb() safely converts Web ReadableStreams to Node.js Readable + let nodeStream: typeof Readable; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeStream = Readable.fromWeb(body as any); + this.feimaLogService.trace('[FeimaOpenAIFetcher] Successfully converted response body to Node.js Readable'); + } catch (error) { + this.feimaLogService.error(`[FeimaOpenAIFetcher] Failed to convert response body: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Failed to convert response body to Node.js stream: ${error}`); + } return { status: response.status, @@ -248,16 +316,16 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { get: (name: string) => response.headers.get(name), }, body: () => nodeStream, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any as NetworkingResponse; + } as unknown as NetworkingResponse; } + */ /** * Build Feima completion request in OpenAI-compatible format */ - private buildFeimaCompletionRequest(params: CompletionParams): FeimaCompletionRequest { + private buildFeimaCompletionRequest(params: CompletionParams, mappedModelId?: string): FeimaCompletionRequest { return { - model: params.engineModelId, + model: mappedModelId || params.engineModelId, prompt: params.prompt.prefix, suffix: params.prompt.suffix, max_tokens: params.postOptions?.max_tokens ?? 500, @@ -271,4 +339,19 @@ export class FeimaOpenAIFetcher extends LiveOpenAIFetcher { extra: params.extra, }; } + + /** + * Map model ID based on Feima preference configuration + */ + private _mapModelForFeimaPreference(modelId: string): string { + if (!this.feimaConfig.getConfig().preferFeimaModels) { + return modelId; + } + + const modelMapping = new Map([ + ['gpt-41-copilot', 'qwen-coder-turbo'] + ]); + + return modelMapping.get(modelId) ?? modelId; + } } diff --git a/src/extension/feimaAuth/common/oauth2Service.ts b/src/extension/feimaAuth/common/oauth2Service.ts index 425e7ce863..617daa239d 100644 --- a/src/extension/feimaAuth/common/oauth2Service.ts +++ b/src/extension/feimaAuth/common/oauth2Service.ts @@ -69,7 +69,7 @@ export interface IOAuth2Service { /** * Check if token needs refresh (within 5 minutes of expiration) */ - shouldRefreshToken(tokenResponse: IAuthorizationTokenResponse): boolean; + shouldRefreshToken(tokenResponse: IAuthorizationTokenResponse, bufferSeconds?: number): boolean; /** * Extract user info from JWT token (if available) @@ -291,13 +291,25 @@ export class OAuth2Service implements IOAuth2Service { */ shouldRefreshToken(tokenResponse: IAuthorizationTokenResponse, bufferSeconds: number = 300): boolean { if (!tokenResponse.expires_in) { + console.debug('[OAuth2Service] shouldRefreshToken: false - no expires_in field'); return false; } - // Calculate expiration (assuming token was just received) - const expiresAt = Date.now() + (tokenResponse.expires_in * 1000); + // IAuthorizationTokenResponse doesn't have issued_at, so we need it passed separately + // This will be handled by the caller (FeimaAuthenticationService) + console.warn('[OAuth2Service] shouldRefreshToken called without issued_at timestamp - assuming current time'); + const assumedIssuedAt = Date.now(); + + // Calculate expiration time from when token was issued (or assumed issued time) + const expiresAt = assumedIssuedAt + (tokenResponse.expires_in * 1000); const now = Date.now(); - return expiresAt < (now + bufferSeconds * 1000); + const timeUntilExpiry = expiresAt - now; + const bufferMs = bufferSeconds * 1000; + const needsRefresh = timeUntilExpiry < bufferMs; + + console.debug(`[OAuth2Service] shouldRefreshToken: ${needsRefresh} - assumedIssuedAt=${new Date(assumedIssuedAt).toISOString()}, expiresAt=${new Date(expiresAt).toISOString()}, now=${new Date(now).toISOString()}, timeUntilExpiry=${Math.round(timeUntilExpiry / 1000)}s, buffer=${bufferSeconds}s`); + + return needsRefresh; } /** diff --git a/src/extension/feimaAuth/vscode-node/feimaAuthenticationService.ts b/src/extension/feimaAuth/vscode-node/feimaAuthenticationService.ts index 6b3be94566..b184a9de15 100644 --- a/src/extension/feimaAuth/vscode-node/feimaAuthenticationService.ts +++ b/src/extension/feimaAuth/vscode-node/feimaAuthenticationService.ts @@ -141,28 +141,54 @@ export class FeimaAuthenticationService implements IFeimaAuthenticationService { } // Check if token needs refresh - const needsRefresh = this._oauth2Service.shouldRefreshToken(stored.tokenResponse); - if (needsRefresh && stored.tokenResponse.refresh_token) { - try { - this._logService.info('[FeimaAuthenticationService] Refreshing expired token'); - const refreshed = await this._oauth2Service.refreshAccessToken(stored.tokenResponse.refresh_token); - await this._saveToken(refreshed, stored.accountId, stored.accountLabel, stored.sessionId); - - // Update cached session with new token - this._cachedSessions = [{ - id: stored.sessionId, - accessToken: refreshed.access_token, - account: { - id: stored.accountId, - label: stored.accountLabel - }, - scopes: [] - }]; - } catch (error) { - this._logService.error('[FeimaAuthenticationService] Token refresh failed:', error); - await this._clearStoredToken(); - this._cachedSessions = []; - return []; + // We need to determine actual token expiry using stored.issuedAt + expires_in + if (!stored.tokenResponse.expires_in) { + this._logService.debug('[FeimaAuthenticationService] Token has no expires_in field'); + } else { + const issuedAt = stored.issuedAt; // When we stored the token + const expiresAt = issuedAt + (stored.tokenResponse.expires_in * 1000); + const now = Date.now(); + const timeUntilExpiry = Math.max(0, expiresAt - now); + const fiveMinutes = 5 * 60 * 1000; + const needsRefresh = timeUntilExpiry < fiveMinutes; + + this._logService.debug(`[FeimaAuthenticationService] Token refresh evaluation: needsRefresh=${needsRefresh}, hasRefreshToken=${!!stored.tokenResponse.refresh_token}`); + this._logService.debug(`[FeimaAuthenticationService] Token expiry details: issuedAt=${new Date(issuedAt).toISOString()}, expiresAt=${new Date(expiresAt).toISOString()}, now=${new Date(now).toISOString()}, timeUntilExpiry=${Math.round(timeUntilExpiry / 1000)}s`); + + if (needsRefresh && stored.tokenResponse.refresh_token) { + try { + this._logService.info('[FeimaAuthenticationService] Refreshing expired token'); + const refreshed = await this._oauth2Service.refreshAccessToken(stored.tokenResponse.refresh_token); + + // If the OAuth2 server didn't return a new refresh_token, preserve the existing one + // (RFC 6749 allows servers to reuse the old refresh_token) + if (!refreshed.refresh_token) { + this._logService.debug('[FeimaAuthenticationService] Server did not return new refresh_token, preserving existing one'); + refreshed.refresh_token = stored.tokenResponse.refresh_token; + } + + await this._saveToken(refreshed, stored.accountId, stored.accountLabel, stored.sessionId); + + // Update cached session with new token + this._cachedSessions = [{ + id: stored.sessionId, + accessToken: refreshed.access_token, + account: { + id: stored.accountId, + label: stored.accountLabel + }, + scopes: [] + }]; + } catch (error) { + this._logService.error('[FeimaAuthenticationService] Token refresh failed:', error); + await this._clearStoredToken(); + this._cachedSessions = []; + return []; + } + } else if (!needsRefresh) { + this._logService.debug('[FeimaAuthenticationService] Token does not need refresh yet'); + } else if (!stored.tokenResponse.refresh_token) { + this._logService.warn('[FeimaAuthenticationService] Token needs refresh but no refresh_token available'); } } @@ -373,11 +399,12 @@ export class FeimaAuthenticationService implements IFeimaAuthenticationService { private async _saveToken(tokenResponse: IAuthorizationTokenResponse, accountId: string, accountLabel: string, sessionId: string): Promise { const data: IStoredTokenData = { tokenResponse, - issuedAt: Date.now(), + issuedAt: Date.now(), // Record when we stored this token sessionId, accountId, accountLabel }; + this._logService.debug(`[FeimaAuthenticationService] Saving token with issuedAt=${new Date(data.issuedAt).toISOString()}`); await this._context.secrets.store(this._secretsKey, JSON.stringify(data)); } diff --git a/src/extension/feimaModels/vscode-node/feimaModelProvider.ts b/src/extension/feimaModels/vscode-node/feimaModelProvider.ts index c91a261a53..3c68dd2e7c 100644 --- a/src/extension/feimaModels/vscode-node/feimaModelProvider.ts +++ b/src/extension/feimaModels/vscode-node/feimaModelProvider.ts @@ -55,17 +55,17 @@ export class FeimaModelProvider implements vscode.LanguageModelChatProvider { ): Promise { this.logService.debug('[FeimaModelProvider] provideLanguageModelChatInformation called'); - // Check if user is authenticated with Feima - const isAuthenticated = await this.authService.isAuthenticated(); - if (!isAuthenticated) { - this.logService.debug('[FeimaModelProvider] Feima not authenticated - returning empty models'); - this._chatEndpoints = []; - return []; - } + try { + // Check if user is authenticated with Feima + const isAuthenticated = await this.authService.isAuthenticated(); + if (!isAuthenticated) { + this.logService.debug('[FeimaModelProvider] Feima not authenticated - returning empty models'); + this._chatEndpoints = []; + return []; + } - this.logService.debug('[FeimaModelProvider] Feima authenticated - fetching endpoints'); + this.logService.debug('[FeimaModelProvider] Feima authenticated - fetching endpoints'); - try { // Get Feima-only endpoints from endpoint provider (no filtering needed) const feimaEndpoints = await this.endpointProvider.getAllChatEndpoints(); @@ -78,11 +78,11 @@ export class FeimaModelProvider implements vscode.LanguageModelChatProvider { const languageModels: vscode.LanguageModelChatInformation[] = []; for (const endpoint of feimaEndpoints) { - // Prepare model detail (multiplier if present) - const modelDetail = endpoint.multiplier !== undefined ? `${endpoint.multiplier}x` : undefined; + // Prepare model detail (multiplier if present, "Free" for multiplier 0) + const modelDetail = endpoint.multiplier === 0 ? 'Free' : (endpoint.multiplier !== undefined ? `${endpoint.multiplier}x` : undefined); - // Prepare tooltip (degradation reason if present, otherwise use endpoint description) - const modelTooltip = endpoint.degradationReason || `${endpoint.name} (${endpoint.version})`; + // Prepare tooltip (degradation reason if present, otherwise undefined) + const modelTooltip = endpoint.degradationReason; // Prepare category for Feima models const modelCategory = { label: 'Feima Models', order: 0 }; @@ -91,6 +91,15 @@ export class FeimaModelProvider implements vscode.LanguageModelChatProvider { const isAuthenticated = await this.authService.isAuthenticated(); const requiresAuthorization = isAuthenticated ? { label: 'Feima User' } : undefined; + // Prepare status icon (only if degradation reason exists and ThemeIcon is available) + let statusIcon: vscode.ThemeIcon | undefined; + try { + statusIcon = endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined; + } catch { + // ThemeIcon may not be available in test environment + statusIcon = undefined; + } + const model: vscode.LanguageModelChatInformation = { id: endpoint.model, name: endpoint.name, @@ -99,7 +108,7 @@ export class FeimaModelProvider implements vscode.LanguageModelChatProvider { tooltip: modelTooltip, detail: modelDetail, category: modelCategory, - statusIcon: endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined, + statusIcon, maxInputTokens: endpoint.modelMaxPromptTokens, maxOutputTokens: endpoint.maxOutputTokens, requiresAuthorization, @@ -119,7 +128,7 @@ export class FeimaModelProvider implements vscode.LanguageModelChatProvider { } catch (error) { this.logService.error( error instanceof Error ? error : new Error(String(error)), - '[FeimaModelProvider] Failed to fetch endpoints' + '[FeimaModelProvider] Failed to provide language model information' ); this._chatEndpoints = []; return []; diff --git a/src/platform/endpoint/node/feimaChatEndpoint.ts b/src/platform/endpoint/node/feimaChatEndpoint.ts index 8b0ee14c1c..19fa45a064 100644 --- a/src/platform/endpoint/node/feimaChatEndpoint.ts +++ b/src/platform/endpoint/node/feimaChatEndpoint.ts @@ -8,7 +8,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { IAuthenticationService } from '../../authentication/common/authentication'; import { IFeimaAuthenticationService } from '../../authentication/node/feimaAuthenticationService'; import { IChatMLFetcher, Source } from '../../chat/common/chatMLFetcher'; -import { ChatLocation, ChatResponse } from '../../chat/common/commonTypes'; +import { ChatLocation, ChatResponse, ChatFetchResponseType } from '../../chat/common/commonTypes'; import { IConfigurationService } from '../../configuration/common/configurationService'; import { IEnvService } from '../../env/common/envService'; import { ILogService } from '../../log/common/logService'; @@ -131,7 +131,7 @@ export class FeimaChatEndpoint extends ChatEndpoint { } // Call parent's makeChatRequest with fresh token in place - return super.makeChatRequest( + const result = await super.makeChatRequest( debugName, messages, finishedCb, @@ -142,5 +142,19 @@ export class FeimaChatEndpoint extends ChatEndpoint { userInitiatedRequest, telemetryProperties ); + + // Handle token expiration errors by clearing the session + // This will trigger VS Code to prompt for re-authentication + if (result.type === ChatFetchResponseType.BadRequest && + (result.reason.includes('token expired or invalid') || result.reason.includes('401'))) { + this._logService.warn('[FeimaChatEndpoint] Token expired, clearing session to trigger re-auth'); + try { + await this.feimaAuthService.signOut(); + } catch (error) { + this._logService.error(error, '[FeimaChatEndpoint] Failed to clear session on token error'); + } + } + + return result; } } diff --git a/test/scenarios/test-feima-auth/auth-flow.0.conversation.json b/test/scenarios/test-feima-auth/auth-flow.0.conversation.json new file mode 100644 index 0000000000..9202e8364c --- /dev/null +++ b/test/scenarios/test-feima-auth/auth-flow.0.conversation.json @@ -0,0 +1,34 @@ +{ + "description": "Feima authentication flow - user initiates OAuth2 login", + "question": "/feima-auth", + "keywords": [ + ["oauth", "login", "authenticate"], + ["sign in", "feima"], + ["authorization", "code"] + ], + "state": { + "activeTextEditor": { + "document": { + "languageId": "typescript", + "uri": "file:///test/workspace/sample.ts" + }, + "selection": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + } + }, + "workspace": { + "folders": [ + { + "uri": "file:///test/workspace", + "name": "test-workspace" + } + ] + }, + "configuration": { + "feima.authBaseUrl": "https://auth.feima.ai", + "feima.apiBaseUrl": "https://api.feima.ai/v1", + "feima.clientId": "vscode-feima-client" + } + } +} \ No newline at end of file diff --git a/test/scenarios/test-feima-auth/auth-flow.0.state.json b/test/scenarios/test-feima-auth/auth-flow.0.state.json new file mode 100644 index 0000000000..6fb1e51d4b --- /dev/null +++ b/test/scenarios/test-feima-auth/auth-flow.0.state.json @@ -0,0 +1,29 @@ +{ + "activeTextEditor": { + "document": { + "languageId": "typescript", + "uri": "file:///test/workspace/sample.ts" + }, + "selection": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 } + } + }, + "workspace": { + "folders": [ + { + "uri": "file:///test/workspace", + "name": "test-workspace" + } + ] + }, + "configuration": { + "feima.authBaseUrl": "https://auth.feima.ai", + "feima.apiBaseUrl": "https://api.feima.ai/v1", + "feima.clientId": "vscode-feima-client", + "feima.preferFeimaModels": true + }, + "authentication": { + "sessions": [] + } +} \ No newline at end of file diff --git a/test/scenarios/test-feima-auth/auth-flow.stest.ts b/test/scenarios/test-feima-auth/auth-flow.stest.ts new file mode 100644 index 0000000000..7433fe3965 --- /dev/null +++ b/test/scenarios/test-feima-auth/auth-flow.stest.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { getLanguage } from '../../../src/util/common/languages'; +import { ssuite, stest } from '../../base/stest'; +import { validate } from '../../base/validate'; +import { fetchConversationScenarios } from '../../e2e/scenarioLoader'; +import { generateScenarioTestRunner } from '../../e2e/scenarioTest'; + +ssuite({ title: 'feima-auth', subtitle: 'OAuth2 authentication flow', location: 'panel' }, (inputPath) => { + + const scenarioFolder = inputPath ?? path.join(__dirname, '..', 'test/scenarios/test-feima-auth'); + const scenarios = fetchConversationScenarios(scenarioFolder); + + for (const scenario of scenarios) { + const language = scenario[0].getState?.().activeTextEditor?.document.languageId; + stest({ description: scenario[0].json.description ?? scenario[0].question.replace('/feima-auth', ''), language: language ? getLanguage(language).languageId : undefined }, generateScenarioTestRunner( + scenario, + async (accessor, question, answer) => { + // Validate that the response includes authentication guidance + const containsAuthGuidance = validate(answer, [ + { anyOf: ['sign in', 'login', 'authenticate', 'oauth'] }, + { anyOf: ['feima', 'feima.ai'] } + ]); + + if (!containsAuthGuidance) { + return { success: false, errorMessage: 'Response should include Feima authentication guidance' }; + } + + // Check for proper OAuth2 flow explanation + const containsOAuthFlow = validate(answer, [ + { anyOf: ['authorization', 'code', 'token'] }, + { anyOf: ['redirect', 'callback', 'uri'] } + ]); + + if (!containsOAuthFlow) { + return { success: false, errorMessage: 'Response should explain OAuth2 flow' }; + } + + // Validate configuration references + if (scenario[0].json.keywords !== undefined) { + const configValidation = validate(answer, scenario[0].json.keywords); + if (configValidation) { + return { success: false, errorMessage: configValidation }; + } + } + + return { success: true, errorMessage: 'Feima authentication flow explained correctly' }; + } + )); + } +}); \ No newline at end of file diff --git a/test/unit/feima/feimaAuthProvider.spec.ts b/test/unit/feima/feimaAuthProvider.spec.ts new file mode 100644 index 0000000000..c61808cf35 --- /dev/null +++ b/test/unit/feima/feimaAuthProvider.spec.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +// eslint-disable-next-line local/no-runtime-import +import * as vscode from 'vscode'; +// eslint-disable-next-line import/no-restricted-paths +import { FeimaAuthProvider } from '../../../src/extension/feimaAuth/vscode-node/feimaAuthProvider'; +import { IFeimaAuthenticationService } from '../../../src/platform/authentication/node/feimaAuthenticationService'; +import { ILogService } from '../../../src/platform/log/common/logService'; + +// Mock VS Code API +vi.mock('vscode', () => ({ + EventEmitter: vi.fn().mockImplementation(() => ({ + event: vi.fn() + })) +})); + +describe('FeimaAuthProvider', () => { + let mockAuthService: IFeimaAuthenticationService; + let mockLogService: ILogService; + let provider: FeimaAuthProvider; + + beforeEach(() => { + mockAuthService = { + _serviceBrand: undefined, + isAuthenticated: vi.fn(), + onDidChangeSessions: { event: vi.fn() } as any, + getSessions: vi.fn(), + createSession: vi.fn(), + removeSession: vi.fn(), + getCachedSessions: vi.fn(), + handleUri: vi.fn(), + getToken: vi.fn(), + refreshToken: vi.fn(), + signOut: vi.fn(), + onDidChangeAuthenticationState: { event: vi.fn() } as any + }; + + mockLogService = { + _serviceBrand: undefined, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + show: vi.fn(), + createSubLogger: vi.fn(), + withExtraTarget: vi.fn() + }; + + provider = new FeimaAuthProvider(mockAuthService, mockLogService); + }); + + describe('Initialization', () => { + it('should initialize with required services', () => { + expect(provider).toBeDefined(); + expect(provider.onDidChangeSessions).toBe(mockAuthService.onDidChangeSessions); + }); + }); + + describe('handleUri', () => { + it('should delegate URI handling to auth service', async () => { + const mockUri = {} as vscode.Uri; + mockAuthService.handleUri = vi.fn().mockResolvedValue(undefined); + + await provider.handleUri(mockUri); + + expect(mockAuthService.handleUri).toHaveBeenCalledWith(mockUri); + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Handling OAuth callback URI'); + }); + + it('should handle URI handling errors', async () => { + const mockUri = {} as vscode.Uri; + const error = new Error('URI handling failed'); + mockAuthService.handleUri = vi.fn().mockRejectedValue(error); + + await expect(provider.handleUri(mockUri)).rejects.toThrow('URI handling failed'); + }); + }); + + describe('getSessions', () => { + it('should delegate session retrieval to auth service', async () => { + const scopes = ['openid', 'profile']; + const options = {}; + const mockSessions: vscode.AuthenticationSession[] = [ + { + id: 'session1', + accessToken: 'token1', + account: { id: 'user1', label: 'User 1' }, + scopes: ['openid'] + } + ]; + + mockAuthService.getSessions = vi.fn().mockResolvedValue(mockSessions); + + const result = await provider.getSessions(scopes, options); + + expect(mockAuthService.getSessions).toHaveBeenCalledWith(scopes, options); + expect(result).toEqual(mockSessions); + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Getting sessions'); + }); + + it('should handle empty scopes', async () => { + const mockSessions: vscode.AuthenticationSession[] = []; + mockAuthService.getSessions = vi.fn().mockResolvedValue(mockSessions); + + const result = await provider.getSessions(undefined, {}); + + expect(mockAuthService.getSessions).toHaveBeenCalledWith(undefined, {}); + expect(result).toEqual([]); + }); + + it('should handle session retrieval errors', async () => { + const error = new Error('Session retrieval failed'); + mockAuthService.getSessions = vi.fn().mockRejectedValue(error); + + await expect(provider.getSessions(['openid'], {})).rejects.toThrow('Session retrieval failed'); + }); + }); + + describe('createSession', () => { + it('should delegate session creation to auth service', async () => { + const scopes = ['openid', 'profile']; + const options = {}; + const mockSession: vscode.AuthenticationSession = { + id: 'session1', + accessToken: 'token1', + account: { id: 'user1', label: 'User 1' }, + scopes: ['openid', 'profile'] + }; + + mockAuthService.createSession = vi.fn().mockResolvedValue(mockSession); + + const result = await provider.createSession(scopes, options); + + expect(mockAuthService.createSession).toHaveBeenCalledWith(scopes, options); + expect(result).toEqual(mockSession); + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Creating new session'); + }); + + it('should handle OAuth2 flow completion', async () => { + const scopes = ['openid', 'email']; + const mockSession: vscode.AuthenticationSession = { + id: 'oauth-session', + accessToken: 'oauth-token', + account: { id: 'user@example.com', label: 'User' }, + scopes: ['openid', 'email'] + }; + + mockAuthService.createSession = vi.fn().mockResolvedValue(mockSession); + + const result = await provider.createSession(scopes, {}); + + expect(result.id).toBe('oauth-session'); + expect(result.scopes).toEqual(['openid', 'email']); + }); + + it('should handle session creation errors', async () => { + const error = new Error('OAuth2 flow failed'); + mockAuthService.createSession = vi.fn().mockRejectedValue(error); + + await expect(provider.createSession(['openid'], {})).rejects.toThrow('OAuth2 flow failed'); + }); + }); + + describe('removeSession', () => { + it('should delegate session removal to auth service', async () => { + const sessionId = 'session-to-remove'; + mockAuthService.removeSession = vi.fn().mockResolvedValue(undefined); + + await provider.removeSession(sessionId); + + expect(mockAuthService.removeSession).toHaveBeenCalledWith(sessionId); + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Removing session: session-to-remove'); + }); + + it('should handle session removal errors', async () => { + const sessionId = 'invalid-session'; + const error = new Error('Session not found'); + mockAuthService.removeSession = vi.fn().mockRejectedValue(error); + + await expect(provider.removeSession(sessionId)).rejects.toThrow('Session not found'); + }); + }); + + describe('getCachedSessions', () => { + it('should delegate cached session retrieval to auth service', () => { + const mockSessions: vscode.AuthenticationSession[] = [ + { + id: 'cached-session', + accessToken: 'cached-token', + account: { id: 'cached-user', label: 'Cached User' }, + scopes: ['openid'] + } + ]; + + mockAuthService.getCachedSessions = vi.fn().mockReturnValue(mockSessions); + + const result = provider.getCachedSessions(); + + expect(mockAuthService.getCachedSessions).toHaveBeenCalled(); + expect(result).toEqual(mockSessions); + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Getting cached sessions'); + }); + + it('should handle empty cached sessions', () => { + mockAuthService.getCachedSessions = vi.fn().mockReturnValue([]); + + const result = provider.getCachedSessions(); + + expect(result).toEqual([]); + }); + + it('should return sessions with proper structure', () => { + const mockSessions: vscode.AuthenticationSession[] = [ + { + id: 'session1', + accessToken: 'token1', + account: { id: 'user1', label: 'User 1' }, + scopes: ['openid', 'profile'] + }, + { + id: 'session2', + accessToken: 'token2', + account: { id: 'user2', label: 'User 2' }, + scopes: ['openid', 'email'] + } + ]; + + mockAuthService.getCachedSessions = vi.fn().mockReturnValue(mockSessions); + + const result = provider.getCachedSessions(); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('session1'); + expect(result[1].scopes).toEqual(['openid', 'email']); + }); + }); + + describe('Event Forwarding', () => { + it('should forward session change events from auth service', () => { + const mockEvent = { event: vi.fn() }; + mockAuthService.onDidChangeSessions = mockEvent as any; + + // Create new provider to test event forwarding + const newProvider = new FeimaAuthProvider(mockAuthService, mockLogService); + + expect(newProvider.onDidChangeSessions).toBe(mockEvent); + }); + }); + + describe('Error Handling', () => { + it('should not expose internal service errors directly', async () => { + mockAuthService.getSessions = vi.fn().mockRejectedValue(new Error('Internal auth error')); + + await expect(provider.getSessions(['openid'], {})).rejects.toThrow('Internal auth error'); + }); + + it('should log all operations for debugging', async () => { + mockAuthService.getSessions = vi.fn().mockResolvedValue([]); + + await provider.getSessions(['openid'], {}); + + expect(mockLogService.debug).toHaveBeenCalledWith('[FeimaAuthProvider] Getting sessions'); + }); + }); + + describe('Interface Compliance', () => { + it('should implement vscode.AuthenticationProvider interface', () => { + expect(typeof provider.handleUri).toBe('function'); + expect(typeof provider.getSessions).toBe('function'); + expect(typeof provider.createSession).toBe('function'); + expect(typeof provider.removeSession).toBe('function'); + expect(typeof provider.getCachedSessions).toBe('function'); + expect(provider.onDidChangeSessions).toBeDefined(); + }); + + it('should implement vscode.UriHandler interface', () => { + expect(typeof provider.handleUri).toBe('function'); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/feima/feimaConfigService.spec.ts b/test/unit/feima/feimaConfigService.spec.ts new file mode 100644 index 0000000000..90c7f34677 --- /dev/null +++ b/test/unit/feima/feimaConfigService.spec.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { IFeimaConfigService, IFeimaConfigData, DEFAULT_FEIMA_CONFIG } from '../../../src/platform/feima/common/feimaConfigService'; + +describe('FeimaConfigService', () => { + // Mock the service implementation for testing + let mockConfigService: IFeimaConfigService; + + beforeEach(() => { + // Create a mock implementation for testing + mockConfigService = { + getConfig: vi.fn(), + getOAuth2Endpoints: vi.fn(), + onDidChangeConfig: vi.fn(), + validateConfig: vi.fn() + }; + }); + + describe('Default Configuration', () => { + it('should have valid default Feima configuration', () => { + expect(DEFAULT_FEIMA_CONFIG).toBeDefined(); + expect(DEFAULT_FEIMA_CONFIG.authBaseUrl).toBe('https://auth.feima.ai'); + expect(DEFAULT_FEIMA_CONFIG.apiBaseUrl).toBe('https://api.feima.ai/v1'); + expect(DEFAULT_FEIMA_CONFIG.clientId).toBe('vscode-feima-client'); + expect(DEFAULT_FEIMA_CONFIG.issuer).toBe('https://auth.feima.ai'); + expect(DEFAULT_FEIMA_CONFIG.modelRefreshInterval).toBe(300); + expect(DEFAULT_FEIMA_CONFIG.quotaShowInStatusBar).toBe(true); + expect(DEFAULT_FEIMA_CONFIG.quotaAlertThreshold).toBe(0.8); + expect(DEFAULT_FEIMA_CONFIG.preferFeimaModels).toBe(true); + }); + + it('should have reasonable model refresh interval', () => { + expect(DEFAULT_FEIMA_CONFIG.modelRefreshInterval).toBeGreaterThanOrEqual(60); + expect(DEFAULT_FEIMA_CONFIG.modelRefreshInterval).toBeLessThanOrEqual(3600); + }); + + it('should have valid quota alert threshold', () => { + expect(DEFAULT_FEIMA_CONFIG.quotaAlertThreshold).toBeGreaterThanOrEqual(0.5); + expect(DEFAULT_FEIMA_CONFIG.quotaAlertThreshold).toBeLessThanOrEqual(0.99); + }); + }); + + describe('OAuth2 Endpoints Derivation', () => { + it('should derive correct OAuth2 endpoints from base URL', () => { + const mockService = { + ...mockConfigService, + getConfig: vi.fn().mockReturnValue(DEFAULT_FEIMA_CONFIG), + getOAuth2Endpoints: vi.fn().mockReturnValue({ + authorizationEndpoint: 'https://auth.feima.ai/oauth/authorize', + tokenEndpoint: 'https://auth.feima.ai/oauth/token', + revocationEndpoint: 'https://auth.feima.ai/oauth/revoke' + }) + }; + + const endpoints = mockService.getOAuth2Endpoints(); + + expect(endpoints.authorizationEndpoint).toContain('/oauth/authorize'); + expect(endpoints.tokenEndpoint).toContain('/oauth/token'); + expect(endpoints.revocationEndpoint).toContain('/oauth/revoke'); + }); + + it('should handle custom auth base URLs', () => { + const customConfig = { + ...DEFAULT_FEIMA_CONFIG, + authBaseUrl: 'https://custom-auth.example.com' + }; + + const mockService = { + ...mockConfigService, + getConfig: vi.fn().mockReturnValue(customConfig), + getOAuth2Endpoints: vi.fn().mockReturnValue({ + authorizationEndpoint: 'https://custom-auth.example.com/oauth/authorize', + tokenEndpoint: 'https://custom-auth.example.com/oauth/token' + }) + }; + + const endpoints = mockService.getOAuth2Endpoints(); + + expect(endpoints.authorizationEndpoint).toContain('custom-auth.example.com'); + expect(endpoints.tokenEndpoint).toContain('custom-auth.example.com'); + }); + }); + + describe('Configuration Validation', () => { + it('should validate required configuration fields', () => { + const mockService = { + ...mockConfigService, + validateConfig: vi.fn().mockReturnValue([]) + }; + + const errors = mockService.validateConfig(); + expect(errors).toEqual([]); + }); + + it('should detect invalid URLs', () => { + const mockService = { + ...mockConfigService, + validateConfig: vi.fn().mockReturnValue(['Invalid authBaseUrl format']) + }; + + const errors = mockService.validateConfig(); + expect(errors).toContain('Invalid authBaseUrl format'); + }); + + it('should validate model refresh interval bounds', () => { + const mockService = { + ...mockConfigService, + validateConfig: vi.fn().mockReturnValue(['modelRefreshInterval must be between 60-3600 seconds']) + }; + + const errors = mockService.validateConfig(); + expect(errors).toContain('modelRefreshInterval must be between 60-3600 seconds'); + }); + + it('should validate quota alert threshold range', () => { + const mockService = { + ...mockConfigService, + validateConfig: vi.fn().mockReturnValue(['quotaAlertThreshold must be between 0.5-0.99']) + }; + + const errors = mockService.validateConfig(); + expect(errors).toContain('quotaAlertThreshold must be between 0.5-0.99'); + }); + }); + + describe('Configuration Change Notifications', () => { + it('should emit change events when configuration updates', () => { + const listener = vi.fn(); + const mockService = { + ...mockConfigService, + onDidChangeConfig: vi.fn().mockImplementation((callback: any) => { + // Simulate configuration change + const newConfig = { ...DEFAULT_FEIMA_CONFIG, preferFeimaModels: false }; + callback(newConfig); + return { dispose: vi.fn() }; + }) + }; + + // Subscribe to changes + mockService.onDidChangeConfig(listener); + + expect(listener).toHaveBeenCalledWith({ ...DEFAULT_FEIMA_CONFIG, preferFeimaModels: false }); + }); + + it('should allow multiple listeners for config changes', () => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + + const mockService = { + ...mockConfigService, + onDidChangeConfig: vi.fn().mockImplementation((callback: any) => { + callback(DEFAULT_FEIMA_CONFIG); + return { dispose: vi.fn() }; + }) + }; + + // Subscribe listeners + mockService.onDidChangeConfig(listener1); + mockService.onDidChangeConfig(listener2); + + // Both listeners should be called + expect(listener1).toHaveBeenCalledWith(DEFAULT_FEIMA_CONFIG); + expect(listener2).toHaveBeenCalledWith(DEFAULT_FEIMA_CONFIG); + }); + }); + + describe('Type Safety', () => { + it('should maintain type safety for configuration interface', () => { + const config: IFeimaConfigData = { + authBaseUrl: 'https://test.com', + apiBaseUrl: 'https://api.test.com', + clientId: 'test-client', + issuer: 'https://test.com', + modelRefreshInterval: 300, + quotaShowInStatusBar: true, + quotaAlertThreshold: 0.8, + preferFeimaModels: true + }; + + expect(config.authBaseUrl).toBe('https://test.com'); + expect(config.preferFeimaModels).toBe(true); + }); + + it('should enforce required fields in configuration', () => { + // This would fail TypeScript compilation if any required fields were missing + const config: IFeimaConfigData = DEFAULT_FEIMA_CONFIG; + expect(config).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/feima/feimaModelProvider.spec.ts b/test/unit/feima/feimaModelProvider.spec.ts new file mode 100644 index 0000000000..f73b4aaf46 --- /dev/null +++ b/test/unit/feima/feimaModelProvider.spec.ts @@ -0,0 +1,951 @@ +/*--------------------------------------------------------------------------------------------- + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +// eslint-disable-next-line local/no-runtime-import +import * as vscode from 'vscode'; +// eslint-disable-next-line import/no-restricted-paths +import { FeimaModelProvider } from '../../../src/extension/feimaModels/vscode-node/feimaModelProvider'; +import { IFeimaAuthenticationService } from '../../../src/platform/authentication/node/feimaAuthenticationService'; +import { IEndpointProvider } from '../../../src/platform/endpoint/common/endpointProvider'; +import { IFeimaModelMetadataFetcher } from '../../../src/platform/endpoint/node/feimaModelMetadataFetcher'; +import { ILogService } from '../../../src/platform/log/common/logService'; +import { IInstantiationService } from '../../../src/util/vs/platform/instantiation/common/instantiation'; +// eslint-disable-next-line import/no-restricted-paths +import { CopilotLanguageModelWrapper } from '../../../src/extension/conversation/vscode-node/languageModelAccess'; + +// Mock VS Code API - using the global vscode shim from vitest config +// vi.mock('vscode', () => ({ +// EventEmitter: vi.fn().mockImplementation(() => ({ +// event: vi.fn(), +// fire: vi.fn() +// })), +// Position: vi.fn(), +// Range: vi.fn(), +// Selection: vi.fn(), +// CancellationTokenSource: vi.fn(), +// Diagnostic: vi.fn(), +// TextEdit: vi.fn(), +// WorkspaceEdit: vi.fn(), +// Uri: vi.fn(), +// MarkdownString: vi.fn(), +// TextEditorCursorStyle: vi.fn(), +// TextEditorLineNumbersStyle: vi.fn(), +// TextEditorRevealType: vi.fn(), +// EndOfLine: vi.fn(), +// DiagnosticSeverity: vi.fn() +// })); + +describe('FeimaModelProvider', () => { + let mockAuthService: IFeimaAuthenticationService; + let mockModelFetcher: IFeimaModelMetadataFetcher; + let mockEndpointProvider: IEndpointProvider; + let mockLogService: ILogService; + let mockInstantiationService: IInstantiationService; + let mockLmWrapper: CopilotLanguageModelWrapper; + let token: vscode.CancellationToken; + + beforeEach(() => { + // Create mocks + token = {} as vscode.CancellationToken; + mockAuthService = { + _serviceBrand: undefined, + isAuthenticated: vi.fn(), + onDidChangeSessions: vi.fn() as any, + getSessions: vi.fn(), + createSession: vi.fn(), + removeSession: vi.fn(), + getCachedSessions: vi.fn(), + handleUri: vi.fn(), + getToken: vi.fn(), + refreshToken: vi.fn(), + signOut: vi.fn(), + onDidChangeAuthenticationState: { event: vi.fn() } as any + }; + + mockModelFetcher = { + onDidModelsRefresh: vi.fn() as any, + isFeimaModel: vi.fn(), + getAllCompletionModels: vi.fn(), + getAllChatModels: vi.fn(), + getChatModelFromFamily: vi.fn(), + getChatModelFromApiModel: vi.fn(), + getEmbeddingsModel: vi.fn() + }; + + mockEndpointProvider = { + _serviceBrand: undefined, + getAllCompletionModels: vi.fn(), + getAllChatEndpoints: vi.fn(), + getChatEndpoint: vi.fn(), + getEmbeddingsEndpoint: vi.fn() + }; + + mockLogService = { + _serviceBrand: undefined, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + show: vi.fn(), + createSubLogger: vi.fn(), + withExtraTarget: vi.fn() + }; + + mockLmWrapper = { + provideLanguageModelResponse: vi.fn(), + provideTokenCount: vi.fn() + } as any; + + mockInstantiationService = { + createInstance: vi.fn().mockReturnValue(mockLmWrapper) + } as any; + }); + + describe('Initialization', () => { + it('should initialize with required services', () => { + const provider = new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + + expect(provider).toBeDefined(); + expect(mockInstantiationService.createInstance).toHaveBeenCalledWith(CopilotLanguageModelWrapper); + }); + + it('should listen for model refresh events', () => { + const mockEventEmitter = { event: vi.fn() }; + mockModelFetcher.onDidModelsRefresh = mockEventEmitter.event; + + new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + + expect(mockEventEmitter.event).toHaveBeenCalled(); + }); + }); + + describe('provideLanguageModelChatInformation', () => { + let provider: FeimaModelProvider; + + beforeEach(() => { + provider = new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + }); + + it('should return empty models when not authenticated', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(false); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toEqual([]); + // Note: Provider uses console.log for debugging, not log service + }); + + it('should return models when authenticated', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0', + multiplier: 2, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: true + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0', + detail: '2x', + category: { label: 'Feima Models', order: 0 }, + maxInputTokens: 8000, + maxOutputTokens: 4000, + isUserSelectable: true, + isDefault: true, + capabilities: { + toolCalling: true, + imageInput: false + } + }); + }); + + it('should handle endpoints with degradation reason', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-gpt-3.5', + name: 'Feima GPT-3.5', + family: 'GPT-3.5', + version: '3.5', + degradationReason: 'High load', + modelMaxPromptTokens: 4000, + maxOutputTokens: 2000, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + console.log('Mock auth service:', mockAuthService.isAuthenticated); + console.log('Mock endpoint provider:', mockEndpointProvider.getAllChatEndpoints); + + // Test the mocks directly + const authResult = await mockAuthService.isAuthenticated(); + console.log('Direct auth call result:', authResult); + + const endpointResult = await mockEndpointProvider.getAllChatEndpoints(); + console.log('Direct endpoint call result:', endpointResult); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + console.log('Result:', result); + + expect(result).toHaveLength(1); + // Note: statusIcon check removed due to ThemeIcon not being available in test environment + expect(result[0].tooltip).toBe('High load'); + }); + + // ===== COMPREHENSIVE MODEL METADATA ATTRIBUTE TESTING ===== + + it('should handle models with different billing multipliers', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-gpt-4-free', + name: 'Feima GPT-4 Free', + family: 'GPT-4', + version: '4.0', + multiplier: 0, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-gpt-4-premium', + name: 'Feima GPT-4 Premium', + family: 'GPT-4', + version: '4.0', + multiplier: 5, + degradationReason: undefined, + modelMaxPromptTokens: 32000, + maxOutputTokens: 8000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: true + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(2); + + // Free model should show "Free" or no multiplier + expect(result[0].detail).toBe('Free'); + + // Premium model should show multiplier + expect(result[1].detail).toBe('5x'); + }); + + it('should handle models with different capability combinations', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-basic', + name: 'Feima Basic', + family: 'GPT-3.5', + version: '3.5', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 4000, + maxOutputTokens: 2000, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-tools', + name: 'Feima Tools', + family: 'GPT-4', + version: '4.0', + multiplier: 2, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-vision', + name: 'Feima Vision', + family: 'GPT-4V', + version: '4.0', + multiplier: 3, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(3); + + // Basic model - no tool calling or vision + expect(result[0].capabilities.toolCalling).toBe(false); + expect(result[0].capabilities.imageInput).toBe(false); + + // Tools model - tool calling but no vision + expect(result[1].capabilities.toolCalling).toBe(true); + expect(result[1].capabilities.imageInput).toBe(false); + + // Vision model - both tool calling and vision + expect(result[2].capabilities.toolCalling).toBe(true); + expect(result[2].capabilities.imageInput).toBe(true); + }); + + it('should handle models with different token limits', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-small', + name: 'Feima Small', + family: 'GPT-3.5', + version: '3.5', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 4000, + maxOutputTokens: 2000, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-large', + name: 'Feima Large', + family: 'GPT-4', + version: '4.0', + multiplier: 2, + degradationReason: undefined, + modelMaxPromptTokens: 32000, + maxOutputTokens: 8000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-xl', + name: 'Feima XL', + family: 'GPT-4-Turbo', + version: '4.0', + multiplier: 3, + degradationReason: undefined, + modelMaxPromptTokens: 128000, + maxOutputTokens: 16000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(3); + + // Small model + expect(result[0].maxInputTokens).toBe(4000); + expect(result[0].maxOutputTokens).toBe(2000); + + // Large model + expect(result[1].maxInputTokens).toBe(32000); + expect(result[1].maxOutputTokens).toBe(8000); + + // XL model + expect(result[2].maxInputTokens).toBe(128000); + expect(result[2].maxOutputTokens).toBe(16000); + }); + + it('should handle models with different visibility settings', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-public', + name: 'Feima Public', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-hidden', + name: 'Feima Hidden', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: false, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(2); + + // Public model should be user selectable + expect(result[0].isUserSelectable).toBe(true); + + // Hidden model should not be user selectable + expect(result[1].isUserSelectable).toBe(false); + }); + + it('should handle models with different default settings', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-regular', + name: 'Feima Regular', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-default', + name: 'Feima Default', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: true + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(2); + + // Regular model should not be default + expect(result[0].isDefault).toBe(false); + + // Default model should be marked as default + expect(result[1].isDefault).toBe(true); + }); + + it('should handle models with various degradation scenarios', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-normal', + name: 'Feima Normal', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-high-load', + name: 'Feima High Load', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: 'High server load - responses may be slower', + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-maintenance', + name: 'Feima Maintenance', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: 'Scheduled maintenance in progress', + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(3); + + // Normal model should have no tooltip + expect(result[0].tooltip).toBeUndefined(); + + // High load model should show degradation reason + expect(result[1].tooltip).toBe('High server load - responses may be slower'); + + // Maintenance model should show degradation reason + expect(result[2].tooltip).toBe('Scheduled maintenance in progress'); + }); + + it('should handle premium vs free model billing display', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-free', + name: 'Feima Free', + family: 'GPT-3.5', + version: '3.5', + multiplier: 0, + degradationReason: undefined, + modelMaxPromptTokens: 4000, + maxOutputTokens: 2000, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-premium-1x', + name: 'Feima Premium 1x', + family: 'GPT-4', + version: '4.0', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 8000, + maxOutputTokens: 4000, + supportsToolCalls: true, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-premium-10x', + name: 'Feima Premium 10x', + family: 'GPT-4-Turbo', + version: '4.0', + multiplier: 10, + degradationReason: undefined, + modelMaxPromptTokens: 32000, + maxOutputTokens: 8000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: true + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(3); + + // Free model (multiplier = 0) should show "Free" + expect(result[0].detail).toBe('Free'); + + // Premium model with 1x multiplier should show "1x" + expect(result[1].detail).toBe('1x'); + + // Premium model with 10x multiplier should show "10x" + expect(result[2].detail).toBe('10x'); + }); + + it('should handle models with extreme token limits', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-mini', + name: 'Feima Mini', + family: 'GPT-3.5', + version: '3.5', + multiplier: 1, + degradationReason: undefined, + modelMaxPromptTokens: 1000, // Very small context + maxOutputTokens: 500, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-max', + name: 'Feima Max', + family: 'GPT-4-Long', + version: '4.0', + multiplier: 5, + degradationReason: undefined, + modelMaxPromptTokens: 2000000, // Very large context (2M tokens) + maxOutputTokens: 100000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(2); + + // Mini model with small limits + expect(result[0].maxInputTokens).toBe(1000); + expect(result[0].maxOutputTokens).toBe(500); + + // Max model with extreme limits + expect(result[1].maxInputTokens).toBe(2000000); + expect(result[1].maxOutputTokens).toBe(100000); + }); + + it('should handle models with mixed degradation and premium features', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + + const mockEndpoints = [ + { + model: 'feima-premium-degraded', + name: 'Feima Premium Degraded', + family: 'GPT-4', + version: '4.0', + multiplier: 5, + degradationReason: 'Premium tier experiencing high demand', + modelMaxPromptTokens: 32000, + maxOutputTokens: 8000, + supportsToolCalls: true, + supportsVision: true, + showInModelPicker: true, + isDefault: false + }, + { + model: 'feima-free-limited', + name: 'Feima Free Limited', + family: 'GPT-3.5', + version: '3.5', + multiplier: 0, + degradationReason: 'Rate limited - upgrade for better performance', + modelMaxPromptTokens: 2000, + maxOutputTokens: 1000, + supportsToolCalls: false, + supportsVision: false, + showInModelPicker: true, + isDefault: false + } + ]; + + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toHaveLength(2); + + // Premium degraded model + expect(result[0].detail).toBe('5x'); + expect(result[0].tooltip).toBe('Premium tier experiencing high demand'); + expect(result[0].capabilities.toolCalling).toBe(true); + expect(result[0].capabilities.imageInput).toBe(true); + + // Free limited model + expect(result[1].detail).toBe('Free'); + expect(result[1].tooltip).toBe('Rate limited - upgrade for better performance'); + expect(result[1].capabilities.toolCalling).toBe(false); + expect(result[1].capabilities.imageInput).toBe(false); + }); + + it('should handle authentication errors gracefully', async () => { + mockAuthService.isAuthenticated = vi.fn().mockRejectedValue(new Error('Auth failed')); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toEqual([]); + expect(mockLogService.error).toHaveBeenCalled(); + }); + + it('should handle endpoint fetch errors gracefully', async () => { + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockRejectedValue(new Error('API error')); + + const result = await provider.provideLanguageModelChatInformation({ silent: false }, token); + + expect(result).toEqual([]); + expect(mockLogService.error).toHaveBeenCalled(); + }); + }); + + describe('provideLanguageModelChatResponse', () => { + let provider: FeimaModelProvider; + let mockModel: vscode.LanguageModelChatInformation; + let token: vscode.CancellationToken; + + beforeEach(() => { + provider = new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + + token = {} as vscode.CancellationToken; + mockModel = { + id: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0' + } as vscode.LanguageModelChatInformation; + + // Set up cached endpoints + const mockEndpoints = [{ + model: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0' + }]; + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + }); + + it('should provide chat response using CopilotLanguageModelWrapper', async () => { + // First populate the cache + await provider.provideLanguageModelChatInformation({ silent: false }, token); + + const messages: vscode.LanguageModelChatMessage[] = [ + vscode.LanguageModelChatMessage.User('Hello') + ]; + const options: vscode.ProvideLanguageModelChatResponseOptions = { + toolMode: vscode.LanguageModelChatToolMode.Auto, + requestInitiator: 'test-extension' + }; + const progress = { report: vi.fn() }; + + await provider.provideLanguageModelChatResponse(mockModel, messages, options, progress, token); + + expect(mockLmWrapper.provideLanguageModelResponse).toHaveBeenCalledWith( + expect.objectContaining({ model: 'feima-gpt-4' }), + messages, + options, + 'GitHub.copilot-chat', + progress, + token + ); + }); + + it('should throw error when endpoint not found', async () => { + const invalidModel = { + ...mockModel, + id: 'non-existent-model' + } as vscode.LanguageModelChatInformation; + + await expect(provider.provideLanguageModelChatResponse( + invalidModel, + [], + { + toolMode: vscode.LanguageModelChatToolMode.Auto, + requestInitiator: 'test-extension' + }, + { report: vi.fn() }, + {} as vscode.CancellationToken + )).rejects.toThrow('Endpoint not found for model: non-existent-model'); + }); + }); + + describe('provideTokenCount', () => { + let provider: FeimaModelProvider; + let mockModel: vscode.LanguageModelChatInformation; + + beforeEach(() => { + provider = new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + + mockModel = { + id: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0' + } as vscode.LanguageModelChatInformation; + }); + + it('should provide token count using CopilotLanguageModelWrapper', async () => { + // Set up cached endpoints + const mockEndpoints = [{ + model: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0' + }]; + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + mockLmWrapper.provideTokenCount = vi.fn().mockResolvedValue(42); + + // Populate cache + await provider.provideLanguageModelChatInformation({ silent: false }, token); + + const result = await provider.provideTokenCount(mockModel, 'Hello world', {} as vscode.CancellationToken); + + expect(mockLmWrapper.provideTokenCount).toHaveBeenCalledWith( + expect.objectContaining({ model: 'feima-gpt-4' }), + 'Hello world' + ); + expect(result).toBe(42); + }); + + it('should fallback to estimation when endpoint not found', async () => { + const invalidModel = { + ...mockModel, + id: 'non-existent-model' + } as vscode.LanguageModelChatInformation; + + const result = await provider.provideTokenCount(invalidModel, 'Hello world', {} as vscode.CancellationToken); + + // Should return estimation based on length/4 + expect(result).toBe(Math.ceil('Hello world'.length / 4)); + expect(mockLogService.warn).toHaveBeenCalledWith( + '[FeimaModelProvider] Endpoint not found for model: non-existent-model, using estimation' + ); + }); + + it('should handle different message types', async () => { + // Set up cached endpoints + const mockEndpoints = [{ + model: 'feima-gpt-4', + name: 'Feima GPT-4', + family: 'GPT-4', + version: '4.0' + }]; + mockEndpointProvider.getAllChatEndpoints = vi.fn().mockResolvedValue(mockEndpoints); + mockAuthService.isAuthenticated = vi.fn().mockResolvedValue(true); + mockLmWrapper.provideTokenCount = vi.fn().mockResolvedValue(25); + + // Populate cache + await provider.provideLanguageModelChatInformation({ silent: false }, token); + + const message = vscode.LanguageModelChatMessage.User('Test message'); + const result = await provider.provideTokenCount(mockModel, message, {} as vscode.CancellationToken); + + expect(mockLmWrapper.provideTokenCount).toHaveBeenCalledWith( + expect.objectContaining({ model: 'feima-gpt-4' }), + message + ); + expect(result).toBe(25); + }); + }); + + describe('fireChangeEvent', () => { + it('should fire change event to notify VS Code', () => { + const provider = new FeimaModelProvider( + mockAuthService, + mockModelFetcher, + mockEndpointProvider, + mockLogService, + mockInstantiationService + ); + + // Mock the EventEmitter + const mockFire = vi.fn(); + (provider as any)._onDidChange = { fire: mockFire }; + + provider.fireChangeEvent(); + + expect(mockFire).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file