add customised github copilot configuration (#8251)

This commit is contained in:
Lifei Zhou
2026-04-03 00:51:50 +11:00
committed by GitHub
parent 1aa07bb63a
commit 03afcf1ffc
4 changed files with 261 additions and 95 deletions
+136 -21
View File
@@ -54,10 +54,43 @@ pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = &[
const GITHUB_COPILOT_DOC_URL: &str = const GITHUB_COPILOT_DOC_URL: &str =
"https://docs.github.com/en/copilot/using-github-copilot/ai-models"; "https://docs.github.com/en/copilot/using-github-copilot/ai-models";
const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; const DEFAULT_GITHUB_HOST: &str = "github.com";
const GITHUB_COPILOT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; const DEFAULT_GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
const GITHUB_COPILOT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
const GITHUB_COPILOT_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token"; fn normalize_host(host: &str) -> String {
let host = host.trim_end_matches('/');
let host = host.strip_prefix("https://").unwrap_or(host);
host.to_string()
}
#[derive(Debug, Clone)]
struct GithubCopilotUrls {
device_code_url: String,
access_token_url: String,
copilot_token_url: String,
}
impl GithubCopilotUrls {
fn new(host: &str, copilot_token_url: Option<&str>) -> Self {
if host == "github.com" {
Self {
device_code_url: "https://github.com/login/device/code".to_string(),
access_token_url: "https://github.com/login/oauth/access_token".to_string(),
copilot_token_url: "https://api.github.com/copilot_internal/v2/token".to_string(),
}
} else {
let base = format!("https://{}", host);
let copilot_token_url = copilot_token_url
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or_else(|| format!("{}/api/copilot_internal/v2/token", base));
Self {
device_code_url: format!("{}/login/device/code", base),
access_token_url: format!("{}/login/oauth/access_token", base),
copilot_token_url,
}
}
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct DeviceCodeInfo { struct DeviceCodeInfo {
@@ -96,8 +129,13 @@ struct DiskCache {
} }
impl DiskCache { impl DiskCache {
fn new() -> Self { fn new(host: &str) -> Self {
let cache_path = Paths::in_config_dir("githubcopilot/info.json"); let cache_path = if host == DEFAULT_GITHUB_HOST {
Paths::in_config_dir("githubcopilot/info.json")
} else {
let safe_host = host.replace(['/', ':', '.'], "_");
Paths::in_config_dir(&format!("githubcopilot/{}/info.json", safe_host))
};
Self { cache_path } Self { cache_path }
} }
@@ -138,12 +176,22 @@ pub struct GithubCopilotProvider {
mu: tokio::sync::Mutex<RefCell<Option<CopilotState>>>, mu: tokio::sync::Mutex<RefCell<Option<CopilotState>>>,
model: ModelConfig, model: ModelConfig,
#[serde(skip)] #[serde(skip)]
urls: GithubCopilotUrls,
#[serde(skip)]
client_id: String,
#[serde(skip)]
name: String, name: String,
} }
impl GithubCopilotProvider { impl GithubCopilotProvider {
pub async fn cleanup() -> Result<()> { pub async fn cleanup() -> Result<()> {
DiskCache::new().clear().await let config = Config::global();
let host = normalize_host(
&config
.get_param::<String>("GITHUB_COPILOT_HOST")
.unwrap_or_else(|_| DEFAULT_GITHUB_HOST.to_string()),
);
DiskCache::new(&host).clear().await
} }
fn payload_contains_image(payload: &Value) -> bool { fn payload_contains_image(payload: &Value) -> bool {
@@ -170,16 +218,29 @@ impl GithubCopilotProvider {
} }
pub async fn from_env(model: ModelConfig) -> Result<Self> { pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = Config::global();
let host = normalize_host(
&config
.get_param::<String>("GITHUB_COPILOT_HOST")
.unwrap_or_else(|_| DEFAULT_GITHUB_HOST.to_string()),
);
let client_id: String = config
.get_param("GITHUB_COPILOT_CLIENT_ID")
.unwrap_or_else(|_| DEFAULT_GITHUB_COPILOT_CLIENT_ID.to_string());
let copilot_token_url: Option<String> = config.get_param("GITHUB_COPILOT_TOKEN_URL").ok();
let urls = GithubCopilotUrls::new(&host, copilot_token_url.as_deref());
let client = Client::builder() let client = Client::builder()
.timeout(Duration::from_secs(600)) .timeout(Duration::from_secs(600))
.build()?; .build()?;
let cache = DiskCache::new(); let cache = DiskCache::new(&host);
let mu = tokio::sync::Mutex::new(RefCell::new(None)); let mu = tokio::sync::Mutex::new(RefCell::new(None));
Ok(Self { Ok(Self {
client, client,
cache, cache,
mu, mu,
model, model,
urls,
client_id,
name: GITHUB_COPILOT_PROVIDER_NAME.to_string(), name: GITHUB_COPILOT_PROVIDER_NAME.to_string(),
}) })
} }
@@ -258,7 +319,7 @@ impl GithubCopilotProvider {
}; };
let resp = self let resp = self
.client .client
.get(GITHUB_COPILOT_API_KEY_URL) .get(&self.urls.copilot_token_url)
.headers(self.get_github_headers()) .headers(self.get_github_headers())
.header(http::header::AUTHORIZATION, format!("bearer {}", &token)) .header(http::header::AUTHORIZATION, format!("bearer {}", &token))
.send() .send()
@@ -311,10 +372,10 @@ impl GithubCopilotProvider {
scope: String, scope: String,
} }
self.client self.client
.post(GITHUB_COPILOT_DEVICE_CODE_URL) .post(&self.urls.device_code_url)
.headers(self.get_github_headers()) .headers(self.get_github_headers())
.json(&DeviceCodeRequest { .json(&DeviceCodeRequest {
client_id: GITHUB_COPILOT_CLIENT_ID.to_string(), client_id: self.client_id.clone(),
scope: "read:user".to_string(), scope: "read:user".to_string(),
}) })
.send() .send()
@@ -346,10 +407,10 @@ impl GithubCopilotProvider {
for attempt in 0..MAX_ATTEMPTS { for attempt in 0..MAX_ATTEMPTS {
let resp = self let resp = self
.client .client
.post(GITHUB_COPILOT_ACCESS_TOKEN_URL) .post(&self.urls.access_token_url)
.headers(self.get_github_headers()) .headers(self.get_github_headers())
.json(&AccessTokenRequest { .json(&AccessTokenRequest {
client_id: GITHUB_COPILOT_CLIENT_ID.to_string(), client_id: self.client_id.clone(),
device_code: device_code.to_string(), device_code: device_code.to_string(),
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(), grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
}) })
@@ -412,13 +473,12 @@ impl ProviderDef for GithubCopilotProvider {
GITHUB_COPILOT_DEFAULT_MODEL, GITHUB_COPILOT_DEFAULT_MODEL,
GITHUB_COPILOT_KNOWN_MODELS.to_vec(), GITHUB_COPILOT_KNOWN_MODELS.to_vec(),
GITHUB_COPILOT_DOC_URL, GITHUB_COPILOT_DOC_URL,
vec![ConfigKey::new_oauth_device_code( vec![
"GITHUB_COPILOT_TOKEN", ConfigKey::new_oauth_device_code("GITHUB_COPILOT_TOKEN", true, true, None, false),
true, ConfigKey::new("GITHUB_COPILOT_HOST", false, false, None, false),
true, ConfigKey::new("GITHUB_COPILOT_CLIENT_ID", false, false, None, false),
None, ConfigKey::new("GITHUB_COPILOT_TOKEN_URL", false, false, None, false),
false, ],
)],
) )
} }
@@ -633,7 +693,7 @@ fn promote_tool_choice(response: Value) -> Value {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::promote_tool_choice; use super::{normalize_host, promote_tool_choice, GithubCopilotUrls};
use serde_json::json; use serde_json::json;
#[test] #[test]
@@ -677,4 +737,59 @@ mod tests {
let promoted = promote_tool_choice(response.clone()); let promoted = promote_tool_choice(response.clone());
assert_eq!(promoted, response); assert_eq!(promoted, response);
} }
#[test]
fn normalize_host_strips_prefix_and_slash() {
assert_eq!(normalize_host("github.com"), "github.com");
assert_eq!(normalize_host("https://github.com"), "github.com");
assert_eq!(normalize_host("github.com/"), "github.com");
assert_eq!(normalize_host("https://github.com/"), "github.com");
assert_eq!(
normalize_host("https://my-enterprise.ghe.com/"),
"my-enterprise.ghe.com"
);
}
#[test]
fn urls_default_github_com() {
let urls = GithubCopilotUrls::new("github.com", None);
assert_eq!(urls.device_code_url, "https://github.com/login/device/code");
assert_eq!(
urls.access_token_url,
"https://github.com/login/oauth/access_token"
);
assert_eq!(
urls.copilot_token_url,
"https://api.github.com/copilot_internal/v2/token"
);
}
#[test]
fn urls_enterprise_host() {
let urls = GithubCopilotUrls::new("my-enterprise.ghe.com", None);
assert_eq!(
urls.device_code_url,
"https://my-enterprise.ghe.com/login/device/code"
);
assert_eq!(
urls.access_token_url,
"https://my-enterprise.ghe.com/login/oauth/access_token"
);
assert_eq!(
urls.copilot_token_url,
"https://my-enterprise.ghe.com/api/copilot_internal/v2/token"
);
}
#[test]
fn urls_enterprise_with_token_url_override() {
let urls = GithubCopilotUrls::new(
"my-enterprise.ghe.com",
Some("https://my-enterprise.ghe.com/api/v3/copilot_internal/v2/token"),
);
assert_eq!(
urls.copilot_token_url,
"https://my-enterprise.ghe.com/api/v3/copilot_internal/v2/token"
);
}
} }
@@ -172,8 +172,10 @@ export default function ProviderConfigurationModal({
primaryParameters = provider.metadata.config_keys; primaryParameters = provider.metadata.config_keys;
} }
// Check if this provider uses OAuth for configuration const configKeys = provider.metadata.config_keys.filter((key) => !key.oauth_flow);
const isOAuthProvider = provider.metadata.config_keys.some((key) => key.oauth_flow); const hasOAuth = provider.metadata.config_keys.some((key) => key.oauth_flow);
const hasConfig = configKeys.length > 0;
const hasDeviceCodeFlow = provider.metadata.config_keys.some((key) => key.device_code_flow);
const isConfigured = provider.is_configured; const isConfigured = provider.is_configured;
const headerText = showDeleteConfirmation const headerText = showDeleteConfirmation
@@ -189,8 +191,10 @@ export default function ProviderConfigurationModal({
? isActiveProvider ? isActiveProvider
? intl.formatMessage(i18n.cannotDeleteActive) ? intl.formatMessage(i18n.cannotDeleteActive)
: intl.formatMessage(i18n.deleteConfirmation) : intl.formatMessage(i18n.deleteConfirmation)
: isOAuthProvider : hasOAuth
? intl.formatMessage(i18n.oauthSignInDescription, { providerName: provider.metadata.display_name }) ? intl.formatMessage(i18n.oauthSignInDescription, {
providerName: provider.metadata.display_name,
})
: isExternalSetup : isExternalSetup
? provider.metadata.description ? provider.metadata.description
: intl.formatMessage(i18n.addApiKeyDescription); : intl.formatMessage(i18n.addApiKeyDescription);
@@ -199,6 +203,16 @@ export default function ProviderConfigurationModal({
setIsOAuthLoading(true); setIsOAuthLoading(true);
setError(null); setError(null);
try { try {
if (hasConfig) {
for (const key of configKeys) {
const entry = configValues[key.name];
const value =
entry?.value ?? (typeof entry?.serverValue === 'string' ? entry.serverValue : null);
if (value) {
await upsert(key.name, value, key.secret);
}
}
}
await configureProviderOauth({ await configureProviderOauth({
path: { name: provider.name }, path: { name: provider.name },
}); });
@@ -228,7 +242,9 @@ export default function ProviderConfigurationModal({
!configValues[parameter.name]?.value && !configValues[parameter.name]?.value &&
!configValues[parameter.name]?.serverValue !configValues[parameter.name]?.serverValue
) { ) {
errors[parameter.name] = intl.formatMessage(i18n.parameterRequired, { paramName: parameter.name }); errors[parameter.name] = intl.formatMessage(i18n.parameterRequired, {
paramName: parameter.name,
});
} }
}); });
@@ -331,7 +347,9 @@ export default function ProviderConfigurationModal({
<> <>
<Dialog open={!!error} onOpenChange={(open) => !open && setError(null)}> <Dialog open={!!error} onOpenChange={(open) => !open && setError(null)}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogTitle className="flex items-center gap-2">{intl.formatMessage(i18n.errorTitle)}</DialogTitle> <DialogTitle className="flex items-center gap-2">
{intl.formatMessage(i18n.errorTitle)}
</DialogTitle>
<DialogDescription className="text-inherit text-base"> <DialogDescription className="text-inherit text-base">
{intl.formatMessage(i18n.errorCheckingConfig)} {intl.formatMessage(i18n.errorCheckingConfig)}
</DialogDescription> </DialogDescription>
@@ -357,78 +375,97 @@ export default function ProviderConfigurationModal({
<div className="py-4"> <div className="py-4">
{/* Contains information used to set up each provider */} {/* Contains information used to set up each provider */}
{/* Only show the form when NOT in delete confirmation mode */} {/* Only show the form when NOT in delete confirmation mode */}
{!showDeleteConfirmation ? ( {!showDeleteConfirmation && (
isOAuthProvider ? ( <>
<div className="flex flex-col items-center gap-4 py-6"> {hasOAuth && (
<Button <div className="flex flex-col items-center gap-4 py-6">
onClick={handleOAuthLogin} <Button
disabled={isOAuthLoading} onClick={handleOAuthLogin}
className="flex items-center gap-2 px-6 py-3" disabled={isOAuthLoading}
size="lg" className="flex items-center gap-2 px-6 py-3"
> size="lg"
<LogIn size={20} /> >
{isOAuthLoading <LogIn size={20} />
? intl.formatMessage(i18n.signingIn) {isOAuthLoading
: intl.formatMessage(i18n.signInWith, { providerName: provider.metadata.display_name })} ? intl.formatMessage(i18n.signingIn)
</Button> : intl.formatMessage(i18n.signInWith, {
<p className="text-sm text-text-secondary text-center"> providerName: provider.metadata.display_name,
{provider.metadata.config_keys.some((key) => key.device_code_flow) })}
? intl.formatMessage(i18n.deviceCodeFlowHint) </Button>
: intl.formatMessage(i18n.browserWindowHint)} <p className="text-sm text-text-secondary text-center">
</p> {hasDeviceCodeFlow
</div> ? intl.formatMessage(i18n.deviceCodeFlowHint)
) : provider.metadata.config_keys.length === 0 && : intl.formatMessage(i18n.browserWindowHint)}
provider.metadata.setup_steps &&
provider.metadata.setup_steps.length > 0 ? (
<div className="space-y-3">
<p className="text-sm text-text-secondary">
{intl.formatMessage(i18n.externalSetupIntro)}
</p>
<ol className="ml-5 list-decimal text-sm text-text-primary space-y-2">
{provider.metadata.setup_steps.map((step, i) => (
<li key={i}>{renderSetupStep(step)}</li>
))}
</ol>
{provider.metadata.model_doc_link && (
<p className="text-sm text-text-secondary mt-4">
{intl.formatMessage(i18n.seeDocumentation, {
link: (chunks: React.ReactNode) => (
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.electron.openExternal(provider.metadata.model_doc_link);
}}
className="underline hover:text-text-primary"
>
{chunks}
</a>
),
})}
</p> </p>
)} </div>
</div> )}
) : (
<> {hasOAuth && hasConfig && (
{/* Contains information used to set up each provider */}
<DefaultProviderSetupForm <DefaultProviderSetupForm
configValues={configValues} configValues={configValues}
setConfigValues={setConfigValues} setConfigValues={setConfigValues}
provider={provider} provider={{
...provider,
metadata: {
...provider.metadata,
config_keys: configKeys,
},
}}
validationErrors={validationErrors} validationErrors={validationErrors}
/> />
)}
{primaryParameters.length > 0 && {isExternalSetup && (
provider.metadata.config_keys && <div className="space-y-3">
provider.metadata.config_keys.length > 0 && <SecureStorageNotice />} <p className="text-sm text-text-secondary">
</> {intl.formatMessage(i18n.externalSetupIntro)}
) </p>
) : null} <ol className="ml-5 list-decimal text-sm text-text-primary space-y-2">
{provider.metadata.setup_steps?.map((step, i) => (
<li key={i}>{renderSetupStep(step)}</li>
))}
</ol>
{provider.metadata.model_doc_link && (
<p className="text-sm text-text-secondary mt-4">
{intl.formatMessage(i18n.seeDocumentation, {
link: (chunks: React.ReactNode) => (
<a
href="#"
onClick={(e) => {
e.preventDefault();
window.electron.openExternal(provider.metadata.model_doc_link);
}}
className="underline hover:text-text-primary"
>
{chunks}
</a>
),
})}
</p>
)}
</div>
)}
{!hasOAuth && !isExternalSetup && (
<>
<DefaultProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
provider={provider}
validationErrors={validationErrors}
/>
{primaryParameters.length > 0 && provider.metadata.config_keys.length > 0 && (
<SecureStorageNotice />
)}
</>
)}
</>
)}
</div> </div>
<DialogFooter> <DialogFooter>
{isOAuthProvider && !showDeleteConfirmation ? ( {hasOAuth && !showDeleteConfirmation ? (
<div className="flex gap-2"> <div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}> <Button variant="outline" onClick={handleCancel}>
{intl.formatMessage(i18n.cancel)} {intl.formatMessage(i18n.cancel)}
</Button> </Button>
@@ -438,10 +475,7 @@ export default function ProviderConfigurationModal({
</Button> </Button>
)} )}
</div> </div>
) : provider.metadata.config_keys.length === 0 && ) : isExternalSetup && !showDeleteConfirmation ? (
provider.metadata.setup_steps &&
provider.metadata.setup_steps.length > 0 &&
!showDeleteConfirmation ? (
<div className="w-full"> <div className="w-full">
<Button <Button
type="button" type="button"
@@ -3,6 +3,7 @@ import { Input } from '../../../../../ui/input';
import { useConfig } from '../../../../../ConfigContext'; import { useConfig } from '../../../../../ConfigContext';
import { ProviderDetails, ConfigKey } from '../../../../../../api'; import { ProviderDetails, ConfigKey } from '../../../../../../api';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../../../../ui/collapsible';
import { configLabels, configPlaceholders } from '../../../../../../utils/configUtils';
import { defineMessages, useIntl } from '../../../../../../i18n'; import { defineMessages, useIntl } from '../../../../../../i18n';
const i18n = defineMessages({ const i18n = defineMessages({
@@ -138,9 +139,11 @@ export default function DefaultProviderSetupForm({
return parameter.default; return parameter.default;
} }
if (configPlaceholders[parameter.name]) return configPlaceholders[parameter.name];
const name = parameter.name.toLowerCase(); const name = parameter.name.toLowerCase();
if (name.includes('api_key')) return intl.formatMessage(i18n.apiKeyPlaceholder); if (name.includes('api_key')) return intl.formatMessage(i18n.apiKeyPlaceholder);
if (name.includes('api_url') || name.includes('host')) return intl.formatMessage(i18n.apiHostPlaceholder); if (name.includes('api_url') || name.includes('host'))
return intl.formatMessage(i18n.apiHostPlaceholder);
if (name.includes('models')) return intl.formatMessage(i18n.modelsPlaceholder); if (name.includes('models')) return intl.formatMessage(i18n.modelsPlaceholder);
return parameter.name return parameter.name
@@ -150,9 +153,11 @@ export default function DefaultProviderSetupForm({
}; };
const getFieldLabel = (parameter: ConfigKey) => { const getFieldLabel = (parameter: ConfigKey) => {
if (configLabels[parameter.name]) return configLabels[parameter.name];
const name = parameter.name.toLowerCase(); const name = parameter.name.toLowerCase();
if (name.includes('api_key')) return intl.formatMessage(i18n.apiKeyLabel); if (name.includes('api_key')) return intl.formatMessage(i18n.apiKeyLabel);
if (name.includes('api_url') || name.includes('host')) return intl.formatMessage(i18n.apiHostLabel); if (name.includes('api_url') || name.includes('host'))
return intl.formatMessage(i18n.apiHostLabel);
if (name.includes('models')) return intl.formatMessage(i18n.modelsLabel); if (name.includes('models')) return intl.formatMessage(i18n.modelsLabel);
let parameter_name = parameter.name.toUpperCase(); let parameter_name = parameter.name.toUpperCase();
+12
View File
@@ -57,6 +57,17 @@ export const configLabels: Record<string, string> = {
// snowflake // snowflake
SNOWFLAKE_HOST: 'Snowflake Host', SNOWFLAKE_HOST: 'Snowflake Host',
SNOWFLAKE_TOKEN: 'Snowflake Token', SNOWFLAKE_TOKEN: 'Snowflake Token',
// github copilot
GITHUB_COPILOT_HOST: 'Custom GitHub Host',
GITHUB_COPILOT_CLIENT_ID: 'Custom GitHub OAuth Client ID',
GITHUB_COPILOT_TOKEN_URL: 'Custom GitHub Copilot Token URL',
};
export const configPlaceholders: Record<string, string> = {
GITHUB_COPILOT_HOST: 'my-enterprise.ghe.com',
GITHUB_COPILOT_CLIENT_ID: 'Iv1.xxxxxxxxxxxxxxxx',
GITHUB_COPILOT_TOKEN_URL: 'https://my-enterprise.ghe.com/api/copilot_internal/v2/token',
}; };
export const providerPrefixes: Record<string, string[]> = { export const providerPrefixes: Record<string, string[]> = {
@@ -70,6 +81,7 @@ export const providerPrefixes: Record<string, string[]> = {
azure_openai: ['AZURE_'], azure_openai: ['AZURE_'],
gcp_vertex_ai: ['GCP_'], gcp_vertex_ai: ['GCP_'],
snowflake: ['SNOWFLAKE_'], snowflake: ['SNOWFLAKE_'],
github_copilot: ['GITHUB_COPILOT_'],
}; };
export const getUiNames = (key: string): string => { export const getUiNames = (key: string): string => {