diff --git a/.github/instructions/docs-style.instructions.md b/.github/instructions/docs-style.instructions.md index 36abe44d7d..32ea46fbfe 100644 --- a/.github/instructions/docs-style.instructions.md +++ b/.github/instructions/docs-style.instructions.md @@ -26,7 +26,7 @@ Do not use `**bold**` or `*italic*` markers inside headings. The heading level p * Acronyms: MCP, BYOK, MAF, SDK, CLI, API, HMAC, CI/CD, SaaS, ISV, FAQ, LLM, AI, EMU, ID, UI, PNG * Tools (keep canonical casing): npm, npx, stdio * Code identifiers in headings: SessionConfig, MessageOptions, TelemetryConfig, ProviderConfig, CopilotClient -* Multi-word proper names: GitHub App, GitHub Actions, GitHub OAuth, Foundry Local, Azure AD, Container Instances +* Multi-word proper names: GitHub App, GitHub Actions, GitHub OAuth, Foundry Local, Managed Identity, Container Instances ## Callouts diff --git a/docs/auth/byok.md b/docs/auth/byok.md index 01d954a40b..7e34fb46f1 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -205,6 +205,7 @@ client.stop().get(); | `baseUrl` / `base_url` | string | **Required.** API endpoint URL | | `apiKey` / `api_key` | string | API key (optional for local providers like Ollama) | | `bearerToken` / `bearer_token` | string | Bearer token auth (takes precedence over apiKey) | +| `bearerTokenProvider` / `bearer_token_provider` | callback | Returns a bearer token on demand (takes precedence over `apiKey` and `bearerToken`) | | `wireApi` / `wire_api` | `"completions"` \| `"responses"` | Select `"completions"` for broad model compatibility (the Chat Completions API); select `"responses"` for multi-turn state management, tool namespacing, and reasoning support (the Responses API). Anthropic models always use the Messages API regardless of this setting. | | `azure.apiVersion` / `azure.api_version` | string | Azure API version (default: `"2024-10-21"`) | @@ -326,7 +327,9 @@ provider: { ### Bearer token authentication -Some providers require bearer token authentication instead of API keys: +Some providers require bearer token authentication instead of API keys. Supply a static token with `bearerToken`, or supply a `bearerTokenProvider` callback that the GitHub Copilot SDK runtime invokes before outbound provider requests. The callback or identity library it wraps manages token caching and refresh. + +Use `bearerToken` when your application already has a token: ```typescript provider: { @@ -339,6 +342,22 @@ provider: { > [!NOTE] > The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token. +Use `bearerTokenProvider` to acquire tokens on demand: + + + +```typescript +provider: { + type: "openai", + baseUrl: "https://my-custom-endpoint.example.com/v1", + bearerTokenProvider: async () => { + return await acquireBearerToken(); + }, +} +``` + +For more details about acquiring and refreshing Microsoft Entra bearer tokens, see [Azure Managed Identity with BYOK](../setup/azure-managed-identity.md). + ## Custom model listing When using BYOK, the CLI server may not know which models your provider supports. You can supply a custom `onListModels` handler at the client level so that `client.listModels()` returns your provider's models in the standard `ModelInfo` format. This lets downstream consumers discover available models without querying the CLI. diff --git a/docs/setup/azure-managed-identity.md b/docs/setup/azure-managed-identity.md index 8afac6e1d3..cac33edb2d 100644 --- a/docs/setup/azure-managed-identity.md +++ b/docs/setup/azure-managed-identity.md @@ -1,27 +1,32 @@ -# Azure managed identity with BYOK +# Azure Managed Identity with BYOK -The Copilot SDK's [BYOK mode](../auth/byok.md) accepts static API keys, but Azure deployments often use **Managed Identity** (Microsoft Entra ID) instead of long-lived keys. Since the SDK doesn't natively support Microsoft Entra authentication, you can use a short-lived bearer token via the `bearer_token` provider config field. +The GitHub Copilot SDK's [BYOK mode](../auth/byok.md) supports static API keys, but Azure deployments often use **Managed Identity** (Microsoft Entra ID) instead of long-lived keys. The GitHub Copilot SDK is designed to compose with the Azure Identity SDK for maximum flexibility. Supply a bearer token provider callback that can fetch fresh tokens on demand using an Azure Identity SDK API. -This guide shows how to use the Azure Identity SDK's `DefaultAzureCredential` API to authenticate with Microsoft Foundry models through the Copilot SDK. +This guide shows how to use Azure Identity SDK APIs to authenticate with Microsoft Foundry models through the GitHub Copilot SDK. Most languages use `DefaultAzureCredential`; Rust uses `DeveloperToolsCredential` locally and `ManagedIdentityCredential` in Azure. ## How it works -Microsoft Foundry's OpenAI-compatible endpoint accepts bearer tokens from Microsoft Entra ID in place of static API keys. The pattern is: +Microsoft Foundry's OpenAI-compatible endpoint (`https://.openai.azure.com/openai/v1/`) accepts bearer tokens from Microsoft Entra ID in place of static API keys. This guide uses a token provider callback so the GitHub Copilot SDK runtime can request fresh tokens on demand. -1. Use `DefaultAzureCredential` to obtain a token for the `https://ai.azure.com/.default` scope -1. Pass the token as the `bearer_token` in the BYOK provider config -1. Refresh the token before it expires (tokens are typically valid for ~1 hour) +Using Python as an example, the flow is: + +1. Configure `DefaultAzureCredential` for your environment. +1. Pass a callback, in `bearer_token_provider` of the BYOK provider configuration, that uses `DefaultAzureCredential` to obtain a token for the `https://ai.azure.com/.default` scope. +1. Let the GitHub Copilot SDK request fresh tokens on demand through that callback. ```mermaid sequenceDiagram participant App as Your Application - participant MEID as Microsoft Entra ID - participant SDK as Copilot SDK + participant SDK as GitHub Copilot SDK participant Foundry as Microsoft Foundry + participant MEID as Microsoft Entra ID + App->>SDK: create_session(provider={bearer_token_provider: callback}) + App->>SDK: send message + SDK->>App: Request token from callback App->>MEID: DefaultAzureCredential.get_token() - MEID-->>App: Bearer token (~1hr) - App->>SDK: create_session(provider={bearer_token: token}) + MEID-->>App: Access token + App-->>SDK: token SDK->>Foundry: Request with Authorization: Bearer Foundry-->>SDK: Model response SDK-->>App: Session events @@ -31,7 +36,7 @@ sequenceDiagram ### Prerequisites -Install the Azure Identity and Copilot SDK packages for your language: +Install the Azure Identity and GitHub Copilot SDK packages for your language:
.NET @@ -43,6 +48,37 @@ dotnet add package GitHub.Copilot.SDK dotnet add package Azure.Core ``` +
+
+Go + + + +```bash +go get github.com/github/copilot-sdk/go +go get github.com/Azure/azure-sdk-for-go/sdk/azidentity +``` + +
+
+Java + + + +```xml + + com.github + copilot-sdk-java + ${copilot.sdk.version} + + + + com.azure + azure-identity + ${azure.identity.version} + +``` +
Python @@ -53,6 +89,17 @@ dotnet add package Azure.Core pip install github-copilot-sdk azure-identity ``` +
+
+Rust + + + +```bash +cargo add github-copilot-sdk azure_identity azure_core +cargo add tokio --features macros,rt-multi-thread +``` +
TypeScript @@ -65,9 +112,11 @@ npm install @github/copilot-sdk @azure/identity
-### Basic usage +### Use a token provider callback + +Use this approach when you want the GitHub Copilot SDK runtime to request fresh tokens on demand through a callback that you provide. The Azure Identity SDK handles token caching and refresh timing. -Get a token using `DefaultAzureCredential` and pass it as the bearer token in your provider configuration: +Here are language-specific implementations:
.NET @@ -81,11 +130,8 @@ using GitHub.Copilot; DefaultAzureCredential credential = new( DefaultAzureCredential.DefaultEnvironmentVariableName); -AccessToken token = await credential.GetTokenAsync( - new TokenRequestContext(new[] { "https://ai.azure.com/.default" })); - await using CopilotClient client = new(); -string? foundryUrl = Environment.GetEnvironmentVariable("FOUNDRY_RESOURCE_URL"); +string foundryUrl = Environment.GetEnvironmentVariable("FOUNDRY_RESOURCE_URL")!; await using CopilotSession session = await client.CreateSessionAsync(new SessionConfig { @@ -93,8 +139,13 @@ await using CopilotSession session = await client.CreateSessionAsync(new Session Provider = new ProviderConfig { Type = "openai", - BaseUrl = $"{foundryUrl!.TrimEnd('/')}/openai/v1/", - BearerToken = token.Token, + BaseUrl = $"{foundryUrl}/openai/v1/", + BearerTokenProvider = async _ => + { + AccessToken token = await credential.GetTokenAsync( + new TokenRequestContext(["https://ai.azure.com/.default"])); + return token.Token; + }, WireApi = "responses", }, }); @@ -104,6 +155,135 @@ AssistantMessageEvent? response = await session.SendAndWaitAsync( Console.WriteLine(response?.Data.Content); ``` +
+
+Go + + + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + copilot "github.com/github/copilot-sdk/go" +) +func main() { + opts := azidentity.DefaultAzureCredentialOptions{RequireAzureTokenCredentials: true} + credential, err := azidentity.NewDefaultAzureCredential(&opts) + if err != nil { + log.Fatal(err) + } + + getBearerToken := func(args copilot.ProviderTokenArgs) (string, error) { + token, err := credential.GetToken(context.Background(), policy.TokenRequestOptions{ + Scopes: []string{"https://ai.azure.com/.default"}, + }) + if err != nil { + return "", err + } + return token.Token, nil + } + + client := copilot.NewClient(nil) + if err := client.Start(context.Background()); err != nil { + log.Fatal(err) + } + defer client.Stop() + + foundryURL := os.Getenv("FOUNDRY_RESOURCE_URL") + + session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ + Model: "gpt-5.5", + Provider: &copilot.ProviderConfig{ + Type: "openai", + BaseURL: fmt.Sprintf("%s/openai/v1/", foundryURL), + BearerTokenProvider: getBearerToken, + WireAPI: "responses", + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Disconnect() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Hello from Managed Identity!", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil { + if data, ok := response.Data.(*copilot.AssistantMessageData); ok { + fmt.Println(data.Content) + } + } +} +``` + +
+
+Java + + + +```java +import com.azure.core.credential.TokenRequestContext; +import com.azure.identity.AzureIdentityEnvVars; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.github.copilot.CopilotClient; +import com.github.copilot.generated.AssistantMessageEvent; +import com.github.copilot.rpc.BearerTokenProvider; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.ProviderConfig; +import com.github.copilot.rpc.SessionConfig; + +public class ManagedIdentityExample { + public static void main(String[] args) throws Exception { + var credential = new DefaultAzureCredentialBuilder() + .requireEnvVars(AzureIdentityEnvVars.AZURE_TOKEN_CREDENTIALS) + .build(); + BearerTokenProvider tokenProvider = providerArgs -> + credential + .getToken(new TokenRequestContext().addScopes("https://ai.azure.com/.default")) + .map(accessToken -> accessToken.getToken()) + .toFuture(); + String foundryUrl = System.getenv("FOUNDRY_RESOURCE_URL"); + + try (var client = new CopilotClient()) { + client.start().get(); + + var session = client.createSession(new SessionConfig() + .setModel("gpt-5.5") + .setProvider(new ProviderConfig() + .setType("openai") + .setBaseUrl(foundryUrl + "/openai/v1/") + .setBearerTokenProvider(tokenProvider) + .setWireApi("responses"))) + .get(); + + AssistantMessageEvent response = session + .sendAndWait(new MessageOptions().setPrompt("Hello from Managed Identity!")) + .get(); + System.out.println(response.getData().content()); + + session.disconnect().get(); + } + } +} +``` +
Python @@ -114,17 +294,15 @@ Console.WriteLine(response?.Data.Content); import asyncio import os -from azure.identity import DefaultAzureCredential +from azure.identity.aio import DefaultAzureCredential from copilot import CopilotClient from copilot.session import PermissionHandler, ProviderConfig -SCOPE = "https://ai.azure.com/.default" - - async def main(): - # Get a token using Managed Identity, Azure CLI, or other credential chain credential = DefaultAzureCredential(require_envvar=True) - token = credential.get_token(SCOPE).token + async def get_bearer_token(_args) -> str: + token = await credential.get_token("https://ai.azure.com/.default") + return token.token foundry_url = os.environ["FOUNDRY_RESOURCE_URL"] @@ -137,7 +315,7 @@ async def main(): provider=ProviderConfig( type="openai", base_url=f"{foundry_url.rstrip('/')}/openai/v1/", - bearer_token=token, # Short-lived bearer token + bearer_token_provider=get_bearer_token, wire_api="responses", ), ) @@ -146,11 +324,75 @@ async def main(): print(response.data.content) await client.stop() + await credential.close() asyncio.run(main()) ``` +
+
+Rust + + + +```rust +use std::sync::Arc; + +use azure_core::credentials::TokenCredential; +use azure_identity::{DeveloperToolsCredential, ManagedIdentityCredential}; +use github_copilot_sdk::{BearerTokenError, Client, ClientOptions, MessageOptions, ProviderTokenArgs}; +use github_copilot_sdk::types::{ProviderConfig, SessionConfig}; + +fn credential_for_environment() -> azure_core::Result> { + match std::env::var("AZURE_TOKEN_CREDENTIALS").as_deref() { + Ok("ManagedIdentityCredential") => Ok(ManagedIdentityCredential::new(None)?), + _ => Ok(DeveloperToolsCredential::new(None)?), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let credential = credential_for_environment()?; + let foundry_url = std::env::var("FOUNDRY_RESOURCE_URL")?; + + let get_bearer_token = { + let credential = credential.clone(); + move |_args: ProviderTokenArgs| { + let credential = credential.clone(); + async move { + let token = credential + .get_token(&["https://ai.azure.com/.default"], None) + .await + .map_err(|err| BearerTokenError::message(err.to_string()))?; + Ok(token.token.secret().to_string()) + } + } + }; + + let mut provider = ProviderConfig::default(); + provider.provider_type = Some("openai".to_string()); + provider.base_url = format!("{}/openai/v1/", foundry_url.trim_end_matches('/')); + provider.bearer_token_provider = Some(Arc::new(get_bearer_token)); + provider.wire_api = Some("responses".to_string()); + + let mut config = SessionConfig::default(); + config.model = Some("gpt-5.5".to_string()); + config.provider = Some(provider); + + let client = Client::start(ClientOptions::default()).await?; + let session = client.create_session(config).await?; + + session + .send_and_wait(MessageOptions::new("Hello from Managed Identity!")) + .await?; + + session.disconnect().await?; + client.stop().await?; + Ok(()) +} +``` +
TypeScript @@ -164,9 +406,10 @@ import { CopilotClient } from "@github/copilot-sdk"; const credential = new DefaultAzureCredential({ requiredEnvVars: ["AZURE_TOKEN_CREDENTIALS"], }); -const tokenResponse = await credential.getToken( - "https://ai.azure.com/.default" -); +const getBearerToken = async () => { + const tokenResponse = await credential.getToken("https://ai.azure.com/.default"); + return tokenResponse.token; +}; const client = new CopilotClient(); @@ -175,12 +418,12 @@ const session = await client.createSession({ provider: { type: "openai", baseUrl: `${process.env.FOUNDRY_RESOURCE_URL}/openai/v1/`, - bearerToken: tokenResponse.token, + bearerTokenProvider: getBearerToken, wireApi: "responses", }, }); -const response = await session.sendAndWait({ prompt: "Hello!" }); +const response = await session.sendAndWait({ prompt: "Hello from Managed Identity!" }); console.log(response?.data.content); await client.stop(); @@ -188,71 +431,28 @@ await client.stop();
-### Token refresh for long-running applications - -Bearer tokens expire (typically after ~1 hour). For servers or long-running agents, refresh the token before creating each session. The following Python example demonstrates this pattern: - - - -```python -from azure.identity import DefaultAzureCredential -from copilot import CopilotClient -from copilot.session import PermissionHandler, ProviderConfig - -SCOPE = "https://ai.azure.com/.default" - - -class ManagedIdentityCopilotAgent: - """Copilot agent that refreshes Microsoft Entra tokens for Microsoft Foundry.""" - - def __init__(self, foundry_url: str, model: str = "gpt-5.5"): - self.foundry_url = foundry_url.rstrip("/") - self.model = model - self.credential = DefaultAzureCredential(require_envvar=True) - self.client = CopilotClient() - - def _get_provider_config(self) -> ProviderConfig: - """Build a ProviderConfig with a fresh bearer token.""" - token = self.credential.get_token(SCOPE).token - return ProviderConfig( - type="openai", - base_url=f"{self.foundry_url}/openai/v1/", - bearer_token=token, - wire_api="responses", - ) - - async def chat(self, prompt: str) -> str: - """Send a prompt and return the response text.""" - # Fresh token for each session - session = await self.client.create_session( - on_permission_request=PermissionHandler.approve_all, - model=self.model, - provider=self._get_provider_config(), - ) - - response = await session.send_and_wait(prompt) - await session.disconnect() - - return response.data.content if response else "" -``` - ## Environment configuration | Variable | Description | Example | |----------|-------------|---------| | `AZURE_TOKEN_CREDENTIALS` | When running in **Azure**, set it to `ManagedIdentityCredential`. When running **locally**, set it to either `dev` or a developer tool credential name, such as `AzureCliCredential`. | `ManagedIdentityCredential` | +| `AZURE_CLIENT_ID` | *Optional.* When running in **Azure**, set this to the client ID of a User-assigned Managed Identity when using `ManagedIdentityCredential`. If not set, Azure uses the System-assigned Managed Identity. | `11111111-2222-3333-4444-555555555555` | | `FOUNDRY_RESOURCE_URL` | Your Microsoft Foundry resource URL | `https://.openai.azure.com` | -No API key environment variable is needed—authentication is handled by `DefaultAzureCredential`, which automatically supports: +No API key environment variable is needed—authentication is handled by Azure Identity credentials. In .NET, Go, Java, Python, and TypeScript, `DefaultAzureCredential` automatically supports: -* **Managed Identity** (system-assigned or user-assigned): for Azure-hosted apps +* **Managed Identity** (System-assigned or User-assigned): for Azure-hosted apps * **Azure CLI** (`az login`): for local development * **Environment variables** (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`): for service principals * **Workload Identity**: for Kubernetes -See the `DefaultAzureCredential` documentation for the full credential chain: +In .NET, Go, Java, Python, and TypeScript, `ManagedIdentityCredential` reads `AZURE_CLIENT_ID` to select a User-assigned Managed Identity. Rust is an exception in this guide. + +In Rust, use `DeveloperToolsCredential` for local development and `ManagedIdentityCredential` when running in Azure. For other languages, see the `DefaultAzureCredential` documentation for the full credential chain: * [.NET](https://aka.ms/azsdk/net/identity/credential-chains#defaultazurecredential-overview) +* [Go](https://aka.ms/azsdk/go/identity/credential-chains#defaultazurecredential-overview) +* [Java](https://aka.ms/azsdk/java/identity/credential-chains#defaultazurecredential-overview) * [Python](https://aka.ms/azsdk/python/identity/credential-chains#defaultazurecredential-overview) * [TypeScript](https://aka.ms/azsdk/js/identity/credential-chains#defaultazurecredential-overview) @@ -270,4 +470,3 @@ See the `DefaultAzureCredential` documentation for the full credential chain: * [BYOK Setup Guide](../auth/byok.md): Static API key configuration * [Backend Services](./backend-services.md): Server-side deployment -* [Azure Identity documentation](https://learn.microsoft.com/python/api/overview/azure/identity-readme)