Migrate Copilot and Anthropic to extensions
This commit is contained in:
@@ -1721,6 +1721,8 @@
|
||||
// If you don't want any of these extensions, add this field to your settings
|
||||
// and change the value to `false`.
|
||||
"auto_install_extensions": {
|
||||
"anthropic": true,
|
||||
"copilot-chat": true,
|
||||
"html": true
|
||||
},
|
||||
// The capabilities granted to extensions.
|
||||
|
||||
153
crates/extension_host/src/anthropic_migration.rs
Normal file
153
crates/extension_host/src/anthropic_migration.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use credentials_provider::CredentialsProvider;
|
||||
use gpui::App;
|
||||
|
||||
const ANTHROPIC_EXTENSION_ID: &str = "anthropic";
|
||||
const ANTHROPIC_PROVIDER_ID: &str = "anthropic";
|
||||
const ANTHROPIC_DEFAULT_API_URL: &str = "https://api.anthropic.com";
|
||||
|
||||
pub fn migrate_anthropic_credentials_if_needed(extension_id: &str, cx: &mut App) {
|
||||
if extension_id != ANTHROPIC_EXTENSION_ID {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension_credential_key = format!(
|
||||
"extension-llm-{}:{}",
|
||||
ANTHROPIC_EXTENSION_ID, ANTHROPIC_PROVIDER_ID
|
||||
);
|
||||
|
||||
let credentials_provider = <dyn CredentialsProvider>::global(cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let existing_credential = credentials_provider
|
||||
.read_credentials(&extension_credential_key, &cx)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if existing_credential.is_some() {
|
||||
log::debug!("Anthropic extension already has credentials, skipping migration");
|
||||
return;
|
||||
}
|
||||
|
||||
let old_credential = credentials_provider
|
||||
.read_credentials(ANTHROPIC_DEFAULT_API_URL, &cx)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let api_key = match old_credential {
|
||||
Some((_, key_bytes)) => match String::from_utf8(key_bytes) {
|
||||
Ok(key) => key,
|
||||
Err(_) => {
|
||||
log::error!("Failed to decode Anthropic API key as UTF-8");
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::debug!("No existing Anthropic API key found to migrate");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("Migrating existing Anthropic API key to Anthropic extension");
|
||||
|
||||
match credentials_provider
|
||||
.write_credentials(&extension_credential_key, "Bearer", api_key.as_bytes(), &cx)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("Successfully migrated Anthropic API key to extension");
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to migrate Anthropic API key: {}", err);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_migrates_credentials_from_old_location(cx: &mut TestAppContext) {
|
||||
let api_key = "sk-ant-test-key-12345";
|
||||
|
||||
cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", api_key.as_bytes());
|
||||
|
||||
cx.update(|cx| {
|
||||
migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let migrated = cx.read_credentials("extension-llm-anthropic:anthropic");
|
||||
assert!(migrated.is_some(), "Credentials should have been migrated");
|
||||
let (username, password) = migrated.unwrap();
|
||||
assert_eq!(username, "Bearer");
|
||||
assert_eq!(String::from_utf8(password).unwrap(), api_key);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skips_migration_if_extension_already_has_credentials(cx: &mut TestAppContext) {
|
||||
let old_api_key = "sk-ant-old-key";
|
||||
let existing_key = "sk-ant-existing-key";
|
||||
|
||||
cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", old_api_key.as_bytes());
|
||||
cx.write_credentials(
|
||||
"extension-llm-anthropic:anthropic",
|
||||
"Bearer",
|
||||
existing_key.as_bytes(),
|
||||
);
|
||||
|
||||
cx.update(|cx| {
|
||||
migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-anthropic:anthropic");
|
||||
let (_, password) = credentials.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(password).unwrap(),
|
||||
existing_key,
|
||||
"Should not overwrite existing credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skips_migration_if_no_old_credentials(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-anthropic:anthropic");
|
||||
assert!(
|
||||
credentials.is_none(),
|
||||
"Should not create credentials if none existed"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skips_migration_for_other_extensions(cx: &mut TestAppContext) {
|
||||
let api_key = "sk-ant-test-key";
|
||||
|
||||
cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", api_key.as_bytes());
|
||||
|
||||
cx.update(|cx| {
|
||||
migrate_anthropic_credentials_if_needed("some-other-extension", cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-anthropic:anthropic");
|
||||
assert!(
|
||||
credentials.is_none(),
|
||||
"Should not migrate for other extensions"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ use credentials_provider::CredentialsProvider;
|
||||
use gpui::App;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const COPILOT_CHAT_EXTENSION_ID: &str = "copilot_chat";
|
||||
const COPILOT_CHAT_PROVIDER_ID: &str = "copilot_chat";
|
||||
const COPILOT_CHAT_EXTENSION_ID: &str = "copilot-chat";
|
||||
const COPILOT_CHAT_PROVIDER_ID: &str = "copilot-chat";
|
||||
|
||||
pub fn migrate_copilot_credentials_if_needed(extension_id: &str, cx: &mut App) {
|
||||
if extension_id != COPILOT_CHAT_EXTENSION_ID {
|
||||
@@ -115,9 +115,10 @@ fn extract_oauth_token(contents: &str, domain: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_token() {
|
||||
fn test_extract_oauth_token_from_hosts_json() {
|
||||
let contents = r#"{
|
||||
"github.com": {
|
||||
"oauth_token": "ghu_test_token_12345"
|
||||
@@ -129,7 +130,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_token_with_prefix() {
|
||||
fn test_extract_oauth_token_with_user_suffix() {
|
||||
let contents = r#"{
|
||||
"github.com:user": {
|
||||
"oauth_token": "ghu_another_token"
|
||||
@@ -141,7 +142,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_token_missing() {
|
||||
fn test_extract_oauth_token_wrong_domain() {
|
||||
let contents = r#"{
|
||||
"gitlab.com": {
|
||||
"oauth_token": "some_token"
|
||||
@@ -158,4 +159,86 @@ mod tests {
|
||||
let token = extract_oauth_token(contents, "github.com");
|
||||
assert_eq!(token, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_token_missing_oauth_token_field() {
|
||||
let contents = r#"{
|
||||
"github.com": {
|
||||
"user": "testuser"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let token = extract_oauth_token(contents, "github.com");
|
||||
assert_eq!(token, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_oauth_token_multiple_entries_picks_first_match() {
|
||||
let contents = r#"{
|
||||
"gitlab.com": {
|
||||
"oauth_token": "gitlab_token"
|
||||
},
|
||||
"github.com": {
|
||||
"oauth_token": "github_token"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let token = extract_oauth_token(contents, "github.com");
|
||||
assert_eq!(token, Some("github_token".to_string()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skips_migration_if_extension_already_has_credentials(cx: &mut TestAppContext) {
|
||||
let existing_token = "existing_oauth_token";
|
||||
|
||||
cx.write_credentials(
|
||||
"extension-llm-copilot-chat:copilot-chat",
|
||||
"api_key",
|
||||
existing_token.as_bytes(),
|
||||
);
|
||||
|
||||
cx.update(|cx| {
|
||||
migrate_copilot_credentials_if_needed(COPILOT_CHAT_EXTENSION_ID, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat");
|
||||
let (_, password) = credentials.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(password).unwrap(),
|
||||
existing_token,
|
||||
"Should not overwrite existing credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_skips_migration_for_other_extensions(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
migrate_copilot_credentials_if_needed("some-other-extension", cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat");
|
||||
assert!(
|
||||
credentials.is_none(),
|
||||
"Should not create credentials for other extensions"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_no_migration_when_no_copilot_config_exists(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
migrate_copilot_credentials_if_needed(COPILOT_CHAT_EXTENSION_ID, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat");
|
||||
assert!(
|
||||
credentials.is_none(),
|
||||
"Should not create credentials when no copilot config exists"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod anthropic_migration;
|
||||
mod capability_granter;
|
||||
mod copilot_migration;
|
||||
pub mod extension_settings;
|
||||
@@ -85,9 +86,9 @@ const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
|
||||
/// we automatically enable env var reading for these extensions on first install.
|
||||
const LEGACY_LLM_EXTENSION_IDS: &[&str] = &[
|
||||
"anthropic",
|
||||
"copilot_chat",
|
||||
"copilot-chat",
|
||||
"google-ai",
|
||||
"open_router",
|
||||
"open-router",
|
||||
"openai",
|
||||
];
|
||||
|
||||
@@ -128,9 +129,9 @@ fn migrate_legacy_llm_provider_env_var(manifest: &ExtensionManifest, cx: &mut Ap
|
||||
.unwrap_or(false);
|
||||
|
||||
// Mark as migrated regardless of whether we enable env var reading
|
||||
let should_enable_env_var = env_var_is_set;
|
||||
settings::update_settings_file(<dyn fs::Fs>::global(cx), cx, {
|
||||
let full_provider_id = full_provider_id.clone();
|
||||
let env_var_is_set = env_var_is_set;
|
||||
move |settings, _| {
|
||||
// Always mark as migrated
|
||||
let migrated = settings
|
||||
@@ -146,7 +147,7 @@ fn migrate_legacy_llm_provider_env_var(manifest: &ExtensionManifest, cx: &mut Ap
|
||||
}
|
||||
|
||||
// Only enable env var reading if the env var is set
|
||||
if env_var_is_set {
|
||||
if should_enable_env_var {
|
||||
let providers = settings
|
||||
.extension
|
||||
.allowed_env_var_providers
|
||||
@@ -889,6 +890,7 @@ impl ExtensionStore {
|
||||
|
||||
// Run extension-specific migrations
|
||||
copilot_migration::migrate_copilot_credentials_if_needed(&extension_id, cx);
|
||||
anthropic_migration::migrate_anthropic_credentials_if_needed(&extension_id, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -296,6 +296,20 @@ impl TestAppContext {
|
||||
&self.text_system
|
||||
}
|
||||
|
||||
/// Simulates writing credentials to the platform keychain.
|
||||
pub fn write_credentials(&self, url: &str, username: &str, password: &[u8]) {
|
||||
let _ = self
|
||||
.test_platform
|
||||
.write_credentials(url, username, password);
|
||||
}
|
||||
|
||||
/// Simulates reading credentials from the platform keychain.
|
||||
pub fn read_credentials(&self, url: &str) -> Option<(String, Vec<u8>)> {
|
||||
smol::block_on(self.test_platform.read_credentials(url))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Simulates writing to the platform clipboard
|
||||
pub fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
self.test_platform.write_to_clipboard(item)
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use collections::VecDeque;
|
||||
use collections::{HashMap, VecDeque};
|
||||
use futures::channel::oneshot;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
@@ -32,6 +32,7 @@ pub(crate) struct TestPlatform {
|
||||
current_clipboard_item: Mutex<Option<ClipboardItem>>,
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
current_primary_item: Mutex<Option<ClipboardItem>>,
|
||||
credentials: Mutex<HashMap<String, (String, Vec<u8>)>>,
|
||||
pub(crate) prompts: RefCell<TestPrompts>,
|
||||
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
|
||||
pub opened_url: RefCell<Option<String>>,
|
||||
@@ -117,6 +118,7 @@ impl TestPlatform {
|
||||
current_clipboard_item: Mutex::new(None),
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
current_primary_item: Mutex::new(None),
|
||||
credentials: Mutex::new(HashMap::default()),
|
||||
weak: weak.clone(),
|
||||
opened_url: Default::default(),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -416,15 +418,20 @@ impl Platform for TestPlatform {
|
||||
self.current_clipboard_item.lock().clone()
|
||||
}
|
||||
|
||||
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
|
||||
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
|
||||
self.credentials
|
||||
.lock()
|
||||
.insert(url.to_string(), (username.to_string(), password.to_vec()));
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn read_credentials(&self, _url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||
Task::ready(Ok(None))
|
||||
fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
|
||||
let result = self.credentials.lock().get(url).cloned();
|
||||
Task::ready(Ok(result))
|
||||
}
|
||||
|
||||
fn delete_credentials(&self, _url: &str) -> Task<Result<()>> {
|
||||
fn delete_credentials(&self, url: &str) -> Task<Result<()>> {
|
||||
self.credentials.lock().remove(url);
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user