From 7d4393e45289a6ffbdcbd1e19a3f202fe012d075 Mon Sep 17 00:00:00 2001 From: Iven Xu Date: Sat, 27 Dec 2025 20:45:41 +1100 Subject: [PATCH] feat: Add Feima authentication, model providers, and Qwen3 integration - Add FeimaAuthProvider with OAuth2 + PKCE authentication flow - Add FeimaModelProvider with mock and Qwen3 models - Add FeimaEndpoint for Qwen3 LLM integration with tool calling - Implement oauth2Service and PKCE utilities - Register feima authentication provider and language model vendor - Add Qwen3 completion provider with OpenAI-compatible API - Refactor command namespace from github.copilot.feima.* to feima.* - Configure VS Code authentication, model, and command registrations - Add telemetry service for logging support - Update language model access wrapper for model integration - Support environment-based configuration for OAuth2 and Qwen3 API Commands: - feima.signIn: Sign in with Feima authentication - feima.signOut: Sign out from Feima authentication - feima.listModels: List available Feima models - feima.checkContextKey: Check authentication status Models: - feima-fast: Fast mock model - feima-smart: Smart mock model - qwen3-coder-plus: Qwen3 real LLM (requires QWEN3_API_KEY) All models support tool calling for inline edits when authenticated. --- package-lock.json | 48 ++- package.json | 38 +++ .../completionsCoreContribution.ts | 30 +- .../vscode-node/qwen3CompletionProvider.ts | 135 ++++++++ .../vscode-node/languageModelAccess.ts | 8 +- .../extension/vscode-node/contributions.ts | 2 + .../extension/vscode-node/services.ts | 6 +- .../feimaAuth/common/oauth2Service.ts | 271 ++++++++++++++++ .../vscode-node/feimaAuthProvider.ts | 244 ++++++++++++++ .../feimaAuth/vscode-node/feimaEndpoint.ts | 244 ++++++++++++++ .../vscode-node/feimaModelProvider.ts | 225 +++++++++++++ .../vscode-node/feimaProvidersContribution.ts | 300 ++++++++++++++++++ .../vscode-node/authenticationService.ts | 4 +- .../telemetry/common/logTelemetryService.ts | 207 ++++++++++++ src/util/common/pkce.ts | 68 ++++ src/util/vs/base/common/oauth.ts | 125 ++++++++ 16 files changed, 1947 insertions(+), 8 deletions(-) create mode 100644 src/extension/completions/vscode-node/qwen3CompletionProvider.ts create mode 100644 src/extension/feimaAuth/common/oauth2Service.ts create mode 100644 src/extension/feimaAuth/vscode-node/feimaAuthProvider.ts create mode 100644 src/extension/feimaAuth/vscode-node/feimaEndpoint.ts create mode 100644 src/extension/feimaModels/vscode-node/feimaModelProvider.ts create mode 100644 src/extension/feimaProviders/vscode-node/feimaProvidersContribution.ts create mode 100644 src/platform/telemetry/common/logTelemetryService.ts create mode 100644 src/util/common/pkce.ts create mode 100644 src/util/vs/base/common/oauth.ts diff --git a/package-lock.json b/package-lock.json index 4e2c6b5327..65fc7bceae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1430,6 +1430,7 @@ "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" @@ -3932,6 +3933,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.6.0.tgz", "integrity": "sha512-OWlrQAnWn9577PhVgqjUvMr1pg57Bc4jv0iL4w0PRuOSRvq67rvHW9Ie/dZVMvCzhSCB+UxhcY/PmCmFj33Q+g==", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -5569,6 +5571,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.44.tgz", "integrity": "sha512-Ye0nlw09GeMp2Suh8qoOv0odfgCoowfM/9MG6WeRD60Gq9wS90bdkdRtYbRkNhXOpG4H+YXGvj4wOWhAC0LJ1g==", "dev": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5580,6 +5583,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz", "integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==", "dev": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -5754,6 +5758,7 @@ "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/types": "8.36.0", @@ -6979,6 +6984,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8917,6 +8923,7 @@ "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", "integrity": "sha512-r2HV5qFkUICyoaKlBEpLKHjxMXATUf/l+h8UZPGBHGLy4DDiY2sOLcIctax4eRnTw5wH2jTMExLntGPJ8eOJxw==", "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.5.3" } @@ -9162,7 +9169,8 @@ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -9520,6 +9528,7 @@ "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9708,6 +9717,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11093,6 +11103,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -12140,6 +12163,7 @@ "integrity": "sha512-ypEvQvInNpUe+u+w8BIcPkQvEqXquyyibWE/1NB5T2BTzIpS5cGEV1LZskDzPSTvNAaT4+5FutvzlvnkxOSKlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.0.3" } @@ -13383,6 +13407,7 @@ "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -13420,6 +13445,7 @@ "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", @@ -15279,6 +15305,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -15292,6 +15319,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -15685,6 +15713,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -15850,6 +15879,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, "node_modules/sax": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", @@ -15861,6 +15897,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "dev": true, + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -18026,7 +18063,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsscmp": { "version": "1.0.6", @@ -18044,6 +18082,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -18236,6 +18275,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18360,6 +18400,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -18505,6 +18546,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -18659,6 +18701,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -19148,6 +19191,7 @@ "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 1648a3b84a..87b1d6695e 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "activationEvents": [ "onStartupFinished", "onLanguageModelChat:copilot", + "onLanguageModelAccess:feima", "onUri", "onFileSystem:ccreq", "onFileSystem:ccsettings" @@ -138,6 +139,12 @@ "contribEditorContentMenu" ], "contributes": { + "authentication": [ + { + "id": "feima-authentication", + "label": "Feima Auth" + } + ], "languageModelTools": [ { "name": "copilot_searchCodebase", @@ -1679,6 +1686,10 @@ } ], "languageModelChatProviders": [ + { + "vendor": "feima", + "displayName": "Feima Models" + }, { "vendor": "copilot", "displayName": "Copilot" @@ -2362,6 +2373,26 @@ "title": "%github.copilot.command.applyCopilotCLIAgentSessionChanges%", "icon": "$(git-stash-pop)", "category": "GitHub Copilot" + }, + { + "command": "feima.signIn", + "title": "Sign in with Feima", + "category": "Feima" + }, + { + "command": "feima.signOut", + "title": "Sign Out", + "category": "Feima" + }, + { + "command": "feima.listModels", + "title": "List Available Models", + "category": "Feima" + }, + { + "command": "feima.checkContextKey", + "title": "Check Authentication Status", + "category": "Feima" } ], "configuration": [ @@ -3963,6 +3994,13 @@ } ], "menus": { + "AccountsContext": [ + { + "command": "feima.signIn", + "group": "1_accounts", + "when": "!github.copilot.feimaAuth.signedIn" + } + ], "editor/title": [ { "command": "github.copilot.debug.generateInlineEditTests", diff --git a/src/extension/completions/vscode-node/completionsCoreContribution.ts b/src/extension/completions/vscode-node/completionsCoreContribution.ts index c2d2d09503..e4865e6272 100644 --- a/src/extension/completions/vscode-node/completionsCoreContribution.ts +++ b/src/extension/completions/vscode-node/completionsCoreContribution.ts @@ -13,10 +13,12 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { createContext, registerUnificationCommands, setup } from '../../completions-core/vscode-node/completionsServiceBridges'; import { CopilotInlineCompletionItemProvider } from '../../completions-core/vscode-node/extension/src/inlineCompletion'; import { unificationStateObservable } from './completionsUnificationContribution'; +import { Qwen3CompletionProvider } from './qwen3CompletionProvider'; export class CompletionsCoreContribution extends Disposable { private _provider: CopilotInlineCompletionItemProvider | undefined; + private _qwen3Provider: Qwen3CompletionProvider | undefined; private readonly _copilotToken = observableFromEvent(this, this.authenticationService.onDidAuthenticationChange, () => this.authenticationService.copilotToken); @@ -37,7 +39,26 @@ export class CompletionsCoreContribution extends Disposable { const configEnabled = configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsEnableGhCompletionsProvider, experimentationService).read(reader); const extensionUnification = unificationStateValue?.extensionUnification ?? false; - if (unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser) { + // Check if Qwen3 is configured + const qwen3ApiKey = process.env.QWEN3_API_KEY; + const qwen3BaseUrl = process.env.QWEN3_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + + if (qwen3ApiKey) { + // Use Qwen3 provider if configured + const qwen3Provider = this._getOrCreateQwen3Provider(qwen3ApiKey, qwen3BaseUrl); + reader.store.add( + languages.registerInlineCompletionItemProvider( + { pattern: '**' }, + qwen3Provider, + { + debounceDelayMs: 0, + excludes: ['qwen3-completions'], + groupId: 'completions' + } + ) + ); + } else if (unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser) { + // Fall back to Copilot provider if Qwen3 not configured const provider = this._getOrCreateProvider(); reader.store.add( languages.registerInlineCompletionItemProvider( @@ -74,4 +95,11 @@ export class CompletionsCoreContribution extends Disposable { } return this._provider; } + + private _getOrCreateQwen3Provider(apiKey: string, baseUrl: string) { + if (!this._qwen3Provider) { + this._qwen3Provider = this._register(this._instantiationService.createInstance(Qwen3CompletionProvider, apiKey, baseUrl)); + } + return this._qwen3Provider; + } } diff --git a/src/extension/completions/vscode-node/qwen3CompletionProvider.ts b/src/extension/completions/vscode-node/qwen3CompletionProvider.ts new file mode 100644 index 0000000000..1ccff2f707 --- /dev/null +++ b/src/extension/completions/vscode-node/qwen3CompletionProvider.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, InlineCompletionContext, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, Position, Range, TextDocument } from 'vscode'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; + +/** + * Qwen3 Completion Provider for inline code completions + */ +export class Qwen3CompletionProvider extends Disposable implements InlineCompletionItemProvider { + + constructor( + private readonly apiKey: string, + private readonly baseUrl: string, + @IFetcherService private readonly fetcherService: IFetcherService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.logService.info(`[Qwen3CompletionProvider] Initialized with baseUrl: ${baseUrl}`); + } + + async provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken + ): Promise { + try { + return await this._provideInlineCompletionItems(document, position, context, token); + } catch (error) { + // Ignore AbortError - this is expected when VS Code cancels the request + // (e.g., user continues typing or navigates away) + if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted'))) { + this.logService.trace('[Qwen3CompletionProvider] Request cancelled'); + return null; + } + + this.logService.error(error instanceof Error ? error : new Error(String(error)), '[Qwen3CompletionProvider] Error providing completions'); + return null; + } + } + + private async _provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + token: CancellationToken + ): Promise { + const startTime = Date.now(); + + // Get text before and after cursor + const fullText = document.getText(); + const offset = document.offsetAt(position); + const prefix = fullText.substring(0, offset); + const suffix = fullText.substring(offset); + + // Build prompt for completion + const prompt = this.buildPrompt(document, prefix, suffix); + + // Create AbortController for cancellation + const abortController = new AbortController(); + const abortListener = token.onCancellationRequested(() => { + abortController.abort(); + }); + + try { + // Make request to Qwen3 API + // Note: For completions endpoint, use 'qwen-coder-turbo' instead of 'qwen3-coder-plus' + // The chat endpoint uses different model names than the completions endpoint + const requestBody = { + model: 'qwen-coder-turbo', + prompt: prompt, + max_tokens: 200, + temperature: 0.2, + stop: ['\n\n', '```'], + }; + + this.logService.debug(`[Qwen3CompletionProvider] Requesting completion at ${document.uri.toString()}:${position.line}:${position.character}`); + + const response = await this.fetcherService.fetch(`${this.baseUrl}/completions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + this.logService.error(`[Qwen3CompletionProvider] API error: ${response.status} ${errorText}`); + return null; + } + + const jsonResponse = await response.json(); + const completion = jsonResponse?.choices?.[0]?.text; + + if (!completion) { + this.logService.debug('[Qwen3CompletionProvider] No completion returned'); + return null; + } + + const latency = Date.now() - startTime; + this.logService.debug(`[Qwen3CompletionProvider] Completion received in ${latency}ms: "${completion.substring(0, 50)}..."`); + + // Create inline completion item + const item = new InlineCompletionItem( + completion, + new Range(position, position) + ); + + return [item]; + + } finally { + abortListener.dispose(); + } + } + + private buildPrompt(document: TextDocument, prefix: string, suffix: string): string { + // Build a completion prompt + // Get last few lines of context (up to 500 chars) + const contextLength = 500; + const contextPrefix = prefix.length > contextLength + ? prefix.substring(prefix.length - contextLength) + : prefix; + + // Simple prompt format for code completion + return `<|fim_prefix|>${contextPrefix}<|fim_suffix|>${suffix.substring(0, 100)}<|fim_middle|>`; + } +} diff --git a/src/extension/conversation/vscode-node/languageModelAccess.ts b/src/extension/conversation/vscode-node/languageModelAccess.ts index da29eca60d..7f289b3a9b 100644 --- a/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -74,8 +74,12 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib // initial this.activationBlocker = Promise.all([ - this._registerChatProvider(), - this._registerEmbeddings(), + this._registerChatProvider().catch(err => { + this._logService.error('[LanguageModelAccess] Failed to register chat provider:', err); + }), + this._registerEmbeddings().catch(err => { + this._logService.error('[LanguageModelAccess] Failed to register embeddings:', err); + }), ]).then(() => { }); } diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index f68ac06d17..b6aa278453 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -22,6 +22,7 @@ import { LogWorkspaceStateContribution } from '../../conversation/vscode-node/lo import { RemoteAgentContribution } from '../../conversation/vscode-node/remoteAgents'; import { DiagnosticsContextContribution } from '../../diagnosticsContext/vscode/diagnosticsContextProvider'; import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; +import { FeimaProvidersContribution } from '../../feimaProviders/vscode-node/feimaProvidersContribution'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; import * as newWorkspaceContribution from '../../getting-started/vscode-node/newWorkspace.contribution'; import { GitHubMcpContrib } from '../../githubMcp/vscode-node/githubMcp.contribution'; @@ -64,6 +65,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ ...vscodeContributions, asContributionFactory(ConversationFeature), workspaceChunkSearchContribution, + asContributionFactory(FeimaProvidersContribution), asContributionFactory(AuthenticationContrib), chatBlockLanguageContribution, asContributionFactory(LoggingActionsContrib), diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index 7a28a57851..1cb26e0395 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -54,8 +54,8 @@ import { ScopeSelectorImpl } from '../../../platform/scopeSelection/vscode-node/ import { ISearchService } from '../../../platform/search/common/searchService'; import { SearchServiceImpl } from '../../../platform/search/vscode-node/searchServiceImpl'; import { ISettingsEditorSearchService } from '../../../platform/settingsEditor/common/settingsEditorSearchService'; +import { LogTelemetryService } from '../../../platform/telemetry/common/logTelemetryService'; import { IExperimentationService, NullExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; -import { NullTelemetryService } from '../../../platform/telemetry/common/nullTelemetryService'; import { ITelemetryService, ITelemetryUserConfig, TelemetryUserConfigImpl } from '../../../platform/telemetry/common/telemetry'; import { APP_INSIGHTS_KEY_ENHANCED, APP_INSIGHTS_KEY_STANDARD } from '../../../platform/telemetry/node/azureInsights'; import { MicrosoftExperimentationService } from '../../../platform/telemetry/vscode-node/microsoftExperimentationService'; @@ -228,7 +228,9 @@ function setupTelemetry(builder: IInstantiationServiceBuilder, extensionContext: ])); } else { // If we're developing or testing we don't want telemetry to be sent, so we turn it off - builder.define(ITelemetryService, new NullTelemetryService()); + // Use LogTelemetryService to see what telemetry would be sent, or NullTelemetryService to disable logging + builder.define(ITelemetryService, new LogTelemetryService()); + // builder.define(ITelemetryService, new NullTelemetryService()); } setupMSFTExperimentationService(builder, extensionContext); diff --git a/src/extension/feimaAuth/common/oauth2Service.ts b/src/extension/feimaAuth/common/oauth2Service.ts new file mode 100644 index 0000000000..93df7a0070 --- /dev/null +++ b/src/extension/feimaAuth/common/oauth2Service.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { generateCodeChallenge, generateCodeVerifier, generateNonce, generateState } from '../../../util/common/pkce'; +import { + getClaimsFromJWT, + IAuthorizationJWTClaims, + IAuthorizationServerMetadata, + IAuthorizationTokenResponse +} from '../../../util/vs/base/common/oauth'; + +/** + * Configuration for OAuth2 authentication + */ +export interface IOAuth2Config { + /** OAuth2 client ID */ + clientId: string; + /** OAuth2 client secret (optional for public clients using PKCE) */ + clientSecret?: string; + /** OAuth2 server metadata */ + serverMetadata: IAuthorizationServerMetadata; + /** Redirect URI registered with the provider */ + redirectUri: string; + /** OAuth2 scopes */ + scopes: string[]; + /** Additional authorization parameters */ + additionalAuthParams?: Record; +} + +/** + * OAuth2 flow state for validation + */ +interface IOAuth2FlowState { + codeVerifier: string; + state: string; + nonce?: string; + startedAt: number; +} + +/** + * OAuth2 service implementing authorization code flow with PKCE + * Follows RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), and OpenID Connect + */ +export class OAuth2Service { + private _currentFlow: IOAuth2FlowState | undefined; + + constructor( + private readonly _config: IOAuth2Config, + private readonly _fetcher: IFetcherService + ) { } + + /** + * Build authorization URL for OAuth2 flow + */ + async buildAuthorizationUrl(): Promise { + // Generate PKCE parameters + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + + // Generate state and nonce + const state = generateState(); + const nonce = this._config.scopes.includes('openid') ? generateNonce() : undefined; + + // Store flow state + this._currentFlow = { + codeVerifier, + state, + nonce, + startedAt: Date.now() + }; + + // Build authorization URL + console.log('[OAuth2Service] Building authorization URL from endpoint:', this._config.serverMetadata.authorization_endpoint); + const authUrl = new URL(this._config.serverMetadata.authorization_endpoint!); + authUrl.searchParams.set('client_id', this._config.clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', this._config.redirectUri); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + authUrl.searchParams.set('scope', this._config.scopes.join(' ')); + console.log('[OAuth2Service] Authorization URL constructed:', authUrl.toString()); + + if (nonce) { + authUrl.searchParams.set('nonce', nonce); + } + + // Add additional parameters + if (this._config.additionalAuthParams) { + for (const [key, value] of Object.entries(this._config.additionalAuthParams)) { + authUrl.searchParams.set(key, value); + } + } + + return authUrl.toString(); + } + + /** + * Validate callback and extract authorization code + * @param query The query string from the callback URI (already parsed by VS Code) + */ + validateCallback(query: string): { code: string } | { error: string } { + // Parse query string - VS Code has already decoded it + const params = new URLSearchParams(query); + + // Check for error + const error = params.get('error'); + if (error) { + return { error: params.get('error_description') || error }; + } + + // Extract code and state + const code = params.get('code'); + const state = params.get('state'); + + if (!code || !state) { + console.error('[OAuth2Service] Missing code or state. Query:', query); + console.error('[OAuth2Service] Parsed params:', { code, state }); + return { error: 'Missing code or state in callback' }; + } + + // Validate state + if (!this._currentFlow || this._currentFlow.state !== state) { + return { error: 'Invalid state - possible CSRF attack' }; + } + + // Check flow expiration (10 minutes) + if (Date.now() - this._currentFlow.startedAt > 10 * 60 * 1000) { + return { error: 'Authorization flow expired' }; + } + + return { code }; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCodeForToken(code: string): Promise { + if (!this._currentFlow) { + throw new Error('No active OAuth2 flow'); + } + + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('client_id', this._config.clientId); + body.append('code', code); + body.append('redirect_uri', this._config.redirectUri); + body.append('code_verifier', this._currentFlow.codeVerifier); + + if (this._config.clientSecret) { + body.append('client_secret', this._config.clientSecret); + } + + const response = await this._fetcher.fetch(this._config.serverMetadata.token_endpoint!, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: body.toString() + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${response.status} ${errorText}`); + } + + const tokenResponse: IAuthorizationTokenResponse = await response.json(); + this._currentFlow = undefined; + + return tokenResponse; + } + + /** + * Refresh access token + */ + async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.append('grant_type', 'refresh_token'); + body.append('client_id', this._config.clientId); + body.append('refresh_token', refreshToken); + + if (this._config.clientSecret) { + body.append('client_secret', this._config.clientSecret); + } + + const response = await this._fetcher.fetch(this._config.serverMetadata.token_endpoint!, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: body.toString() + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token refresh failed: ${response.status} ${errorText}`); + } + + return await response.json(); + } + + /** + * Check if token needs refresh based on expiration + */ + shouldRefreshToken(tokenResponse: IAuthorizationTokenResponse, bufferSeconds: number = 300): boolean { + if (!tokenResponse.expires_in) { + return false; + } + + // Calculate expiration (assuming token was just received) + const expiresAt = Date.now() + (tokenResponse.expires_in * 1000); + const now = Date.now(); + return expiresAt < (now + bufferSeconds * 1000); + } + + /** + * Extract user info from token response + */ + getUserInfo(tokenResponse: IAuthorizationTokenResponse): IAuthorizationJWTClaims | undefined { + try { + const token = tokenResponse.id_token || tokenResponse.access_token; + return token ? getClaimsFromJWT(token) : undefined; + } catch { + return undefined; + } + } + + /** + * Revoke token (if revocation endpoint is supported) + */ + async revokeToken(token: string, tokenTypeHint?: 'access_token' | 'refresh_token'): Promise { + if (!this._config.serverMetadata.revocation_endpoint) { + console.warn('[OAuth2Service] Revocation endpoint not available'); + return; + } + + const body = new URLSearchParams(); + body.append('token', token); + body.append('client_id', this._config.clientId); + + if (tokenTypeHint) { + body.append('token_type_hint', tokenTypeHint); + } + + if (this._config.clientSecret) { + body.append('client_secret', this._config.clientSecret); + } + + try { + const response = await this._fetcher.fetch(this._config.serverMetadata.revocation_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: body.toString() + }); + + // RFC 7009: revocation endpoint returns 200 even if token doesn't exist + if (!response.ok) { + console.warn(`[OAuth2Service] Token revocation returned ${response.status}`); + } + } catch (error) { + console.warn('[OAuth2Service] Token revocation failed:', error); + } + } +} diff --git a/src/extension/feimaAuth/vscode-node/feimaAuthProvider.ts b/src/extension/feimaAuth/vscode-node/feimaAuthProvider.ts new file mode 100644 index 0000000000..66a636d168 --- /dev/null +++ b/src/extension/feimaAuth/vscode-node/feimaAuthProvider.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IAuthorizationTokenResponse } from '../../../util/vs/base/common/oauth'; +import { IOAuth2Config, OAuth2Service } from '../common/oauth2Service'; + +/** + * Stored token data in VS Code secrets + */ +interface IStoredTokenData { + tokenResponse: IAuthorizationTokenResponse; + issuedAt: number; + sessionId: string; + accountId: string; + accountLabel: string; +} + +/** + * Authentication provider using OAuth2 + PKCE flow + */ +export class FeimaAuthProvider implements vscode.AuthenticationProvider, vscode.UriHandler { + private _onDidChangeSessions = new vscode.EventEmitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; + + private readonly _oauth2Service: OAuth2Service; + private readonly _secretsKey = 'feimaAuth.tokens'; + private _pendingCallback: ((result: { code: string } | { error: string }) => void) | undefined; + + constructor( + private readonly _context: IVSCodeExtensionContext, + config: IOAuth2Config, + oauth2Service: OAuth2Service + ) { + this._oauth2Service = oauth2Service; + } + + /** + * Handle OAuth callback URI + */ + async handleUri(uri: vscode.Uri): Promise { + + if (!this._pendingCallback) { + console.warn('[FeimaAuthProvider] Received callback without pending request'); + vscode.window.showErrorMessage('OAuth callback received but no authentication was in progress. Please try signing in again.'); + return; + } + + // Validate callback using the parsed query string from VS Code + const result = this._oauth2Service.validateCallback(uri.query); + this._pendingCallback(result); + this._pendingCallback = undefined; + } + + /** + * Get existing sessions + */ + async getSessions( + _scopes: readonly string[] | undefined, + _options: vscode.AuthenticationProviderSessionOptions + ): Promise { + const stored = await this._loadStoredToken(); + if (!stored) { + return []; + } + + // Check if token needs refresh + const needsRefresh = this._oauth2Service.shouldRefreshToken(stored.tokenResponse); + if (needsRefresh && stored.tokenResponse.refresh_token) { + try { + const refreshed = await this._oauth2Service.refreshAccessToken(stored.tokenResponse.refresh_token); + await this._saveToken(refreshed, stored.accountId, stored.accountLabel); + stored.tokenResponse = refreshed; + stored.issuedAt = Date.now(); + } catch (error) { + console.error('[FeimaAuthProvider] Token refresh failed:', error); + await this._clearStoredToken(); + return []; + } + } + + return [{ + id: stored.sessionId, + accessToken: stored.tokenResponse.access_token, + account: { + id: stored.accountId, + label: stored.accountLabel + }, + scopes: [] + }]; + } + + /** + * Create a new session with OAuth2 flow + */ + async createSession( + _scopes: readonly string[], + _options: vscode.AuthenticationProviderSessionOptions + ): Promise { + + // Build authorization URL + const authUrl = await this._oauth2Service.buildAuthorizationUrl(); + + // Open in browser + const opened = await vscode.env.openExternal(vscode.Uri.parse(authUrl)); + if (!opened) { + throw new Error('Failed to open authentication URL'); + } + + // Wait for callback + const result = await new Promise<{ code: string } | { error: string }>((resolve) => { + this._pendingCallback = resolve; + // Timeout after 5 minutes + setTimeout(() => { + if (this._pendingCallback) { + this._pendingCallback({ error: 'Authentication timed out' }); + this._pendingCallback = undefined; + } + }, 5 * 60 * 1000); + }); + + if ('error' in result) { + throw new Error(`Authentication failed: ${result.error}`); + } + + // Exchange code for token + const tokenResponse = await this._oauth2Service.exchangeCodeForToken(result.code); + + // Extract user info + const userInfo = this._oauth2Service.getUserInfo(tokenResponse); + const accountId = userInfo?.sub || `user-${Date.now()}`; + const accountLabel = userInfo?.email || userInfo?.preferred_username || userInfo?.name || 'Dummy User'; + + // Save token + await this._saveToken(tokenResponse, accountId, accountLabel); + + const session: vscode.AuthenticationSession = { + id: `feima-session-${Date.now()}`, + accessToken: tokenResponse.access_token, + account: { + id: accountId, + label: accountLabel + }, + scopes: [] + }; + + // Fire the change event + this._onDidChangeSessions.fire({ + added: [session], + removed: [], + changed: [] + }); + + return session; + } + + /** + * Remove a session + */ + async removeSession(sessionId: string): Promise { + console.log('[FeimaAuthProvider] removeSession called for:', sessionId); + const stored = await this._loadStoredToken(); + console.log('[FeimaAuthProvider] Stored session:', stored ? stored.sessionId : 'NONE'); + if (!stored || stored.sessionId !== sessionId) { + console.log('[FeimaAuthProvider] No matching session found, skipping removal'); + return; + } + + // Revoke tokens if supported + try { + if (stored.tokenResponse.refresh_token) { + await this._oauth2Service.revokeToken(stored.tokenResponse.refresh_token, 'refresh_token'); + } + await this._oauth2Service.revokeToken(stored.tokenResponse.access_token, 'access_token'); + } catch (error) { + console.warn('[FeimaAuthProvider] Token revocation failed:', error); + } + + // Clear stored token + await this._clearStoredToken(); + console.log('[FeimaAuthProvider] Token cleared from secrets'); + + // Verify it's cleared + const verify = await this._loadStoredToken(); + console.log('[FeimaAuthProvider] Verification after clear:', verify ? 'STILL EXISTS!' : 'Correctly cleared'); + + // Fire the change event + console.log('[FeimaAuthProvider] Firing session removed event'); + this._onDidChangeSessions.fire({ + added: [], + removed: [{ + id: sessionId, + accessToken: stored.tokenResponse.access_token, + account: { + id: stored.accountId, + label: stored.accountLabel + }, + scopes: [] + }], + changed: [] + }); + } + + /** + * Load stored token from secrets + */ + private async _loadStoredToken(): Promise { + try { + if (!this._context) { + console.error('[FeimaAuthProvider] Context is undefined in _loadStoredToken'); + return undefined; + } + const json = await this._context.secrets.get(this._secretsKey); + return json ? JSON.parse(json) : undefined; + } catch (error) { + console.error('[FeimaAuthProvider] Failed to load token:', error); + return undefined; + } + } + + /** + * Save token to secrets + */ + private async _saveToken(tokenResponse: IAuthorizationTokenResponse, accountId: string, accountLabel: string): Promise { + const data: IStoredTokenData = { + tokenResponse, + issuedAt: Date.now(), + sessionId: `feima-session-${Date.now()}`, + accountId, + accountLabel + }; + await this._context.secrets.store(this._secretsKey, JSON.stringify(data)); + } + + /** + * Clear stored token + */ + private async _clearStoredToken(): Promise { + await this._context.secrets.delete(this._secretsKey); + } +} diff --git a/src/extension/feimaAuth/vscode-node/feimaEndpoint.ts b/src/extension/feimaAuth/vscode-node/feimaEndpoint.ts new file mode 100644 index 0000000000..bdf44eb049 --- /dev/null +++ b/src/extension/feimaAuth/vscode-node/feimaEndpoint.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Raw } from '@vscode/prompt-tsx'; +import type { CancellationToken } from 'vscode'; +import { Source } from '../../../platform/chat/common/chatMLFetcher'; +import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; +import { EndpointEditToolName } from '../../../platform/endpoint/common/endpointProvider'; +import { ILogService } from '../../../platform/log/common/logService'; +import { FinishedCallback, ICopilotToolCall, IResponseDelta, OptionalChatRequestParams } from '../../../platform/networking/common/fetch'; +import { IFetcherService, Response } from '../../../platform/networking/common/fetcherService'; +import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions } from '../../../platform/networking/common/networking'; +import { ChatCompletion, rawMessageToCAPI } from '../../../platform/networking/common/openai'; +import { ITelemetryService, TelemetryProperties } from '../../../platform/telemetry/common/telemetry'; +import { TelemetryData } from '../../../platform/telemetry/common/telemetryData'; +import { ITokenizerProvider } from '../../../platform/tokenizer/node/tokenizer'; +import { ITokenizer, TokenizerType } from '../../../util/common/tokenizer'; +import { AsyncIterableObject } from '../../../util/vs/base/common/async'; +import { generateUuid } from '../../../util/vs/base/common/uuid'; + +/** + * Feima Endpoint for integrating Qwen3 Coder LLM (OpenAI-compatible API) + */ +export class FeimaEndpoint implements IChatEndpoint { + // Model configuration + readonly model = 'qwen3-coder-plus'; + readonly name = 'Qwen3 Coder'; + readonly version = '1.0'; + readonly family = 'qwen3'; + readonly tokenizer: TokenizerType = TokenizerType.CL100K; + readonly modelMaxPromptTokens = 120000; // Leave some buffer from 128k + readonly maxOutputTokens = 8192; + + // Model capabilities + readonly supportsToolCalls = true; // Qwen3 supports tool calling + readonly supportsVision = false; + readonly supportsPrediction = false; + readonly supportsThinkingContentInHistory = false; + readonly supportedEditTools: readonly EndpointEditToolName[] = []; + + // Model metadata + readonly showInModelPicker = true; + readonly isDefault = false; + readonly isFallback = false; + readonly isPremium = false; + readonly multiplier = undefined; + readonly restrictedToSkus = undefined; + readonly customModel = undefined; + readonly isExtensionContributed = true; + readonly degradationReason = undefined; + + // Policy + readonly policy: 'enabled' = 'enabled'; + + constructor( + private readonly apiKey: string, + private readonly baseUrl: string, + private readonly tokenizerProvider: ITokenizerProvider, + @IFetcherService private readonly fetcherService: IFetcherService, + @ILogService private readonly logService: ILogService, + ) { + this.logService.info(`[FeimaEndpoint] Initialized with baseUrl: ${baseUrl}`); + } + + get apiType(): string { + return 'openai-compatible'; + } + + get urlOrRequestMetadata(): string { + return `${this.baseUrl}/chat/completions`; + } + + getExtraHeaders(): Record { + return { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }; + } + + acquireTokenizer(): ITokenizer { + return this.tokenizerProvider.acquireTokenizer(this); + } + + async acceptChatPolicy(): Promise { + // No policy acceptance needed for custom endpoint + return true; + } + + createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody { + const request: IEndpointBody = { + messages: rawMessageToCAPI(options.messages), + model: this.model, + stream: false, // Disable streaming for simplicity + max_tokens: this.maxOutputTokens, + }; + + if (options.postOptions) { + Object.assign(request, options.postOptions); + } + + // Log the request body to debug tool calling + this.logService.debug(`[FeimaEndpoint] Request body: ${JSON.stringify(request, null, 2)}`); + + return request; + } + + async makeChatRequest( + debugName: string, + messages: Raw.ChatMessage[], + finishedCb: FinishedCallback | undefined, + token: CancellationToken, + location: ChatLocation, + source?: Source, + requestOptions?: Omit, + userInitiatedRequest?: boolean, + telemetryProperties?: TelemetryProperties, + ): Promise { + return this.makeChatRequest2({ + debugName, + messages, + finishedCb, + location, + source, + requestOptions, + userInitiatedRequest, + telemetryProperties, + }, token); + } + + async makeChatRequest2(options: IMakeChatRequestOptions, token: CancellationToken): Promise { + const requestId = generateUuid(); + + try { + const body = this.createRequestBody({ + ...options, + requestId, + postOptions: options.requestOptions ?? {}, + }); + + this.logService.debug(`[FeimaEndpoint] Making request to ${this.urlOrRequestMetadata} with ${options.messages.length} messages`); + + // Create AbortController to convert VS Code CancellationToken to AbortSignal + const abortController = new AbortController(); + const abortListener = token.onCancellationRequested(() => { + abortController.abort(); + }); + + try { + // Make the HTTP request + const response = await this.fetcherService.fetch(this.urlOrRequestMetadata, { + method: 'POST', + headers: this.getExtraHeaders(), + body: JSON.stringify(body), + signal: abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + this.logService.error(`[FeimaEndpoint] Request failed: ${response.status} ${errorText}`); + return { + type: ChatFetchResponseType.Failed, + reason: `Qwen3 API error: ${response.status} ${errorText}`, + requestId, + serverRequestId: undefined, + }; + } + + // Get response text + const responseText = await response.text(); + const jsonResponse = JSON.parse(responseText); + + // Log the full response for debugging + this.logService.debug(`[FeimaEndpoint] Response: ${JSON.stringify(jsonResponse, null, 2)}`); + + // Extract the assistant message (OpenAI format) + const choice = jsonResponse?.choices?.[0]; + const message = choice?.message; + const assistantMessage = message?.content || ''; + const toolCalls = message?.tool_calls; + + // Prepare the response delta + const delta: IResponseDelta = { text: assistantMessage }; + + // Convert OpenAI tool_calls format to Copilot's copilotToolCalls format + if (toolCalls && Array.isArray(toolCalls)) { + delta.copilotToolCalls = toolCalls.map((tc: { id: string; function?: { name?: string; arguments?: string } }): ICopilotToolCall => ({ + id: tc.id, + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}', + })); + this.logService.debug(`[FeimaEndpoint] Converted ${toolCalls.length} tool calls to copilotToolCalls format`); + } + + // Call the finish callback with the complete delta + if (options.finishedCb) { + await options.finishedCb(assistantMessage, 0, delta); + } + + return { + type: ChatFetchResponseType.Success, + requestId, + serverRequestId: jsonResponse.id, + value: assistantMessage, + usage: jsonResponse.usage, + resolvedModel: jsonResponse.model || this.model, + }; + } finally { + abortListener.dispose(); + } + } catch (error) { + this.logService.error(error instanceof Error ? error : new Error(String(error)), '[FeimaEndpoint] Request error'); + return { + type: ChatFetchResponseType.Failed, + reason: `Qwen3 API error: ${error instanceof Error ? error.message : String(error)}`, + requestId, + serverRequestId: undefined, + }; + } + } + + async processResponseFromChatEndpoint( + telemetryService: ITelemetryService, + logService: ILogService, + response: Response, + expectedNumChoices: number, + finishCallback: FinishedCallback, + telemetryData: TelemetryData, + cancellationToken?: CancellationToken + ): Promise> { + // Not used in our simplified implementation + // This method is part of IChatEndpoint interface but we handle responses directly in makeChatRequest2 + logService.warn('[FeimaEndpoint] processResponseFromChatEndpoint called but not implemented'); + throw new Error('processResponseFromChatEndpoint not implemented for FeimaEndpoint'); + } + + cloneWithTokenOverride(modelMaxPromptTokens: number): IChatEndpoint { + // Create a shallow copy with overridden token limit + return Object.create(this, { + modelMaxPromptTokens: { value: modelMaxPromptTokens, writable: false, enumerable: true } + }); + } +} diff --git a/src/extension/feimaModels/vscode-node/feimaModelProvider.ts b/src/extension/feimaModels/vscode-node/feimaModelProvider.ts new file mode 100644 index 0000000000..f1b0aaa0ae --- /dev/null +++ b/src/extension/feimaModels/vscode-node/feimaModelProvider.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IChatEndpoint } from '../../../platform/networking/common/networking'; +import { CopilotLanguageModelWrapper } from '../../conversation/vscode-node/languageModelAccess'; + +/** + * Dummy authentication provider ID - must match the ID used in DummyAuthProvider + */ +const FEIMA_AUTH_PROVIDER_ID = 'feima-authentication'; + +/** + * Dummy language model provider that returns mock responses. + * This is for PoC purposes to test chat functionality without real AI models. + */ +export class FeimaModelProvider implements vscode.LanguageModelChatProvider { + private readonly _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeLanguageModelChatInformation = this._onDidChange.event; + + constructor( + private readonly qwen3Endpoint: IChatEndpoint | undefined, + private readonly lmWrapper: CopilotLanguageModelWrapper | undefined + ) { } + + /** + * Fire a change event to notify VS Code that model information has changed + */ + public fireChangeEvent(): void { + this._onDidChange.fire(); + } + + /** + * Provide available dummy models + */ + async provideLanguageModelChatInformation( + _options: { silent: boolean }, + _token: vscode.CancellationToken + ): Promise { + console.log('[FeimaModelProvider] provideLanguageModelChatInformation called'); + + // Check if user is authenticated with feima auth + const session = await vscode.authentication.getSession( + FEIMA_AUTH_PROVIDER_ID, + [], + { createIfNone: false, silent: true } + ); + + if (!session) { + console.log('[FeimaModelProvider] No feima auth session found - returning empty models'); + return []; + } + + console.log('[FeimaModelProvider] Dummy auth session found - returning models'); + const models: vscode.LanguageModelChatInformation[] = [ + { + id: 'feima-fast', + name: 'Feima Fast Model', + family: 'feima-fast', + tooltip: 'A fast dummy model for testing (requires feima auth)', + detail: '1x', + maxInputTokens: 100000, + maxOutputTokens: 4096, + version: '1.0.0', + isUserSelectable: true, + capabilities: { + toolCalling: true + } + // Note: No authProviderId - makes this model universally accessible + }, + { + id: 'feima-smart', + name: 'Feima Smart Model', + family: 'feima-smart', + tooltip: 'A smarter dummy model for testing (requires feima auth)', + detail: '2x', + maxInputTokens: 200000, + maxOutputTokens: 8192, + version: '1.0.0', + isUserSelectable: true, + capabilities: { + toolCalling: true + } + // Note: Authentication is checked in provideLanguageModelChatInformation() + }, + ]; + + // Add Qwen3 Coder model if endpoint is configured + if (this.qwen3Endpoint) { + console.log('[FeimaModelProvider] Adding Qwen3 Coder model to list'); + models.push({ + id: 'qwen3-coder-plus', + name: 'Qwen3 Coder', + family: 'qwen3', + tooltip: 'Real Qwen3 Coder LLM (OpenAI-compatible API)', + detail: 'Real LLM', + maxInputTokens: this.qwen3Endpoint.modelMaxPromptTokens, + maxOutputTokens: this.qwen3Endpoint.maxOutputTokens, + version: '1.0.0', + isUserSelectable: true, + capabilities: { + toolCalling: this.qwen3Endpoint.supportsToolCalls + } + }); + } else { + console.log('[FeimaModelProvider] Qwen3 endpoint NOT configured - model will not be available'); + } + console.log('[FeimaModelProvider] Returning', models.length, 'models:', models.map(m => m.id).join(', ')); + return models; + } + + /** + * Provide chat responses - returns dummy text with simulated delay + */ + async provideLanguageModelChatResponse( + model: vscode.LanguageModelChatInformation, + messages: vscode.LanguageModelChatMessage[], + options: vscode.ProvideLanguageModelChatResponseOptions, + progress: vscode.Progress, + token: vscode.CancellationToken + ): Promise { + + // Use real Qwen3 LLM for qwen3-coder-plus model + if (model.id === 'qwen3-coder-plus' && this.qwen3Endpoint && this.lmWrapper) { + console.log('[FeimaModelProvider] Using real Qwen3 LLM for qwen3-coder-plus'); + return this.lmWrapper.provideLanguageModelResponse( + this.qwen3Endpoint, + messages, + options, + 'GitHub.copilot-chat', // extensionId - must be the actual extension ID from package.json + progress, + token + ); + } + + console.log('[FeimaModelProvider] Using feima response for model:', model.id); + + // For other models, use feima responses + // Check for cancellation + if (token.isCancellationRequested) { + return; + } + + // Simulate processing delay + await new Promise(resolve => setTimeout(resolve, 500)); + + if (token.isCancellationRequested) { + return; + } + + // Get the last user message + const lastMessage = messages[messages.length - 1]; + const userMessageText = typeof lastMessage.content === 'string' + ? lastMessage.content + : lastMessage.content.map(part => + part instanceof vscode.LanguageModelTextPart ? part.value : '[non-text]' + ).join(''); + + // Generate feima response + const responseText = this.generateDummyResponse(model, userMessageText, messages.length); + + // Stream the response word by word for realistic effect + const words = responseText.split(' '); + for (let i = 0; i < words.length; i++) { + if (token.isCancellationRequested) { + return; + } + + const word = i === 0 ? words[i] : ' ' + words[i]; + progress.report(new vscode.LanguageModelTextPart(word)); + + // Small delay between words for streaming effect + await new Promise(resolve => setTimeout(resolve, 30)); + } + } + + /** + * Provide token count estimation + */ + async provideTokenCount( + _model: vscode.LanguageModelChatInformation, + text: string | vscode.LanguageModelChatMessage, + _token: vscode.CancellationToken + ): Promise { + // Simple estimation: ~4 characters per token + if (typeof text === 'string') { + return Math.ceil(text.length / 4); + } + + // For messages, count all text content + const content = text.content; + + // Sum up all text parts + let totalLength = 0; + for (const part of content) { + // Check if it's a LanguageModelTextPart by checking for value property + if ('value' in part && typeof part.value === 'string') { + totalLength += part.value.length; + } + } + return Math.ceil(totalLength / 4); + } + + /** + * Generate a feima response based on the model and input + */ + private generateDummyResponse( + model: vscode.LanguageModelChatInformation, + userMessage: string, + messageCount: number + ): string { + const responses = [ + `Hello! I'm the ${model.name}, a dummy AI model for testing. You said: "${userMessage.substring(0, 50)}${userMessage.length > 50 ? '...' : ''}"`, + `This is a simulated response from ${model.name}. I received ${messageCount} message(s) in this conversation.`, + `I'm ${model.name}, processing your request about "${userMessage.substring(0, 40)}${userMessage.length > 40 ? '...' : ''}". This is a mock response for PoC purposes.`, + `${model.name} here! I understand you're asking about "${userMessage.substring(0, 30)}${userMessage.length > 30 ? '...' : ''}". Let me provide a feima response for testing.` + ]; + + // Pick a response based on message length + const index = userMessage.length % responses.length; + return responses[index]; + } +} diff --git a/src/extension/feimaProviders/vscode-node/feimaProvidersContribution.ts b/src/extension/feimaProviders/vscode-node/feimaProvidersContribution.ts new file mode 100644 index 0000000000..879adca1e9 --- /dev/null +++ b/src/extension/feimaProviders/vscode-node/feimaProvidersContribution.ts @@ -0,0 +1,300 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { ILogService } from '../../../platform/log/common/logService'; +import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { ITokenizerProvider } from '../../../platform/tokenizer/node/tokenizer'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IAuthorizationServerMetadata } from '../../../util/vs/base/common/oauth'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { IExtensionContribution } from '../../common/contributions'; +import { CopilotLanguageModelWrapper } from '../../conversation/vscode-node/languageModelAccess'; +import { IOAuth2Config, OAuth2Service } from '../../feimaAuth/common/oauth2Service'; +import { FeimaAuthProvider } from '../../feimaAuth/vscode-node/feimaAuthProvider'; +import { FeimaEndpoint } from '../../feimaAuth/vscode-node/feimaEndpoint'; +import { FeimaModelProvider } from '../../feimaModels/vscode-node/feimaModelProvider'; + +// Context key for tracking dummy auth sign-in state +const FEIMA_AUTH_SIGNED_IN_KEY = 'github.copilot.feimaAuth.signedIn'; + +/** + * Contribution that registers the dummy authentication provider and dummy model provider. + * Uses OAuth2 + PKCE flow for authentication. + */ +export class FeimaProvidersContribution extends Disposable implements IExtensionContribution { + + readonly id = 'feimaProviders'; + private readonly authProvider: FeimaAuthProvider; + private readonly modelProvider: FeimaModelProvider; + + constructor( + @IFetcherService private readonly fetcherService: IFetcherService, + @IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext, + @ITokenizerProvider private readonly tokenizerProvider: ITokenizerProvider, + @ILogService private readonly logService: ILogService, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(); + + console.log('[FeimaProviders] Starting registration...'); + + // Initialize context key to false immediately (before checking sessions) + // This ensures the menu item is visible from the start + vscode.commands.executeCommand('setContext', FEIMA_AUTH_SIGNED_IN_KEY, false); + + // Configure OAuth2 + // TODO: Move to configuration or environment variables + const oauth2Config: IOAuth2Config = this.getOAuth2Config(); + const oauth2Service = new OAuth2Service(oauth2Config, this.fetcherService); + + // Initialize context key for sign-in state + const updateSignInContext = async () => { + const sessions = await vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: false, silent: true } + ); + const isSignedIn = !!sessions; + await vscode.commands.executeCommand('setContext', FEIMA_AUTH_SIGNED_IN_KEY, isSignedIn); + }; + + // Register authentication provider + this.authProvider = new FeimaAuthProvider(this.context, oauth2Config, oauth2Service); + + // Register URI handler for OAuth callbacks + this._register(vscode.window.registerUriHandler(this.authProvider)); + + this._register( + vscode.authentication.registerAuthenticationProvider( + 'feima-authentication', + 'Feima Auth', + this.authProvider, + { supportsMultipleAccounts: false } + ) + ); + + // Function to request session access (adds menu item to Accounts menu) + const requestSessionAccess = () => { + vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: true } + ).then(undefined, () => { + // Ignore error if user doesn't sign in immediately + // The menu item will remain available + }); + }; + + // Update context key when sessions change + this._register( + vscode.authentication.onDidChangeSessions(async (e: vscode.AuthenticationSessionsChangeEvent) => { + if (e.provider.id === 'feima-authentication') { + + // Check current session state + const sessions = await vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: false, silent: true } + ); + const isSignedIn = !!sessions; + await vscode.commands.executeCommand('setContext', FEIMA_AUTH_SIGNED_IN_KEY, isSignedIn); + + // Fire model change event to update model availability + this.modelProvider.fireChangeEvent(); + + // If no session exists (sign-out), request session access again + // to re-add the sign-in menu item to Accounts menu + if (!isSignedIn) { + requestSessionAccess(); + } + } + }) + ); + + // Set initial context key state + updateSignInContext(); + + // Request session access to add sign-in menu item to Accounts menu + // By passing createIfNone: true, VS Code will automatically add a menu entry + // in the Accounts menu for signing in (with a numbered badge on the Accounts icon) + requestSessionAccess(); + + // Create Feima endpoint if configured + let qwen3Endpoint: FeimaEndpoint | undefined; + let lmWrapper: CopilotLanguageModelWrapper | undefined; + + const qwen3ApiKey = process.env.QWEN3_API_KEY; + const qwen3BaseUrl = process.env.QWEN3_BASE_URL || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + + if (qwen3ApiKey) { + qwen3Endpoint = new FeimaEndpoint( + qwen3ApiKey, + qwen3BaseUrl, + this.tokenizerProvider, + this.fetcherService, + this.logService + ); + lmWrapper = this.instantiationService.createInstance(CopilotLanguageModelWrapper); + } else { + console.log('[FeimaProviders] QWEN3_API_KEY not found - qwen3-coder-plus model will not be available'); + } + + // Register language model provider + this.modelProvider = new FeimaModelProvider(qwen3Endpoint, lmWrapper); + this._register( + vscode.lm.registerLanguageModelChatProvider('feima', this.modelProvider) + ); + + // Fire the change event after a short delay to notify VS Code + setTimeout(() => { + console.log('[FeimaProviders] Firing model information change event'); + this.modelProvider.fireChangeEvent(); + }, 1000); + + // Register the Sign In action (similar to ChatSetupFromAccountsAction) + this._register(this.registerSignInAction()); + + // Register other commands + this._register(this.registerOtherCommands()); + + } + + /** + * Register the Sign In with Dummy action that appears in the Accounts menu. + * This is similar to ChatSetupFromAccountsAction but for the dummy auth provider. + */ + private registerSignInAction(): vscode.Disposable { + return vscode.commands.registerCommand('feima.signIn', async () => { + try { + console.log('[FeimaProviders] Sign in action triggered'); + + // Request a session - this will trigger createSession if no session exists + const session = await vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: true } + ); + + if (session) { + await vscode.commands.executeCommand('setContext', FEIMA_AUTH_SIGNED_IN_KEY, true); + vscode.window.showInformationMessage( + `โœ… Dummy authentication successful! Signed in as: ${session.account.label}` + ); + } + } catch (error) { + vscode.window.showErrorMessage( + `Failed to sign in with Feima Auth: ${error}` + ); + } + }); + } + + private registerOtherCommands(): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + + // Register command to sign out + disposables.push( + vscode.commands.registerCommand('feima.signOut', async () => { + try { + const session = await vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: false, silent: true } + ); + + if (session) { + const confirmed = await vscode.window.showInformationMessage( + `Sign out of Feima Auth (${session.account.label})?`, + 'Sign Out', + 'Cancel' + ); + + if (confirmed === 'Sign Out') { + // Directly call the provider's removeSession method + // This will fire onDidChangeSessions which updates the context key + await this.authProvider.removeSession(session.id); + console.log(`[FeimaProviders] Session ${session.id} removed`); + vscode.window.showInformationMessage('โœ… Signed out of Feima Auth'); + } + } else { + vscode.window.showInformationMessage('No active Feima Auth session'); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to sign out: ${error}`); + } + }) + ); + + // Register debug command to list available models + disposables.push( + vscode.commands.registerCommand('feima.listModels', async () => { + try { + const models = await vscode.lm.selectChatModels(); + console.log('[FeimaProviders] Available models from VS Code:', models); + const modelInfo = models.map(m => `${m.id} (${m.vendor}) - ${m.name}`).join('\n'); + vscode.window.showInformationMessage( + `Found ${models.length} model(s):\n${modelInfo || 'No models found'}` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to list models: ${error}`); + } + }) + ); + + // Register debug command to check context key state + disposables.push( + vscode.commands.registerCommand('feima.checkContextKey', async () => { + const sessions = await vscode.authentication.getSession( + 'feima-authentication', + [], + { createIfNone: false, silent: true } + ); + const isSignedIn = !!sessions; + vscode.window.showInformationMessage( + `Context key ${FEIMA_AUTH_SIGNED_IN_KEY} should be: ${isSignedIn}\nSession exists: ${!!sessions}` + ); + console.log(`[FeimaProviders] Context key check - isSignedIn: ${isSignedIn}, session:`, sessions); + }) + ); + + return vscode.Disposable.from(...disposables); + } + + /** + * Get OAuth2 configuration. + * TODO: Move to VS Code settings or environment variables for production use. + */ + private getOAuth2Config(): IOAuth2Config { + // Example configuration for a generic OAuth2 provider + // This can be configured for AWS Cognito, Auth0, Okta, Azure AD, etc. + const serverMetadata: IAuthorizationServerMetadata = { + issuer: process.env.OAUTH_ISSUER || 'https://example.com', + authorization_endpoint: process.env.OAUTH_AUTH_ENDPOINT || 'https://example.com/oauth2/authorize', + token_endpoint: process.env.OAUTH_TOKEN_ENDPOINT || 'https://example.com/oauth2/token', + revocation_endpoint: process.env.OAUTH_REVOCATION_ENDPOINT, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['openid', 'email', 'profile'] + }; + + // For remote/WSL scenarios, the callback URI is transformed by vscode.env.asExternalUri() + // Local: vscode://github.copilot-chat/oauth/callback + // Remote/WSL: https://vscode.dev/redirect?url=vscode://github.copilot-chat/oauth/callback + return { + clientId: process.env.OAUTH_CLIENT_ID || 'your-client-id', + clientSecret: process.env.OAUTH_CLIENT_SECRET, // Optional for public clients with PKCE + serverMetadata, + redirectUri: `${vscode.env.uriScheme}://github.copilot-chat/oauth/callback`, // Base URI - transformed for remote + scopes: ['openid', 'email', 'profile'], + additionalAuthParams: { + // Optional: add provider-specific parameters + // For example, Azure AD might need 'prompt': 'select_account' + } + }; + } +} diff --git a/src/platform/authentication/vscode-node/authenticationService.ts b/src/platform/authentication/vscode-node/authenticationService.ts index b3b0661aae..e13be11b38 100644 --- a/src/platform/authentication/vscode-node/authenticationService.ts +++ b/src/platform/authentication/vscode-node/authenticationService.ts @@ -26,8 +26,10 @@ export class AuthenticationService extends BaseAuthenticationService { super(logService, tokenStore, tokenManager, configurationService); this._register(authentication.onDidChangeSessions((e) => { if (e.provider.id === authProviderId(configurationService) || e.provider.id === AuthProviderId.Microsoft) { - this._logService.debug('Handling onDidChangeSession.'); + this._logService.debug(`Handling onDidChangeSession for provider: ${e.provider.id}`); void this._handleAuthChangeEvent(); + } else { + this._logService.debug(`Ignoring onDidChangeSession for provider: ${e.provider.id}`); } })); this._register(this._domainService.onDidChangeDomains((e) => { diff --git a/src/platform/telemetry/common/logTelemetryService.ts b/src/platform/telemetry/common/logTelemetryService.ts new file mode 100644 index 0000000000..12f8430091 --- /dev/null +++ b/src/platform/telemetry/common/logTelemetryService.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService, TelemetryDestination, TelemetryEventMeasurements, TelemetryEventProperties } from './telemetry'; + +/** + * A telemetry service implementation that logs all telemetry events to the console. + * Useful for development and debugging to see what telemetry is being sent. + */ +export class LogTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + + private readonly enableColors = true; + private readonly colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + + // Foreground colors + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + + // Background colors + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + }; + + constructor() { + console.log(this.colorize(`${this.colors.bright}${this.colors.cyan}`, '='.repeat(80))); + console.log(this.colorize(`${this.colors.bright}${this.colors.cyan}`, '๐Ÿ” LogTelemetryService initialized - All telemetry will be logged to console')); + console.log(this.colorize(`${this.colors.bright}${this.colors.cyan}`, '='.repeat(80))); + } + + dispose(): void { + console.log(this.colorize(`${this.colors.dim}${this.colors.cyan}`, '๐Ÿ”Œ LogTelemetryService disposed')); + } + + private colorize(colorCode: string, text: string): string { + if (!this.enableColors) { + return text; + } + return `${colorCode}${text}${this.colors.reset}`; + } + + private formatProperties(properties?: TelemetryEventProperties): string { + if (!properties || Object.keys(properties).length === 0) { + return this.colorize(this.colors.dim, ' (no properties)'); + } + + let result = ''; + for (const [key, value] of Object.entries(properties)) { + // Handle TelemetryTrustedValue + const displayValue = typeof value === 'object' && value !== null && 'value' in value + ? value.value + : value; + const truncated = String(displayValue).length > 100 + ? String(displayValue).substring(0, 97) + '...' + : displayValue; + result += `\n ${this.colorize(this.colors.cyan, key)}: ${this.colorize(this.colors.white, String(truncated))}`; + } + return result; + } + + private formatMeasurements(measurements?: TelemetryEventMeasurements): string { + if (!measurements || Object.keys(measurements).length === 0) { + return this.colorize(this.colors.dim, ' (no measurements)'); + } + + let result = ''; + for (const [key, value] of Object.entries(measurements)) { + result += `\n ${this.colorize(this.colors.magenta, key)}: ${this.colorize(this.colors.yellow, String(value))}`; + } + return result; + } + + private logEvent( + eventType: string, + destination: string, + eventName: string, + properties?: TelemetryEventProperties, + measurements?: TelemetryEventMeasurements + ): void { + const timestamp = new Date().toISOString(); + const typeColor = eventType.includes('Error') ? this.colors.red : this.colors.green; + const destColor = destination.includes('MSFT') ? this.colors.blue : this.colors.magenta; + + console.log(''); + console.log(this.colorize(`${this.colors.bright}${typeColor}`, `๐Ÿ“Š ${eventType}`)); + console.log(this.colorize(this.colors.dim, ` Time: ${timestamp}`)); + console.log(this.colorize(destColor, ` Destination: ${destination}`)); + console.log(this.colorize(`${this.colors.bright}${this.colors.yellow}`, ` Event: ${eventName}`)); + + if (properties && Object.keys(properties).length > 0) { + console.log(this.colorize(`${this.colors.bright}${this.colors.cyan}`, ' Properties:')); + console.log(this.formatProperties(properties)); + } + + if (measurements && Object.keys(measurements).length > 0) { + console.log(this.colorize(`${this.colors.bright}${this.colors.magenta}`, ' Measurements:')); + console.log(this.formatMeasurements(measurements)); + } + + console.log(this.colorize(this.colors.dim, ' ' + '-'.repeat(78))); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('Internal MSFT Event', 'Microsoft (Internal)', eventName, properties, measurements); + } + + sendMSFTTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('MSFT Event', 'Microsoft (External)', eventName, properties, measurements); + } + + sendMSFTTelemetryErrorEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('MSFT Error Event', 'Microsoft (External)', eventName, properties, measurements); + } + + sendGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('GitHub Event', 'GitHub (Standard)', eventName, properties, measurements); + } + + sendGHTelemetryErrorEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('GitHub Error Event', 'GitHub (Standard)', eventName, properties, measurements); + } + + sendGHTelemetryException(maybeError: unknown, origin: string): void { + const timestamp = new Date().toISOString(); + console.log(''); + console.log(this.colorize(`${this.colors.bright}${this.colors.bgRed}${this.colors.white}`, '๐Ÿ’ฅ GitHub Exception')); + console.log(this.colorize(this.colors.dim, ` Time: ${timestamp}`)); + console.log(this.colorize(this.colors.magenta, ` Destination: GitHub (Standard)`)); + console.log(this.colorize(`${this.colors.bright}${this.colors.red}`, ` Origin: ${origin}`)); + console.log(this.colorize(this.colors.yellow, ' Error:')); + + if (maybeError instanceof Error) { + console.log(this.colorize(this.colors.red, ` Name: ${maybeError.name}`)); + console.log(this.colorize(this.colors.red, ` Message: ${maybeError.message}`)); + if (maybeError.stack) { + const stackLines = maybeError.stack.split('\n').slice(0, 5); // First 5 lines + console.log(this.colorize(this.colors.dim, ' Stack (first 5 lines):')); + stackLines.forEach(line => console.log(this.colorize(this.colors.dim, ` ${line}`))); + } + } else { + console.log(this.colorize(this.colors.red, ` ${String(maybeError)}`)); + } + + console.log(this.colorize(this.colors.dim, ' ' + '-'.repeat(78))); + } + + sendTelemetryEvent(eventName: string, destination: TelemetryDestination, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + const destLabel = destination.microsoft ? 'Microsoft' : 'GitHub'; + const destDetail = destination.github && typeof destination.github === 'object' + ? `GitHub (prefix: ${destination.github.eventNamePrefix})` + : destLabel; + this.logEvent('Generic Event', destDetail, eventName, properties, measurements); + } + + sendTelemetryErrorEvent(eventName: string, destination: TelemetryDestination, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + const destLabel = destination.microsoft ? 'Microsoft' : 'GitHub'; + const destDetail = destination.github && typeof destination.github === 'object' + ? `GitHub (prefix: ${destination.github.eventNamePrefix})` + : destLabel; + this.logEvent('Generic Error Event', destDetail, eventName, properties, measurements); + } + + setSharedProperty(name: string, value: string): void { + console.log(this.colorize(this.colors.cyan, `๐Ÿ”ง Set Shared Property: ${name} = ${value}`)); + } + + setAdditionalExpAssignments(expAssignments: string[]): void { + console.log(this.colorize(this.colors.cyan, `๐Ÿงช Set Experiment Assignments: [${expAssignments.join(', ')}]`)); + } + + postEvent(eventName: string, props: Map): void { + const timestamp = new Date().toISOString(); + console.log(''); + console.log(this.colorize(`${this.colors.bright}${this.colors.blue}`, `๐Ÿ“ฎ Experimentation Event`)); + console.log(this.colorize(this.colors.dim, ` Time: ${timestamp}`)); + console.log(this.colorize(`${this.colors.bright}${this.colors.yellow}`, ` Event: ${eventName}`)); + + if (props.size > 0) { + console.log(this.colorize(`${this.colors.bright}${this.colors.cyan}`, ' Properties:')); + props.forEach((value, key) => { + console.log(` ${this.colorize(this.colors.cyan, key)}: ${this.colorize(this.colors.white, value)}`); + }); + } + + console.log(this.colorize(this.colors.dim, ' ' + '-'.repeat(78))); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('GitHub Enhanced Event', 'GitHub (Enhanced)', eventName, properties, measurements); + } + + sendEnhancedGHTelemetryErrorEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.logEvent('GitHub Enhanced Error Event', 'GitHub (Enhanced)', eventName, properties, measurements); + } +} diff --git a/src/util/common/pkce.ts b/src/util/common/pkce.ts new file mode 100644 index 0000000000..4d303921dd --- /dev/null +++ b/src/util/common/pkce.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * PKCE (Proof Key for Code Exchange) utilities for OAuth2 authorization code flow + * Implements RFC 7636 - https://tools.ietf.org/html/rfc7636 + * + * Based on VS Code's microsoft-authentication extension implementation. + */ + +function dec2hex(dec: number): string { + return ('0' + dec.toString(16)).slice(-2); +} + +function sha256(plain: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(plain); + return crypto.subtle.digest('SHA-256', data); +} + +function base64urlencode(a: ArrayBuffer): string { + let str = ''; + const bytes = new Uint8Array(a); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + str += String.fromCharCode(bytes[i]); + } + // Use btoa for base64 encoding (browser-compatible) + return btoa(str) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Generate cryptographically secure random string for PKCE code verifier + * Generates 56 characters (28 random bytes as hex) + */ +export function generateCodeVerifier(): string { + const array = new Uint32Array(56 / 2); + crypto.getRandomValues(array); + return Array.from(array, dec2hex).join(''); +} + +/** + * Generate code challenge from code verifier using SHA-256 + * code_challenge = BASE64URL(SHA256(ASCII(code_verifier))) + */ +export async function generateCodeChallenge(codeVerifier: string): Promise { + const hashed = await sha256(codeVerifier); + return base64urlencode(hashed); +} + +/** + * Generate random state parameter for CSRF protection + */ +export function generateState(): string { + return generateCodeVerifier(); +} + +/** + * Generate random nonce for OpenID Connect + */ +export function generateNonce(): string { + return generateCodeVerifier(); +} diff --git a/src/util/vs/base/common/oauth.ts b/src/util/vs/base/common/oauth.ts new file mode 100644 index 0000000000..95e1f42a4f --- /dev/null +++ b/src/util/vs/base/common/oauth.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Decode base64url string (JWT format) + */ +function decodeBase64(input: string): string { +// Convert base64url to base64 +const base64 = input +.replace(/-/g, '+') +.replace(/_/g, '/'); + +// Decode base64 to string +try { +return atob(base64); +} catch { +throw new Error('Invalid base64 encoding'); +} +} + +/** + * Base OAuth 2.0 error codes as specified in RFC 6749. + */ +export const enum AuthorizationErrorType { +InvalidRequest = 'invalid_request', +InvalidClient = 'invalid_client', +InvalidGrant = 'invalid_grant', +UnauthorizedClient = 'unauthorized_client', +UnsupportedGrantType = 'unsupported_grant_type', +InvalidScope = 'invalid_scope' +} + +/** + * Metadata about an OAuth 2.0 Authorization Server. + */ +export interface IAuthorizationServerMetadata { +issuer: string; +authorization_endpoint?: string; +token_endpoint?: string; +revocation_endpoint?: string; +scopes_supported?: string[]; +response_types_supported: string[]; +grant_types_supported?: string[]; +code_challenge_methods_supported?: string[]; +} + +/** + * Token response from OAuth 2.0 authorization server. + */ +export interface IAuthorizationTokenResponse { +access_token: string; +token_type: string; +expires_in?: number; +refresh_token?: string; +scope?: string; +id_token?: string; +} + +/** + * Standard JWT claims as defined in RFC 7519. + */ +export interface IAuthorizationJWTClaims { +iss?: string; +sub?: string; +aud?: string | string[]; +exp?: number; +nbf?: number; +iat?: number; +jti?: string; +email?: string; +email_verified?: boolean; +name?: string; +given_name?: string; +family_name?: string; +middle_name?: string; +nickname?: string; +preferred_username?: string; +profile?: string; +picture?: string; +website?: string; +gender?: string; +birthdate?: string; +zoneinfo?: string; +locale?: string; +phone_number?: string; +phone_number_verified?: boolean; +address?: { +formatted?: string; +street_address?: string; +locality?: string; +region?: string; +postal_code?: string; +country?: string; +}; +updated_at?: number; +[key: string]: any; +} + +/** + * Parse JWT token and extract claims. + */ +export function getClaimsFromJWT(token: string): IAuthorizationJWTClaims { +const parts = token.split('.'); +if (parts.length !== 3) { +throw new Error('Invalid JWT token format: token must have three parts separated by dots'); +} + +const [_header, payload, _signature] = parts; + +try { +const decodedPayload = JSON.parse(decodeBase64(payload)); +if (typeof decodedPayload !== 'object') { +throw new Error('Invalid JWT token format: payload is not a JSON object'); +} + +return decodedPayload; +} catch (e) { +if (e instanceof Error) { +throw new Error(`Failed to parse JWT token: ${e.message}`); +} +throw new Error('Failed to parse JWT token'); +} +}