deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
326
packages/ai-copilot/src/browser/copilot-auth-dialog.tsx
Normal file
326
packages/ai-copilot/src/browser/copilot-auth-dialog.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { DialogProps, DialogError } from '@theia/core/lib/browser/dialogs';
|
||||
import { ReactDialog } from '@theia/core/lib/browser/dialogs/react-dialog';
|
||||
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
|
||||
import { WindowService } from '@theia/core/lib/browser/window/window-service';
|
||||
import { CommandService, nls } from '@theia/core';
|
||||
import { CopilotAuthService, DeviceCodeResponse } from '../common/copilot-auth-service';
|
||||
|
||||
const OPEN_AI_CONFIG_VIEW_COMMAND = 'aiConfiguration:open';
|
||||
|
||||
type AuthDialogState = 'loading' | 'waiting' | 'polling' | 'success' | 'error';
|
||||
|
||||
@injectable()
|
||||
export class CopilotAuthDialogProps extends DialogProps {
|
||||
enterpriseUrl?: string;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class CopilotAuthDialog extends ReactDialog<boolean> {
|
||||
|
||||
@inject(CopilotAuthService)
|
||||
protected readonly authService: CopilotAuthService;
|
||||
|
||||
@inject(ClipboardService)
|
||||
protected readonly clipboardService: ClipboardService;
|
||||
|
||||
@inject(WindowService)
|
||||
protected readonly windowService: WindowService;
|
||||
|
||||
@inject(CommandService)
|
||||
protected readonly commandService: CommandService;
|
||||
|
||||
protected state: AuthDialogState = 'loading';
|
||||
protected deviceCodeResponse?: DeviceCodeResponse;
|
||||
protected errorMessage?: string;
|
||||
protected copied = false;
|
||||
|
||||
static readonly ID = 'copilot-auth-dialog';
|
||||
|
||||
constructor(
|
||||
@inject(CopilotAuthDialogProps) protected override readonly props: CopilotAuthDialogProps
|
||||
) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.titleNode.textContent = nls.localize('theia/ai/copilot/auth/title', 'Sign in to GitHub Copilot');
|
||||
this.appendAcceptButton(nls.localize('theia/ai/copilot/auth/authorize', 'I have authorized'));
|
||||
this.appendCloseButton(nls.localizeByDefault('Cancel'));
|
||||
}
|
||||
|
||||
protected updateButtonStates(): void {
|
||||
const isPolling = this.state === 'polling';
|
||||
const isSuccess = this.state === 'success';
|
||||
if (this.acceptButton) {
|
||||
this.acceptButton.disabled = isPolling || isSuccess;
|
||||
if (isSuccess) {
|
||||
this.acceptButton.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (this.closeButton) {
|
||||
if (isSuccess) {
|
||||
this.closeButton.textContent = nls.localizeByDefault('Close');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async open(): Promise<boolean | undefined> {
|
||||
this.initiateFlow();
|
||||
return super.open();
|
||||
}
|
||||
|
||||
override update(): void {
|
||||
super.update();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
protected async initiateFlow(): Promise<void> {
|
||||
try {
|
||||
this.state = 'loading';
|
||||
this.update();
|
||||
|
||||
this.deviceCodeResponse = await this.authService.initiateDeviceFlow(this.props.enterpriseUrl);
|
||||
this.state = 'waiting';
|
||||
this.update();
|
||||
} catch (error) {
|
||||
this.state = 'error';
|
||||
this.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async accept(): Promise<void> {
|
||||
if (this.state !== 'waiting' || !this.deviceCodeResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = 'polling';
|
||||
this.update();
|
||||
|
||||
try {
|
||||
const success = await this.authService.pollForToken(
|
||||
this.deviceCodeResponse.device_code,
|
||||
this.deviceCodeResponse.interval,
|
||||
this.props.enterpriseUrl
|
||||
);
|
||||
|
||||
if (success) {
|
||||
this.state = 'success';
|
||||
this.update();
|
||||
} else {
|
||||
this.state = 'error';
|
||||
this.errorMessage = nls.localize('theia/ai/copilot/auth/expired',
|
||||
'Authorization expired or was denied. Please try again.');
|
||||
this.update();
|
||||
}
|
||||
} catch (error) {
|
||||
this.state = 'error';
|
||||
this.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
get value(): boolean {
|
||||
return this.state === 'success';
|
||||
}
|
||||
|
||||
protected override isValid(_value: boolean, _mode: DialogError): DialogError {
|
||||
if (this.state === 'error') {
|
||||
return this.errorMessage ?? 'An error occurred';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
protected handleCopyCode = async (): Promise<void> => {
|
||||
if (this.deviceCodeResponse) {
|
||||
await this.clipboardService.writeText(this.deviceCodeResponse.user_code);
|
||||
this.copied = true;
|
||||
this.update();
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
this.update();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
protected handleOpenUrl = (): void => {
|
||||
if (this.deviceCodeResponse) {
|
||||
this.windowService.openNewWindow(this.deviceCodeResponse.verification_uri, { external: true });
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): React.ReactNode {
|
||||
return (
|
||||
<div className="theia-copilot-auth-dialog-content">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected renderContent(): React.ReactNode {
|
||||
switch (this.state) {
|
||||
case 'loading':
|
||||
return this.renderLoading();
|
||||
case 'waiting':
|
||||
return this.renderWaiting();
|
||||
case 'polling':
|
||||
return this.renderPolling();
|
||||
case 'success':
|
||||
return this.renderSuccess();
|
||||
case 'error':
|
||||
return this.renderError();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected renderLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="theia-copilot-auth-state">
|
||||
<div className="theia-spin">
|
||||
<span className="codicon codicon-loading"></span>
|
||||
</div>
|
||||
<p>{nls.localize('theia/ai/copilot/auth/initiating', 'Initiating authentication...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected renderWaiting(): React.ReactNode {
|
||||
const response = this.deviceCodeResponse!;
|
||||
return (
|
||||
<div className="theia-copilot-auth-waiting">
|
||||
<p className="theia-copilot-auth-instructions">
|
||||
{nls.localize('theia/ai/copilot/auth/instructions',
|
||||
'To authorize Theia to use GitHub Copilot, visit the URL below and enter the code:')}
|
||||
</p>
|
||||
|
||||
<div className="theia-copilot-auth-code-section">
|
||||
<div className="theia-copilot-auth-code-display">
|
||||
<span className="theia-copilot-auth-code">{response.user_code}</span>
|
||||
<button
|
||||
className="theia-button secondary theia-copilot-copy-button"
|
||||
onClick={this.handleCopyCode}
|
||||
title={this.copied
|
||||
? nls.localize('theia/ai/copilot/auth/copied', 'Copied!')
|
||||
: nls.localize('theia/ai/copilot/auth/copyCode', 'Copy code')}
|
||||
>
|
||||
<span className={`codicon ${this.copied ? 'codicon-check' : 'codicon-copy'}`}></span>
|
||||
{nls.localizeByDefault('Copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theia-copilot-auth-url-section">
|
||||
<button
|
||||
className="theia-button theia-copilot-open-url-button"
|
||||
onClick={this.handleOpenUrl}
|
||||
>
|
||||
<span className="codicon codicon-link-external"></span>
|
||||
{nls.localize('theia/ai/copilot/auth/openGitHub', 'Open GitHub')}
|
||||
</button>
|
||||
<span className="theia-copilot-auth-url">{response.verification_uri}</span>
|
||||
</div>
|
||||
|
||||
<p className="theia-copilot-auth-hint">
|
||||
{nls.localize('theia/ai/copilot/auth/hint',
|
||||
'After entering the code and authorizing, click "I have authorized" below.')}
|
||||
</p>
|
||||
|
||||
<div className="theia-copilot-auth-privacy">
|
||||
<p className="theia-copilot-auth-privacy-text">
|
||||
{nls.localize('theia/ai/copilot/auth/privacy',
|
||||
'Theia is an open-source project. We only request access to your GitHub username ' +
|
||||
'to connect to GitHub Copilot services — no other data is accessed or stored.')}
|
||||
</p>
|
||||
<p className="theia-copilot-auth-tos-text">
|
||||
{nls.localize('theia/ai/copilot/auth/tos',
|
||||
'By signing in, you agree to the ')}
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/github-terms/github-terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={this.handleOpenTos}
|
||||
>
|
||||
{nls.localize('theia/ai/copilot/auth/tosLink', 'GitHub Terms of Service')}
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected handleOpenTos = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
this.windowService.openNewWindow('https://docs.github.com/en/site-policy/github-terms/github-terms-of-service', { external: true });
|
||||
};
|
||||
|
||||
protected renderPolling(): React.ReactNode {
|
||||
return (
|
||||
<div className="theia-copilot-auth-state">
|
||||
<div className="theia-spin">
|
||||
<span className="codicon codicon-loading"></span>
|
||||
</div>
|
||||
<p>{nls.localize('theia/ai/copilot/auth/verifying', 'Verifying authorization...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected renderSuccess(): React.ReactNode {
|
||||
return (
|
||||
<div className="theia-copilot-auth-state theia-copilot-auth-success">
|
||||
<span className="codicon codicon-check"></span>
|
||||
<p>{nls.localize('theia/ai/copilot/auth/success', 'Successfully signed in to GitHub Copilot!')}</p>
|
||||
<p className="theia-copilot-auth-success-hint">
|
||||
{nls.localize('theia/ai/copilot/auth/successHint',
|
||||
'If your GitHub account has access to Copilot, you can now configure Copilot language models in the ')}
|
||||
<a href="#" onClick={this.handleOpenAIConfig}>
|
||||
{nls.localize('theia/ai/copilot/auth/aiConfiguration', 'AI Configuration')}
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected handleOpenAIConfig = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
this.commandService.executeCommand(OPEN_AI_CONFIG_VIEW_COMMAND);
|
||||
};
|
||||
|
||||
protected handleRetry = (): void => {
|
||||
this.initiateFlow();
|
||||
};
|
||||
|
||||
protected renderError(): React.ReactNode {
|
||||
return (
|
||||
<div className="theia-copilot-auth-state theia-copilot-auth-error">
|
||||
<span className="codicon codicon-error"></span>
|
||||
<p>{this.errorMessage}</p>
|
||||
<button
|
||||
className="theia-button"
|
||||
onClick={this.handleRetry}
|
||||
>
|
||||
{nls.localizeByDefault('Try Again')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Command, CommandContribution, CommandRegistry, Disposable, DisposableCollection, PreferenceService } from '@theia/core';
|
||||
import { CopilotAuthService, CopilotAuthState } from '../common/copilot-auth-service';
|
||||
import { CopilotAuthDialog, CopilotAuthDialogProps } from './copilot-auth-dialog';
|
||||
import { COPILOT_ENTERPRISE_URL_PREF } from '../common/copilot-preferences';
|
||||
|
||||
export namespace CopilotCommands {
|
||||
export const SIGN_IN: Command = Command.toLocalizedCommand(
|
||||
{ id: 'copilot.signIn', label: 'Sign in to GitHub Copilot', category: 'Copilot' },
|
||||
'theia/ai/copilot/commands/signIn',
|
||||
'theia/ai/copilot/category'
|
||||
);
|
||||
|
||||
export const SIGN_OUT: Command = Command.toLocalizedCommand(
|
||||
{ id: 'copilot.signOut', label: 'Sign out of GitHub Copilot', category: 'Copilot' },
|
||||
'theia/ai/copilot/commands/signOut',
|
||||
'theia/ai/copilot/category'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command contribution for GitHub Copilot authentication commands.
|
||||
*/
|
||||
@injectable()
|
||||
export class CopilotCommandContribution implements CommandContribution, Disposable {
|
||||
|
||||
@inject(CopilotAuthService)
|
||||
protected readonly authService: CopilotAuthService;
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(CopilotAuthDialogProps)
|
||||
protected readonly dialogProps: CopilotAuthDialogProps;
|
||||
|
||||
@inject(CopilotAuthDialog)
|
||||
protected readonly authDialog: CopilotAuthDialog;
|
||||
|
||||
protected authState: CopilotAuthState = { isAuthenticated: false };
|
||||
protected readonly toDispose = new DisposableCollection();
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.authService.getAuthState().then(state => {
|
||||
this.authState = state;
|
||||
});
|
||||
|
||||
this.toDispose.push(this.authService.onAuthStateChanged(state => {
|
||||
this.authState = state;
|
||||
}));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
registerCommands(registry: CommandRegistry): void {
|
||||
registry.registerCommand(CopilotCommands.SIGN_IN, {
|
||||
execute: async () => {
|
||||
const enterpriseUrl = this.preferenceService.get<string>(COPILOT_ENTERPRISE_URL_PREF);
|
||||
this.dialogProps.enterpriseUrl = enterpriseUrl || undefined;
|
||||
const result = await this.authDialog.open();
|
||||
if (result) {
|
||||
this.authState = await this.authService.getAuthState();
|
||||
}
|
||||
},
|
||||
isEnabled: () => !this.authState.isAuthenticated
|
||||
});
|
||||
|
||||
registry.registerCommand(CopilotCommands.SIGN_OUT, {
|
||||
execute: async () => {
|
||||
await this.authService.signOut();
|
||||
},
|
||||
isEnabled: () => this.authState.isAuthenticated
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { PreferenceService } from '@theia/core';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { CopilotLanguageModelsManager, CopilotModelDescription, COPILOT_PROVIDER_ID } from '../common';
|
||||
import { COPILOT_MODELS_PREF, COPILOT_ENTERPRISE_URL_PREF } from '../common/copilot-preferences';
|
||||
import { AICorePreferences, PREFERENCE_NAME_MAX_RETRIES } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
|
||||
@injectable()
|
||||
export class CopilotFrontendApplicationContribution implements FrontendApplicationContribution {
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(CopilotLanguageModelsManager)
|
||||
protected readonly manager: CopilotLanguageModelsManager;
|
||||
|
||||
@inject(AICorePreferences)
|
||||
protected readonly aiCorePreferences: AICorePreferences;
|
||||
|
||||
protected prevModels: string[] = [];
|
||||
|
||||
onStart(): void {
|
||||
this.preferenceService.ready.then(() => {
|
||||
const models = this.preferenceService.get<string[]>(COPILOT_MODELS_PREF, []);
|
||||
this.manager.createOrUpdateLanguageModels(...models.map((modelId: string) => this.createCopilotModelDescription(modelId)));
|
||||
this.prevModels = [...models];
|
||||
|
||||
this.preferenceService.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === COPILOT_MODELS_PREF) {
|
||||
this.handleModelChanges(this.preferenceService.get<string[]>(COPILOT_MODELS_PREF, []));
|
||||
} else if (event.preferenceName === COPILOT_ENTERPRISE_URL_PREF) {
|
||||
this.manager.refreshModelsStatus();
|
||||
}
|
||||
});
|
||||
|
||||
this.aiCorePreferences.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === PREFERENCE_NAME_MAX_RETRIES) {
|
||||
this.updateAllModels();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected handleModelChanges(newModels: string[]): void {
|
||||
const oldModels = new Set(this.prevModels);
|
||||
const updatedModels = new Set(newModels);
|
||||
|
||||
const modelsToRemove = [...oldModels].filter(model => !updatedModels.has(model));
|
||||
const modelsToAdd = [...updatedModels].filter(model => !oldModels.has(model));
|
||||
|
||||
this.manager.removeLanguageModels(...modelsToRemove.map(model => `${COPILOT_PROVIDER_ID}/${model}`));
|
||||
this.manager.createOrUpdateLanguageModels(...modelsToAdd.map((modelId: string) => this.createCopilotModelDescription(modelId)));
|
||||
this.prevModels = newModels;
|
||||
}
|
||||
|
||||
protected updateAllModels(): void {
|
||||
const models = this.preferenceService.get<string[]>(COPILOT_MODELS_PREF, []);
|
||||
this.manager.createOrUpdateLanguageModels(...models.map((modelId: string) => this.createCopilotModelDescription(modelId)));
|
||||
}
|
||||
|
||||
protected createCopilotModelDescription(modelId: string): CopilotModelDescription {
|
||||
const id = `${COPILOT_PROVIDER_ID}/${modelId}`;
|
||||
const maxRetries = this.aiCorePreferences.get(PREFERENCE_NAME_MAX_RETRIES) ?? 3;
|
||||
|
||||
return {
|
||||
id,
|
||||
model: modelId,
|
||||
enableStreaming: true,
|
||||
supportsStructuredOutput: true,
|
||||
maxRetries
|
||||
};
|
||||
}
|
||||
}
|
||||
86
packages/ai-copilot/src/browser/copilot-frontend-module.ts
Normal file
86
packages/ai-copilot/src/browser/copilot-frontend-module.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import '../../src/browser/style/index.css';
|
||||
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { CommandContribution, Emitter, Event, nls, PreferenceContribution } from '@theia/core';
|
||||
import {
|
||||
FrontendApplicationContribution,
|
||||
RemoteConnectionProvider,
|
||||
ServiceConnectionProvider
|
||||
} from '@theia/core/lib/browser';
|
||||
import {
|
||||
CopilotLanguageModelsManager,
|
||||
COPILOT_LANGUAGE_MODELS_MANAGER_PATH,
|
||||
CopilotAuthService,
|
||||
COPILOT_AUTH_SERVICE_PATH,
|
||||
CopilotAuthServiceClient,
|
||||
CopilotAuthState
|
||||
} from '../common';
|
||||
import { CopilotPreferencesSchema } from '../common/copilot-preferences';
|
||||
import { CopilotFrontendApplicationContribution } from './copilot-frontend-application-contribution';
|
||||
import { CopilotCommandContribution } from './copilot-command-contribution';
|
||||
import { CopilotStatusBarContribution } from './copilot-status-bar-contribution';
|
||||
import { CopilotAuthDialog, CopilotAuthDialogProps } from './copilot-auth-dialog';
|
||||
|
||||
class CopilotAuthServiceClientImpl implements CopilotAuthServiceClient {
|
||||
protected readonly onAuthStateChangedEmitter = new Emitter<CopilotAuthState>();
|
||||
readonly onAuthStateChangedEvent: Event<CopilotAuthState> = this.onAuthStateChangedEmitter.event;
|
||||
onAuthStateChanged(state: CopilotAuthState): void {
|
||||
this.onAuthStateChangedEmitter.fire(state);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: CopilotPreferencesSchema });
|
||||
|
||||
bind(CopilotCommandContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(CopilotCommandContribution);
|
||||
|
||||
bind(CopilotStatusBarContribution).toSelf().inSingletonScope();
|
||||
bind(FrontendApplicationContribution).toService(CopilotStatusBarContribution);
|
||||
|
||||
bind(CopilotAuthDialogProps).toConstantValue({
|
||||
title: nls.localize('theia/ai/copilot/commands/signIn', 'Sign in to GitHub Copilot')
|
||||
});
|
||||
bind(CopilotAuthDialog).toSelf().inSingletonScope();
|
||||
|
||||
bind(CopilotFrontendApplicationContribution).toSelf().inSingletonScope();
|
||||
bind(FrontendApplicationContribution).toService(CopilotFrontendApplicationContribution);
|
||||
|
||||
bind(CopilotAuthServiceClientImpl).toConstantValue(new CopilotAuthServiceClientImpl());
|
||||
bind(CopilotAuthServiceClient).toService(CopilotAuthServiceClientImpl);
|
||||
|
||||
bind(CopilotLanguageModelsManager).toDynamicValue(ctx => {
|
||||
const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
return provider.createProxy<CopilotLanguageModelsManager>(COPILOT_LANGUAGE_MODELS_MANAGER_PATH);
|
||||
}).inSingletonScope();
|
||||
|
||||
bind(CopilotAuthService).toDynamicValue(ctx => {
|
||||
const provider = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
|
||||
const clientImpl = ctx.container.get(CopilotAuthServiceClientImpl);
|
||||
const proxy = provider.createProxy<CopilotAuthService>(COPILOT_AUTH_SERVICE_PATH, clientImpl);
|
||||
return new Proxy(proxy, {
|
||||
get(target: CopilotAuthService, prop: string | symbol, receiver: unknown): unknown {
|
||||
if (prop === 'onAuthStateChanged') {
|
||||
return clientImpl.onAuthStateChangedEvent;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
}) as CopilotAuthService;
|
||||
}).inSingletonScope();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar/status-bar-types';
|
||||
import { Disposable, DisposableCollection, nls } from '@theia/core';
|
||||
import { CopilotAuthService, CopilotAuthState } from '../common/copilot-auth-service';
|
||||
import { CopilotCommands } from './copilot-command-contribution';
|
||||
|
||||
const COPILOT_STATUS_BAR_ID = 'copilot-auth-status';
|
||||
|
||||
/**
|
||||
* Frontend contribution that displays GitHub Copilot authentication status in the status bar.
|
||||
*/
|
||||
@injectable()
|
||||
export class CopilotStatusBarContribution implements FrontendApplicationContribution, Disposable {
|
||||
|
||||
@inject(StatusBar)
|
||||
protected readonly statusBar: StatusBar;
|
||||
|
||||
@inject(CopilotAuthService)
|
||||
protected readonly authService: CopilotAuthService;
|
||||
|
||||
protected authState: CopilotAuthState = { isAuthenticated: false };
|
||||
protected readonly toDispose = new DisposableCollection();
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.toDispose.push(this.authService.onAuthStateChanged(state => {
|
||||
this.authState = state;
|
||||
this.updateStatusBar();
|
||||
}));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
onStart(): void {
|
||||
this.authService.getAuthState().then(state => {
|
||||
this.authState = state;
|
||||
this.updateStatusBar();
|
||||
});
|
||||
}
|
||||
|
||||
protected updateStatusBar(): void {
|
||||
const isAuthenticated = this.authState.isAuthenticated;
|
||||
|
||||
let text: string;
|
||||
let tooltip: string;
|
||||
let command: string;
|
||||
|
||||
if (isAuthenticated) {
|
||||
const accountLabel = this.authState.accountLabel ?? 'GitHub';
|
||||
text = `$(github) ${accountLabel}`;
|
||||
tooltip = nls.localize('theia/ai/copilot/statusBar/signedIn',
|
||||
'Signed in to GitHub Copilot as {0}. Click to sign out.', accountLabel);
|
||||
command = CopilotCommands.SIGN_OUT.id;
|
||||
} else {
|
||||
text = '$(github) Copilot';
|
||||
tooltip = nls.localize('theia/ai/copilot/statusBar/signedOut',
|
||||
'Not signed in to GitHub Copilot. Click to sign in.');
|
||||
command = CopilotCommands.SIGN_IN.id;
|
||||
}
|
||||
|
||||
this.statusBar.setElement(COPILOT_STATUS_BAR_ID, {
|
||||
text,
|
||||
tooltip,
|
||||
alignment: StatusBarAlignment.RIGHT,
|
||||
priority: 100,
|
||||
command
|
||||
});
|
||||
}
|
||||
}
|
||||
20
packages/ai-copilot/src/browser/index.ts
Normal file
20
packages/ai-copilot/src/browser/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export * from './copilot-auth-dialog';
|
||||
export * from './copilot-command-contribution';
|
||||
export * from './copilot-status-bar-contribution';
|
||||
export * from './copilot-frontend-application-contribution';
|
||||
167
packages/ai-copilot/src/browser/style/index.css
Normal file
167
packages/ai-copilot/src/browser/style/index.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* *****************************************************************************
|
||||
* Copyright (C) 2026 EclipseSource GmbH.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License v. 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0.
|
||||
*
|
||||
* This Source Code may also be made available under the following Secondary
|
||||
* Licenses when the conditions for such availability set forth in the Eclipse
|
||||
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
* with the GNU Classpath Exception which is available at
|
||||
* https://www.gnu.org/software/classpath/license.html.
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
**************************************************************************** */
|
||||
|
||||
.theia-copilot-auth-dialog-content {
|
||||
padding: 16px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-state .theia-spin {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-success .codicon-check {
|
||||
font-size: 48px;
|
||||
color: var(--theia-successBackground);
|
||||
}
|
||||
|
||||
.theia-copilot-auth-error .codicon-error {
|
||||
font-size: 48px;
|
||||
color: var(--theia-errorForeground);
|
||||
}
|
||||
|
||||
.theia-copilot-auth-waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-instructions {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-code-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-code-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--theia-editor-background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--theia-panel-border);
|
||||
}
|
||||
|
||||
.theia-copilot-auth-code {
|
||||
font-family: var(--theia-code-font-family);
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
color: var(--theia-textLink-foreground);
|
||||
}
|
||||
|
||||
.theia-copilot-copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-url-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theia-copilot-open-url-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-url {
|
||||
font-family: var(--theia-code-font-family);
|
||||
font-size: 12px;
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.theia-copilot-auth-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--theia-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-privacy {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--theia-panel-border);
|
||||
}
|
||||
|
||||
.theia-copilot-auth-privacy-text,
|
||||
.theia-copilot-auth-tos-text {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--theia-descriptionForeground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-tos-text:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-privacy a {
|
||||
color: var(--theia-textLink-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-privacy a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-success-hint {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--theia-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-success-hint a {
|
||||
color: var(--theia-textLink-foreground);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theia-copilot-auth-success-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Spinning animation for loading indicator */
|
||||
.theia-spin .codicon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
103
packages/ai-copilot/src/common/copilot-auth-service.ts
Normal file
103
packages/ai-copilot/src/common/copilot-auth-service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Event } from '@theia/core';
|
||||
|
||||
export const COPILOT_AUTH_SERVICE_PATH = '/services/copilot/auth';
|
||||
export const CopilotAuthService = Symbol('CopilotAuthService');
|
||||
export const CopilotAuthServiceClient = Symbol('CopilotAuthServiceClient');
|
||||
|
||||
/**
|
||||
* Response from GitHub's device code endpoint.
|
||||
*/
|
||||
export interface DeviceCodeResponse {
|
||||
/** URL where user should enter the code (e.g., https://github.com/login/device) */
|
||||
verification_uri: string;
|
||||
/** Code to display to the user (e.g., XXXX-XXXX) */
|
||||
user_code: string;
|
||||
/** Device code used for polling */
|
||||
device_code: string;
|
||||
/** Polling interval in seconds */
|
||||
interval: number;
|
||||
/** Expiration time in seconds */
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current authentication state.
|
||||
*/
|
||||
export interface CopilotAuthState {
|
||||
/** Whether the user is authenticated */
|
||||
isAuthenticated: boolean;
|
||||
/** GitHub username if authenticated */
|
||||
accountLabel?: string;
|
||||
/** GitHub Enterprise URL if using enterprise */
|
||||
enterpriseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client interface for receiving auth state change notifications.
|
||||
*/
|
||||
export interface CopilotAuthServiceClient {
|
||||
onAuthStateChanged(state: CopilotAuthState): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for handling GitHub Copilot OAuth Device Flow authentication.
|
||||
*/
|
||||
export interface CopilotAuthService {
|
||||
/**
|
||||
* Initiates the OAuth Device Flow.
|
||||
* Returns device code information for the UI to display.
|
||||
* @param enterpriseUrl Optional GitHub Enterprise domain
|
||||
*/
|
||||
initiateDeviceFlow(enterpriseUrl?: string): Promise<DeviceCodeResponse>;
|
||||
|
||||
/**
|
||||
* Polls for the access token after user authorizes.
|
||||
* @param deviceCode The device code from initiateDeviceFlow
|
||||
* @param interval Polling interval in seconds
|
||||
* @param enterpriseUrl Optional GitHub Enterprise domain
|
||||
* @returns true if authentication succeeded, false if expired/denied
|
||||
*/
|
||||
pollForToken(deviceCode: string, interval: number, enterpriseUrl?: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get the current authentication state.
|
||||
*/
|
||||
getAuthState(): Promise<CopilotAuthState>;
|
||||
|
||||
/**
|
||||
* Get the access token for API calls.
|
||||
* @returns The access token or undefined if not authenticated
|
||||
*/
|
||||
getAccessToken(): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Sign out and clear stored credentials.
|
||||
*/
|
||||
signOut(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set the client to receive auth state change notifications.
|
||||
*/
|
||||
setClient(client: CopilotAuthServiceClient | undefined): void;
|
||||
|
||||
/**
|
||||
* Event fired when authentication state changes.
|
||||
*/
|
||||
readonly onAuthStateChanged: Event<CopilotAuthState>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export const COPILOT_LANGUAGE_MODELS_MANAGER_PATH = '/services/copilot/language-model-manager';
|
||||
export const CopilotLanguageModelsManager = Symbol('CopilotLanguageModelsManager');
|
||||
|
||||
export const COPILOT_PROVIDER_ID = 'copilot';
|
||||
|
||||
export interface CopilotModelDescription {
|
||||
/**
|
||||
* The identifier of the model which will be shown in the UI.
|
||||
* Format: copilot/{modelName}
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The model ID as used by the Copilot API (e.g., 'gpt-4o', 'claude-3.5-sonnet').
|
||||
*/
|
||||
model: string;
|
||||
/**
|
||||
* Indicate whether the streaming API shall be used.
|
||||
*/
|
||||
enableStreaming: boolean;
|
||||
/**
|
||||
* Flag to configure whether the model supports structured output.
|
||||
*/
|
||||
supportsStructuredOutput: boolean;
|
||||
/**
|
||||
* Maximum number of retry attempts when a request fails.
|
||||
*/
|
||||
maxRetries: number;
|
||||
}
|
||||
|
||||
export interface CopilotLanguageModelsManager {
|
||||
/**
|
||||
* Create or update language models in the registry.
|
||||
*/
|
||||
createOrUpdateLanguageModels(...models: CopilotModelDescription[]): Promise<void>;
|
||||
/**
|
||||
* Remove language models from the registry.
|
||||
*/
|
||||
removeLanguageModels(...modelIds: string[]): void;
|
||||
/**
|
||||
* Refresh the status of all Copilot models (e.g., after authentication state changes).
|
||||
*/
|
||||
refreshModelsStatus(): Promise<void>;
|
||||
}
|
||||
53
packages/ai-copilot/src/common/copilot-preferences.ts
Normal file
53
packages/ai-copilot/src/common/copilot-preferences.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
import { nls, PreferenceSchema } from '@theia/core';
|
||||
|
||||
export const COPILOT_MODELS_PREF = 'ai-features.copilot.models';
|
||||
export const COPILOT_ENTERPRISE_URL_PREF = 'ai-features.copilot.enterpriseUrl';
|
||||
|
||||
export const CopilotPreferencesSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
[COPILOT_MODELS_PREF]: {
|
||||
type: 'array',
|
||||
description: nls.localize('theia/ai/copilot/models/description',
|
||||
'GitHub Copilot models to use. Available models depend on your Copilot subscription.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
// https://models.dev/?search=copilot
|
||||
default: [
|
||||
'claude-haiku-4.5',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-opus-4.5',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.2',
|
||||
],
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
[COPILOT_ENTERPRISE_URL_PREF]: {
|
||||
type: 'string',
|
||||
markdownDescription: nls.localize('theia/ai/copilot/enterpriseUrl/mdDescription',
|
||||
'GitHub Enterprise domain for Copilot API (e.g., `github.mycompany.com`). Leave empty for GitHub.com.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
19
packages/ai-copilot/src/common/index.ts
Normal file
19
packages/ai-copilot/src/common/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export * from './copilot-language-models-manager';
|
||||
export * from './copilot-auth-service';
|
||||
export * from './copilot-preferences';
|
||||
274
packages/ai-copilot/src/node/copilot-auth-service-impl.ts
Normal file
274
packages/ai-copilot/src/node/copilot-auth-service-impl.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { Emitter, Event } from '@theia/core';
|
||||
import { KeyStoreService } from '@theia/core/lib/common/key-store';
|
||||
import {
|
||||
CopilotAuthService,
|
||||
CopilotAuthServiceClient,
|
||||
CopilotAuthState,
|
||||
DeviceCodeResponse
|
||||
} from '../common/copilot-auth-service';
|
||||
|
||||
const COPILOT_CLIENT_ID = 'Iv23ctNZvWb5IGBKdyPY';
|
||||
const COPILOT_SCOPE = 'read:user';
|
||||
const COPILOT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
const KEYSTORE_SERVICE = 'theia-copilot-auth';
|
||||
const KEYSTORE_ACCOUNT = 'github-copilot';
|
||||
const USER_AGENT = 'Theia-Copilot/1.0.0';
|
||||
|
||||
/**
|
||||
* Maximum number of polling attempts for token retrieval.
|
||||
* With a default 5-second interval, this allows approximately 5 minutes of polling.
|
||||
*/
|
||||
const MAX_POLLING_ATTEMPTS = 60;
|
||||
|
||||
interface StoredCredentials {
|
||||
accessToken: string;
|
||||
accountLabel?: string;
|
||||
enterpriseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend implementation of the GitHub Copilot OAuth Device Flow authentication service.
|
||||
* Handles device code generation, token polling, and credential storage.
|
||||
*/
|
||||
@injectable()
|
||||
export class CopilotAuthServiceImpl implements CopilotAuthService {
|
||||
|
||||
@inject(KeyStoreService)
|
||||
protected readonly keyStoreService: KeyStoreService;
|
||||
|
||||
protected client: CopilotAuthServiceClient | undefined;
|
||||
protected cachedState: CopilotAuthState | undefined;
|
||||
|
||||
protected readonly onAuthStateChangedEmitter = new Emitter<CopilotAuthState>();
|
||||
readonly onAuthStateChanged: Event<CopilotAuthState> = this.onAuthStateChangedEmitter.event;
|
||||
|
||||
setClient(client: CopilotAuthServiceClient | undefined): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
protected getOAuthEndpoints(enterpriseUrl?: string): { deviceCodeUrl: string; accessTokenUrl: string } {
|
||||
if (enterpriseUrl) {
|
||||
const domain = enterpriseUrl
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '');
|
||||
return {
|
||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`
|
||||
};
|
||||
}
|
||||
return {
|
||||
deviceCodeUrl: 'https://github.com/login/device/code',
|
||||
accessTokenUrl: 'https://github.com/login/oauth/access_token'
|
||||
};
|
||||
}
|
||||
|
||||
async initiateDeviceFlow(enterpriseUrl?: string): Promise<DeviceCodeResponse> {
|
||||
const endpoints = this.getOAuthEndpoints(enterpriseUrl);
|
||||
|
||||
const response = await fetch(endpoints.deviceCodeUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: COPILOT_CLIENT_ID,
|
||||
scope: COPILOT_SCOPE
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to initiate device authorization: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as DeviceCodeResponse;
|
||||
return data;
|
||||
}
|
||||
|
||||
async pollForToken(deviceCode: string, interval: number, enterpriseUrl?: string): Promise<boolean> {
|
||||
const endpoints = this.getOAuthEndpoints(enterpriseUrl);
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < MAX_POLLING_ATTEMPTS) {
|
||||
await this.delay(interval * 1000);
|
||||
attempts++;
|
||||
|
||||
const response = await fetch(endpoints.accessTokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: COPILOT_CLIENT_ID,
|
||||
device_code: deviceCode,
|
||||
grant_type: COPILOT_GRANT_TYPE
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Token request failed: ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
if (data.access_token) {
|
||||
// Get user info for account label
|
||||
const accountLabel = await this.fetchAccountLabel(data.access_token, enterpriseUrl);
|
||||
|
||||
// Store credentials
|
||||
const credentials: StoredCredentials = {
|
||||
accessToken: data.access_token,
|
||||
accountLabel,
|
||||
enterpriseUrl
|
||||
};
|
||||
|
||||
await this.keyStoreService.setPassword(
|
||||
KEYSTORE_SERVICE,
|
||||
KEYSTORE_ACCOUNT,
|
||||
JSON.stringify(credentials)
|
||||
);
|
||||
|
||||
// Update cached state and notify
|
||||
const newState: CopilotAuthState = {
|
||||
isAuthenticated: true,
|
||||
accountLabel,
|
||||
enterpriseUrl
|
||||
};
|
||||
this.updateAuthState(newState);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (data.error === 'authorization_pending') {
|
||||
// User hasn't authorized yet, continue polling
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.error === 'slow_down') {
|
||||
// Increase polling interval
|
||||
interval += 5;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.error === 'expired_token' || data.error === 'access_denied') {
|
||||
console.error(`Authorization failed: ${data.error} - ${data.error_description}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
console.error(`Unexpected error: ${data.error} - ${data.error_description}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async fetchAccountLabel(accessToken: string, enterpriseUrl?: string): Promise<string | undefined> {
|
||||
try {
|
||||
const apiBaseUrl = enterpriseUrl
|
||||
? `https://${enterpriseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v3`
|
||||
: 'https://api.github.com';
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json() as { login?: string };
|
||||
return userData.login;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch GitHub user info:', error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getAuthState(): Promise<CopilotAuthState> {
|
||||
if (this.cachedState) {
|
||||
return this.cachedState;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await this.keyStoreService.getPassword(KEYSTORE_SERVICE, KEYSTORE_ACCOUNT);
|
||||
if (stored) {
|
||||
const credentials: StoredCredentials = JSON.parse(stored);
|
||||
this.cachedState = {
|
||||
isAuthenticated: true,
|
||||
accountLabel: credentials.accountLabel,
|
||||
enterpriseUrl: credentials.enterpriseUrl
|
||||
};
|
||||
return this.cachedState;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve Copilot credentials:', error);
|
||||
}
|
||||
|
||||
this.cachedState = { isAuthenticated: false };
|
||||
return this.cachedState;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string | undefined> {
|
||||
try {
|
||||
const stored = await this.keyStoreService.getPassword(KEYSTORE_SERVICE, KEYSTORE_ACCOUNT);
|
||||
if (stored) {
|
||||
const credentials: StoredCredentials = JSON.parse(stored);
|
||||
return credentials.accessToken;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve Copilot access token:', error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
try {
|
||||
await this.keyStoreService.deletePassword(KEYSTORE_SERVICE, KEYSTORE_ACCOUNT);
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete Copilot credentials:', error);
|
||||
}
|
||||
|
||||
const newState: CopilotAuthState = { isAuthenticated: false };
|
||||
this.updateAuthState(newState);
|
||||
}
|
||||
|
||||
protected updateAuthState(state: CopilotAuthState): void {
|
||||
this.cachedState = state;
|
||||
this.onAuthStateChangedEmitter.fire(state);
|
||||
this.client?.onAuthStateChanged(state);
|
||||
}
|
||||
|
||||
protected delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
58
packages/ai-copilot/src/node/copilot-backend-module.ts
Normal file
58
packages/ai-copilot/src/node/copilot-backend-module.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { ConnectionHandler, RpcConnectionHandler } from '@theia/core';
|
||||
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
|
||||
import {
|
||||
CopilotLanguageModelsManager,
|
||||
COPILOT_LANGUAGE_MODELS_MANAGER_PATH,
|
||||
CopilotAuthService,
|
||||
COPILOT_AUTH_SERVICE_PATH,
|
||||
CopilotAuthServiceClient
|
||||
} from '../common';
|
||||
import { CopilotLanguageModelsManagerImpl } from './copilot-language-models-manager-impl';
|
||||
import { CopilotAuthServiceImpl } from './copilot-auth-service-impl';
|
||||
|
||||
const copilotConnectionModule = ConnectionContainerModule.create(({ bind }) => {
|
||||
bind(CopilotAuthServiceImpl).toSelf().inSingletonScope();
|
||||
bind(CopilotAuthService).toService(CopilotAuthServiceImpl);
|
||||
|
||||
bind(CopilotLanguageModelsManagerImpl).toSelf().inSingletonScope();
|
||||
bind(CopilotLanguageModelsManager).toService(CopilotLanguageModelsManagerImpl);
|
||||
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new RpcConnectionHandler<CopilotAuthServiceClient>(
|
||||
COPILOT_AUTH_SERVICE_PATH,
|
||||
client => {
|
||||
const authService = ctx.container.get<CopilotAuthServiceImpl>(CopilotAuthService);
|
||||
authService.setClient(client);
|
||||
return authService;
|
||||
}
|
||||
)
|
||||
).inSingletonScope();
|
||||
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new RpcConnectionHandler(
|
||||
COPILOT_LANGUAGE_MODELS_MANAGER_PATH,
|
||||
() => ctx.container.get(CopilotLanguageModelsManager)
|
||||
)
|
||||
).inSingletonScope();
|
||||
});
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(ConnectionContainerModule).toConstantValue(copilotConnectionModule);
|
||||
});
|
||||
262
packages/ai-copilot/src/node/copilot-language-model.ts
Normal file
262
packages/ai-copilot/src/node/copilot-language-model.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
ImageContent,
|
||||
LanguageModel,
|
||||
LanguageModelMessage,
|
||||
LanguageModelParsedResponse,
|
||||
LanguageModelRequest,
|
||||
LanguageModelResponse,
|
||||
LanguageModelStatus,
|
||||
LanguageModelTextResponse,
|
||||
TokenUsageService,
|
||||
UserRequest
|
||||
} from '@theia/ai-core';
|
||||
import { CancellationToken } from '@theia/core';
|
||||
import OpenAI from 'openai';
|
||||
import { RunnableToolFunctionWithoutParse } from 'openai/lib/RunnableFunction';
|
||||
import { ChatCompletionMessageParam } from 'openai/resources';
|
||||
import { StreamingAsyncIterator } from '@theia/ai-openai/lib/node/openai-streaming-iterator';
|
||||
import { COPILOT_PROVIDER_ID } from '../common';
|
||||
import type { RunnerOptions } from 'openai/lib/AbstractChatCompletionRunner';
|
||||
import type { ChatCompletionStream } from 'openai/lib/ChatCompletionStream';
|
||||
|
||||
const COPILOT_API_BASE_URL = 'https://api.githubcopilot.com';
|
||||
const USER_AGENT = 'Theia-Copilot/1.0.0';
|
||||
|
||||
/**
|
||||
* Language model implementation for GitHub Copilot.
|
||||
* Uses the OpenAI SDK to communicate with the Copilot API.
|
||||
*/
|
||||
export class CopilotLanguageModel implements LanguageModel {
|
||||
|
||||
protected runnerOptions: RunnerOptions = {
|
||||
maxChatCompletions: 100
|
||||
};
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public model: string,
|
||||
public status: LanguageModelStatus,
|
||||
public enableStreaming: boolean,
|
||||
public supportsStructuredOutput: boolean,
|
||||
public maxRetries: number,
|
||||
protected readonly accessTokenProvider: () => Promise<string | undefined>,
|
||||
protected readonly enterpriseUrlProvider: () => string | undefined,
|
||||
protected readonly tokenUsageService?: TokenUsageService
|
||||
) { }
|
||||
|
||||
protected getSettings(request: LanguageModelRequest): Record<string, unknown> {
|
||||
return request.settings ?? {};
|
||||
}
|
||||
|
||||
async request(request: UserRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse> {
|
||||
const openai = await this.initializeCopilotClient();
|
||||
|
||||
if (request.response_format?.type === 'json_schema' && this.supportsStructuredOutput) {
|
||||
return this.handleStructuredOutputRequest(openai, request);
|
||||
}
|
||||
|
||||
const settings = this.getSettings(request);
|
||||
|
||||
if (!this.enableStreaming || (typeof settings.stream === 'boolean' && !settings.stream)) {
|
||||
return this.handleNonStreamingRequest(openai, request);
|
||||
}
|
||||
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
return { text: '' };
|
||||
}
|
||||
|
||||
if (this.id.startsWith(`${COPILOT_PROVIDER_ID}/`)) {
|
||||
settings['stream_options'] = { include_usage: true };
|
||||
}
|
||||
|
||||
let runner: ChatCompletionStream;
|
||||
const tools = this.createTools(request);
|
||||
|
||||
if (tools) {
|
||||
runner = openai.chat.completions.runTools({
|
||||
model: this.model,
|
||||
messages: this.processMessages(request.messages),
|
||||
stream: true,
|
||||
tools: tools,
|
||||
tool_choice: 'auto',
|
||||
...settings
|
||||
}, {
|
||||
...this.runnerOptions,
|
||||
maxRetries: this.maxRetries
|
||||
});
|
||||
} else {
|
||||
runner = openai.chat.completions.stream({
|
||||
model: this.model,
|
||||
messages: this.processMessages(request.messages),
|
||||
stream: true,
|
||||
...settings
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { stream: new StreamingAsyncIterator(runner as any, request.requestId, cancellationToken, this.tokenUsageService, this.id) };
|
||||
}
|
||||
|
||||
protected async handleNonStreamingRequest(openai: OpenAI, request: UserRequest): Promise<LanguageModelTextResponse> {
|
||||
const settings = this.getSettings(request);
|
||||
const response = await openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: this.processMessages(request.messages),
|
||||
...settings
|
||||
});
|
||||
|
||||
const message = response.choices[0].message;
|
||||
|
||||
if (this.tokenUsageService && response.usage) {
|
||||
await this.tokenUsageService.recordTokenUsage(
|
||||
this.id,
|
||||
{
|
||||
inputTokens: response.usage.prompt_tokens,
|
||||
outputTokens: response.usage.completion_tokens,
|
||||
requestId: request.requestId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
text: message.content ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
protected async handleStructuredOutputRequest(openai: OpenAI, request: UserRequest): Promise<LanguageModelParsedResponse> {
|
||||
const settings = this.getSettings(request);
|
||||
const result = await openai.chat.completions.parse({
|
||||
model: this.model,
|
||||
messages: this.processMessages(request.messages),
|
||||
response_format: request.response_format,
|
||||
...settings
|
||||
});
|
||||
|
||||
const message = result.choices[0].message;
|
||||
if (message.refusal || message.parsed === undefined) {
|
||||
console.error('Error in Copilot chat completion:', JSON.stringify(message));
|
||||
}
|
||||
|
||||
if (this.tokenUsageService && result.usage) {
|
||||
await this.tokenUsageService.recordTokenUsage(
|
||||
this.id,
|
||||
{
|
||||
inputTokens: result.usage.prompt_tokens,
|
||||
outputTokens: result.usage.completion_tokens,
|
||||
requestId: request.requestId
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: message.content ?? '',
|
||||
parsed: message.parsed
|
||||
};
|
||||
}
|
||||
|
||||
protected createTools(request: LanguageModelRequest): RunnableToolFunctionWithoutParse[] | undefined {
|
||||
return request.tools?.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
function: (args_string: string) => tool.handler(args_string)
|
||||
}
|
||||
} as RunnableToolFunctionWithoutParse));
|
||||
}
|
||||
|
||||
protected async initializeCopilotClient(): Promise<OpenAI> {
|
||||
const accessToken = await this.accessTokenProvider();
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated with GitHub Copilot. Please sign in first.');
|
||||
}
|
||||
|
||||
const enterpriseUrl = this.enterpriseUrlProvider();
|
||||
const baseURL = enterpriseUrl
|
||||
? `https://copilot-api.${enterpriseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}`
|
||||
: COPILOT_API_BASE_URL;
|
||||
|
||||
return new OpenAI({
|
||||
apiKey: accessToken,
|
||||
baseURL,
|
||||
defaultHeaders: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Openai-Intent': 'conversation-edits',
|
||||
'X-Initiator': 'user'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected processMessages(messages: LanguageModelMessage[]): ChatCompletionMessageParam[] {
|
||||
return messages.filter(m => m.type !== 'thinking').map(m => this.toOpenAIMessage(m));
|
||||
}
|
||||
|
||||
protected toOpenAIMessage(message: LanguageModelMessage): ChatCompletionMessageParam {
|
||||
if (LanguageModelMessage.isTextMessage(message)) {
|
||||
return {
|
||||
role: this.toOpenAiRole(message),
|
||||
content: message.text
|
||||
};
|
||||
}
|
||||
if (LanguageModelMessage.isToolUseMessage(message)) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
tool_calls: [{
|
||||
id: message.id,
|
||||
function: {
|
||||
name: message.name,
|
||||
arguments: JSON.stringify(message.input)
|
||||
},
|
||||
type: 'function'
|
||||
}]
|
||||
};
|
||||
}
|
||||
if (LanguageModelMessage.isToolResultMessage(message)) {
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: message.tool_use_id,
|
||||
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
|
||||
};
|
||||
}
|
||||
if (LanguageModelMessage.isImageMessage(message) && message.actor === 'user') {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: ImageContent.isBase64(message.image)
|
||||
? `data:${message.image.mimeType};base64,${message.image.base64data}`
|
||||
: message.image.url
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown message type: '${JSON.stringify(message)}'`);
|
||||
}
|
||||
|
||||
protected toOpenAiRole(message: LanguageModelMessage): 'developer' | 'user' | 'assistant' | 'system' {
|
||||
if (message.actor === 'system') {
|
||||
return 'developer';
|
||||
} else if (message.actor === 'ai') {
|
||||
return 'assistant';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { LanguageModelRegistry, LanguageModelStatus, TokenUsageService } from '@theia/ai-core';
|
||||
import { Disposable, DisposableCollection } from '@theia/core';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { CopilotLanguageModelsManager, CopilotModelDescription, COPILOT_PROVIDER_ID } from '../common';
|
||||
import { CopilotLanguageModel } from './copilot-language-model';
|
||||
import { CopilotAuthServiceImpl } from './copilot-auth-service-impl';
|
||||
|
||||
/**
|
||||
* Backend implementation of the Copilot language models manager.
|
||||
* Manages registration and lifecycle of Copilot language models in the AI language model registry.
|
||||
*/
|
||||
@injectable()
|
||||
export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsManager, Disposable {
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
protected readonly languageModelRegistry: LanguageModelRegistry;
|
||||
|
||||
@inject(TokenUsageService)
|
||||
protected readonly tokenUsageService: TokenUsageService;
|
||||
|
||||
@inject(CopilotAuthServiceImpl)
|
||||
protected readonly authService: CopilotAuthServiceImpl;
|
||||
|
||||
protected enterpriseUrl: string | undefined;
|
||||
protected readonly toDispose = new DisposableCollection();
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.toDispose.push(this.authService.onAuthStateChanged(() => {
|
||||
this.refreshModelsStatus();
|
||||
}));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
setEnterpriseUrl(url: string | undefined): void {
|
||||
this.enterpriseUrl = url;
|
||||
}
|
||||
|
||||
protected async calculateStatus(): Promise<LanguageModelStatus> {
|
||||
const authState = await this.authService.getAuthState();
|
||||
if (authState.isAuthenticated) {
|
||||
return { status: 'ready' };
|
||||
}
|
||||
return { status: 'unavailable', message: 'Not signed in to GitHub Copilot' };
|
||||
}
|
||||
|
||||
async createOrUpdateLanguageModels(...modelDescriptions: CopilotModelDescription[]): Promise<void> {
|
||||
const status = await this.calculateStatus();
|
||||
|
||||
for (const modelDescription of modelDescriptions) {
|
||||
const model = await this.languageModelRegistry.getLanguageModel(modelDescription.id);
|
||||
|
||||
if (model) {
|
||||
if (!(model instanceof CopilotLanguageModel)) {
|
||||
console.warn(`Copilot: model ${modelDescription.id} is not a Copilot model`);
|
||||
continue;
|
||||
}
|
||||
await this.languageModelRegistry.patchLanguageModel<CopilotLanguageModel>(modelDescription.id, {
|
||||
model: modelDescription.model,
|
||||
enableStreaming: modelDescription.enableStreaming,
|
||||
supportsStructuredOutput: modelDescription.supportsStructuredOutput,
|
||||
status,
|
||||
maxRetries: modelDescription.maxRetries
|
||||
});
|
||||
} else {
|
||||
this.languageModelRegistry.addLanguageModels([
|
||||
new CopilotLanguageModel(
|
||||
modelDescription.id,
|
||||
modelDescription.model,
|
||||
status,
|
||||
modelDescription.enableStreaming,
|
||||
modelDescription.supportsStructuredOutput,
|
||||
modelDescription.maxRetries,
|
||||
() => this.authService.getAccessToken(),
|
||||
() => this.enterpriseUrl,
|
||||
this.tokenUsageService
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeLanguageModels(...modelIds: string[]): void {
|
||||
this.languageModelRegistry.removeLanguageModels(modelIds);
|
||||
}
|
||||
|
||||
async refreshModelsStatus(): Promise<void> {
|
||||
const status = await this.calculateStatus();
|
||||
const allModels = await this.languageModelRegistry.getLanguageModels();
|
||||
|
||||
for (const model of allModels) {
|
||||
if (model instanceof CopilotLanguageModel && model.id.startsWith(`${COPILOT_PROVIDER_ID}/`)) {
|
||||
await this.languageModelRegistry.patchLanguageModel<CopilotLanguageModel>(model.id, {
|
||||
status
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/ai-copilot/src/node/index.ts
Normal file
19
packages/ai-copilot/src/node/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export * from './copilot-auth-service-impl';
|
||||
export * from './copilot-language-model';
|
||||
export * from './copilot-language-models-manager-impl';
|
||||
27
packages/ai-copilot/src/package.spec.ts
Normal file
27
packages/ai-copilot/src/package.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
/* note: this bogus test file is required so that
|
||||
we are able to run mocha unit tests on this
|
||||
package, without having any actual unit tests in it.
|
||||
This way a coverage report will be generated,
|
||||
showing 0% coverage, instead of no report.
|
||||
This file can be removed once we have real unit
|
||||
tests in place. */
|
||||
|
||||
describe('ai-copilot package', () => {
|
||||
it('support code coverage statistics', () => true);
|
||||
});
|
||||
Reference in New Issue
Block a user