From 77ab0e1867e0f2d1722a847dd34c42cc55ea4ee5 Mon Sep 17 00:00:00 2001 From: Nicholas M Denny Date: Sun, 2 Aug 2026 07:37:36 -0500 Subject: [PATCH] feat: add GitHub Copilot SDK backend with CLI fallback --- CHANGELOG.md | 27 + README.md | 57 +- docs/copilot-cli-backend.md | 113 ++ docs/copilot-sdk-backend.md | 204 ++++ graphify/__main__.py | 7 +- graphify/cli.py | 95 +- graphify/dedup.py | 29 +- graphify/llm.py | 1017 ++++++++++++++++- graphify/prs.py | 13 + .../agents/references/github-and-merge.md | 2 +- .../skills/amp/references/github-and-merge.md | 2 +- .../claude/references/github-and-merge.md | 2 +- .../claw/references/github-and-merge.md | 2 +- .../codex/references/github-and-merge.md | 2 +- .../copilot/references/github-and-merge.md | 2 +- .../droid/references/github-and-merge.md | 2 +- .../kilo/references/github-and-merge.md | 2 +- .../kiro/references/github-and-merge.md | 2 +- .../opencode/references/github-and-merge.md | 2 +- .../skills/pi/references/github-and-merge.md | 2 +- .../trae/references/github-and-merge.md | 2 +- .../vscode/references/github-and-merge.md | 2 +- .../windows/references/github-and-merge.md | 2 +- pyproject.toml | 18 +- tests/test_copilot_cli_backend.py | 385 +++++++ tests/test_copilot_sdk_backend.py | 543 +++++++++ tests/test_dedup.py | 42 + tests/test_image_vision.py | 38 +- tests/test_labeling.py | 32 +- tests/test_prs.py | 74 ++ ...s__agents__references__github-and-merge.md | 2 +- ...ills__amp__references__github-and-merge.md | 2 +- ...s__claude__references__github-and-merge.md | 2 +- ...lls__claw__references__github-and-merge.md | 2 +- ...ls__codex__references__github-and-merge.md | 2 +- ...__copilot__references__github-and-merge.md | 2 +- ...ls__droid__references__github-and-merge.md | 2 +- ...lls__kilo__references__github-and-merge.md | 2 +- ...lls__kiro__references__github-and-merge.md | 2 +- ..._opencode__references__github-and-merge.md | 2 +- ...kills__pi__references__github-and-merge.md | 2 +- ...lls__trae__references__github-and-merge.md | 2 +- ...s__vscode__references__github-and-merge.md | 2 +- ...__windows__references__github-and-merge.md | 2 +- .../references/shared/github-and-merge.md | 2 +- uv.lock | 27 +- 46 files changed, 2684 insertions(+), 95 deletions(-) create mode 100644 docs/copilot-cli-backend.md create mode 100644 docs/copilot-sdk-backend.md create mode 100644 tests/test_copilot_cli_backend.py create mode 100644 tests/test_copilot_sdk_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffa5cf55..f1ee8f713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.32 (unreleased) + +- Feat: add an explicit `copilot-sdk` LLM backend for semantic extraction, LLM deduplication, community labels, PR triage, and raster-image attachments. It uses the official Python SDK with one persistent headless runtime and a fresh isolated session per Graphify request, defaults to the enterprise-authenticated system Copilot CLI, runs in SDK `empty` mode with no tools/MCP/persistent memory/remote sessions, and rejects every permission request. SDK state is placed under a temporary Copilot home; each request session is disconnected and permanently deleted, and uncertain cleanup discards the runtime. SDK requests automatically fall back to the existing `copilot-cli` transport unless `GRAPHIFY_COPILOT_SDK_FALLBACK=0`; Python 3.10 therefore remains supported through the fallback while the optional SDK dependency is installed only on Python 3.11+. Both Copilot backends remain explicit-only. +- Feat: add an explicit `copilot-cli` LLM backend for semantic extraction, LLM deduplication, community labels, and PR triage. It reuses the official GitHub Copilot CLI credential store and supports GHE.com enterprise SSO through `copilot login --host ...` / `COPILOT_GH_HOST`, including configurable `--model` selection. Prompts are piped over stdin from an empty temporary directory, tool and repository-agent features are restricted where the installed CLI supports those controls, remote session export is disabled when available, and requests run serially unless `GRAPHIFY_COPILOT_CLI_PARALLEL=1` is set. The backend is never auto-selected merely because the CLI is installed. +- Fix: incremental extraction and `_rebuild_code` no longer drop a file's other tier (#2333, #2334, #2336). Node/edge ownership was keyed on `source_file` alone, so a semantic re-extract deleted a doc's AST headings and a full rebuild deleted document AST nodes. Merge is now tier-aware (an AST re-extract replaces only AST nodes and keeps the semantic layer, and vice versa), the `_origin` provenance marker is backfilled on load so old graphs self-heal, and the full-rebuild drop is scoped to sources actually regenerated. +- Fix: `graphify update` preserves the graph's `directed` flag instead of rebuilding it undirected (#2342, thanks @Rishet11), so God-node / path ranking keeps its direction on both the clustered and `--no-cluster` rebuild paths. +- Fix: a numeric or otherwise non-string node id from an LLM fragment no longer aborts the build with a TypeError (#2326, thanks @Rishet11); ids are coerced consistently across nodes, edges, and hyperedges. +- Fix: `graphify query` renders every edge between visited nodes, not just the traversal-tree edges, so the returned subgraph matches the real induced subgraph (#2323, thanks @Rishet11). +- Fix: `graphify update` writes `manifest.json` to the target's `graphify-out` instead of the current working directory (#2316, thanks @Rishet11), so running it from elsewhere can no longer prune the target's own manifest rows. +- Fix: a real Python package named `coverage/` is no longer silently dropped; the prune is gated on coverage-report artefacts (#2339, thanks @Manoj21k). +- Fix: a custom `GRAPHIFY_OUT` name no longer prunes every same-named directory in the tree; only the configured output path is excluded (#2273, thanks @oleksii-tumanov). +- Fix: C# member calls resolve for receivers declared inline via `out var`, `is`, `case`, and switch-arm patterns (#2346, thanks @JensD-git), and members of a `partial class` split across files now attach to one merged class node so cross-half calls resolve (#2332). +- Fix: members of a Kotlin anonymous object (`object : Foo { ... }`) are now extracted, with their `implements` and `calls` edges (#2347). +- Fix: Ruby mixins declared with compact/nested syntax now resolve, and a qualified external mixin can no longer fabricate a phantom hub (#2302, thanks @FolatheDuckofDuckingburg). `module Foo::Bar` and `module Foo; module Bar` are canonicalized to the same fully-qualified label, and `include`/`extend`/`prepend` keep the full constant path, so `include Foo::Bar` resolves. Mixin resolution is now scoped and lexical: a qualified external name like `extend ActiveSupport::Concern` no longer binds to any local module named `Concern`, while a genuine in-corpus `include Foo::Concern` still resolves. Nested-declared classes keep their last-segment index so typed-receiver calls (`Processor.new`) continue to resolve. +- Perf: dedup drops an O(nodes x components) scan in remap construction (#2328, thanks @stupidprogrammer4), with identical results. + +## 0.9.31 (2026-07-30) + +- Feature: the MCP server is dual-compatible with SDK 1.x AND 2.x (#2308, thanks @NiSHoW), lifting the `mcp<2` cap 0.9.30 introduced to `mcp>=1,<3`. The 2.0 SDK removed the low-level decorator API (`Server.list_tools`/`call_tool`/...); `_build_server` now binds the same handlers via the 1.x decorators or the 2.x `on_*` constructor callbacks, picked at runtime, and adapts `Tool.inputSchema`, `Resource.uri` (plain `str` in 2.x), and the dropped `AnyUrl` re-export. Verified with full stdio handshakes under both mcp 1.29 and 2.0. +- Fix: C# member calls on a typed receiver no longer drop true `calls` edges when the same local name is reused across methods (#2299, thanks @JensD-git). Receiver typing was per-file and poisoned a name on any conflicting/untypable rebind anywhere in the file; it is now per-method (mirroring the Java resolver), so an untypable `var x = ...` in one method can't delete a typed-parameter call edge in another. +- Fix: SQL cross-file table references (e.g. a prisma migration referencing a table created in an earlier one) resolve to the real table node instead of leaking an absolute-path id and losing the foreign key (#2324). References now mint a sourceless stub that collapses onto the real definition, and identifiers are normalized so a quoted definition (`"public"."users"`) matches an unquoted reference (`public.users`). +- Fix: `graphify path` and `explain` no longer print reversed hops (#2309). They now recover edge direction from the stored `_src`/`_tgt` markers instead of the persisted endpoint order, so a graph.json written with flipped storage order (older graphs, raw dumps, merge-driver output) renders the true direction. +- Fix: `export const X = ` now emits a graph node, so a named import of a scalar export is no longer left dangling (#2266, thanks @oleksii-tumanov). +- Fix: Go predeclared functions (`make`, `len`, `append`, `new`, ...) no longer fabricate call edges to same-named user symbols (#2313, thanks @PathGao); the filter is scoped to Go bare-identifier callees so it can't affect other languages or same-file method calls. +- Fix: `graphify explain` refuses and lists candidates when a name matches symbols in more than one file, instead of silently resolving to an arbitrary one (#2233, thanks @0bLoM). +- Fix: the Antigravity install workflow no longer hardcodes the global skill path for a project-scoped install (#2319, thanks @MalikHaroonKhokhar). + ## 0.9.30 (2026-07-29) - Fix: pin `mcp` below 2.0 so a fresh `graphifyy[mcp]` / `graphifyy[all]` install works again (#2277, #2279, #2291). The `mcp` 2.0.0 major dropped the `mcp.types.AnyUrl` re-export and the `Server` decorator-registration API that `graphify/serve.py` uses, so an unpinned resolve broke `graphify-mcp` on every new install with an `ImportError`. The `mcp` and `all` extras now require `mcp>=1,<2` (resolving to 1.29.0) and `starlette>=1.3.1,<2`. Adapting to the mcp 2.x API is tracked as a follow-up. diff --git a/README.md b/README.md index e171cd0a1..ca3a37e1a 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,17 @@ YC S26

+

+ Early access to the graphify platform is open before the public v1 launch: app.graphify.com +

+ Type `/graphify` in your AI coding assistant and it maps your entire project (code, docs, PDFs, images, videos) into a **knowledge graph** you can **query instead of grepping** through files. - **Code maps for free, fully local.** Code is parsed with tree-sitter AST: deterministic, no LLM, nothing leaves your machine. (Docs, PDFs, images and video use your assistant's model, or a configured API key, for a semantic pass.) - **Every edge is explained.** Each connection is tagged `EXTRACTED` (explicit in the source) or `INFERRED` (resolved by graphify), so you can tell what was read directly from what was inferred. - **Not a vector index.** No embeddings, no vector store: a real graph you traverse. Ask a question, trace the path between two things, or explain one concept. -> Want this always-on, updating in the background across your code, docs, and meetings rather than only on demand? That is what we are building at **[graphify.com](https://graphify.com)**. You can join the waitlist there. +> Want this always-on, updating in the background across your code, docs, and meetings rather than only on demand? That is what we are building at **[graphify.com](https://graphify.com)**, and early access is open now at **[app.graphify.com](https://app.graphify.com/login)**.

graphify's interactive graph.html showing the FastAPI codebase as a force-directed knowledge graph with a legend of detected communities @@ -256,6 +260,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` | | `anthropic` | Anthropic Claude API (`--backend claude`, uses `ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` | | `bedrock` | AWS Bedrock (uses IAM, no API key) | `uv tool install "graphifyy[bedrock]"` | +| `copilot` | GitHub Copilot SDK backend (Python 3.11+; automatic CLI fallback) | `uv tool install --python 3.12 "graphifyy[copilot]"` | | `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` | | `sql` | SQL schema extraction | `uv tool install "graphifyy[sql]"` | | `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` | @@ -511,9 +516,17 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe | `AZURE_OPENAI_API_VERSION` | Azure API version override | optional — default `2024-12-01-preview` | | `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-4o` | | `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) | +| `COPILOT_GH_HOST` | GitHub host inherited by the official Copilot SDK/CLI runtime, including GHE.com data-residency hosts such as `example.ghe.com` | `--backend copilot-sdk` or `copilot-cli` (recommended when multiple accounts/hosts are configured) | +| `GRAPHIFY_COPILOT_SDK_MODEL`, `GRAPHIFY_COPILOT_MODEL`, or `COPILOT_MODEL` | Model requested from the Copilot SDK; `--model` takes precedence | optional — default `auto`, subject to enterprise policy and plan availability | +| `GRAPHIFY_COPILOT_CLI_MODEL` or `COPILOT_MODEL` | Model requested from the standalone Copilot CLI backend; `--model` takes precedence | optional — default `auto` | +| `GRAPHIFY_COPILOT_SDK_CLI_PATH` or `COPILOT_CLI_PATH` | Managed system Copilot CLI executable used by the SDK's stdio transport | optional — defaults to `copilot` / `copilot.cmd` | +| `GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI` | Use the SDK-downloaded version-pinned runtime instead of the system CLI | optional — set `1`; system CLI is the default for managed enterprise workstations | +| `GRAPHIFY_COPILOT_SDK_FALLBACK` | Permit automatic fallback from `copilot-sdk` to `copilot-cli` | optional — enabled by default; set `0` to require SDK success | +| `GRAPHIFY_COPILOT_SDK_PARALLEL` | Allow concurrent SDK sessions | optional — set `1` to opt in; serial by default | +| `GRAPHIFY_COPILOT_CLI_PARALLEL` | Allow concurrent standalone Copilot CLI subprocesses | optional — set `1` to opt in; serial by default | | `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag | | `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files | -| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, Anthropic SDK, and Bedrock backends (default: 600) | optional — also `--api-timeout` flag | +| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, copilot-sdk, copilot-cli, Anthropic SDK, and Bedrock backends (default: 600) | optional — also `--api-timeout` flag | | `GRAPHIFY_MAX_RETRIES` | How many times to retry a rate-limited (429) request before giving up (default: 6; honors `Retry-After`) | optional — raise for strict per-org limits (e.g. kimi); `0` disables | | `GRAPHIFY_FORCE` | Force graph rebuild even with fewer nodes | optional — also `--force` flag | | `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` | @@ -529,13 +542,46 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe --- +## GitHub Copilot SDK and CLI backends + +`--backend copilot-sdk` is the preferred structured integration. It uses the official Python SDK to control a persistent headless Copilot runtime over JSON-RPC, creates a fresh isolated session for each Graphify request, disconnects and permanently deletes that session after use, supports image file attachments, and automatically falls back to the existing one-shot `copilot-cli` transport when the SDK is unavailable or fails. `--backend copilot-cli` remains available as a standalone diagnostic and compatibility backend. + +Install the optional SDK under Python 3.11 or newer, then authenticate the official CLI to the enterprise host: + +```bash +uv tool install --python 3.12 "graphifyy[copilot]" +copilot login --host https://example.ghe.com + +export COPILOT_GH_HOST=example.ghe.com +graphify extract ./docs --backend copilot-sdk --model auto +``` + +```powershell +$env:COPILOT_GH_HOST = "example.ghe.com" +graphify extract ./docs ` + --backend copilot-sdk ` + --model auto +``` + +Graphify defaults the SDK to the managed system `copilot` executable so it uses the same enterprise login and software-management path. Set `GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI=1` to opt into the SDK-downloaded matching runtime. On Python 3.10, the SDK package cannot load and the explicit `copilot-sdk` backend uses `copilot-cli`; set `GRAPHIFY_COPILOT_SDK_FALLBACK=0` to require SDK success. + +The SDK client runs in `mode="empty"` with a temporary working directory and temporary `COPILOT_HOME`, exposes no tools or MCP servers, rejects every permission request, disables persistent memory/infinite sessions and remote sessions, and does not configure SDK telemetry. Each request uses a unique session ID; Graphify disconnects and permanently deletes the session before returning, and discards the runtime if cleanup cannot be verified. It is never auto-selected. These controls limit local agent capabilities, but the selected Copilot model still receives the source chunks and remains governed by enterprise policy and approved-use boundaries. + +SDK model precedence is `--model`, `GRAPHIFY_COPILOT_SDK_MODEL`, `GRAPHIFY_COPILOT_MODEL`, `COPILOT_MODEL`, then `auto`. Requests are serial by default; `GRAPHIFY_COPILOT_SDK_PARALLEL=1` opts into concurrency. The CLI fallback has separate `GRAPHIFY_COPILOT_CLI_MODEL` and `GRAPHIFY_COPILOT_CLI_PARALLEL` controls. + +Copilot responses do not expose provider-style billing data to Graphify, so token counts are estimates and Graphify's provider-cost field is `$0`; GitHub AI credits or plan allowances can still be consumed. + +See [`docs/copilot-sdk-backend.md`](docs/copilot-sdk-backend.md) for architecture, GitHub Enterprise setup, fallback behavior, image handling, security controls, and troubleshooting. See [`docs/copilot-cli-backend.md`](docs/copilot-cli-backend.md) for the standalone fallback transport. + +--- + ## Privacy - **Code files** — processed locally via tree-sitter. Nothing leaves your machine. A code-only corpus requires no API key — `graphify extract` runs fully offline. On a mixed repo, add `--code-only` to index just the code and skip the docs/PDFs/images that would otherwise need an LLM. - **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine. -- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), or the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription). The `--dedup-llm` flag uses the same key. +- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription), or the official Copilot SDK/CLI (`--backend copilot-sdk` or `copilot-cli`, using its signed-in GitHub/Copilot account). The `--dedup-llm` flag uses the same backend. - **Data residency** — `graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China. -- **No telemetry**, no usage tracking, no analytics. +- **Graphify telemetry** — Graphify itself adds no telemetry, usage tracking, or analytics. External LLM providers and their CLIs retain their own service and enterprise-monitoring behavior; Graphify does not opt the Copilot SDK into telemetry, while enterprise-managed runtime settings still apply. - **Query logging** — every `graphify query`, `graphify path`, `graphify explain`, and MCP `query_graph` call is logged to `~/.cache/graphify-queries.log` in JSON Lines format (timestamp, question, corpus, nodes returned, duration). Full subgraph responses are **not** stored by default. Set `GRAPHIFY_QUERY_LOG_DISABLE=1` to opt out, or `GRAPHIFY_QUERY_LOG=/dev/null` to silence without disabling the code path. --- @@ -716,7 +762,7 @@ graphify antigravity install # .agents/rules + .agents/workflows (Google A graphify antigravity uninstall graphify extract ./docs # headless LLM extraction for CI (no IDE needed) -graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock, or claude-cli +graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock, azure, claude-cli, copilot-sdk, or copilot-cli graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL) - no API key needed for loopback OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # any OpenAI-compatible server (llama.cpp, vLLM, LM Studio) @@ -725,6 +771,7 @@ GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # overr GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # unload model after each chunk (saves VRAM on small GPUs) graphify extract ./docs --backend bedrock # AWS Bedrock via IAM - no API key, uses AWS credential chain graphify extract ./docs --backend claude-cli # route through Claude Code CLI - no API key, uses your Claude subscription +COPILOT_GH_HOST=example.ghe.com graphify extract ./docs --backend copilot-sdk --model auto # preferred SDK transport with automatic CLI fallback graphify extract ./docs --backend azure # Azure OpenAI (set AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) graphify extract ./docs --max-workers 16 # AST parallelism (also GRAPHIFY_MAX_WORKERS) graphify extract --postgres "postgresql://user:pass@host/db" # introspect live PostgreSQL schema directly diff --git a/docs/copilot-cli-backend.md b/docs/copilot-cli-backend.md new file mode 100644 index 000000000..61c93fd84 --- /dev/null +++ b/docs/copilot-cli-backend.md @@ -0,0 +1,113 @@ +# GitHub Copilot CLI backend + +> For new Graphify integrations, prefer [`--backend copilot-sdk`](copilot-sdk-backend.md). It keeps a structured persistent runtime, supports image attachments, and automatically uses this CLI adapter as its fallback. The explicit `copilot-cli` backend remains useful for Python 3.10, diagnostics, enterprise SDK/CLI compatibility issues, and deployments that do not permit the Python SDK package. + +Graphify's `copilot-cli` backend uses the official GitHub Copilot CLI as a local subprocess for semantic extraction, LLM-assisted deduplication, community naming, and PR triage. It is designed for accounts whose authentication and model access are managed by GitHub Enterprise Cloud, including GHE.com data-residency hosts. + +## Why the CLI integration + +The Copilot CLI already owns OAuth device-flow authentication, enterprise SSO, host selection, model policy, and credential storage. Graphify therefore does not implement GitHub's authentication flow, exchange tokens, or write an enterprise access token. It starts the CLI with the normal inherited environment and credential-store access required for that login. This also avoids depending on undocumented Copilot HTTP endpoints. + +The backend is explicit-only: Graphify will not select it just because a `copilot` executable is installed. Pass `--backend copilot-cli` whenever corpus content should be sent through Copilot. + +## GitHub Enterprise Cloud setup + +First verify that Copilot CLI is installed and that your organization or enterprise has enabled the Copilot CLI policy for your assigned seat: + +```bash +copilot version +copilot login --host https://example.ghe.com +``` + +The OAuth device flow opens a browser. Complete your enterprise sign-in and any required SSO authorization. Then pin the host for Graphify runs so another stored GitHub account cannot be selected accidentally. + +### bash or zsh + +```bash +export COPILOT_GH_HOST=example.ghe.com +graphify extract ./docs --backend copilot-cli --model auto +``` + +### PowerShell + +```powershell +$env:COPILOT_GH_HOST = "example.ghe.com" +graphify extract ./docs --backend copilot-cli --model auto +``` + +`auto` lets Copilot choose from models permitted by the enterprise policy. A specific permitted model can be supplied with `--model`. Precedence is: + +1. `--model` +2. `GRAPHIFY_COPILOT_CLI_MODEL` +3. `COPILOT_MODEL` +4. `auto` + +## Execution and safety behavior + +For each Graphify LLM request, the backend: + +- resolves `copilot.cmd` first on Windows and `copilot` elsewhere; +- sends the prompt through standard input rather than command-line arguments; +- requests silent output and runs in a newly created empty temporary working directory; +- capability-detects and applies current CLI hardening flags, including disabling custom repository instructions, interactive questions, automatic updates, experimental features, built-in MCP servers, remote control, and remote export; +- denies the built-in `memory`, `read`, `shell`, `url`, and `write` tool classes; +- disables prompt-mode project extensions, repository hooks, workspace MCP loading, and MCP tool snapshot caching through child-process environment settings; and +- inherits `COPILOT_GH_HOST`, token environment variables, and Copilot CLI credential-store access without parsing, printing, or persisting credential values. + +These controls reduce the local agent surface, but they do not rewrite user-scoped Copilot plugins, skills, MCP configuration, or enterprise-managed settings. The fresh temporary directory avoids repository resources and location-scoped approvals from the source tree; administrators should still review user and managed Copilot configuration before approving regulated workloads. The prompt and source material are sent to the model service selected by GitHub Copilot and governed by the enterprise's policies. These controls are not a substitute for organizational authorization to process a particular data type. + +## Session data and billing + +Copilot CLI stores session data locally. On current CLI releases Graphify adds `--no-remote-export`, which prevents the Graphify invocation from being exported or synchronized to GitHub. Local session state remains. GitHub gates remote-session options by account capability; if the CLI rejects the opt-out flag, Graphify warns and retries without it. Configure `"remoteExport": false` in Copilot's settings as a backstop and review the applicable enterprise policy. + +Graphify's own cost report shows zero provider API dollars because Copilot does not expose API-style token accounting to this integration. The reported token counts are character-based estimates. The underlying Copilot interaction can consume AI credits or another plan allowance controlled by GitHub and the applicable enterprise policy. + +## Concurrency + +Graphify runs Copilot CLI requests serially by default. A single semantic extraction can produce many prompts because files are chunked and a truncated response can be bisected and retried. Serial execution avoids concurrent local sessions and unexpected bursts against enterprise limits. + +Set this only after confirming the account and policy tolerate parallel sessions: + +```bash +export GRAPHIFY_COPILOT_CLI_PARALLEL=1 +``` + +`--max-concurrency` then controls the upper bound as it does for other Graphify backends. + +## Authentication troubleshooting + +Check the selected host and remove token variables that could override the stored enterprise OAuth login: + +```bash +printf '%s\n' "$COPILOT_GH_HOST" +env | grep -E '^(COPILOT_GITHUB_TOKEN|GH_TOKEN|GITHUB_TOKEN)=' +``` + +PowerShell equivalent: + +```powershell +$env:COPILOT_GH_HOST +Get-ChildItem Env:COPILOT_GITHUB_TOKEN,Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue +``` + +Then repeat the login and run a small, approved test corpus: + +```bash +copilot login --host https://example.ghe.com +COPILOT_GH_HOST=example.ghe.com graphify extract ./docs --backend copilot-cli --model auto +``` + +Common errors: + +- **CLI not found**: install the official GitHub Copilot CLI and confirm `copilot version` works in the same shell that launches Graphify. +- **Not authenticated or wrong account**: repeat `copilot login --host https://example.ghe.com`, set `COPILOT_GH_HOST`, and remove conflicting token variables. +- **Model unavailable**: use `--model auto` or select a model enabled by enterprise policy. +- **Copilot CLI disabled**: an enterprise or organization administrator must enable the Copilot CLI policy and the user must have an eligible seat. +- **Hollow or malformed graph output**: Graphify treats it as truncation and retries smaller chunks. Lower `--token-budget`, use a stronger permitted model, or increase `GRAPHIFY_API_TIMEOUT` when needed. + +## Current limitations + +- Raster images are described to Copilot as references; use `copilot-sdk` when first-class image attachments are required. +- Silent text output does not identify the actual model selected by `auto`, so Graphify records the requested model value. +- Copilot CLI may export OpenTelemetry traces or metrics when enabled by process environment or enterprise-managed settings. Graphify does not override those administrator controls. +- There is no live enterprise-environment test in the repository test suite. Tests mock the executable, process environment, stdin, output, and failure modes so they require no credentials and send no data externally. diff --git a/docs/copilot-sdk-backend.md b/docs/copilot-sdk-backend.md new file mode 100644 index 000000000..d5b009a9f --- /dev/null +++ b/docs/copilot-sdk-backend.md @@ -0,0 +1,204 @@ +# GitHub Copilot SDK backend + +Graphify's `copilot-sdk` backend uses the official Python package `github-copilot-sdk` as the preferred GitHub Copilot transport and retains the existing `copilot-cli` backend as an automatic fallback. It supports semantic extraction, LLM-assisted deduplication, community naming, PR triage, and raster-image attachments. + +The backend is explicit-only. Graphify does not select Copilot merely because the SDK package or a `copilot` executable is installed. Corpus content is sent through Copilot only after `--backend copilot-sdk` or `--backend copilot-cli` is selected. + +## Architecture + +```text +Graphify + | + | preferred + v +GitHub Copilot Python SDK + | + | JSON-RPC over stdio + v +Enterprise-authenticated Copilot CLI runtime + | + v +GitHub Copilot service + +If SDK startup or request handling fails: +Graphify -> one-shot copilot-cli fallback -> GitHub Copilot service +``` + +Graphify keeps one SDK client and headless runtime alive for the process, but creates a new isolated session with a unique ID for every LLM request. Before returning, it disconnects and permanently deletes that session. This avoids a full runtime startup per document chunk without carrying conversation state from one Graphify request into another. + +## Requirements and installation + +The official Python SDK requires Python 3.11 or newer. Graphify itself continues to support Python 3.10, so the dependency is optional and guarded by a Python-version marker. + +For a tool installation, choose Python 3.11 or newer explicitly: + +```bash +uv tool install --python 3.12 "graphifyy[copilot]" +``` + +Alternative installs: + +```bash +pipx install --python python3.12 "graphifyy[copilot]" +python -m pip install "graphifyy[copilot]" + +# From a Graphify source checkout: +python -m pip install -e ".[copilot]" +``` + +On Python 3.10, `--backend copilot-sdk` remains a valid explicit backend, but the SDK cannot load and Graphify immediately uses the installed `copilot-cli` fallback. To require the SDK and reject fallback, use Python 3.11+ and set `GRAPHIFY_COPILOT_SDK_FALLBACK=0`. + +## GitHub Enterprise Cloud setup + +Graphify uses the enterprise login and credential store owned by the official Copilot CLI. Authenticate the managed CLI first: + +```bash +copilot version +copilot login --host https://example.ghe.com +``` + +Pin the host when launching Graphify so another stored account cannot be selected accidentally. + +### bash or zsh + +```bash +export COPILOT_GH_HOST=example.ghe.com +graphify extract ./docs \ + --backend copilot-sdk \ + --model auto +``` + +### PowerShell + +```powershell +$env:COPILOT_GH_HOST = "example.ghe.com" +graphify extract ./docs ` + --backend copilot-sdk ` + --model auto +``` + +The organization or enterprise providing the Copilot seat must permit Copilot CLI/SDK use, and the requested model must be available under that policy. + +## Runtime selection + +For managed enterprise workstations, Graphify defaults to the system-installed `copilot` executable. This is the same executable authenticated with `copilot login --host ...` and is easier for administrators to inventory and approve. + +Resolution order is: + +1. `GRAPHIFY_COPILOT_SDK_CLI_PATH` +2. `COPILOT_CLI_PATH` +3. `copilot.cmd` on Windows, then `copilot` +4. `copilot` on other platforms + +To use the SDK's version-pinned downloaded runtime instead of the system executable: + +```bash +export GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI=1 +python -m copilot download-runtime +``` + +Bundled-runtime mode is opt-in because it may introduce a second Copilot executable outside the workstation's normal software-management path. Authentication still comes from the signed-in user or supported token environment variables. + +## Model selection + +Precedence for `copilot-sdk` is: + +1. `--model` +2. `GRAPHIFY_COPILOT_SDK_MODEL` +3. `GRAPHIFY_COPILOT_MODEL` +4. `COPILOT_MODEL` +5. `auto` + +`auto` lets Copilot select an eligible model. A specific model name is still subject to enterprise policy and account entitlement. + +The standalone `copilot-cli` backend retains its existing precedence: `--model`, `GRAPHIFY_COPILOT_CLI_MODEL`, `COPILOT_MODEL`, then `auto`. + +## Isolation and safety behavior + +For SDK requests, Graphify: + +- starts the client with `mode="empty"`, so ambient CLI filesystem, shell, MCP, skill, and workspace capabilities are not exposed by default; +- uses a newly created empty temporary working directory and temporary `COPILOT_HOME` rather than the source repository or the user's normal Copilot state directory; +- supplies an empty tool allowlist and no MCP servers; +- rejects every permission request as a second line of defense; +- disables persistent Copilot memory and infinite-session persistence; +- disables remote sessions and does not configure SDK telemetry; +- creates a fresh uniquely identified session per request, then disconnects and permanently deletes it after the response; +- preserves `COPILOT_GH_HOST` and the normal Copilot authentication environment without parsing, printing, or persisting token values; and +- fails closed when the installed SDK lacks any required isolation or session-deletion option, and discards the runtime if cleanup cannot be verified, then uses the separately hardened CLI fallback when enabled. + +The model still receives every document chunk sent for semantic extraction. These controls limit local agent capabilities; they do not constitute authorization to process any particular category of data. Enterprise policy, approved-use boundaries, and user-scoped Copilot configuration still apply. + +## Image handling + +The SDK backend sends raster images as first-class file attachments with an absolute path. The Copilot runtime reads and encodes the image. Graphify therefore does not load the image into a base64 request body. + +If an SDK request falls back to the one-shot CLI backend, the CLI path has no equivalent attachment channel in this integration. Graphify changes the fallback prompt so it accurately describes the image as an unseen file reference rather than claiming the pixels were attached. + +## Automatic CLI fallback + +Fallback is enabled by default. It covers Python 3.10, a missing or incompatible SDK package, runtime startup errors, JSON-RPC transport failures, request timeouts, and other SDK exceptions. Graphify prints one warning for each distinct SDK failure and runs that request through `copilot-cli`. + +Disable fallback when validation requires proof that the SDK path was used: + +```bash +export GRAPHIFY_COPILOT_SDK_FALLBACK=0 +``` + +When both transports fail, Graphify reports both causes in one error. The CLI fallback uses the same requested model and the same enterprise host/authentication environment. + +## Concurrency + +SDK requests are serial by default even though the runtime is persistent. Semantic extraction can generate many prompts and retries; serial dispatch avoids accidental bursts against an enterprise seat and simplifies session isolation. + +After validating account and policy limits, parallel dispatch can be enabled with: + +```bash +export GRAPHIFY_COPILOT_SDK_PARALLEL=1 +``` + +`--max-concurrency` then controls the upper bound. The standalone CLI transport has its own `GRAPHIFY_COPILOT_CLI_PARALLEL=1` opt-in. + +## Authentication troubleshooting + +Check the host and any token variables that can override the stored enterprise login: + +```bash +printf '%s\n' "$COPILOT_GH_HOST" +env | grep -E '^(COPILOT_GITHUB_TOKEN|GH_TOKEN|GITHUB_TOKEN)=' +``` + +PowerShell: + +```powershell +$env:COPILOT_GH_HOST +Get-ChildItem Env:COPILOT_GITHUB_TOKEN,Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue +``` + +Then validate the CLI independently and retry a small approved corpus: + +```bash +copilot version +copilot login --host https://example.ghe.com +COPILOT_GH_HOST=example.ghe.com graphify extract ./docs \ + --backend copilot-sdk --model auto +``` + +Common failures: + +- **SDK not installed or Python too old:** install `graphifyy[copilot]` under Python 3.11+, or permit the CLI fallback. +- **System CLI not found:** install the official CLI, set `GRAPHIFY_COPILOT_SDK_CLI_PATH`, or explicitly opt into the SDK-downloaded runtime. +- **Wrong account or host:** repeat the GHE.com login, set `COPILOT_GH_HOST`, and remove conflicting token variables. +- **Login works in the standalone CLI but the SDK reports no stored login:** Graphify deliberately gives the SDK runtime a temporary `COPILOT_HOME`. OAuth credentials in the system keychain remain available, but a plaintext file fallback stored only under the user's normal `COPILOT_HOME` is not copied. Use the automatic `copilot-cli` fallback, or a policy-approved token environment variable, on hosts without a credential store. +- **SDK/CLI version mismatch:** upgrade the system CLI or use `GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI=1` with the SDK's matching downloaded runtime. +- **Model unavailable:** use `--model auto` or select a model enabled by enterprise policy. +- **Timeout:** increase `GRAPHIFY_API_TIMEOUT`; Graphify discards the failed SDK runtime before the next attempt. +- **Malformed graph JSON:** Graphify marks a hollow result as truncated and adaptively retries smaller chunks; lowering `--token-budget` can help. + +## Accounting and limitations + +The SDK response currently does not provide Graphify with provider-style token and billing fields, so Graphify records character-based token estimates and `$0` in its own provider-cost estimator. Copilot requests can still consume GitHub AI credits or another plan allowance. + +The repository tests replace the SDK, runtime, executable, network, and credentials with fakes. They verify lifecycle reuse, temporary Copilot state, per-request session isolation and deletion, cleanup-failure handling, tool denial, enterprise-host inheritance, image attachments, Python-version behavior, SDK-to-CLI fallback, and dispatch through extraction/deduplication/labeling/triage. They do not authenticate to an external GitHub host or transmit user data. + +For the standalone fallback transport, see [GitHub Copilot CLI backend](copilot-cli-backend.md). diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d..822b570b7 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -545,13 +545,13 @@ def _run_cli() -> None: print(" --no-label keep 'Community N' placeholders (skip LLM community naming)") print(" --backend= backend to use for community naming (default: auto-detect)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/copilot-sdk/copilot-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" label (re)name communities with the configured LLM backend, regenerate report") print(" --missing-only keep existing labels and only name missing/placeholder communities") print(" --backend= backend to use (default: auto-detect from API keys)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/copilot-sdk/copilot-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") @@ -593,7 +593,8 @@ def _run_cli() -> None: print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)") print(" --label NAME project label in header") print(" extract headless full extraction (AST + semantic LLM) for CI/scripts") - print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)") + print(" --backend B gemini|kimi|claude|openai|deepseek|ollama|bedrock|azure|claude-cli|copilot-sdk|copilot-cli") + print(" (default: whichever API key is set; CLI backends require explicit selection)") print(" openai also reaches self-hosted OpenAI-compatible servers (llama.cpp,") print(" vLLM, LM Studio): set OPENAI_BASE_URL (e.g. http://localhost:8080/v1)") print(" and OPENAI_MODEL to the model name your server serves") diff --git a/graphify/cli.py b/graphify/cli.py index dc9ed08cc..363543281 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -914,10 +914,17 @@ def dispatch_command(cmd: str) -> None: # a seed with no outgoing edges. Direction is instead preserved # per-edge below (mirrors graphify/build.py's _src/_tgt pattern) # so the *rendering* stays correct without narrowing traversal. + # Keep in-file markers when present (#2309): unconditionally + # overwriting them with source/target would clobber the true + # direction of a link persisted in flipped endpoint order. _raw = dict( _raw, links=[ - {**link, "_src": link.get("source"), "_tgt": link.get("target")} + { + **link, + "_src": link.get("_src", link.get("source")), + "_tgt": link.get("_tgt", link.get("target")), + } for link in _raw.get("links", []) ], ) @@ -1259,12 +1266,19 @@ def dispatch_command(cmd: str) -> None: # direction — never a fabricated `calls` (#2074). A pair may carry # several parallel relations; show all, and fall back to an honest # "related" when the stored edge has no relation. - if G.has_edge(u, v): - datas = edge_datas(G, u, v) - forward = True - else: - datas = edge_datas(G, v, u) - forward = False + # Direction truth lives in the per-link _src/_tgt markers (#2309): + # undirected NetworkX storage canonicalizes endpoint order, so the + # persisted source/target arc can be flipped relative to the real + # caller→callee direction. Recover it from _src when present, else + # fall back to the loaded arc tail (markerless canonical files keep + # today's behavior). + fwd, bwd = [], [] + for a, b in ((u, v), (v, u)): + if G.has_edge(a, b): + for d in edge_datas(G, a, b): + (fwd if d.get("_src", a) == u else bwd).append(d) + datas = fwd or bwd + forward = bool(fwd) rels = sorted({d.get("relation") for d in datas if d.get("relation")}) rel = "/".join(rels) if rels else "related" confs = sorted({d.get("confidence") for d in datas if d.get("confidence")}) @@ -1289,7 +1303,7 @@ def dispatch_command(cmd: str) -> None: if len(sys.argv) < 3: print('Usage: graphify explain "" [--graph path]', file=sys.stderr) sys.exit(1) - from graphify.serve import _find_node + from graphify.serve import _find_node, find_node_ambiguity from networkx.readwrite import json_graph label = sys.argv[2] @@ -1316,6 +1330,14 @@ def dispatch_command(cmd: str) -> None: if not matches: print(f"No node matching '{label}' found.") sys.exit(0) + rivals = find_node_ambiguity(G, label) + if rivals: + print(f"Ambiguous: '{label}' matches {len(rivals)} nodes in different files.") + for rival in rivals: + print(f" {G.nodes[rival].get('source_file') or rival}") + print(f" id: {rival}") + print("Retry with the repo-relative path or the full node id.") + sys.exit(1) nid = matches[0] d = G.nodes[nid] print(f"Node: {d.get('label', nid)}") @@ -1352,10 +1374,20 @@ def dispatch_command(cmd: str) -> None: print(f" Degree: {G.degree(nid)}") from graphify.build import edge_data connections: list[tuple[str, str, dict]] = [] # (direction, neighbor_id, edge_data) + # Classify by the edge's TRUE direction, not the loaded arc order: + # a link persisted in flipped endpoint order carries its truth in the + # per-edge _src marker (#2309). Markerless edges fall back to the arc + # tail (today's behavior). for nb in G.successors(nid): - connections.append(("out", nb, edge_data(G, nid, nb))) + _ed = edge_data(G, nid, nb) + connections.append( + ("out" if _ed.get("_src", nid) == nid else "in", nb, _ed) + ) for nb in G.predecessors(nid): - connections.append(("in", nb, edge_data(G, nb, nid))) + _ed = edge_data(G, nb, nid) + connections.append( + ("in" if _ed.get("_src", nb) == nb else "out", nb, _ed) + ) if connections: print(f"\nConnections ({len(connections)}):") connections.sort(key=lambda c: G.degree(c[1]), reverse=True) @@ -2095,10 +2127,17 @@ def _load_graph(p: str): data = dict(data, links=data["edges"]) # Preserve stored edge direction across undirected node_link_graph (#2261). # Mirrors cli.py's query pattern and export.py's _src/_tgt restoration. + # Keep in-file markers when present (#2309): unconditionally + # overwriting them with source/target would clobber the true + # direction of a link persisted in flipped endpoint order. data = dict( data, links=[ - {**link, "_src": link.get("source"), "_tgt": link.get("target")} + { + **link, + "_src": link.get("_src", link.get("source")), + "_tgt": link.get("_tgt", link.get("target")), + } for link in data.get("links", []) ], ) @@ -2564,7 +2603,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": # has an API key set. if len(sys.argv) < 3: print( - "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama] " + "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama|bedrock|azure|claude-cli|copilot-sdk|copilot-cli] " "[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] " "[--no-gitignore] [--code-only] " "[--max-workers N] [--token-budget N] [--max-concurrency N] " @@ -2974,7 +3013,8 @@ def _parse_float(name: str, raw: str) -> float: "error: no LLM API key found (" + "; ".join(reasons) + "). " "Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY " "(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), " - "DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only " + "DEEPSEEK_API_KEY (deepseek), or pass --backend (including " + "claude-cli, copilot-sdk, or copilot-cli). A code-only " "corpus needs no key." + hint, file=sys.stderr, ) @@ -3020,6 +3060,35 @@ def _parse_float(name: str, raw: str) -> float: file=sys.stderr, ) sys.exit(1) + elif backend == "copilot-cli": + from graphify.llm import _resolve_copilot_cli_command + try: + _resolve_copilot_cli_command() + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + allow_no_key = True + elif backend == "copilot-sdk": + from graphify.llm import ( + _env_enabled, + _resolve_copilot_cli_command, + _resolve_copilot_sdk_cli_command, + ) + try: + if _env_enabled("GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI"): + # Bundled runtime is an explicit opt-in. The SDK + # import itself is lazy; if absent, retain CLI as a + # usable fallback when it is installed. + try: + import copilot as _copilot_sdk # noqa: F401 + except ImportError: + _resolve_copilot_cli_command() + else: + _resolve_copilot_sdk_cli_command() + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + allow_no_key = True if not allow_no_key: print( f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", diff --git a/graphify/dedup.py b/graphify/dedup.py index bc2f1c418..e38ab9cf1 100644 --- a/graphify/dedup.py +++ b/graphify/dedup.py @@ -574,10 +574,26 @@ def deduplicate_entities( components = uf.components() remap: dict[str, str] = {} + # id -> (position, node), built once. Previously each component re-scanned + # the whole unique_nodes list, making remap construction O(nodes x + # components) — 31% of dedup wall-clock on a 50k-node corpus. + # The position is carried so group_nodes keeps unique_nodes order: _pick_winner + # resolves ties (equal chunk-suffix status and equal id length) via min(), + # which returns the first minimum, so reordering here would silently change + # which node survives. + nodes_by_id: dict[str, tuple[int, dict]] = { + n["id"]: (i, n) for i, n in enumerate(unique_nodes) + } + for root, members in components.items(): if len(members) == 1: continue - group_nodes = [n for n in unique_nodes if n["id"] in members] + group_nodes = [ + n for _, n in sorted( + (nodes_by_id[m] for m in members if m in nodes_by_id), + key=lambda pair: pair[0], + ) + ] winner = _pick_winner(group_nodes) if group_nodes else {"id": root} winner_id = winner["id"] for member in members: @@ -654,7 +670,16 @@ def _llm_tiebreak( if backend not in BACKENDS: print(f"[graphify] --dedup-llm: unknown backend {backend!r}, skipping LLM tiebreaker.", flush=True) return - if not _get_backend_api_key(backend): + # Bedrock and CLI-backed providers authenticate outside Graphify's API + # key environment-variable path. Let the shared dispatcher validate + # their own credential/runtime requirements instead of silently + # skipping the tiebreaker. + if not _get_backend_api_key(backend) and backend not in ( + "bedrock", + "claude-cli", + "copilot-cli", + "copilot-sdk", + ): env_keys = _format_backend_env_keys(backend) print(f"[graphify] --dedup-llm: {env_keys} not set, skipping LLM tiebreaker.", flush=True) return diff --git a/graphify/llm.py b/graphify/llm.py index 30d7a6d6f..ca4ad28ff 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -4,13 +4,18 @@ # this module provides a direct API path for non-Claude-Code environments. from __future__ import annotations +import atexit +import asyncio import base64 import hashlib import json import os import re import sys +import tempfile +import threading import time +import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, replace @@ -215,6 +220,40 @@ def _resolve_ollama_base_url(default: str) -> str: # CLI's Read tool rather than as inline base64 (see `_call_claude_cli`). "vision": True, }, + "copilot-cli": { + # Routes through the locally-installed official GitHub Copilot CLI. + # Authentication and token exchange stay with Copilot CLI (including + # GHE.com enterprise SSO); graphify does not implement or persist + # Copilot credentials. + # `auto` lets the enterprise policy and Copilot plan select an allowed + # model. Users can override it with --model, + # GRAPHIFY_COPILOT_CLI_MODEL, or Copilot's own COPILOT_MODEL. + "default_model": "auto", + "model_env_keys": ["GRAPHIFY_COPILOT_CLI_MODEL", "COPILOT_MODEL"], + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + }, + "copilot-sdk": { + # Preferred GitHub Copilot transport. The Python SDK controls a + # headless Copilot CLI runtime over JSON-RPC and reuses that runtime + # across graphify requests. Authentication remains owned by Copilot + # CLI, including GHE.com enterprise SSO. If SDK startup or transport + # fails, graphify falls back to the existing one-shot copilot-cli + # backend unless GRAPHIFY_COPILOT_SDK_FALLBACK=0. + "default_model": "auto", + "model_env_keys": [ + "GRAPHIFY_COPILOT_SDK_MODEL", + "GRAPHIFY_COPILOT_MODEL", + "COPILOT_MODEL", + ], + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + # Images are sent as SDK file attachments. The SDK/runtime handles + # encoding and model-specific downsampling. + "vision": True, + }, } @@ -519,6 +558,24 @@ def _resolve_under_root(path: Path, root: Path) -> Path | None: return resolved_path +def _prompt_path(path: Path, root: Path) -> str: + """Return a stable POSIX-style path for prompts and ``source_file``. + + Model-facing paths are data identifiers, not host-native filesystem paths. + Always using forward slashes keeps prompts, cache keys, and generated graph + metadata portable between Windows and POSIX systems. + """ + try: + return path.relative_to(root).as_posix() + except ValueError: + # ``path`` and ``root`` can differ in absolute/relative form even after + # the caller has verified containment with ``_resolve_under_root``. + try: + return path.resolve().relative_to(root.resolve()).as_posix() + except (OSError, RuntimeError, ValueError): + return path.as_posix() + + # Known prompt-injection / chat-template sentinels that a hostile source file # might embed to try to break out of the untrusted_source block or impersonate a # system/role turn. Neutralised (not deleted — we keep byte offsets stable enough @@ -579,10 +636,7 @@ def _read_files(units: "list[Path | FileSlice]", root: Path) -> str: if safe_path is None: print(f"[graphify] skipping {p}: symlink target outside corpus root", file=sys.stderr) continue - try: - rel = str(p.relative_to(root)) - except ValueError: - rel = str(p) + rel = _prompt_path(p, root) try: if isinstance(u, FileSlice): content = read_slice_text(u) @@ -747,10 +801,11 @@ def _bind_node_evidence(result: dict, text_units: "list[Path | FileSlice]", root # many for the claude-cli Read-tool loop to work through. Keeps memory and # request size bounded on image-dense corpora. _MAX_IMAGES_PER_CHUNK = 20 -# Backends that read an image by file path (claude-cli's Read tool) -# instead of inlining base64. They open the file themselves and downsample as -# needed, so `_MAX_IMAGE_BYTES` does not apply and the bytes never need loading. -_PATH_IMAGE_BACKENDS = {"claude-cli"} +# Backends that receive an image by file path instead of graphify inlining +# base64. claude-cli opens the path with its Read tool; copilot-sdk sends a +# first-class file attachment and lets the runtime encode/downsample it. +# `_MAX_IMAGE_BYTES` therefore does not apply and the bytes need not be loaded. +_PATH_IMAGE_BACKENDS = {"claude-cli", "copilot-sdk"} @dataclass @@ -763,7 +818,7 @@ class _ImageRef: becomes a graph node. """ - path: Path # absolute path (claude-cli reads it via the Read tool) + path: Path # absolute path (CLI Read tool or SDK file attachment) rel: str # path relative to the corpus root (the node's source_file) media_type: str # e.g. "image/png" raw: bytes | None @@ -800,8 +855,8 @@ def _build_image_refs(image_files: list[Path], root: Path, *, read_bytes: bool = `read_bytes=True` (base64 backends) loads the pixels and drops any image over `_MAX_IMAGE_BYTES` to a reference, because a base64 request body has a hard - size ceiling. `read_bytes=False` (path-based backends — claude-cli) - skips the read entirely: those backends open the file themselves and + size ceiling. `read_bytes=False` (path-based backends — claude-cli and + copilot-sdk) skips the read entirely: those backends open the file themselves and downsample as needed, so there is no per-image size limit and no reason to load (potentially tens of MB of) bytes that would never be used. """ @@ -811,10 +866,7 @@ def _build_image_refs(image_files: list[Path], root: Path, *, read_bytes: bool = if abs_path is None: print(f"[graphify] skipping image {p}: symlink target outside corpus root", file=sys.stderr) continue - try: - rel = str(p.relative_to(root)) - except ValueError: - rel = str(p) + rel = _prompt_path(p, root) media = _IMAGE_MEDIA_TYPES.get(p.suffix.lower(), "image/png") raw: bytes | None = None if read_bytes: @@ -852,7 +904,12 @@ def _backend_supports_vision(backend: str) -> bool: return bool(BACKENDS.get(backend, {}).get("vision", False)) -def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str: +def _image_notes( + refs: list[_ImageRef], + *, + with_paths: bool = False, + file_attachments: bool = False, +) -> str: """Text block listing the images so the model emits one node per image. Always included alongside the visual payload (and used on its own when the @@ -867,6 +924,11 @@ def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str: "Use the Read tool to open and view each image file at the path below, " "then emit one node per image" ) + elif file_attachments: + header = ( + "The following image file(s) are attached as visual input. Emit one " + "node per image" + ) else: header = ( "The following image file(s) are attached as visual input. Emit one " @@ -882,14 +944,24 @@ def _image_notes(refs: list[_ImageRef], *, with_paths: bool = False) -> str: note = f"[image {i}] source_file: {r.rel}" if with_paths: note += f" path: {r.path}" - if r.raw is None and not with_paths: + if r.raw is None and not with_paths and not file_attachments: note += " (not shown: unreadable or exceeds size limit)" lines.append(note) return "\n".join(lines) -def _with_image_notes(user_message: str, refs: list[_ImageRef], *, with_paths: bool = False) -> str: - notes = _image_notes(refs, with_paths=with_paths) +def _with_image_notes( + user_message: str, + refs: list[_ImageRef], + *, + with_paths: bool = False, + file_attachments: bool = False, +) -> str: + notes = _image_notes( + refs, + with_paths=with_paths, + file_attachments=file_attachments, + ) if not notes: return user_message if not user_message.strip(): @@ -1118,8 +1190,10 @@ def _format_backend_env_keys(backend: str) -> str: def _default_model_for_backend(backend: str) -> str: """Return configured model override or backend default model.""" cfg = BACKENDS[backend] - model_env_key = cfg.get("model_env_key") - if model_env_key: + model_env_keys = cfg.get("model_env_keys") or [cfg.get("model_env_key")] + for model_env_key in model_env_keys: + if not model_env_key: + continue model = os.environ.get(model_env_key) if model: return model @@ -1566,6 +1640,827 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo return result +# Cache the Copilot CLI help text per resolved command. Graphify probes +# capabilities instead of assuming every enterprise workstation is running the +# same Copilot CLI release; optional hardening flags are only passed when the +# installed binary advertises them. +_COPILOT_CLI_HELP: dict[str, str] = {} + + +def _resolve_copilot_cli_command() -> str: + """Return an executable GitHub Copilot CLI command or raise a clear error. + + npm-style Windows installs expose both ``copilot.ps1`` and ``copilot.cmd``. + ``subprocess.run`` cannot launch the PowerShell shim directly, so prefer the + ``.cmd`` shim just as the Claude CLI backend does. + """ + import platform + import shutil + + if platform.system() == "Windows": + cmd_path = shutil.which("copilot.cmd") + if cmd_path: + return cmd_path + copilot_path = shutil.which("copilot") + if copilot_path: + return copilot_path + raise RuntimeError( + "GitHub Copilot CLI not found on $PATH. Install the official `copilot` " + "CLI, then authenticate (for GHE.com: `copilot login --host " + "https://.ghe.com`)." + ) + + +def _copilot_cli_help(copilot_cmd: str) -> str: + """Return cached ``copilot help`` output, or an empty string on failure.""" + import subprocess + + cached = _COPILOT_CLI_HELP.get(copilot_cmd) + if cached is not None: + return cached + try: + proc = subprocess.run( + [copilot_cmd, "help"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + check=False, + **_no_window_kwargs(), + ) + help_text = f"{proc.stdout or ''}\n{proc.stderr or ''}" + except (OSError, subprocess.SubprocessError): + help_text = "" + _COPILOT_CLI_HELP[copilot_cmd] = help_text + return help_text + + +def _copilot_cli_supports(help_text: str, option: str) -> bool: + """Return whether help advertises *option* as a complete CLI token. + + A plain substring check is unsafe for prefix-related options: for example, + ``--no-remote`` is a substring of ``--no-remote-export``. Match option-name + boundaries while still accepting value forms such as ``--model=MODEL``. + """ + return re.search( + rf"(? bool: + """Detect account/version errors specific to remote-session opt-out flags.""" + lowered = detail.lower() + if "remote" not in lowered: + return False + return any( + marker in lowered + for marker in ( + "not available", + "unavailable", + "not enabled", + "unknown option", + "unknown argument", + "unsupported option", + "requires the remote", + ) + ) + + +def _run_copilot_cli(prompt: str, *, model: str) -> str: + """Run the official GitHub Copilot CLI as a one-shot text completion. + + The prompt is piped over stdin instead of placed in ``argv``. This avoids + Windows command-line length limits for graphify's large extraction chunks. + Authentication, enterprise host selection, and SSO remain owned by the + Copilot CLI credential store and inherited environment. + """ + import subprocess + import tempfile + + copilot_cmd = _resolve_copilot_cli_command() + help_text = _copilot_cli_help(copilot_cmd) + args = [copilot_cmd, "-s"] + + # --model is preferred because it makes the selected model explicit in the + # child process. COPILOT_MODEL is also set below as a compatibility fallback + # for older CLI releases whose help does not advertise the flag. + if _copilot_cli_supports(help_text, "--model"): + args.append(f"--model={model}") + + # Keep this completion path tool-restricted. Graphify has already assembled + # the complete source payload, so Copilot needs no filesystem, shell, URL, + # memory, repository-instruction, or interactive-question capabilities. + # Flags are capability-gated because enterprise workstations may lag the + # public CLI release. + optional_flags = ( + "--no-color", + "--no-custom-instructions", + "--no-ask-user", + "--no-auto-update", + "--no-bash-env", + "--no-experimental", + "--disable-builtin-mcps", + ) + for flag in optional_flags: + if _copilot_cli_supports(help_text, flag): + args.append(flag) + # --no-remote-export also disables remote control. Older CLIs may only + # expose --no-remote, so use that as a compatibility fallback rather than + # passing both prefix-related options. + remote_opt_out = None + if _copilot_cli_supports(help_text, "--no-remote-export"): + remote_opt_out = "--no-remote-export" + elif _copilot_cli_supports(help_text, "--no-remote"): + remote_opt_out = "--no-remote" + if remote_opt_out: + args.append(remote_opt_out) + if _copilot_cli_supports(help_text, "--deny-tool"): + args.append("--deny-tool=memory,read,shell,url,write") + + child_env = os.environ.copy() + child_env.update( + { + "COPILOT_MODEL": model, + "COPILOT_PROMPT_FRAME": "0", + "COPILOT_AUTO_UPDATE": "false", + "COPILOT_ALLOW_ALL": "false", + "COPILOT_MCP_TOOL_CACHE": "false", + "GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS": "false", + "GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS": "false", + "GITHUB_COPILOT_PROMPT_MODE_WORKSPACE_MCP": "false", + "NO_COLOR": "1", + } + ) + + # An empty temporary working directory is a second line of defence for old + # CLI versions that predate --no-custom-instructions: there is no repository + # content, AGENTS.md, hook, or workspace MCP configuration to discover. + with tempfile.TemporaryDirectory(prefix="graphify-copilot-") as workdir: + timeout_s = _resolve_api_timeout() + run_kwargs = { + "input": prompt, + "capture_output": True, + "text": True, + "encoding": "utf-8", + "errors": "replace", + "timeout": timeout_s, + "check": False, + "cwd": workdir, + "env": child_env, + **_no_window_kwargs(), + } + + def _invoke(cli_args: list[str]): + try: + return subprocess.run(cli_args, **run_kwargs) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"copilot timed out after {timeout_s:g} seconds. Increase " + "GRAPHIFY_API_TIMEOUT (or pass --api-timeout) for slower " + "enterprise/model responses." + ) from exc + except OSError as exc: + raise RuntimeError( + f"Could not execute GitHub Copilot CLI at {copilot_cmd!r}: {exc}" + ) from exc + + proc = _invoke(args) + if proc.returncode != 0 and remote_opt_out: + detail = (proc.stderr or proc.stdout or "").strip() + if _copilot_remote_opt_out_rejected(detail): + print( + "[graphify] Copilot CLI rejected its remote-session opt-out " + "flag; retrying without that optional flag. Configure " + 'Copilot CLI setting `remoteExport: false` when required.', + file=sys.stderr, + ) + retry_args = [arg for arg in args if arg != remote_opt_out] + proc = _invoke(retry_args) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "unknown error").strip()[:500] + host = os.environ.get("COPILOT_GH_HOST", "").strip() + login_host = host if "://" in host else f"https://{host}" + login_hint = ( + f"`copilot login --host {login_host}`" + if host + else "`copilot login --host https://.ghe.com`" + ) + raise RuntimeError( + f"copilot exited {proc.returncode}: {detail}. " + f"Verify Copilot CLI authentication with {login_hint}." + ) + return (proc.stdout or "").strip() + + +def _call_copilot_cli( + user_message: str, + max_tokens: int = 8192, + *, + model: str = "auto", + deep_mode: bool = False, + images: list[_ImageRef] | None = None, +) -> dict: + """Extract a graph through GitHub Copilot CLI subscription authentication.""" + user_message = _with_image_notes(user_message, images or []) + combined_message = ( + _extraction_system(deep=deep_mode) + + "\n\n---\n" + + "Now extract the knowledge graph from the following source file(s) " + + "and output ONLY the JSON object described above. No prose, no " + + "preamble, no markdown fences. Keep the JSON response within " + + f"approximately {max_tokens} tokens.\n\n" + + user_message + ) + raw_content = _run_copilot_cli(combined_message, model=model) + result = _parse_llm_json(raw_content or "{}") + # Copilot CLI's silent text mode does not expose token accounting. These + # estimates preserve graphify's run metrics. Graphify records zero provider + # cost because the CLI does not return enough billing data to calculate it; + # GitHub plan entitlements or AI credits may still be consumed. + result["input_tokens"] = len(combined_message) // _CHARS_PER_TOKEN + result["output_tokens"] = len(raw_content) // _CHARS_PER_TOKEN + result["model"] = model + result["finish_reason"] = "stop" + if _response_is_hollow(raw_content, result): + print( + "[graphify] copilot-cli returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + +# ── GitHub Copilot SDK transport ───────────────────────────────────────────── +# +# The Python SDK is async and controls a long-running, headless Copilot CLI +# runtime over JSON-RPC. Graphify's extraction API is synchronous and may call +# from worker threads, so the bridge below owns one dedicated asyncio loop and +# one SDK client in a daemon thread. Each Graphify request gets a fresh SDK +# session in an isolated temporary Copilot home. Graphify disconnects and +# permanently deletes that session before returning while reusing the runtime. + + +class _CopilotSdkUnavailable(RuntimeError): + """The optional Copilot SDK/runtime cannot be used in this process.""" + + +def _env_enabled(name: str, *, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() not in {"", "0", "false", "no", "off"} + + +def _load_copilot_sdk(): + """Import the optional SDK and its typed reject decision lazily.""" + major, minor = sys.version_info[:2] + if (major, minor) < (3, 11): + raise _CopilotSdkUnavailable( + "github-copilot-sdk requires Python 3.11 or newer; this Graphify " + f"process is running Python {major}.{minor}." + ) + try: + from copilot import CopilotClient, RuntimeConnection + except ImportError as exc: + raise _CopilotSdkUnavailable( + "GitHub Copilot SDK is not installed. Install Graphify's optional " + "backend with `pip install \"graphifyy[copilot]\"` or install " + "`github-copilot-sdk>=1.0.7`." + ) from exc + try: + from copilot.rpc import PermissionDecisionReject + except ImportError: + # The stable SDK also re-exports permission decisions at package root; + # retain this import fallback for patched enterprise distributions. + try: + from copilot import PermissionDecisionReject + except ImportError as exc: + raise _CopilotSdkUnavailable( + "Installed github-copilot-sdk does not expose the stable " + "permission-decision API. Upgrade to version 1.0.7 or newer." + ) from exc + return CopilotClient, RuntimeConnection, PermissionDecisionReject + + +def _copilot_sdk_child_env() -> dict[str, str]: + """Return a hardened environment for the SDK-managed runtime process.""" + child_env = os.environ.copy() + child_env.update( + { + "COPILOT_AUTO_UPDATE": "false", + "COPILOT_ALLOW_ALL": "false", + "COPILOT_MCP_TOOL_CACHE": "false", + # Suppress ambient marketplace/personal plugin discovery. Graphify + # never supplies a plugin directory, so this leaves the set empty. + "COPILOT_PLUGIN_DIR_ONLY": "true", + "GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS": "false", + "GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS": "false", + "GITHUB_COPILOT_PROMPT_MODE_WORKSPACE_MCP": "false", + "NO_COLOR": "1", + } + ) + return child_env + + +def _resolve_copilot_sdk_cli_command() -> str: + """Resolve the system CLI used by the SDK's stdio transport. + + Managed enterprise deployments should use the same system binary the user has + authenticated with ``copilot login --host ...``. An explicit path can be + supplied for managed workstations; otherwise reuse the CLI backend's + cross-platform resolution. + """ + import shutil + + configured = ( + os.environ.get("GRAPHIFY_COPILOT_SDK_CLI_PATH", "").strip() + or os.environ.get("COPILOT_CLI_PATH", "").strip() + ) + if configured: + expanded = os.path.expandvars(os.path.expanduser(configured)) + resolved = shutil.which(expanded) or expanded + if Path(resolved).is_file(): + return str(Path(resolved)) + raise _CopilotSdkUnavailable( + f"Configured Copilot SDK CLI path does not exist: {configured!r}." + ) + return _resolve_copilot_cli_command() + + +def _filter_supported_kwargs(callable_obj, kwargs: dict) -> dict: + """Filter keyword arguments for SDK minor-version compatibility.""" + import inspect + + try: + signature = inspect.signature(callable_obj) + except (TypeError, ValueError): + return kwargs + if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in signature.parameters.values()): + return kwargs + return {key: value for key, value in kwargs.items() if key in signature.parameters} + + +def _require_supported_kwargs( + callable_obj, + names: set[str], + *, + api_name: str, +) -> None: + """Fail closed when an SDK lacks Graphify's isolation controls. + + The SDK is optional and the CLI transport remains available, so silently + dropping a security-relevant option is the wrong compatibility strategy. + Optional quality-of-life options are still filtered by + ``_filter_supported_kwargs``; the controls listed here must exist. + """ + import inspect + + try: + signature = inspect.signature(callable_obj) + except (TypeError, ValueError) as exc: + raise _CopilotSdkUnavailable( + f"Cannot inspect {api_name}; install github-copilot-sdk>=1.0.7." + ) from exc + if any( + parameter.kind == inspect.Parameter.VAR_KEYWORD + for parameter in signature.parameters.values() + ): + return + missing = sorted(name for name in names if name not in signature.parameters) + if missing: + raise _CopilotSdkUnavailable( + f"Installed github-copilot-sdk lacks required {api_name} option(s): " + f"{', '.join(missing)}. Upgrade to version 1.0.7 or newer." + ) + + +def _copilot_sdk_content(response) -> str: + """Extract assistant text from a stable-SDK response object defensively.""" + if response is None: + return "" + data = getattr(response, "data", None) + if data is None and isinstance(response, dict): + data = response.get("data", response) + content = getattr(data, "content", None) + if content is None and isinstance(data, dict): + content = data.get("content") + if content is None: + content = getattr(response, "content", None) + if content is None: + return "" + if isinstance(content, str): + return content.strip() + if isinstance(content, (dict, list)): + return json.dumps(content, ensure_ascii=False) + return str(content).strip() + + +class _CopilotSdkRuntime: + """Thread-safe synchronous bridge to one persistent Copilot SDK client.""" + + def __init__(self, *, cli_path: str | None, use_bundled_runtime: bool): + self.cli_path = cli_path + self.use_bundled_runtime = use_bundled_runtime + self._client = None + self._loop: asyncio.AbstractEventLoop | None = None + self._start_error: BaseException | None = None + self._ready = threading.Event() + self._closed = False + self._workdir = tempfile.TemporaryDirectory(prefix="graphify-copilot-sdk-") + self._workspace = Path(self._workdir.name) / "workspace" + self._copilot_home = Path(self._workdir.name) / "copilot-home" + self._workspace.mkdir() + self._copilot_home.mkdir() + self._thread = threading.Thread( + target=self._thread_main, + name="graphify-copilot-sdk", + daemon=True, + ) + self._thread.start() + startup_timeout = max(5.0, min(_resolve_api_timeout(), 120.0)) + if not self._ready.wait(startup_timeout): + self.close() + raise _CopilotSdkUnavailable( + f"Copilot SDK runtime did not start within {startup_timeout:g} " + "seconds. Increase GRAPHIFY_API_TIMEOUT for a slower enterprise " + "workstation, or use --backend copilot-cli." + ) + if self._start_error is not None: + error = self._start_error + self.close() + raise _CopilotSdkUnavailable(f"Copilot SDK runtime failed to start: {error}") from error + + def _thread_main(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + try: + timeout = max(5.0, min(_resolve_api_timeout(), 120.0)) + loop.run_until_complete(asyncio.wait_for(self._async_start(), timeout=timeout)) + except BaseException as exc: # captured and raised by the constructing thread + self._start_error = exc + self._ready.set() + else: + self._ready.set() + loop.run_forever() + finally: + try: + loop.run_until_complete(self._async_stop()) + except BaseException: + pass + loop.close() + + async def _async_start(self) -> None: + CopilotClient, RuntimeConnection, reject_type = _load_copilot_sdk() + self._reject_type = reject_type + connection = None + child_env = _copilot_sdk_child_env() + # Keep runtime config and session data out of the user's normal + # Copilot home. Logged-in-user authentication still comes from the + # system credential store; this directory is deleted with the runtime. + child_env["COPILOT_HOME"] = str(self._copilot_home) + required_client_options = { + "base_directory", + "working_directory", + "use_logged_in_user", + "enable_remote_sessions", + "mode", + } + if not self.use_bundled_runtime: + required_client_options.add("connection") + connection = RuntimeConnection.for_stdio(path=self.cli_path) + # Stable SDKs require child-process environment variables to live + # on RuntimeConnection when an explicit stdio/tcp connection is + # supplied; setting both connection.env and CopilotClient(env=...) + # is rejected. Keeping the hardened environment on the connection + # also makes the process boundary explicit. + connection.env = child_env + client_kwargs = { + "connection": connection, + "base_directory": str(self._copilot_home), + "working_directory": str(self._workspace), + "use_logged_in_user": True, + # Current stable naming plus the previous SDK spelling; signature + # filtering keeps only the one supported by the installed version. + "enable_remote_sessions": False, + "remote": False, + # Empty mode disables ambient filesystem/shell/MCP/skill defaults. + "mode": "empty", + } + if connection is None: + client_kwargs.pop("connection") + required_client_options.add("env") + # The SDK-managed bundled runtime has no caller-created connection, + # so client-level env is the supported way to harden its process. + client_kwargs["env"] = child_env + _require_supported_kwargs( + CopilotClient, + required_client_options, + api_name="CopilotClient", + ) + self._client = CopilotClient( + **_filter_supported_kwargs(CopilotClient, client_kwargs) + ) + if not callable(getattr(self._client, "delete_session", None)): + raise _CopilotSdkUnavailable( + "Installed github-copilot-sdk lacks CopilotClient.delete_session; " + "upgrade to version 1.0.7 or newer." + ) + await self._client.start() + + async def _async_stop(self) -> None: + client, self._client = self._client, None + if client is not None: + stop = getattr(client, "stop", None) + if stop is not None: + await stop() + + def _deny_permission(self, _request, _invocation=None): + return self._reject_type( + feedback="Graphify's copilot-sdk backend disables all agent tools." + ) + + async def _async_complete( + self, + prompt: str, + *, + model: str, + attachments: list[dict] | None, + timeout_s: float, + ) -> str: + if self._client is None: + raise _CopilotSdkUnavailable("Copilot SDK client is not running.") + session_id = f"graphify-{os.getpid()}-{uuid.uuid4().hex}" + session_kwargs = { + "model": model, + "session_id": session_id, + "on_permission_request": self._deny_permission, + "available_tools": [], + "mcp_servers": {}, + "memory": {"enabled": False}, + "infinite_sessions": {"enabled": False}, + "enable_config_discovery": False, + "enable_session_telemetry": False, + } + create_session = self._client.create_session + _require_supported_kwargs( + create_session, + { + "model", + "session_id", + "on_permission_request", + "available_tools", + "mcp_servers", + "memory", + "infinite_sessions", + "enable_config_discovery", + }, + api_name="CopilotClient.create_session", + ) + session = await create_session( + **_filter_supported_kwargs(create_session, session_kwargs) + ) + response = None + request_error: BaseException | None = None + try: + send_kwargs = {"attachments": attachments} if attachments else {} + response = await asyncio.wait_for( + session.send_and_wait(prompt, **send_kwargs), + timeout=timeout_s, + ) + except BaseException as exc: + request_error = exc + finally: + cleanup_errors: list[str] = [] + disconnect = getattr(session, "disconnect", None) + if disconnect is not None: + try: + await disconnect() + except BaseException as exc: + cleanup_errors.append(f"disconnect failed: {exc}") + try: + await self._client.delete_session(session_id) + except BaseException as exc: + cleanup_errors.append(f"session deletion failed: {exc}") + if cleanup_errors: + detail = "; ".join(cleanup_errors) + if request_error is not None: + detail = f"request failed: {request_error}; {detail}" + raise RuntimeError( + "Copilot SDK request cleanup could not be verified; the " + f"runtime will be discarded ({detail})." + ) from (request_error or None) + if request_error is not None: + raise request_error + text = _copilot_sdk_content(response) + if not text: + raise RuntimeError("Copilot SDK returned no assistant text.") + return text + + def complete( + self, + prompt: str, + *, + model: str, + attachments: list[dict] | None = None, + ) -> str: + if self._closed or self._loop is None or not self._thread.is_alive(): + raise _CopilotSdkUnavailable("Copilot SDK runtime is not available.") + timeout_s = _resolve_api_timeout() + future = asyncio.run_coroutine_threadsafe( + self._async_complete( + prompt, + model=model, + attachments=attachments, + timeout_s=timeout_s, + ), + self._loop, + ) + try: + return future.result(timeout=timeout_s + 5.0) + except TimeoutError as exc: + future.cancel() + raise RuntimeError( + f"Copilot SDK timed out after {timeout_s:g} seconds. Increase " + "GRAPHIFY_API_TIMEOUT or use --backend copilot-cli." + ) from exc + + def close(self) -> None: + if self._closed: + return + self._closed = True + loop = self._loop + if loop is not None and loop.is_running(): + try: + future = asyncio.run_coroutine_threadsafe(self._async_stop(), loop) + future.result(timeout=5.0) + except BaseException: + pass + loop.call_soon_threadsafe(loop.stop) + if self._thread.is_alive() and threading.current_thread() is not self._thread: + self._thread.join(timeout=5.0) + self._workdir.cleanup() + + +_COPILOT_SDK_RUNTIME: _CopilotSdkRuntime | None = None +_COPILOT_SDK_RUNTIME_SIGNATURE: tuple | None = None +_COPILOT_SDK_RUNTIME_LOCK = threading.Lock() +_COPILOT_SDK_FALLBACK_WARNED: set[str] = set() + + +def _copilot_sdk_signature(*, cli_path: str | None, bundled: bool) -> tuple: + auth_material = "\0".join( + os.environ.get(name, "") + for name in ( + "COPILOT_GH_HOST", + "GH_HOST", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "COPILOT_HOME", + "HOME", + "USERPROFILE", + ) + ) + auth_fingerprint = hashlib.sha256(auth_material.encode("utf-8")).hexdigest() + return (os.getpid(), bundled, cli_path, auth_fingerprint) + + +def _get_copilot_sdk_runtime() -> _CopilotSdkRuntime: + global _COPILOT_SDK_RUNTIME, _COPILOT_SDK_RUNTIME_SIGNATURE + + bundled = _env_enabled("GRAPHIFY_COPILOT_SDK_USE_BUNDLED_CLI") + cli_path = None if bundled else _resolve_copilot_sdk_cli_command() + signature = _copilot_sdk_signature(cli_path=cli_path, bundled=bundled) + with _COPILOT_SDK_RUNTIME_LOCK: + if ( + _COPILOT_SDK_RUNTIME is not None + and _COPILOT_SDK_RUNTIME_SIGNATURE == signature + ): + return _COPILOT_SDK_RUNTIME + if _COPILOT_SDK_RUNTIME is not None: + _COPILOT_SDK_RUNTIME.close() + runtime = _CopilotSdkRuntime( + cli_path=cli_path, + use_bundled_runtime=bundled, + ) + _COPILOT_SDK_RUNTIME = runtime + _COPILOT_SDK_RUNTIME_SIGNATURE = signature + return runtime + + +def _discard_copilot_sdk_runtime() -> None: + global _COPILOT_SDK_RUNTIME, _COPILOT_SDK_RUNTIME_SIGNATURE + + with _COPILOT_SDK_RUNTIME_LOCK: + runtime, _COPILOT_SDK_RUNTIME = _COPILOT_SDK_RUNTIME, None + _COPILOT_SDK_RUNTIME_SIGNATURE = None + if runtime is not None: + runtime.close() + + +atexit.register(_discard_copilot_sdk_runtime) + + +def _run_copilot_sdk( + prompt: str, + *, + model: str, + attachments: list[dict] | None = None, + fallback_prompt: str | None = None, +) -> str: + """Complete through the SDK, automatically falling back to copilot-cli.""" + try: + return _get_copilot_sdk_runtime().complete( + prompt, + model=model, + attachments=attachments, + ) + except Exception as sdk_exc: + _discard_copilot_sdk_runtime() + if not _env_enabled("GRAPHIFY_COPILOT_SDK_FALLBACK", default=True): + raise RuntimeError( + "copilot-sdk failed and CLI fallback is disabled by " + f"GRAPHIFY_COPILOT_SDK_FALLBACK=0: {sdk_exc}" + ) from sdk_exc + warning_key = type(sdk_exc).__name__ + ":" + str(sdk_exc) + if warning_key not in _COPILOT_SDK_FALLBACK_WARNED: + _COPILOT_SDK_FALLBACK_WARNED.add(warning_key) + print( + f"[graphify] copilot-sdk unavailable ({sdk_exc}); falling back " + "to copilot-cli for this request.", + file=sys.stderr, + ) + try: + return _run_copilot_cli(fallback_prompt or prompt, model=model) + except Exception as cli_exc: + raise RuntimeError( + "Both GitHub Copilot transports failed. " + f"copilot-sdk: {sdk_exc}; copilot-cli fallback: {cli_exc}" + ) from cli_exc + + +def _call_copilot_sdk( + user_message: str, + max_tokens: int = 8192, + *, + model: str = "auto", + deep_mode: bool = False, + images: list[_ImageRef] | None = None, +) -> dict: + """Extract a graph through the Copilot SDK with CLI fallback.""" + refs = images or [] + sdk_user_message = _with_image_notes( + user_message, + refs, + file_attachments=True, + ) + # The one-shot CLI fallback has no SDK attachment channel. Its alternate + # prompt therefore labels those images as unseen references rather than + # incorrectly claiming pixels were attached. + cli_user_message = _with_image_notes(user_message, refs) + + def _combined(message: str) -> str: + return ( + _extraction_system(deep=deep_mode) + + "\n\n---\n" + + "Now extract the knowledge graph from the following source file(s) " + + "and output ONLY the JSON object described above. No prose, no " + + "preamble, no markdown fences. Keep the JSON response within " + + f"approximately {max_tokens} tokens.\n\n" + + message + ) + + combined_message = _combined(sdk_user_message) + fallback_message = _combined(cli_user_message) + attachments = [ + {"type": "file", "path": str(ref.path.resolve())} + for ref in refs + ] + raw_content = _run_copilot_sdk( + combined_message, + model=model, + attachments=attachments or None, + fallback_prompt=fallback_message, + ) + result = _parse_llm_json(raw_content or "{}") + result["input_tokens"] = len(combined_message) // _CHARS_PER_TOKEN + result["output_tokens"] = len(raw_content) // _CHARS_PER_TOKEN + result["model"] = model + result["finish_reason"] = "stop" + if _response_is_hollow(raw_content, result): + print( + "[graphify] copilot-sdk returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" + return result + + def _azure_client(api_key: str, endpoint: str): """Construct an AzureOpenAI client with env-driven api_version and timeout.""" try: @@ -1735,7 +2630,12 @@ def extract_files_direct( file=sys.stderr, ) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ( + "bedrock", + "claude-cli", + "copilot-cli", + "copilot-sdk", + ): raise ValueError( f"No API key for backend '{backend}'. " f"Set {_format_backend_env_keys(backend)} or pass api_key=." @@ -1748,7 +2648,8 @@ def extract_files_direct( user_msg = _read_files(text_files, root) vision = _backend_supports_vision(backend) # Only base64 (inline) vision backends need the bytes loaded + size-capped; - # path-based backends (claude-cli) and non-vision backends do not. + # path-based backends (claude-cli and copilot-sdk) and non-vision backends + # do not. read_bytes = vision and backend not in _PATH_IMAGE_BACKENDS image_refs = _build_image_refs(image_files, root, read_bytes=read_bytes) if image_files else [] if image_refs and not vision: @@ -1759,6 +2660,22 @@ def extract_files_direct( result = _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "claude-cli": result = _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + elif backend == "copilot-cli": + result = _call_copilot_cli( + user_msg, + max_tokens=max_out, + model=mdl, + deep_mode=deep_mode, + images=image_refs, + ) + elif backend == "copilot-sdk": + result = _call_copilot_sdk( + user_msg, + max_tokens=max_out, + model=mdl, + deep_mode=deep_mode, + images=image_refs, + ) elif backend == "bedrock": result = _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "azure": @@ -2284,10 +3201,15 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | # responses after 3-4 chunks (#798). Force serial unless the user opts in. if backend == "ollama" and os.environ.get("GRAPHIFY_OLLAMA_PARALLEL", "").strip() != "1": max_concurrency = 1 - # claude-cli shells out to a Claude Code session; parallel subprocesses conflict - # over session state. Force serial unless the user explicitly opts in. + # CLI subscription backends use local account/session state and are more + # likely to hit per-user enterprise limits when several subprocesses start + # together. Force serial unless the user explicitly opts in. if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "copilot-cli" and os.environ.get("GRAPHIFY_COPILOT_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 + if backend == "copilot-sdk" and os.environ.get("GRAPHIFY_COPILOT_SDK_PARALLEL", "").strip() != "1": + max_concurrency = 1 def _checkpoint_chunk(result: dict, chunk: "list[Path | FileSlice]") -> None: # Persist each chunk's semantic results to the cache as soon as it # completes. Without this, the semantic cache is only written once, at @@ -2530,7 +3452,12 @@ def _call_llm( ollama_url = _resolve_ollama_base_url(cfg.get("base_url", "")) _validate_ollama_base_url(ollama_url) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ( + "bedrock", + "claude-cli", + "copilot-cli", + "copilot-sdk", + ): raise ValueError( f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}." ) @@ -2598,6 +3525,35 @@ def _rec(inp, out) -> None: ) return envelope.get("result", "") + if backend == "copilot-cli": + completion_prompt = ( + prompt + + "\n\nReturn only the requested answer and keep it within " + + f"approximately {max_tokens} tokens." + ) + text = _run_copilot_cli(completion_prompt, model=mdl) + _rec( + len(completion_prompt) // _CHARS_PER_TOKEN, + len(text) // _CHARS_PER_TOKEN, + ) + return text + + if backend == "copilot-sdk": + completion_prompt = ( + prompt + + "\n\nReturn only the requested answer and keep it within " + + f"approximately {max_tokens} tokens." + ) + text = _run_copilot_sdk( + completion_prompt, + model=mdl, + fallback_prompt=completion_prompt, + ) + _rec( + len(completion_prompt) // _CHARS_PER_TOKEN, + len(text) // _CHARS_PER_TOKEN, + ) + return text if backend == "bedrock": try: @@ -2796,7 +3752,10 @@ def detect_backend() -> str | None: _validate_ollama_base_url(ollama_url) return "ollama" for name in BACKENDS: - if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"): + if name not in ( + "gemini", "kimi", "claude", "openai", "deepseek", "azure", + "bedrock", "ollama", "claude-cli", "copilot-cli", "copilot-sdk", + ): if _get_backend_api_key(name): return name return None @@ -3009,6 +3968,10 @@ def label_communities( max_concurrency = 1 if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "copilot-cli" and os.environ.get("GRAPHIFY_COPILOT_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 + if backend == "copilot-sdk" and os.environ.get("GRAPHIFY_COPILOT_SDK_PARALLEL", "").strip() != "1": + max_concurrency = 1 workers = max(1, min(max_concurrency, n_batches)) def _run_batch(batch_idx: int): diff --git a/graphify/prs.py b/graphify/prs.py index 9534e6c00..305ab6e59 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -672,6 +672,19 @@ def triage_with_opus(prs: list[PRInfo], base: str) -> None: print(f" {line}") print() + elif backend in ("copilot-cli", "copilot-sdk"): + from graphify.llm import _call_llm + + result = _call_llm( + prompt, + backend=backend, + model=model, + max_tokens=1024, + ) + for line in result.splitlines(): + print(f" {line}") + print() + except Exception as e: print(f"\n\n {red(f'Triage failed: {e}')}", file=sys.stderr) diff --git a/graphify/skills/agents/references/github-and-merge.md b/graphify/skills/agents/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/agents/references/github-and-merge.md +++ b/graphify/skills/agents/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/amp/references/github-and-merge.md b/graphify/skills/amp/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/amp/references/github-and-merge.md +++ b/graphify/skills/amp/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/claude/references/github-and-merge.md b/graphify/skills/claude/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/claude/references/github-and-merge.md +++ b/graphify/skills/claude/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/claw/references/github-and-merge.md b/graphify/skills/claw/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/claw/references/github-and-merge.md +++ b/graphify/skills/claw/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/codex/references/github-and-merge.md b/graphify/skills/codex/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/codex/references/github-and-merge.md +++ b/graphify/skills/codex/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/copilot/references/github-and-merge.md b/graphify/skills/copilot/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/copilot/references/github-and-merge.md +++ b/graphify/skills/copilot/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/droid/references/github-and-merge.md b/graphify/skills/droid/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/droid/references/github-and-merge.md +++ b/graphify/skills/droid/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/kilo/references/github-and-merge.md b/graphify/skills/kilo/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/kilo/references/github-and-merge.md +++ b/graphify/skills/kilo/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/kiro/references/github-and-merge.md b/graphify/skills/kiro/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/kiro/references/github-and-merge.md +++ b/graphify/skills/kiro/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/opencode/references/github-and-merge.md b/graphify/skills/opencode/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/opencode/references/github-and-merge.md +++ b/graphify/skills/opencode/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/pi/references/github-and-merge.md b/graphify/skills/pi/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/pi/references/github-and-merge.md +++ b/graphify/skills/pi/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/trae/references/github-and-merge.md b/graphify/skills/trae/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/trae/references/github-and-merge.md +++ b/graphify/skills/trae/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/vscode/references/github-and-merge.md b/graphify/skills/vscode/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/vscode/references/github-and-merge.md +++ b/graphify/skills/vscode/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/graphify/skills/windows/references/github-and-merge.md b/graphify/skills/windows/references/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/graphify/skills/windows/references/github-and-merge.md +++ b/graphify/skills/windows/references/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/pyproject.toml b/pyproject.toml index 916312e2c..f50f171c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.30" +version = "0.9.32" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = "Apache-2.0" @@ -52,11 +52,11 @@ Issues = "https://github.com/Graphify-Labs/graphify/issues" # starlette is pulled in transitively by mcp, but graphify/serve.py imports it # directly for the HTTP transport, so declare it here and floor it above the # CVE-2026-48818 / CVE-2026-54283 fixes (both resolved by 1.3.1) (#1391, #1396). -# mcp is capped below 2.0: the 2.0.0 major dropped the mcp.types.AnyUrl -# re-export and the Server decorator-registration API graphify/serve.py uses, -# so an unpinned resolve broke every fresh graphifyy[mcp] install (#2277/#2279/ -# #2291). starlette is capped below its next major for the same reason. -mcp = ["mcp>=1,<2", "starlette>=1.3.1,<2"] +# serve.py is dual-compat with the 1.x decorator API and the 2.x on_* +# constructor-callback API (registration is picked at runtime in +# _build_server), lifting the <2 cap 0.9.30 introduced for #2277/#2279/#2291; +# cap below 3 as the tested range. starlette stays capped below its next major. +mcp = ["mcp>=1,<3", "starlette>=1.3.1,<2"] neo4j = ["neo4j"] falkordb = ["falkordb"] pdf = ["pypdf>=6.12.0", "markdownify"] @@ -73,6 +73,10 @@ bedrock = ["boto3"] anthropic = ["anthropic"] gemini = ["openai", "tiktoken"] openai = ["openai", "tiktoken"] +# The official Python SDK requires Python 3.11+. Graphify itself retains its +# Python 3.10 floor; on 3.10 the explicit copilot-sdk backend transparently +# uses its copilot-cli fallback instead. +copilot = ["github-copilot-sdk>=1.0.7,<2; python_version >= '3.11'"] chinese = ["jieba"] sql = ["tree-sitter-sql"] # extract_pascal() uses tree-sitter-pascal for AST-quality extraction (more @@ -85,7 +89,7 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp>=1,<2", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] +all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "github-copilot-sdk>=1.0.7,<2; python_version >= '3.11'", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/test_copilot_cli_backend.py b/tests/test_copilot_cli_backend.py new file mode 100644 index 000000000..a8d363e8b --- /dev/null +++ b/tests/test_copilot_cli_backend.py @@ -0,0 +1,385 @@ +"""Tests for the GitHub Copilot CLI subscription backend. + +The subprocess and executable lookup are mocked so the suite needs neither a +Copilot installation nor network/enterprise credentials. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from graphify import llm + +_RESPONSE = json.dumps( + { + "nodes": [ + { + "id": "policy", + "label": "Policy", + "file_type": "document", + "source_file": "policy.md", + } + ], + "edges": [], + "hyperedges": [], + } +) + +_HELP = "\n".join( + [ + "-s, --silent", + "--model=MODEL", + "--no-color", + "--no-custom-instructions", + "--no-ask-user", + "--no-auto-update", + "--no-bash-env", + "--no-experimental", + "--disable-builtin-mcps", + "--no-remote", + "--no-remote-export", + "--deny-tool=TOOL", + ] +) + + +@pytest.fixture +def fake_copilot(monkeypatch): + completed = MagicMock(returncode=0, stdout=_RESPONSE, stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", return_value=completed + ) as run: + yield run + + +def test_backend_registered_with_zero_cost(): + assert "copilot-cli" in llm.BACKENDS + assert llm.BACKENDS["copilot-cli"]["default_model"] == "auto" + pricing = llm.BACKENDS["copilot-cli"]["pricing"] + assert pricing == {"input": 0.0, "output": 0.0} + assert llm.estimate_cost("copilot-cli", 1_000_000, 1_000_000) == 0.0 + + +def test_default_model_precedence(monkeypatch): + monkeypatch.delenv("GRAPHIFY_COPILOT_CLI_MODEL", raising=False) + monkeypatch.delenv("COPILOT_MODEL", raising=False) + assert llm._default_model_for_backend("copilot-cli") == "auto" + + monkeypatch.setenv("COPILOT_MODEL", "gpt-5-mini") + assert llm._default_model_for_backend("copilot-cli") == "gpt-5-mini" + + monkeypatch.setenv("GRAPHIFY_COPILOT_CLI_MODEL", "claude-sonnet-4.6") + assert llm._default_model_for_backend("copilot-cli") == "claude-sonnet-4.6" + + +def test_returns_parsed_graph_and_estimated_usage(fake_copilot): + result = llm._call_copilot_cli("source", model="auto") + assert result["nodes"][0]["id"] == "policy" + assert result["model"] == "auto" + assert result["finish_reason"] == "stop" + assert result["input_tokens"] > 0 + assert result["output_tokens"] > 0 + + +def test_large_prompt_is_piped_over_stdin_not_argv(fake_copilot): + llm._call_copilot_cli("UNIQUE_SOURCE_MARKER", model="auto") + argv = fake_copilot.call_args.args[0] + sent = fake_copilot.call_args.kwargs["input"] + + assert "-p" not in argv + assert "--prompt" not in " ".join(argv) + assert "UNIQUE_SOURCE_MARKER" not in " ".join(argv) + assert "UNIQUE_SOURCE_MARKER" in sent + assert "graphify semantic extraction agent" in sent + assert "output ONLY the JSON object" in sent + assert "untrusted_source" in sent + + +def test_model_and_non_agentic_hardening_flags_are_forwarded(fake_copilot): + llm._call_copilot_cli("source", model="gpt-5-mini") + argv = fake_copilot.call_args.args[0] + + assert "-s" in argv + assert "--model=gpt-5-mini" in argv + for flag in ( + "--no-color", + "--no-custom-instructions", + "--no-ask-user", + "--no-auto-update", + "--no-bash-env", + "--no-experimental", + "--disable-builtin-mcps", + "--no-remote-export", + ): + assert flag in argv + assert "--deny-tool=memory,read,shell,url,write" in argv + + +def test_child_environment_preserves_enterprise_host_and_disables_agent_features( + monkeypatch, fake_copilot +): + monkeypatch.setenv("COPILOT_GH_HOST", "example.ghe.com") + llm._call_copilot_cli("source", model="auto") + env = fake_copilot.call_args.kwargs["env"] + + assert env["COPILOT_GH_HOST"] == "example.ghe.com" + assert env["COPILOT_MODEL"] == "auto" + assert env["COPILOT_ALLOW_ALL"] == "false" + assert env["COPILOT_MCP_TOOL_CACHE"] == "false" + assert env["GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS"] == "false" + assert env["GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS"] == "false" + assert env["GITHUB_COPILOT_PROMPT_MODE_WORKSPACE_MCP"] == "false" + + +def test_runs_from_ephemeral_empty_working_directory(fake_copilot): + llm._call_copilot_cli("source", model="auto") + cwd = Path(fake_copilot.call_args.kwargs["cwd"]) + assert cwd.name.startswith("graphify-copilot-") + assert not cwd.exists(), "temporary working directory should be cleaned up" + + +def test_optional_flags_are_omitted_for_older_cli(monkeypatch): + completed = MagicMock(returncode=0, stdout=_RESPONSE, stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: "legacy help") + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", return_value=completed + ) as run: + llm._call_copilot_cli("source", model="gpt-5-mini") + + argv = run.call_args.args[0] + assert argv == ["/fake/bin/copilot", "-s"] + # Model selection still works through the official COPILOT_MODEL fallback. + assert run.call_args.kwargs["env"]["COPILOT_MODEL"] == "gpt-5-mini" + + +def test_capability_detection_does_not_confuse_remote_option_prefixes(): + assert llm._copilot_cli_supports("--no-remote-export", "--no-remote-export") + assert not llm._copilot_cli_supports("--no-remote-export", "--no-remote") + assert llm._copilot_cli_supports("--model=MODEL", "--model") + + +def test_remote_feature_rejection_retries_without_optional_flag(monkeypatch, capsys): + rejected = MagicMock( + returncode=2, + stdout="", + stderr="Remote sessions feature is not available for this account", + ) + completed = MagicMock(returncode=0, stdout=_RESPONSE, stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", side_effect=[rejected, completed] + ) as run: + result = llm._call_copilot_cli("source", model="auto") + + assert result["nodes"][0]["id"] == "policy" + assert "--no-remote-export" in run.call_args_list[0].args[0] + assert "--no-remote-export" not in run.call_args_list[1].args[0] + assert "retrying without" in capsys.readouterr().err + + +def test_malformed_output_is_dropped_and_marked_for_retry(monkeypatch): + completed = MagicMock(returncode=0, stdout="not json", stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", return_value=completed + ): + result = llm._call_copilot_cli("source", model="auto") + + assert result["nodes"] == [] + assert result["edges"] == [] + assert result["finish_reason"] == "length" + + +def test_extract_files_direct_dispatches_without_api_key(tmp_path, fake_copilot): + source = tmp_path / "policy.md" + source.write_text("# Policy\n\nA policy document.\n", encoding="utf-8") + result = llm.extract_files_direct( + files=[source], backend="copilot-cli", root=tmp_path + ) + assert fake_copilot.called + assert result["nodes"][0]["source_file"] == "policy.md" + + +def test_simple_completion_path_uses_copilot_cli(monkeypatch, fake_copilot): + fake_copilot.return_value.stdout = "compact answer" + usage = {} + out = llm._call_llm( + "Summarize this", backend="copilot-cli", model="auto", usage_out=usage + ) + assert out == "compact answer" + assert usage["input"] > 0 + assert usage["output"] > 0 + + +def test_hollow_response_is_marked_as_truncation(monkeypatch): + completed = MagicMock(returncode=0, stdout="", stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", return_value=completed + ): + result = llm._call_copilot_cli("source", model="auto") + assert result["finish_reason"] == "length" + + +def test_nonzero_exit_includes_ghe_login_hint(monkeypatch): + completed = MagicMock(returncode=1, stdout="", stderr="not authenticated") + monkeypatch.setenv("COPILOT_GH_HOST", "example.ghe.com") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", return_value=completed + ): + with pytest.raises( + RuntimeError, + match=r"copilot login --host https://example\.ghe\.com", + ): + llm._call_copilot_cli("source", model="auto") + + +def test_timeout_has_actionable_graphify_guidance(monkeypatch): + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired("copilot", 120), + ): + with pytest.raises(RuntimeError, match="GRAPHIFY_API_TIMEOUT"): + llm._call_copilot_cli("source", model="auto") + + +def test_execution_oserror_has_resolved_command_context(monkeypatch): + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + with patch("shutil.which", return_value="/fake/bin/copilot"), patch( + "subprocess.run", + side_effect=OSError("executable disappeared"), + ): + with pytest.raises(RuntimeError, match="/fake/bin/copilot"): + llm._call_copilot_cli("source", model="auto") + + +def test_missing_cli_has_install_and_ghe_auth_guidance(): + with patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="GitHub Copilot CLI not found") as exc: + llm._call_copilot_cli("source", model="auto") + assert "copilot login --host" in str(exc.value) + + +def test_windows_prefers_cmd_shim(monkeypatch): + completed = MagicMock(returncode=0, stdout=_RESPONSE, stderr="") + monkeypatch.setattr(llm, "_copilot_cli_help", lambda _cmd: _HELP) + + def fake_which(name): + return { + "copilot": r"C:\Users\u\AppData\Roaming\npm\copilot.ps1", + "copilot.cmd": r"C:\Users\u\AppData\Roaming\npm\copilot.cmd", + }.get(name) + + with patch("platform.system", return_value="Windows"), patch( + "shutil.which", side_effect=fake_which + ), patch("subprocess.run", return_value=completed) as run: + llm._call_copilot_cli("source", model="auto") + + assert run.call_args.args[0][0] == r"C:\Users\u\AppData\Roaming\npm\copilot.cmd" + + +def test_help_probe_is_cached(monkeypatch): + llm._COPILOT_CLI_HELP.clear() + completed = MagicMock(returncode=0, stdout="--model=MODEL", stderr="") + with patch("subprocess.run", return_value=completed) as run: + assert "--model" in llm._copilot_cli_help("/fake/copilot") + assert "--model" in llm._copilot_cli_help("/fake/copilot") + assert run.call_count == 1 + + +def test_detect_backend_does_not_auto_select_copilot(monkeypatch): + """A binary on PATH is not consent to send corpus data to Copilot.""" + for key in ( + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "OLLAMA_BASE_URL", + "OLLAMA_HOST", + ): + monkeypatch.delenv(key, raising=False) + with patch("shutil.which", return_value="/fake/bin/copilot"): + assert llm.detect_backend() is None + + +def _ok_graph(nodes=None): + return { + "nodes": nodes or [], + "edges": [], + "hyperedges": [], + "input_tokens": 1, + "output_tokens": 1, + "model": "auto", + "finish_reason": "stop", + } + + +def test_extract_corpus_parallel_copilot_runs_serially(tmp_path, monkeypatch): + files = [tmp_path / f"f{i}.md" for i in range(6)] + for source in files: + source.write_text("hello", encoding="utf-8") + + def fake_extract(chunk, *_, **__): + return _ok_graph(nodes=[{"id": source.stem} for source in chunk]) + + monkeypatch.delenv("GRAPHIFY_COPILOT_CLI_PARALLEL", raising=False) + with patch("graphify.llm.extract_files_direct", side_effect=fake_extract), patch( + "graphify.llm.ThreadPoolExecutor" + ) as pool: + result = llm.extract_corpus_parallel( + files, + backend="copilot-cli", + model="auto", + root=tmp_path, + token_budget=None, + chunk_size=2, + max_concurrency=4, + ) + + pool.assert_not_called() + assert len(result["nodes"]) == 6 + + +def test_extract_corpus_parallel_copilot_parallel_opt_in(tmp_path, monkeypatch): + files = [tmp_path / f"f{i}.md" for i in range(4)] + for source in files: + source.write_text("hello", encoding="utf-8") + + monkeypatch.setenv("GRAPHIFY_COPILOT_CLI_PARALLEL", "1") + with patch("graphify.llm.extract_files_direct", return_value=_ok_graph()), patch( + "graphify.llm.ThreadPoolExecutor" + ) as pool: + pool.return_value.__enter__ = lambda value: value + pool.return_value.__exit__ = lambda _value, *_args: False + pool.return_value.submit = lambda fn, *args, **kwargs: type( + "Future", (), {"result": lambda self: fn(*args, **kwargs)} + )() + try: + llm.extract_corpus_parallel( + files, + backend="copilot-cli", + model="auto", + root=tmp_path, + token_budget=None, + chunk_size=2, + max_concurrency=4, + ) + except Exception: + # The minimal future mock only needs to prove the pool path was selected. + pass + + pool.assert_called() diff --git a/tests/test_copilot_sdk_backend.py b/tests/test_copilot_sdk_backend.py new file mode 100644 index 000000000..e22da87c5 --- /dev/null +++ b/tests/test_copilot_sdk_backend.py @@ -0,0 +1,543 @@ +"""Tests for the GitHub Copilot SDK backend and CLI fallback. + +The official SDK, runtime, executable, network, and enterprise credentials are +all replaced with fakes. These tests therefore exercise Graphify's transport +lifecycle and dispatch without transmitting data or requiring a Copilot seat. +""" +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from graphify import llm + +_RESPONSE = json.dumps( + { + "nodes": [ + { + "id": "policy", + "label": "Policy", + "file_type": "document", + "source_file": "policy.md", + } + ], + "edges": [], + "hyperedges": [], + } +) + + +@pytest.fixture(autouse=True) +def reset_sdk_runtime(): + llm._discard_copilot_sdk_runtime() + llm._COPILOT_SDK_FALLBACK_WARNED.clear() + yield + llm._discard_copilot_sdk_runtime() + llm._COPILOT_SDK_FALLBACK_WARNED.clear() + + +class _FakeBridge: + def __init__(self, text: str = _RESPONSE): + self.text = text + self.calls: list[dict] = [] + + def complete(self, prompt, *, model, attachments=None): + self.calls.append( + {"prompt": prompt, "model": model, "attachments": attachments} + ) + return self.text + + +def _ok_graph(nodes=None): + return { + "nodes": nodes or [], + "edges": [], + "hyperedges": [], + "input_tokens": 1, + "output_tokens": 1, + "model": "auto", + "finish_reason": "stop", + } + + +def test_backend_registered_with_zero_cost_and_vision(): + cfg = llm.BACKENDS["copilot-sdk"] + assert cfg["default_model"] == "auto" + assert cfg["vision"] is True + assert cfg["pricing"] == {"input": 0.0, "output": 0.0} + assert llm.estimate_cost("copilot-sdk", 1_000_000, 1_000_000) == 0.0 + + +def test_default_model_precedence(monkeypatch): + for name in ( + "GRAPHIFY_COPILOT_SDK_MODEL", + "GRAPHIFY_COPILOT_MODEL", + "COPILOT_MODEL", + ): + monkeypatch.delenv(name, raising=False) + assert llm._default_model_for_backend("copilot-sdk") == "auto" + + monkeypatch.setenv("COPILOT_MODEL", "gpt-5-mini") + assert llm._default_model_for_backend("copilot-sdk") == "gpt-5-mini" + + monkeypatch.setenv("GRAPHIFY_COPILOT_MODEL", "gpt-5.2") + assert llm._default_model_for_backend("copilot-sdk") == "gpt-5.2" + + monkeypatch.setenv("GRAPHIFY_COPILOT_SDK_MODEL", "claude-sonnet-4.6") + assert llm._default_model_for_backend("copilot-sdk") == "claude-sonnet-4.6" + + +def test_sdk_requires_python_311(monkeypatch): + monkeypatch.setattr(llm.sys, "version_info", (3, 10, 14)) + + with pytest.raises(llm._CopilotSdkUnavailable, match="Python 3.11"): + llm._load_copilot_sdk() + + +def test_sdk_capability_check_fails_closed_for_legacy_client(): + class LegacyClient: + def __init__( + self, + *, + connection=None, + working_directory=None, + use_logged_in_user=True, + enable_remote_sessions=False, + ): + pass + + with pytest.raises(llm._CopilotSdkUnavailable, match="mode"): + llm._require_supported_kwargs( + LegacyClient, + { + "connection", + "working_directory", + "use_logged_in_user", + "enable_remote_sessions", + "mode", + }, + api_name="CopilotClient", + ) + + +def test_sdk_extraction_parses_graph_and_estimates_usage(monkeypatch): + bridge = _FakeBridge() + monkeypatch.setattr(llm, "_get_copilot_sdk_runtime", lambda: bridge) + + result = llm._call_copilot_sdk("source", model="auto") + + assert result["nodes"][0]["id"] == "policy" + assert result["model"] == "auto" + assert result["finish_reason"] == "stop" + assert result["input_tokens"] > 0 + assert result["output_tokens"] > 0 + assert "graphify semantic extraction agent" in bridge.calls[0]["prompt"] + assert "output ONLY the JSON object" in bridge.calls[0]["prompt"] + + +def test_sdk_images_are_file_attachments_not_loaded_as_base64(tmp_path, monkeypatch): + image = tmp_path / "diagram.png" + image.write_bytes(b"not-a-real-image-but-no-read-is-required") + bridge = _FakeBridge() + monkeypatch.setattr(llm, "_get_copilot_sdk_runtime", lambda: bridge) + + result = llm.extract_files_direct( + [image], + backend="copilot-sdk", + root=tmp_path, + ) + + assert result["nodes"][0]["id"] == "policy" + call = bridge.calls[0] + assert call["attachments"] == [ + {"type": "file", "path": str(image.resolve())} + ] + assert "source_file: diagram.png" in call["prompt"] + assert "not shown" not in call["prompt"] + + +def test_sdk_transport_failure_falls_back_to_cli(monkeypatch, capsys): + def fail_sdk(): + raise llm._CopilotSdkUnavailable("SDK package missing") + + cli = MagicMock(return_value=_RESPONSE) + monkeypatch.setattr(llm, "_get_copilot_sdk_runtime", fail_sdk) + monkeypatch.setattr(llm, "_run_copilot_cli", cli) + + result = llm._call_copilot_sdk("source", model="auto") + + assert result["nodes"][0]["id"] == "policy" + cli.assert_called_once() + assert cli.call_args.kwargs["model"] == "auto" + assert "falling back to copilot-cli" in capsys.readouterr().err + + +def test_fallback_warning_is_emitted_once(monkeypatch, capsys): + monkeypatch.setattr( + llm, + "_get_copilot_sdk_runtime", + MagicMock(side_effect=llm._CopilotSdkUnavailable("not installed")), + ) + monkeypatch.setattr(llm, "_run_copilot_cli", MagicMock(return_value="ok")) + + assert llm._run_copilot_sdk("one", model="auto") == "ok" + assert llm._run_copilot_sdk("two", model="auto") == "ok" + + assert capsys.readouterr().err.count("falling back to copilot-cli") == 1 + + +def test_fallback_can_be_disabled(monkeypatch): + monkeypatch.setenv("GRAPHIFY_COPILOT_SDK_FALLBACK", "0") + monkeypatch.setattr( + llm, + "_get_copilot_sdk_runtime", + MagicMock(side_effect=llm._CopilotSdkUnavailable("not installed")), + ) + cli = MagicMock() + monkeypatch.setattr(llm, "_run_copilot_cli", cli) + + with pytest.raises(RuntimeError, match="fallback is disabled"): + llm._run_copilot_sdk("prompt", model="auto") + cli.assert_not_called() + + +def test_both_transport_failures_are_reported(monkeypatch): + monkeypatch.setattr( + llm, + "_get_copilot_sdk_runtime", + MagicMock(side_effect=llm._CopilotSdkUnavailable("SDK unavailable")), + ) + monkeypatch.setattr( + llm, + "_run_copilot_cli", + MagicMock(side_effect=RuntimeError("CLI unauthenticated")), + ) + + with pytest.raises(RuntimeError, match="Both GitHub Copilot transports failed") as exc: + llm._run_copilot_sdk("prompt", model="auto") + assert "SDK unavailable" in str(exc.value) + assert "CLI unauthenticated" in str(exc.value) + + +def test_cli_fallback_prompt_does_not_claim_image_pixels_are_attached( + tmp_path, monkeypatch +): + image = tmp_path / "diagram.png" + image.write_bytes(b"pixels") + ref = llm._ImageRef(image, "diagram.png", "image/png", None) + monkeypatch.setattr( + llm, + "_get_copilot_sdk_runtime", + MagicMock(side_effect=llm._CopilotSdkUnavailable("SDK unavailable")), + ) + cli = MagicMock(return_value=_RESPONSE) + monkeypatch.setattr(llm, "_run_copilot_cli", cli) + + llm._call_copilot_sdk("source", model="auto", images=[ref]) + + fallback_prompt = cli.call_args.args[0] + assert "source_file: diagram.png (not shown" in fallback_prompt + + +def test_extract_files_direct_dispatches_without_api_key(tmp_path, monkeypatch): + source = tmp_path / "policy.md" + source.write_text("# Policy\n", encoding="utf-8") + call = MagicMock(return_value=_ok_graph([{"id": "policy"}])) + monkeypatch.setattr(llm, "_call_copilot_sdk", call) + + result = llm.extract_files_direct( + [source], + backend="copilot-sdk", + root=tmp_path, + ) + + assert result["nodes"][0]["id"] == "policy" + call.assert_called_once() + assert call.call_args.kwargs["model"] == "auto" + + +def test_simple_completion_path_uses_sdk(monkeypatch): + sdk = MagicMock(return_value="compact answer") + monkeypatch.setattr(llm, "_run_copilot_sdk", sdk) + usage = {} + + out = llm._call_llm( + "Summarize this", + backend="copilot-sdk", + model="auto", + usage_out=usage, + ) + + assert out == "compact answer" + assert usage["input"] > 0 + assert usage["output"] > 0 + assert sdk.call_args.kwargs["model"] == "auto" + + +def test_detect_backend_does_not_auto_select_copilot_sdk(monkeypatch): + for key in ( + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_ENDPOINT", + "AWS_PROFILE", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "OLLAMA_BASE_URL", + "OLLAMA_HOST", + ): + monkeypatch.delenv(key, raising=False) + assert llm.detect_backend() is None + + +def test_extract_corpus_parallel_sdk_runs_serially(tmp_path, monkeypatch): + files = [tmp_path / f"f{i}.md" for i in range(6)] + for source in files: + source.write_text("hello", encoding="utf-8") + + def fake_extract(chunk, *_, **__): + return _ok_graph([{"id": Path(source).stem} for source in chunk]) + + monkeypatch.delenv("GRAPHIFY_COPILOT_SDK_PARALLEL", raising=False) + with patch("graphify.llm.extract_files_direct", side_effect=fake_extract), patch( + "graphify.llm.ThreadPoolExecutor" + ) as pool: + result = llm.extract_corpus_parallel( + files, + backend="copilot-sdk", + model="auto", + root=tmp_path, + token_budget=None, + chunk_size=2, + max_concurrency=4, + ) + + pool.assert_not_called() + assert len(result["nodes"]) == 6 + + +def test_sdk_runtime_reuses_client_and_denies_tools(monkeypatch): + state = { + "clients": [], + "sessions": [], + "connections": [], + "deleted_sessions": [], + } + + class FakeReject: + def __init__(self, *, feedback): + self.feedback = feedback + + class FakeConnection: + def __init__(self, path): + self.path = path + self.env = None + + @staticmethod + def for_stdio(*, path): + value = FakeConnection(path) + state["connections"].append(value) + return value + + class FakeSession: + def __init__(self, kwargs): + self.kwargs = kwargs + self.send_calls = [] + self.disconnected = False + state["sessions"].append(self) + + async def send_and_wait(self, prompt, **kwargs): + self.send_calls.append((prompt, kwargs)) + return SimpleNamespace(data=SimpleNamespace(content="answer")) + + async def disconnect(self): + self.disconnected = True + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.started = False + self.stopped = False + state["clients"].append(self) + + async def start(self): + self.started = True + + async def stop(self): + self.stopped = True + + async def create_session(self, **kwargs): + return FakeSession(kwargs) + + async def delete_session(self, session_id): + state["deleted_sessions"].append(session_id) + + monkeypatch.setenv("COPILOT_GH_HOST", "example.ghe.com") + monkeypatch.setattr( + llm, + "_load_copilot_sdk", + lambda: (FakeClient, FakeConnection, FakeReject), + ) + + runtime = llm._CopilotSdkRuntime( + cli_path="/managed/copilot", + use_bundled_runtime=False, + ) + try: + assert runtime.complete("first", model="auto") == "answer" + assert runtime.complete( + "second", + model="gpt-5.2", + attachments=[{"type": "file", "path": "/tmp/image.png"}], + ) == "answer" + + assert len(state["clients"]) == 1 + assert len(state["sessions"]) == 2 + client = state["clients"][0] + assert client.started is True + connection = client.kwargs["connection"] + assert connection.path == "/managed/copilot" + assert connection.env["COPILOT_GH_HOST"] == "example.ghe.com" + assert connection.env["COPILOT_PLUGIN_DIR_ONLY"] == "true" + assert connection.env["COPILOT_HOME"] == client.kwargs["base_directory"] + assert client.kwargs["mode"] == "empty" + assert client.kwargs["use_logged_in_user"] is True + assert "env" not in client.kwargs + assert Path(client.kwargs["working_directory"]).exists() + assert Path(client.kwargs["base_directory"]).exists() + + first = state["sessions"][0] + assert first.kwargs["model"] == "auto" + assert first.kwargs["available_tools"] == [] + assert first.kwargs["mcp_servers"] == {} + assert first.kwargs["memory"] == {"enabled": False} + assert first.kwargs["infinite_sessions"] == {"enabled": False} + assert first.kwargs["enable_config_discovery"] is False + decision = first.kwargs["on_permission_request"](object(), {}) + assert isinstance(decision, FakeReject) + assert "disables all agent tools" in decision.feedback + assert first.disconnected is True + + second = state["sessions"][1] + assert second.send_calls[0][1]["attachments"] == [ + {"type": "file", "path": "/tmp/image.png"} + ] + assert state["deleted_sessions"] == [ + first.kwargs["session_id"], + second.kwargs["session_id"], + ] + assert state["deleted_sessions"][0] != state["deleted_sessions"][1] + finally: + workdir = Path(state["clients"][0].kwargs["working_directory"]) + runtime.close() + assert state["clients"][0].stopped is True + assert not workdir.exists() + + +def test_sdk_runtime_can_explicitly_use_bundled_cli(monkeypatch): + state = {} + + class FakeReject: + def __init__(self, *, feedback): + self.feedback = feedback + + class FakeConnection: + @staticmethod + def for_stdio(*, path): # pragma: no cover - must not be called + raise AssertionError(path) + + class FakeSession: + async def send_and_wait(self, prompt, **kwargs): + return SimpleNamespace(data=SimpleNamespace(content="ok")) + + async def disconnect(self): + return None + + class FakeClient: + def __init__(self, **kwargs): + state["kwargs"] = kwargs + + async def start(self): + return None + + async def stop(self): + return None + + async def create_session(self, **kwargs): + return FakeSession() + + async def delete_session(self, session_id): + state["deleted_session"] = session_id + + monkeypatch.setattr( + llm, + "_load_copilot_sdk", + lambda: (FakeClient, FakeConnection, FakeReject), + ) + runtime = llm._CopilotSdkRuntime(cli_path=None, use_bundled_runtime=True) + try: + assert runtime.complete("hello", model="auto") == "ok" + assert "connection" not in state["kwargs"] + assert state["kwargs"]["env"]["COPILOT_HOME"] == state["kwargs"]["base_directory"] + assert state["deleted_session"].startswith("graphify-") + finally: + runtime.close() + + +def test_sdk_runtime_cleanup_failure_is_fatal(monkeypatch): + class FakeReject: + def __init__(self, *, feedback): + self.feedback = feedback + + class FakeConnection: + env = None + + @staticmethod + def for_stdio(*, path): + return FakeConnection() + + class FakeSession: + async def send_and_wait(self, prompt, **kwargs): + return SimpleNamespace(data=SimpleNamespace(content="answer")) + + async def disconnect(self): + return None + + class FakeClient: + def __init__(self, **kwargs): + pass + + async def start(self): + return None + + async def stop(self): + return None + + async def create_session(self, **kwargs): + return FakeSession() + + async def delete_session(self, session_id): + raise RuntimeError("disk cleanup denied") + + monkeypatch.setattr( + llm, + "_load_copilot_sdk", + lambda: (FakeClient, FakeConnection, FakeReject), + ) + runtime = llm._CopilotSdkRuntime( + cli_path="/managed/copilot", + use_bundled_runtime=False, + ) + try: + with pytest.raises(RuntimeError, match="cleanup could not be verified"): + runtime.complete("hello", model="auto") + finally: + runtime.close() diff --git a/tests/test_dedup.py b/tests/test_dedup.py index e1370fc7a..5ee2a3995 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -1,5 +1,7 @@ """Tests for graphify/dedup.py entity deduplication pipeline.""" from __future__ import annotations +from unittest.mock import patch + import pytest from graphify.dedup import deduplicate_entities, _defines_id, _entropy, _shingles @@ -120,6 +122,46 @@ def test_dedup_llm_flag_accepted(): assert len(result_nodes) == 2 +def test_dedup_llm_copilot_cli_does_not_require_api_key(monkeypatch): + """Keyless CLI auth must reach the shared LLM dispatcher.""" + nodes = _make_nodes("Authentication Manager", "Authorization Manager") + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + with patch("graphify.llm._call_llm", return_value="1. yes") as call: + result_nodes, _ = deduplicate_entities( + nodes, + [], + communities={}, + dedup_llm_backend="copilot-cli", + ) + + call.assert_called_once() + assert call.call_args.kwargs["backend"] == "copilot-cli" + assert len(result_nodes) == 1 + + +def test_dedup_llm_copilot_sdk_does_not_require_api_key(monkeypatch): + """SDK auth and its CLI fallback must reach the shared dispatcher.""" + nodes = _make_nodes("Authentication Manager", "Authorization Manager") + monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + with patch("graphify.llm._call_llm", return_value="1. yes") as call: + result_nodes, _ = deduplicate_entities( + nodes, + [], + communities={}, + dedup_llm_backend="copilot-sdk", + ) + + call.assert_called_once() + assert call.call_args.kwargs["backend"] == "copilot-sdk" + assert len(result_nodes) == 1 + + # ── build integration ───────────────────────────────────────────────────────── def test_build_calls_dedup(): diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index e50d0bc43..4a7ac3ac6 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -42,6 +42,19 @@ def _make_corpus(tmp_path): return img, svg, doc +def _symlink_or_skip(link, target): + """Create a test symlink or skip when Windows denies that privilege.""" + try: + link.symlink_to(target) + except OSError as exc: + if sys.platform == "win32" and getattr(exc, "winerror", None) in {5, 1314}: + pytest.skip( + "Windows symlink creation requires Developer Mode or the " + "SeCreateSymbolicLinkPrivilege privilege" + ) + raise + + # ── pure helpers ────────────────────────────────────────────────────────────── def test_pdf_routed_through_pypdf_not_readtext(tmp_path, monkeypatch): @@ -70,6 +83,15 @@ def test_non_pdf_still_read_as_plain_text(tmp_path): assert "# hello" in llm._file_to_text(md) +def test_prompt_path_uses_posix_separators_for_windows_paths(): + from pathlib import PureWindowsPath + + root = PureWindowsPath("C:/repo") + path = root / "sub" / "diagram.png" + + assert llm._prompt_path(path, root) == "sub/diagram.png" + + def test_read_files_skips_out_of_root_symlink(tmp_path): root = tmp_path / "root" root.mkdir() @@ -78,7 +100,7 @@ def test_read_files_skips_out_of_root_symlink(tmp_path): secret = outside / "secret.md" secret.write_text("SECRET SHOULD NOT REACH THE PROMPT") link = root / "secret.md" - link.symlink_to(secret) + _symlink_or_skip(link, secret) out = llm._read_files([link], root) @@ -112,7 +134,7 @@ def test_build_image_refs_skips_out_of_root_symlink(tmp_path): secret = outside / "secret.png" secret.write_bytes(_PNG_BYTES) link = root / "secret.png" - link.symlink_to(secret) + _symlink_or_skip(link, secret) refs = llm._build_image_refs([link], root) @@ -129,7 +151,7 @@ def test_build_image_refs_drops_oversized(tmp_path, monkeypatch): def test_path_backend_skips_byte_read_and_size_cap(tmp_path, monkeypatch): - # Path-based backends (claude-cli) read the file themselves, so + # Path-based backends (claude-cli and copilot-sdk) use the file path, so # _build_image_refs(read_bytes=False) loads no bytes and applies no size cap. big = tmp_path / "huge.png" big.write_bytes(b"x" * 64) @@ -161,7 +183,15 @@ def fake_run(args, **kw): def test_capability_flags(monkeypatch): - for b in ("claude", "claude-cli", "openai", "gemini", "bedrock", "kimi"): + for b in ( + "claude", + "claude-cli", + "copilot-sdk", + "openai", + "gemini", + "bedrock", + "kimi", + ): assert llm._backend_supports_vision(b), b assert not llm._backend_supports_vision("deepseek") # ollama is opt-in via env (default model is text-only) diff --git a/tests/test_labeling.py b/tests/test_labeling.py index bb7bc3c9d..94e724030 100644 --- a/tests/test_labeling.py +++ b/tests/test_labeling.py @@ -403,7 +403,7 @@ def test_label_communities_runs_batches_concurrently(monkeypatch): def test_label_communities_forces_serial_for_ollama(monkeypatch): - """ollama/claude-cli must stay serial regardless of --max-concurrency.""" + """Ollama stays serial unless its explicit parallel opt-in is set.""" G, communities = _many_communities(8) fake_batch, state = _peak_tracker() monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch) @@ -412,6 +412,36 @@ def test_label_communities_forces_serial_for_ollama(monkeypatch): assert state["peak"] == 1, "ollama must be forced serial" +def test_label_communities_forces_serial_for_copilot_cli(monkeypatch): + G, communities = _many_communities(8) + fake_batch, state = _peak_tracker() + monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch) + monkeypatch.delenv("GRAPHIFY_COPILOT_CLI_PARALLEL", raising=False) + label_communities( + G, + communities, + backend="copilot-cli", + batch_size=1, + max_concurrency=8, + ) + assert state["peak"] == 1, "copilot-cli must be forced serial" + + +def test_label_communities_forces_serial_for_copilot_sdk(monkeypatch): + G, communities = _many_communities(8) + fake_batch, state = _peak_tracker() + monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch) + monkeypatch.delenv("GRAPHIFY_COPILOT_SDK_PARALLEL", raising=False) + label_communities( + G, + communities, + backend="copilot-sdk", + batch_size=1, + max_concurrency=8, + ) + assert state["peak"] == 1, "copilot-sdk must be forced serial" + + def test_label_communities_salvages_truncated_reply(monkeypatch): # #1690: a reply truncated mid-object (a stingy token budget or model # preamble) used to hard-fail the whole batch with `Expecting value: line 1 diff --git a/tests/test_prs.py b/tests/test_prs.py index 4acc343e6..bc5ecbbf0 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -21,6 +21,8 @@ fetch_worktrees, format_prs_text, _detect_default_branch, + _resolve_triage_backend, + triage_with_opus, ) @@ -406,6 +408,78 @@ def test_empty_nodes(self): assert build_community_labels({"nodes": []}) == {} +# ── Copilot CLI triage backend ─────────────────────────────────────────────── + +class TestCopilotCliTriage: + def test_explicit_backend_uses_copilot_default_model(self, monkeypatch): + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "copilot-cli") + monkeypatch.delenv("GRAPHIFY_TRIAGE_MODEL", raising=False) + monkeypatch.delenv("GRAPHIFY_COPILOT_CLI_MODEL", raising=False) + monkeypatch.delenv("COPILOT_MODEL", raising=False) + + assert _resolve_triage_backend() == ("copilot-cli", "auto") + + def test_explicit_triage_model_takes_precedence(self, monkeypatch): + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "copilot-cli") + monkeypatch.setenv("GRAPHIFY_TRIAGE_MODEL", "gpt-5-mini") + monkeypatch.setenv("GRAPHIFY_COPILOT_CLI_MODEL", "ignored-model") + + assert _resolve_triage_backend() == ("copilot-cli", "gpt-5-mini") + + def test_triage_routes_through_shared_copilot_completion(self, capsys): + pr = make_pr(number=42, title="Harden auth flow") + + with patch( + "graphify.prs._resolve_triage_backend", + return_value=("copilot-cli", "auto"), + ), patch( + "graphify.llm._call_llm", + return_value="#42 — Review authentication changes first.", + ) as call: + triage_with_opus([pr], base="v8") + + prompt = call.call_args.args[0] + assert "PR #42" in prompt + assert call.call_args.kwargs == { + "backend": "copilot-cli", + "model": "auto", + "max_tokens": 1024, + } + assert "Review authentication changes first" in capsys.readouterr().out + + +class TestCopilotSdkTriage: + def test_explicit_backend_uses_sdk_default_model(self, monkeypatch): + monkeypatch.setenv("GRAPHIFY_TRIAGE_BACKEND", "copilot-sdk") + monkeypatch.delenv("GRAPHIFY_TRIAGE_MODEL", raising=False) + monkeypatch.delenv("GRAPHIFY_COPILOT_SDK_MODEL", raising=False) + monkeypatch.delenv("GRAPHIFY_COPILOT_MODEL", raising=False) + monkeypatch.delenv("COPILOT_MODEL", raising=False) + + assert _resolve_triage_backend() == ("copilot-sdk", "auto") + + def test_triage_routes_through_sdk_with_cli_fallback(self, capsys): + pr = make_pr(number=43, title="Validate enterprise SSO") + + with patch( + "graphify.prs._resolve_triage_backend", + return_value=("copilot-sdk", "auto"), + ), patch( + "graphify.llm._call_llm", + return_value="#43 — Validate the enterprise authentication path.", + ) as call: + triage_with_opus([pr], base="v8") + + prompt = call.call_args.args[0] + assert "PR #43" in prompt + assert call.call_args.kwargs == { + "backend": "copilot-sdk", + "model": "auto", + "max_tokens": 1024, + } + assert "Validate the enterprise authentication path" in capsys.readouterr().out + + # ── Windows cp1252 subprocess-decode hardening (decode-side sibling of #1505) ── class TestSubprocessOutputEncoding: diff --git a/tools/skillgen/expected/graphify__skills__agents__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__agents__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__agents__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__agents__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__amp__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__amp__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__amp__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__amp__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__claude__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__claude__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__claude__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__claude__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__claw__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__claw__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__claw__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__claw__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__codex__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__codex__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__codex__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__codex__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__copilot__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__copilot__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__copilot__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__copilot__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__droid__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__droid__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__droid__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__droid__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__kilo__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__kilo__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__kilo__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__kilo__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__kiro__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__kiro__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__kiro__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__kiro__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__opencode__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__opencode__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__opencode__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__opencode__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__pi__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__pi__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__pi__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__pi__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__trae__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__trae__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__trae__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__trae__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__vscode__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__vscode__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__vscode__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__vscode__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/expected/graphify__skills__windows__references__github-and-merge.md b/tools/skillgen/expected/graphify__skills__windows__references__github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/expected/graphify__skills__windows__references__github-and-merge.md +++ b/tools/skillgen/expected/graphify__skills__windows__references__github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/tools/skillgen/fragments/references/shared/github-and-merge.md b/tools/skillgen/fragments/references/shared/github-and-merge.md index a41ea06e1..5466e366e 100644 --- a/tools/skillgen/fragments/references/shared/github-and-merge.md +++ b/tools/skillgen/fragments/references/shared/github-and-merge.md @@ -33,7 +33,7 @@ The skill pipeline writes all intermediate and final outputs to `graphify-out/` graphify extract ./core/ # → ./core/graphify-out/graph.json graphify extract ./service/ # → ./service/graphify-out/graph.json graphify extract ./platform/ # → ./platform/graphify-out/graph.json -# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set +# Add --backend gemini|kimi|openai|deepseek|claude-cli|copilot-sdk|copilot-cli for the configured provider or CLI # Then merge at the project root: graphify merge-graphs \ diff --git a/uv.lock b/uv.lock index de14f1838..74a1ef0d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.30" +version = "0.9.31" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1133,6 +1133,7 @@ all = [ { name = "falkordb" }, { name = "faster-whisper", marker = "python_full_version >= '3.11'" }, { name = "graspologic", marker = "python_full_version < '3.13'" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'" }, { name = "jieba" }, { name = "markdownify" }, { name = "matplotlib" }, @@ -1161,6 +1162,9 @@ bedrock = [ chinese = [ { name = "jieba" }, ] +copilot = [ + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'" }, +] dm = [ { name = "tree-sitter-dm" }, ] @@ -1258,14 +1262,16 @@ requires-dist = [ { name = "faster-whisper", marker = "python_full_version >= '3.11' and extra == 'video'" }, { name = "graspologic", marker = "python_full_version < '3.13' and extra == 'all'" }, { name = "graspologic", marker = "python_full_version < '3.13' and extra == 'leiden'" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11' and extra == 'all'", specifier = ">=1.0.7,<2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11' and extra == 'copilot'", specifier = ">=1.0.7,<2" }, { name = "jieba", marker = "extra == 'all'" }, { name = "jieba", marker = "extra == 'chinese'" }, { name = "markdownify", marker = "extra == 'all'" }, { name = "markdownify", marker = "extra == 'pdf'" }, { name = "matplotlib", marker = "extra == 'all'" }, { name = "matplotlib", marker = "extra == 'svg'" }, - { name = "mcp", marker = "extra == 'all'", specifier = ">=1,<2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1,<2" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1,<3" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1,<3" }, { name = "neo4j", marker = "extra == 'all'" }, { name = "neo4j", marker = "extra == 'neo4j'" }, { name = "networkx", specifier = ">=3.4" }, @@ -1331,7 +1337,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "copilot", "chinese", "sql", "pascal", "dm", "terraform", "all"] [package.metadata.requires-dev] dev = [ @@ -1395,6 +1401,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/51/21097af79f3d68626539ab829bdbf6cc42933f020e161972927d916e394c/graspologic_native-1.2.5-cp38-abi3-win_amd64.whl", hash = "sha256:c3ef2172d774083d7e2c8e77daccd218571ddeebeb2c1703cebb1a2cc4c56e07", size = 210438, upload-time = "2025-04-02T19:34:21.139Z" }, ] +[[package]] +name = "github-copilot-sdk" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dateutil" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ec/734b0d28abbf7d26eb1a60810deedbcc0719910d64f018477010baf3e3f3/github_copilot_sdk-1.0.7-py3-none-any.whl", hash = "sha256:6f3e74e1779a4508dd6636e06fd0aa55eb6244805a66d61a4f21b30c958e2985" }, +] + [[package]] name = "h11" version = "0.16.0"