diff --git a/CHANGELOG.md b/CHANGELOG.md index a708066..92c397a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added (documentation) +- Azure Managed Identity BYOK guide (`doc/auth/azure-managed-identity.md`): shows how to use `DefaultAzureCredential` with short-lived bearer tokens for Azure AI Foundry, with Clojure examples for basic usage and token refresh (upstream PR #498). +- Updated BYOK limitations to link to the Managed Identity workaround instead of listing it as fully unsupported. +- Added Azure Managed Identity guide to `doc/auth/index.md` and `doc/index.md`. + +### Added (upstream PR #512 sync) +- `examples/file_attachments.clj` — Demonstrates sending file attachments with prompts using `:attachments` in message options. +- `examples/session_resume.clj` — Demonstrates session resume: create session, send secret word, resume by ID, verify context preserved. +- `examples/infinite_sessions.clj` — Demonstrates infinite sessions with context compaction thresholds for long conversations. +- `examples/lifecycle_hooks.clj` — Demonstrates all 6 lifecycle hooks: session start/end, pre/post tool use, user prompt submitted, error occurred. +- `examples/reasoning_effort.clj` — Demonstrates the `:reasoning-effort` session config option. + ## [0.1.28.0] - 2026-02-27 ### Changed (upstream PR #554 sync) - **BREAKING**: `:on-permission-request` is now **required** when calling `create-session`, `resume-session`, ` +```clojure +(require '[github.copilot-sdk :as copilot]) +(require '[github.copilot-sdk.helpers :as h]) + +(import '[com.azure.identity DefaultAzureCredentialBuilder] + '[com.azure.core.credential TokenRequestContext]) + +(def cognitive-services-scope "https://cognitiveservices.azure.com/.default") + +(defn get-azure-token + "Obtain a short-lived bearer token string from Entra ID." + [] + (let [credential (.build (DefaultAzureCredentialBuilder.)) + context (doto (TokenRequestContext.) + (.addScopes (into-array String [cognitive-services-scope])))] + (-> (.getToken credential context) + (.block) + (.getToken)))) + +(def foundry-url (System/getenv "AZURE_AI_FOUNDRY_RESOURCE_URL")) + +(copilot/with-client-session [session + {:model "gpt-4.1" + :provider {:provider-type :openai + :base-url (str foundry-url "/openai/v1/") + :bearer-token (get-azure-token) + :wire-api :responses}}] + (println (h/query "Hello from Managed Identity!" :session session))) +``` + +### 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: + + +```clojure +(require '[github.copilot-sdk :as copilot]) +(require '[github.copilot-sdk.helpers :as h]) + +(import '[com.azure.identity DefaultAzureCredentialBuilder] + '[com.azure.core.credential TokenRequestContext]) + +(def cognitive-services-scope "https://cognitiveservices.azure.com/.default") +(def credential (.build (DefaultAzureCredentialBuilder.))) +(def context (doto (TokenRequestContext.) + (.addScopes (into-array String [cognitive-services-scope])))) + +(defn fresh-provider-config + "Build a provider config with a freshly obtained bearer token." + [foundry-url] + (let [token (-> (.getToken credential context) + (.block) + (.getToken))] + {:provider-type :openai + :base-url (str foundry-url "/openai/v1/") + :bearer-token token + :wire-api :responses})) + +(def foundry-url (System/getenv "AZURE_AI_FOUNDRY_RESOURCE_URL")) + +;; Each session gets a fresh token +(copilot/with-client [client {}] + (dotimes [_ 3] + (copilot/with-session [session client + {:model "gpt-4.1" + :provider (fresh-provider-config foundry-url)}] + (println (h/query "Hello!" :session session))))) +``` + +## Environment Configuration + +| Variable | Description | Example | +|----------|-------------|---------| +| `AZURE_AI_FOUNDRY_RESOURCE_URL` | Your Azure AI Foundry resource URL | `https://myresource.openai.azure.com` | + +No API key environment variable is needed — authentication is handled by `DefaultAzureCredential`, which automatically supports: + +- **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](https://learn.microsoft.com/java/api/com.azure.identity.defaultazurecredential) for the full credential chain. + +## When to Use This Pattern + +| Scenario | Recommendation | +|----------|----------------| +| Azure-hosted app with Managed Identity | ✅ Use this pattern | +| App with existing Azure AD service principal | ✅ Use this pattern | +| Local development with `az login` | ✅ Use this pattern | +| Non-Azure environment with static API key | Use [standard BYOK](./byok.md) | +| GitHub Copilot subscription available | Use [GitHub auth](./index.md#github-signed-in-user) | + +## See Also + +- [BYOK Setup Guide](./byok.md) — Static API key configuration +- [Authentication Overview](./index.md) — All authentication methods +- [Azure Identity documentation](https://learn.microsoft.com/java/api/overview/azure/identity-readme) diff --git a/doc/auth/byok.md b/doc/auth/byok.md index 6f68c21..ca55f2e 100644 --- a/doc/auth/byok.md +++ b/doc/auth/byok.md @@ -167,13 +167,14 @@ Some providers require bearer token authentication instead of API keys: ### Identity Limitations -BYOK authentication is **key-based only**. The following are NOT supported: +BYOK authentication uses **static credentials that you supply** (API keys or bearer tokens); it does not natively perform Entra ID, OIDC, or managed identity flows. However, you can use `DefaultAzureCredential` to obtain a short-lived bearer token and pass it via `:bearer-token`. See the [Azure Managed Identity workaround](./azure-managed-identity.md) for details. + +The following identity flows are NOT natively supported (you must handle them yourself and pass the resulting credential to BYOK): - ❌ Microsoft Entra ID (Azure AD) managed identities or service principals - ❌ Third-party identity providers (OIDC, SAML, etc.) -- ❌ Azure Managed Identity -You must use an API key or bearer token that you manage yourself. +You must provide and manage the API key or bearer token that BYOK uses. ### Feature Limitations diff --git a/doc/auth/index.md b/doc/auth/index.md index 183caec..993458b 100644 --- a/doc/auth/index.md +++ b/doc/auth/index.md @@ -134,5 +134,6 @@ To prevent the SDK from automatically using stored credentials or `gh` CLI auth: ## Next Steps - [BYOK Documentation](./byok.md) — Use your own API keys +- [Azure Managed Identity](./azure-managed-identity.md) — Azure BYOK without static API keys - [Getting Started Guide](../getting-started.md) — Build your first Copilot-powered app - [MCP Servers](../mcp/overview.md) — Connect to external tools diff --git a/doc/index.md b/doc/index.md index 8e41317..f4d5501 100644 --- a/doc/index.md +++ b/doc/index.md @@ -11,6 +11,7 @@ Clojure SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. - [Authentication](auth/index.md) — GitHub auth, OAuth, environment variables, priority order - [BYOK Providers](auth/byok.md) — Bring Your Own Key for OpenAI, Azure, Anthropic, Ollama +- [Azure Managed Identity](auth/azure-managed-identity.md) — Azure BYOK with Managed Identity (no API keys) - [MCP Servers](mcp/overview.md) — Model Context Protocol server integration - [MCP Debugging](mcp/debugging.md) — Troubleshooting MCP connections diff --git a/examples/README.md b/examples/README.md index 74ad768..ca2bffb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -67,6 +67,21 @@ clojure -A:examples -X byok-provider/run :provider-name '"ollama"' # MCP local server (requires npx/Node.js) clojure -A:examples -X mcp-local-server/run clojure -A:examples -X mcp-local-server/run-with-custom-tools + +# File attachments +clojure -A:examples -X file-attachments/run + +# Session resume +clojure -A:examples -X session-resume/run + +# Infinite sessions (context compaction) +clojure -A:examples -X infinite-sessions/run + +# Lifecycle hooks +clojure -A:examples -X lifecycle-hooks/run + +# Reasoning effort +clojure -A:examples -X reasoning-effort/run ``` Or run all examples: @@ -74,8 +89,8 @@ Or run all examples: ./run-all-examples.sh ``` -> **Note:** `run-all-examples.sh` runs the core examples (1–9) that need only the Copilot CLI. -> Example 10 (BYOK) and Example 11 (MCP) require external dependencies (API keys, Node.js) and must be run manually. +> **Note:** `run-all-examples.sh` runs 14 examples that need only the Copilot CLI (examples 1–9 and 12–16). +> Examples 10 (BYOK) and 11 (MCP) require external dependencies (API keys, Node.js) and must be run manually. With a custom CLI path: ```bash @@ -584,6 +599,137 @@ See [doc/mcp/overview.md](../doc/mcp/overview.md) for full MCP documentation. --- +## Example 12: File Attachments (`file_attachments.clj`) + +**Difficulty:** Beginner +**Concepts:** File attachments, message options + +Attach files to a prompt so the model can analyze their contents. + +### What It Demonstrates + +- Sending `:attachments` in message options with `send-and-wait!` +- File attachment type: `{:type :file :path "/absolute/path"}` +- Resolving relative paths to absolute with `java.io.File` + +### Usage + +```bash +# Attach and analyze deps.edn (default) +clojure -A:examples -X file-attachments/run + +# Attach a different file +clojure -A:examples -X file-attachments/run :file-path '"README.md"' +``` + +--- + +## Example 13: Session Resume (`session_resume.clj`) + +**Difficulty:** Intermediate +**Concepts:** Session persistence, session resume, multi-session lifecycle + +Resume a previous session by ID to continue a conversation with preserved context. + +### What It Demonstrates + +- Creating a session and sending a message to store context +- Retrieving the session ID from the session map +- Resuming a session with `copilot/resume-session` +- Verifying context is preserved across resume +- Manual session lifecycle with `with-client` (ensures `stop!`/session cleanup), `create-session`, `resume-session` + +### Usage + +```bash +# Default: remembers "PINEAPPLE" +clojure -A:examples -X session-resume/run + +# Custom secret word +clojure -A:examples -X session-resume/run :secret-word '"MANGO"' +``` + +--- + +## Example 14: Infinite Sessions (`infinite_sessions.clj`) + +**Difficulty:** Intermediate +**Concepts:** Infinite sessions, context compaction, long conversations + +Enable infinite sessions so the SDK automatically compacts older messages when the context window fills up. + +### What It Demonstrates + +- Configuring `:infinite-sessions` with compaction thresholds +- `:background-compaction-threshold` — when background compaction starts (80%) +- `:buffer-exhaustion-threshold` — when urgent compaction triggers (95%) +- Sending multiple prompts in a long-running session + +### Usage + +```bash +clojure -A:examples -X infinite-sessions/run + +# Custom prompts +clojure -A:examples -X infinite-sessions/run :prompts '["What is Clojure?" "Who created it?" "When?"]' +``` + +--- + +## Example 15: Lifecycle Hooks (`lifecycle_hooks.clj`) + +**Difficulty:** Intermediate +**Concepts:** Hooks, callbacks, tool use monitoring + +Register callbacks for session lifecycle events: start/end, tool use, prompts, and errors. + +### What It Demonstrates + +- Configuring `:hooks` in session config with all 6 hook types +- `:on-session-start` — fires when session begins +- `:on-session-end` — fires when session ends +- `:on-pre-tool-use` — fires before a tool runs (return `{:approved true}` to allow) +- `:on-post-tool-use` — fires after a tool completes +- `:on-user-prompt-submitted` — fires when user sends a prompt +- `:on-error-occurred` — fires on errors +- Collecting and summarizing hook events + +### Usage + +```bash +clojure -A:examples -X lifecycle-hooks/run + +# Custom prompt +clojure -A:examples -X lifecycle-hooks/run :prompt '"List all .md files using glob"' +``` + +--- + +## Example 16: Reasoning Effort (`reasoning_effort.clj`) + +**Difficulty:** Beginner +**Concepts:** Reasoning effort, model configuration + +Control how much reasoning the model applies with the `:reasoning-effort` option. + +### What It Demonstrates + +- Setting `:reasoning-effort` in session config +- Valid values: `"low"`, `"medium"`, `"high"`, `"xhigh"` +- Lower effort produces faster, more concise responses + +### Usage + +```bash +# Default: low reasoning effort +clojure -A:examples -X reasoning-effort/run + +# Higher reasoning effort +clojure -A:examples -X reasoning-effort/run :effort '"high"' +``` + +--- + ## Clojure vs JavaScript Comparison Here's how common patterns compare between the Clojure and JavaScript SDKs: diff --git a/examples/file_attachments.clj b/examples/file_attachments.clj new file mode 100644 index 0000000..f93001e --- /dev/null +++ b/examples/file_attachments.clj @@ -0,0 +1,24 @@ +(ns file-attachments + "Demonstrates sending file attachments with a prompt. + Attaches the project's deps.edn file and asks the model to analyze it." + (:require [github.copilot-sdk :as copilot])) + +;; See examples/README.md for usage + +(def defaults + {:prompt "Summarize the dependencies in the attached file in 2-3 sentences." + :file-path "deps.edn"}) + +(defn run + [{:keys [prompt file-path] + :or {prompt (:prompt defaults) file-path (:file-path defaults)}}] + (let [abs-path (.getAbsolutePath (java.io.File. file-path))] + (copilot/with-client-session [session {:on-permission-request copilot/approve-all + :model "claude-haiku-4.5"}] + (println "📎 Attaching:" abs-path) + (println "Q:" prompt) + (let [response (copilot/send-and-wait! + session + {:prompt prompt + :attachments [{:type :file :path abs-path}]})] + (println "🤖:" (get-in response [:data :content])))))) diff --git a/examples/infinite_sessions.clj b/examples/infinite_sessions.clj new file mode 100644 index 0000000..ccd4a9b --- /dev/null +++ b/examples/infinite_sessions.clj @@ -0,0 +1,32 @@ +(ns infinite-sessions + "Example: Infinite sessions with context compaction. + + Demonstrates how to enable infinite sessions so the SDK automatically + compacts older messages when the context window fills up, allowing + arbitrarily long conversations." + (:require [github.copilot-sdk :as copilot] + [github.copilot-sdk.helpers :as h])) + +;; See examples/README.md for usage + +(def defaults + {:prompts ["What is the capital of France?" + "What is the capital of Japan?" + "What is the capital of Brazil?"]}) + +(defn run + [{:keys [prompts] :or {prompts (:prompts defaults)}}] + (copilot/with-client-session + [session {:on-permission-request copilot/approve-all + :model "claude-haiku-4.5" + :available-tools [] + :system-message {:mode :replace + :content "Answer concisely in one sentence."} + :infinite-sessions {:enabled true + :background-compaction-threshold 0.80 + :buffer-exhaustion-threshold 0.95}}] + (doseq [prompt prompts] + (println "Q:" prompt) + (println "🤖:" (h/query prompt :session session)) + (println)) + (println "✅ All prompts completed with infinite sessions enabled."))) diff --git a/examples/lifecycle_hooks.clj b/examples/lifecycle_hooks.clj new file mode 100644 index 0000000..ef135c2 --- /dev/null +++ b/examples/lifecycle_hooks.clj @@ -0,0 +1,58 @@ +(ns lifecycle-hooks + "Lifecycle hooks: register callbacks for session start/end, tool use, prompts, and errors." + (:require [github.copilot-sdk :as copilot] + [github.copilot-sdk.helpers :as h])) + +;; See examples/README.md for usage + +(def defaults + {:prompt "Use the glob tool to list all .clj files in the examples directory. Just list the filenames."}) + +(defn run + [{:keys [prompt] :or {prompt (:prompt defaults)}}] + (let [fired-hooks (atom []) + record! (fn [hook-name data] + (swap! fired-hooks conj {:hook hook-name :data data}))] + (copilot/with-client-session + [session {:on-permission-request copilot/approve-all + :model "claude-haiku-4.5" + :hooks {:on-session-start + (fn [data _ctx] + (println "🚀 Hook: session-start") + (record! :on-session-start data)) + + :on-session-end + (fn [data _ctx] + (println "🏁 Hook: session-end") + (record! :on-session-end data)) + + :on-pre-tool-use + (fn [data _ctx] + (println "🔧 Hook: pre-tool-use —" (:tool-name data)) + (record! :on-pre-tool-use data) + {:approved true}) + + :on-post-tool-use + (fn [data _ctx] + (println "✅ Hook: post-tool-use —" (:tool-name data)) + (record! :on-post-tool-use data)) + + :on-user-prompt-submitted + (fn [data _ctx] + (println "💬 Hook: user-prompt-submitted") + (record! :on-user-prompt-submitted data)) + + :on-error-occurred + (fn [data _ctx] + (println "❌ Hook: error-occurred") + (record! :on-error-occurred data))}}] + + (println "\nPrompt:" prompt "\n") + (println "🤖:" (h/query prompt :session session)) + + (println "\n--- Hook summary ---") + (let [events @fired-hooks + freqs (frequencies (map :hook events))] + (println "Total hooks fired:" (count events)) + (doseq [[hook cnt] (sort-by (comp str key) freqs)] + (println (str " " hook " × " cnt))))))) diff --git a/examples/reasoning_effort.clj b/examples/reasoning_effort.clj new file mode 100644 index 0000000..b5a9114 --- /dev/null +++ b/examples/reasoning_effort.clj @@ -0,0 +1,22 @@ +(ns reasoning-effort + "Demonstrates setting the reasoning effort level for a session. + The :reasoning-effort option accepts \"low\", \"medium\", \"high\", or \"xhigh\" + and controls how much reasoning the model applies to its responses." + (:require [github.copilot-sdk :as copilot] + [github.copilot-sdk.helpers :as h])) + +;; See examples/README.md for usage + +(def defaults + {:prompt "What is the capital of France? Answer in one sentence." + :effort "low"}) + +(defn run + [{:keys [prompt effort] :or {prompt (:prompt defaults) effort (:effort defaults)}}] + (println "Reasoning effort:" effort) + (copilot/with-client-session [session {:on-permission-request copilot/approve-all + :model "gpt-5.2" + :reasoning-effort effort + :available-tools []}] + (println "Q:" prompt) + (println "🤖:" (h/query prompt :session session)))) diff --git a/examples/session_resume.clj b/examples/session_resume.clj new file mode 100644 index 0000000..da67d69 --- /dev/null +++ b/examples/session_resume.clj @@ -0,0 +1,37 @@ +(ns session-resume + "Demonstrates session resume: create a session, teach it a secret word, + then resume the session by ID and verify the model remembers it." + (:require [github.copilot-sdk :as copilot])) + +;; See examples/README.md for usage + +(def defaults + {:secret-word "PINEAPPLE" + :prompt "What was the secret word I told you? Reply with just the word."}) + +(defn run + [{:keys [secret-word prompt] + :or {secret-word (:secret-word defaults) prompt (:prompt defaults)}}] + (copilot/with-client [client {}] + (println "Creating session...") + (let [session (copilot/create-session + client + {:on-permission-request copilot/approve-all + :model "claude-haiku-4.5" + :available-tools []})] + (println "Sending secret word:" secret-word) + (let [result (copilot/send-and-wait! + session + {:prompt (str "Remember this secret word: " secret-word + ". Confirm you have memorized it.")})] + (println "🤖:" (get-in result [:data :content]))) + + (let [session-id (:session-id session)] + (println "\nResuming session" session-id "...") + (let [resumed (copilot/resume-session + client session-id + {:on-permission-request copilot/approve-all + :available-tools []})] + (println "Asking:" prompt) + (let [result (copilot/send-and-wait! resumed {:prompt prompt})] + (println "🤖:" (get-in result [:data :content])))))))) diff --git a/run-all-examples.sh b/run-all-examples.sh index d3b6485..d9371be 100755 --- a/run-all-examples.sh +++ b/run-all-examples.sh @@ -41,3 +41,23 @@ clojure -A:examples -X session-events/run echo "" echo "=== user-input ===" clojure -A:examples -X user-input/run-simple + +echo "" +echo "=== file-attachments ===" +clojure -A:examples -X file-attachments/run + +echo "" +echo "=== session-resume ===" +clojure -A:examples -X session-resume/run + +echo "" +echo "=== infinite-sessions ===" +clojure -A:examples -X infinite-sessions/run + +echo "" +echo "=== lifecycle-hooks ===" +clojure -A:examples -X lifecycle-hooks/run + +echo "" +echo "=== reasoning-effort ===" +clojure -A:examples -X reasoning-effort/run