From 78c56ddac983b48367f0b7e03ffefcfc8a57be68 Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Fri, 31 Jul 2026 23:59:18 +0200 Subject: [PATCH 1/4] Add BYOK providers and harden launcher --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 34 ++- .github/workflows/dependabot-auto-merge.yml | 53 ---- CHANGELOG.md | 12 + CONTRIBUTING.md | 15 +- README.md | 108 +++++--- SECURITY.md | 14 +- bin/copilot-byok.mjs | 3 +- docs/provider-verification.md | 51 ++++ examples/providers.example.json | 49 +++- package-lock.json | 14 +- package.json | 15 +- schemas/providers.schema.json | 81 ++++++ src/args.mjs | 59 ++++- src/cli.mjs | 59 ++++- src/config.mjs | 259 ++++++++++++++++++-- src/copilot-bin.mjs | 50 ++++ src/model-ranking.mjs | 23 +- src/process-env.mjs | 20 +- src/provider-env.mjs | 8 +- test/args.test.mjs | 24 +- test/cli.test.mjs | 202 ++++++++++++++- test/config.test.mjs | 110 ++++++++- test/copilot-bin.test.mjs | 28 +++ test/model-ranking.test.mjs | 45 ++++ test/provider-env.test.mjs | 20 +- 26 files changed, 1189 insertions(+), 168 deletions(-) create mode 100644 .github/CODEOWNERS delete mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 docs/provider-verification.md create mode 100644 schemas/providers.schema.json create mode 100644 src/copilot-bin.mjs create mode 100644 test/copilot-bin.test.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2679de5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @TheStreamCode diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0151dc..c30c81d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,22 +4,42 @@ on: push: pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: test: runs-on: ${{ matrix.os }} + timeout-minutes: 10 strategy: + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - node: [20, 22] + os: [ubuntu-latest, windows-latest, macos-latest] + node: [22, 24] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: npm - - run: npm ci - - run: npm run lint - - run: npm test + + - name: Install dependencies + run: npm ci + + - name: Run quality checks + run: npm run check + + - name: Audit production dependencies + if: matrix.os == 'ubuntu-latest' && matrix.node == 24 + run: npm audit --omit=dev --audit-level=high + + - name: Verify package contents + if: matrix.os == 'ubuntu-latest' && matrix.node == 24 + run: npm pack --dry-run diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 761ac6b..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Dependabot auto-merge - -# Enables auto-merge on Dependabot's patch and minor updates. GitHub then merges -# them once the branch protection checks pass — a red build never lands. -# -# Major bumps are deliberately excluded: they are the ones that break builds, and -# they stay open for a human to read. -# -# No step approves anything. The branch protection on these repositories requires -# passing status checks but not an approving review, so auto-merge is satisfied by -# a green CI alone. That is the point: GitHub documents restricting workflows from -# approving pull requests as a security best practice, and a bot approving its own -# dependency bumps would be oversight in name only. -# -# Why pull_request_target and not pull_request: GitHub treats Dependabot runs as if -# they came from a fork, so on `pull_request` the GITHUB_TOKEN is read-only and -# cannot enable auto-merge. `pull_request_target` runs in the base repository's -# context with a writable token. That trigger is dangerous when a workflow checks -# out and runs the pull request's code, because untrusted code would inherit those -# permissions — this workflow never checks out the PR, it only reads metadata and -# calls the API. - -on: pull_request_target - -permissions: - contents: write - pull-requests: write - -jobs: - auto-merge: - # Check the author rather than the actor, so the job cannot be driven by a - # pull request someone else opened. - if: github.event.pull_request.user.login == 'dependabot[bot]' - runs-on: ubuntu-latest - - steps: - - name: Fetch Dependabot metadata - id: meta - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - - - name: Enable auto-merge for patch and minor updates - if: | - steps.meta.outputs.update-type == 'version-update:semver-patch' || - steps.meta.outputs.update-type == 'version-update:semver-minor' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Leave major updates for manual review - if: steps.meta.outputs.update-type == 'version-update:semver-major' - run: | - echo "::notice::Major update (${{ steps.meta.outputs.dependency-names }}) left for manual review." diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6c619..abfbbf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project are documented in this file. ## [Unreleased] - Added a standard `.gitattributes` file to normalize line endings across platforms (`* text=auto eol=lf`, with binary markers for image/vsix assets). +- Added documented built-in presets for OpenRouter, Moonshot AI/Kimi, Alibaba Model Studio Token Plan, and Tencent Cloud Token Plan. +- Updated the Z.ai Coding Plan fallback to `glm-5.1`. +- Added `--offline` and `--wire-api completions|responses` support for current Copilot CLI BYOK capabilities. +- Set the required `COPILOT_MODEL` catalog value separately from `COPILOT_PROVIDER_WIRE_MODEL`. +- Hardened process launching against Windows shell injection by using shell-free cross-platform spawning. +- Added standalone Copilot binary discovery that skips the stale VS Code shim when an npm installation is available. +- Documented a reproducible provider verification matrix with explicit evidence levels. +- Added strict provider-config validation, model-catalog timeouts, safe same-origin authentication defaults, and case-insensitive secret sanitization. +- Added a distributable JSON Schema for provider configuration editor support. +- Improved model ranking for OpenRouter metadata and provider catalogs returned as arrays. +- Expanded tests, package checks, security documentation, and CI coverage for Node.js 22/24 on Windows, macOS, and Linux. +- Pinned GitHub Actions to immutable commits and removed the inactive Dependabot auto-merge workflow. ## 0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91957ab..417ff46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,15 +5,20 @@ Thanks for your interest in improving copilot-byok-switcher. ## Development ```bash -npm install -npm run lint -npm test +npm ci +npm run check +npm run test:coverage +npm pack --dry-run ``` -Keep changes focused and covered by tests under `test/`. The CLI must stay cross-platform (Windows, macOS, Linux) and must never transmit provider API keys anywhere. +Development requires Node.js 22.13 or newer. Keep changes focused and covered by tests under `test/`. The CLI must remain cross-platform across Windows, macOS, and Linux. + +Provider changes must cite the provider's official API documentation in `README.md` and include tests for endpoint, model, aliases, and credential environment names. Never add real credentials, inline secret examples, or unauthenticated cross-origin credential forwarding. ## Pull Requests - Add or update tests for behavior changes. -- Run `npm run lint` and `npm test` before submitting. +- Run `npm run check` and `npm pack --dry-run` before submitting. - Keep user-facing behavior documented in `README.md`. +- Update `CHANGELOG.md` for user-visible changes. +- Use a focused title and explain both the behavior change and its verification. diff --git a/README.md b/README.md index 4ddf0fb..488d332 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,21 @@ It lets you run GitHub Copilot CLI through native Copilot or a configurable Open - Works on Windows, macOS, and Linux with one Node.js CLI. - Supports GitHub Copilot CLI native mode and BYOK providers. - Keeps provider API keys in environment variables, not in generated shell profiles. -- Separates Copilot's built-in `MODEL_ID` from the provider `WIRE_MODEL` to avoid catalog warnings. +- Separates Copilot's required catalog model (`COPILOT_MODEL`) from the provider wire model. - Ranks provider models automatically using recency, tool support, model family, version, and context length. - Passes BYOK variables only to the child Copilot process. +- Avoids the stale VS Code Copilot shim when a working npm CLI is also available on `PATH`. +- Supports Copilot offline mode and both `completions` and `responses` wire APIs. - Uses a generic provider config, so any OpenAI-compatible or Anthropic-compatible provider can be added. ## Requirements -- Node.js 20 or newer. -- GitHub Copilot CLI installed and available as `copilot` on `PATH`. +- Node.js 22.13 or newer. +- GitHub Copilot CLI 1.0.20 or newer installed and available as `copilot` on `PATH`. -If your Copilot binary is not named `copilot`, set: +Use the latest stable Copilot CLI when possible. Version 1.0.64 or newer includes the most important BYOK Responses and WebSocket fixes recorded in the official [Copilot CLI changelog](https://github.com/github/copilot-cli/blob/main/changelog.md). + +The launcher automatically prefers a working standalone CLI over the known stale VS Code shim. If your Copilot binary has another name or location, set: ```sh COPILOT_BIN=/path/to/copilot @@ -78,6 +82,10 @@ copilot-byok --provider chutes --no-model-prompt copilot-byok --provider deepseek --no-model-prompt copilot-byok --provider zai --no-model-prompt copilot-byok --provider minimax --no-model-prompt +copilot-byok --provider openrouter --no-model-prompt +copilot-byok --provider moonshot --no-model-prompt +copilot-byok --provider alibaba-token-plan --no-model-prompt +copilot-byok --provider tencent-token-plan --no-model-prompt ``` Pass a prompt to Copilot CLI: @@ -101,18 +109,13 @@ copilot-byok --provider fireworks --list-models copilot-byok --provider deepseek --list-models copilot-byok --provider zai --list-models copilot-byok --provider minimax --list-models +copilot-byok --provider openrouter --list-models +copilot-byok --provider moonshot --list-models ``` ## Built-In Providers -The CLI includes defaults for: - -- `chutes` -- `opencode-go` aliases: `go`, `opencode` -- `fireworks` aliases: `fire`, `fireworks-ai` -- `deepseek` alias: `deepseek-ai` -- `zai` aliases: `z-ai`, `glm` -- `minimax` alias: `minimax-ai` +The CLI includes defaults for Chutes, OpenCode Go, Fireworks AI, OpenRouter, Moonshot AI (Kimi), DeepSeek, Z.ai (GLM), MiniMax, Alibaba Model Studio Token Plan, and Tencent Cloud Token Plan. API keys are read from environment variables. For example: @@ -123,6 +126,10 @@ export FIREWORKS_API_KEY=... export DEEPSEEK_API_KEY=... export ZAI_API_KEY=... export MINIMAX_API_KEY=... +export OPENROUTER_API_KEY=... +export MOONSHOT_API_KEY=... +export ALIBABA_TOKEN_PLAN_API_KEY=... +export TENCENT_TOKEN_PLAN_API_KEY=... ``` PowerShell: @@ -134,29 +141,45 @@ $env:FIREWORKS_API_KEY = "..." $env:DEEPSEEK_API_KEY = "..." $env:ZAI_API_KEY = "..." $env:MINIMAX_API_KEY = "..." +$env:OPENROUTER_API_KEY = "..." +$env:MOONSHOT_API_KEY = "..." +$env:ALIBABA_TOKEN_PLAN_API_KEY = "..." +$env:TENCENT_TOKEN_PLAN_API_KEY = "..." ``` -Built-in provider defaults are based on the public provider documentation: - -| Provider | Type | Base URL | Models URL | API key env | Default model | -|---|---|---|---|---|---| -| Chutes | `openai` | `https://llm.chutes.ai/v1` | `https://llm.chutes.ai/v1/models` | `CHUTES_API_KEY`, `COPILOT_CHUTES_API_KEY` | auto-ranked catalog model | -| OpenCode Go | `anthropic` | `https://opencode.ai/zen/go` | `https://opencode.ai/zen/go/v1/models` | `OPENCODE_GO_API_KEY`, `OPENCODE_API_KEY`, `CLAUDE_GO_API_KEY` | auto-ranked catalog model | -| Fireworks AI | `anthropic` | `https://api.fireworks.ai/inference` | `https://api.fireworks.ai/v1/accounts/fireworks/models?filter=supports_serverless%3Dtrue&pageSize=200` | `FIREWORKS_API_KEY`, `FIREWORKS_KEY`, `CLAUDE_FIRE_API_KEY` | auto-ranked serverless model | -| DeepSeek AI | `openai` | `https://api.deepseek.com` | `https://api.deepseek.com/models` | `DEEPSEEK_API_KEY`, `COPILOT_DEEPSEEK_API_KEY` | `deepseek-v4-pro` | -| Z.ai | `openai` | `https://api.z.ai/api/coding/paas/v4` | `https://api.z.ai/api/coding/paas/v4/models` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `GLM_API_KEY`, `COPILOT_ZAI_API_KEY` | `glm-4.7` | -| MiniMax | `openai` | `https://api.minimax.io/v1` | `https://api.minimax.io/v1/models` | `MINIMAX_API_KEY`, `COPILOT_MINIMAX_API_KEY` | `MiniMax-M2.7` | +Every preset is based on the provider's official API documentation: + +| Provider (id / aliases) | Protocol | Official endpoint | Default model | Documentation | +|---|---|---|---|---| +| Chutes (`chutes`) | OpenAI | `https://llm.chutes.ai/v1` | auto-ranked | [Chutes docs](https://chutes.ai/docs) | +| OpenCode Go (`opencode-go`, `go`, `opencode`) | Anthropic | `https://opencode.ai/zen/go` | auto-ranked | [OpenCode Go docs](https://opencode.ai/docs/go/) | +| Fireworks AI (`fireworks`, `fire`) | Anthropic | `https://api.fireworks.ai/inference` | auto-ranked | [Fireworks Anthropic compatibility](https://docs.fireworks.ai/tools-sdks/anthropic-compatibility) | +| OpenRouter (`openrouter`, `or`) | OpenAI | `https://openrouter.ai/api/v1` | `openrouter/auto` | [OpenRouter models API](https://openrouter.ai/docs/api/api-reference/models/get-models) | +| Moonshot AI (`moonshot`, `kimi`) | OpenAI | `https://api.moonshot.ai/v1` | `kimi-k3` | [Kimi models API](https://platform.kimi.ai/docs/api/list-models) | +| DeepSeek (`deepseek`) | OpenAI | `https://api.deepseek.com` | `deepseek-v4-pro` | [DeepSeek models API](https://api-docs.deepseek.com/api/list-models) | +| Z.ai (`zai`, `glm`) | OpenAI | `https://api.z.ai/api/coding/paas/v4` | `glm-5.1` | [Z.ai HTTP API](https://docs.z.ai/guides/develop/http/introduction) | +| MiniMax (`minimax`) | OpenAI | `https://api.minimax.io/v1` | `MiniMax-M2.7` | [MiniMax models API](https://platform.minimax.io/docs/api-reference/models/openai/list-models) | +| Alibaba Model Studio Token Plan (`alibaba-token-plan`, `qwen`) | OpenAI | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.7-plus` | [Alibaba Token Plan quickstart](https://help.aliyun.com/en/model-studio/token-plan-personal-quick-start) | +| Tencent Cloud Token Plan (`tencent-token-plan`, `tokenhub`) | OpenAI | `https://api.lkeap.cloud.tencent.com/plan/v3` | `tc-code-latest` | [Tencent Token Plan quickstart](https://cloud.tencent.com/document/product/1823/130119) | Provider-specific examples: ```sh copilot-byok --provider deepseek --no-model-prompt -p "Explain this repository" -copilot-byok --provider zai --model glm-4.7 -p "Reply exactly: OK" +copilot-byok --provider zai --model glm-5.1 -p "Reply exactly: OK" copilot-byok --provider minimax --model MiniMax-M2.7 -p "Summarize the latest diff" +copilot-byok --provider openrouter --offline --no-model-prompt +copilot-byok --provider kimi --model kimi-k3 -p "Review this diff" +copilot-byok --provider qwen --model qwen3.7-plus -p "Fix the failing tests" +copilot-byok --provider tokenhub --model tc-code-latest -p "Refactor this module" ``` Z.ai uses the Coding Plan endpoint by default because it is the endpoint documented for coding tools. If you need the general Z.ai API endpoint instead, create a custom provider with `baseUrl` set to `https://api.z.ai/api/paas/v4`. +The Alibaba preset is specifically for the Beijing Model Studio Token Plan and requires its dedicated plan key. The Tencent preset targets the mainland China personal Token Plan and requires its dedicated `sk-tp-...` key. Pay-as-you-go, enterprise, international, and other regions use different endpoints and should be configured as custom providers. + +Gemini is intentionally not duplicated as a built-in BYOK preset because current Copilot CLI releases already expose Gemini through the [native Copilot model catalog](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference). A direct Google endpoint can still be added as a custom provider when separate Google billing and credentials are required. + ## Custom Provider Config By default, the CLI looks for: @@ -174,6 +197,7 @@ Example: ```json { + "$schema": "https://raw.githubusercontent.com/TheStreamCode/copilot-byok-switcher/main/schemas/providers.schema.json", "providers": [ { "id": "my-provider", @@ -189,16 +213,16 @@ Example: } ``` -## Why `catalogModelId` And Wire Model Are Separate +## Why `catalogModelId` and the wire model are separate GitHub Copilot CLI BYOK supports these variables: ```text -COPILOT_PROVIDER_MODEL_ID +COPILOT_MODEL COPILOT_PROVIDER_WIRE_MODEL ``` -`COPILOT_PROVIDER_MODEL_ID` should be a model known by Copilot's built-in catalog, such as `gpt-4.1` or `claude-sonnet-4.6`. Copilot uses it for tool support, prompting strategy, and token defaults. +`catalogModelId` sets the required `COPILOT_MODEL` value. It should be a model known by Copilot's built-in catalog, such as `gpt-4.1` or `claude-sonnet-4.6`; Copilot uses that catalog entry for internal capabilities and defaults. `COPILOT_PROVIDER_WIRE_MODEL` is the model name sent to your provider, such as `moonshotai/Kimi-K2.6-TEE` or `accounts/fireworks/models/minimax-m2p5`. @@ -210,6 +234,19 @@ Model "custom-provider-model" is not in the built-in catalog. It also avoids GPT-5-specific warnings for providers that only support chat completions. +See GitHub's official [Copilot CLI BYOK documentation](https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models) for the underlying environment variables and compatibility requirements. + +## Copilot CLI features + +The switcher exposes current Copilot CLI BYOK capabilities without changing global shell state: + +- `--offline` sets `COPILOT_OFFLINE=true`, preventing Copilot from connecting to GitHub while the selected provider remains reachable. +- `--wire-api completions|responses` overrides `COPILOT_PROVIDER_WIRE_API` for providers that implement the selected OpenAI wire API. +- `maxOutputTokens` is forwarded to Copilot and is honored by recent Responses-provider releases. +- Provider configuration also works when Copilot is used through ACP; that behavior is implemented by Copilot CLI itself. + +Use `responses` only when the selected provider and model officially support it. Otherwise keep the default completions API. + ## Model Ranking When no `--model` is provided, the CLI fetches the provider model catalog and ranks models with these rules: @@ -228,16 +265,20 @@ When no `--model` is provided, the CLI fetches the provider model catalog and ra | Field | Required | Description | |---|---:|---| -| `id` | yes | Provider id used by `--provider`. | +| `id` | no | Provider id used by `--provider`; derived from `name` when omitted. | | `name` | yes | Display name. | +| `aliases` | no | Additional unique names accepted by `--provider`. | | `type` | yes | Copilot BYOK provider type: `openai`, `anthropic`, or `azure`. | | `baseUrl` | yes | BYOK provider base URL. | | `apiKeyEnv` | no | Env var name or list of names for API key lookup. | | `bearerTokenEnv` | no | Env var name or list for bearer token lookup. | | `modelsUrl` | no | URL used to fetch model catalog. | +| `modelsAuth` | no | Send the provider bearer token to a cross-origin catalog only when explicitly `true`; use `false` or `none` to disable catalog auth. | +| `modelsHeaders` | no | Non-secret string headers for model catalog requests. | +| `modelsTimeoutMs` | no | Catalog timeout from 10 to 300000 ms; defaults to 10000 ms. | | `catalogModelId` | no | Built-in Copilot model id for internal capabilities. | | `defaultModel` | no | Fallback model when catalog fetch fails. | -| `wireApi` | no | Optional BYOK wire API, such as `responses`. | +| `wireApi` | no | BYOK wire API: `completions` or `responses`. | | `maxPromptTokens` | no | Manual prompt token limit. | | `maxOutputTokens` | no | Manual output token limit. | | `requireToolSupport` | no | Exclude models without tool support metadata. | @@ -248,15 +289,20 @@ When no `--model` is provided, the CLI fetches the provider model catalog and ra - Provider config files cannot contain inline API keys or bearer tokens. - Dry-run output redacts secret env values. - Stale `COPILOT_PROVIDER_*` variables are stripped before launching Copilot. +- Provider source-key variables are removed case-insensitively from the child environment. +- Catalog requests time out after 10 seconds by default. +- Credentials are sent automatically only to same-origin catalog URLs; cross-origin auth requires `modelsAuth: true`. - BYOK environment variables are passed only to the child Copilot process. - The tool does not write tokens to disk. ## Development -Run tests: +Run the complete local quality gate: ```sh -npm test +npm run check +npm run test:coverage +npm pack --dry-run ``` Run a dry-run: @@ -265,6 +311,8 @@ Run a dry-run: copilot-byok --provider chutes --no-model-prompt --dry-run -p "hello" ``` +See [Provider verification](docs/provider-verification.md) for the latest reproducible test matrix. It distinguishes endpoint reachability, authenticated catalog access, and complete Copilot CLI inference; these are intentionally not treated as equivalent claims. + ## Support If this CLI saves you time when testing Copilot BYOK providers, support continued maintenance through GitHub Sponsors: [github.com/sponsors/TheStreamCode](https://github.com/sponsors/TheStreamCode). diff --git a/SECURITY.md b/SECURITY.md index 27670b9..042238b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,17 @@ # Security Policy +## Supported versions + +Security fixes are applied to the latest release only. + +## Reporting a vulnerability + Please do not report security vulnerabilities through public GitHub issues. -Email security concerns to info@mikesoft.it with a clear description, affected version, and reproduction details. +Email security concerns to info@mikesoft.it with the affected version, impact, reproduction steps, and any suggested mitigation. You should receive an acknowledgement within five business days. + +## Secret handling + +This launcher reads provider credentials from environment variables and passes the selected credential to the child GitHub Copilot CLI process. Copilot then sends it to the configured provider. Authenticated model-catalog requests are made directly by this launcher; credentials are sent automatically only when the catalog and API share the same origin, unless `modelsAuth: true` explicitly opts in to cross-origin authentication. -This tool reads and writes local GitHub Copilot CLI configuration and may handle provider API keys. It does not transmit your keys anywhere; review configuration changes before applying them, especially in shared or untrusted environments. +The launcher does not write credentials to disk. Provider configuration files reject inline API keys, bearer tokens, and secret-bearing model headers. Review custom provider URLs before use and do not run untrusted configurations. diff --git a/bin/copilot-byok.mjs b/bin/copilot-byok.mjs index b8df3ef..555357b 100644 --- a/bin/copilot-byok.mjs +++ b/bin/copilot-byok.mjs @@ -4,6 +4,7 @@ import { main } from '../src/cli.mjs'; main().then((exitCode) => { process.exitCode = exitCode; }).catch((error) => { - console.error(`copilot-byok: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(`copilot-byok: ${message}`); process.exitCode = 1; }); diff --git a/docs/provider-verification.md b/docs/provider-verification.md new file mode 100644 index 0000000..f02eb69 --- /dev/null +++ b/docs/provider-verification.md @@ -0,0 +1,51 @@ +# Provider verification + +Last verified: 2026-07-31 + +This document separates three different levels of evidence: + +1. **Endpoint**: DNS, TLS, and the documented HTTP route responded. An unauthenticated `400`, `401`, `403`, or `429` proves route reachability only. +2. **Catalog**: the switcher fetched and ranked the provider model catalog with an available API key. +3. **End-to-end**: the switcher launched GitHub Copilot CLI, Copilot called the selected provider, and a real model response completed with exit code `0`. + +## Test environment + +- Windows +- Node.js 24.18.0 +- npm 11.16.0 +- GitHub Copilot CLI 1.0.77 installed through npm as `@github/copilot` +- Switcher offline mode enabled for inference, preventing Copilot from contacting GitHub +- Custom instructions, built-in MCPs, user MCPs, and tools disabled to isolate provider behavior + +## Provider matrix + +| Provider | Endpoint | Authenticated catalog | End-to-end inference | Evidence | +|---|---:|---:|---:|---| +| Chutes | Yes | Yes | Yes | `moonshotai/Kimi-K3-TEE` returned the expected sentinel response; exit code `0`. | +| OpenCode Go | Yes | Yes | Not completed | The endpoint and catalog are valid; the available account returned `429 Monthly usage limit reached` before generation. | +| Fireworks AI | Yes | Not tested | Not tested | The Anthropic-compatible route returned an application-level response without credentials. | +| OpenRouter | Yes | Not tested | Not tested | The documented route required authentication. | +| Moonshot AI (Kimi) | Yes | Not tested | Not tested | The documented route required authentication. | +| DeepSeek | Yes | Not tested | Not tested | The documented route required authentication. | +| Z.ai | Yes | Not tested | Not tested | The documented Coding Plan route required authentication. | +| MiniMax | Yes | Not tested | Not tested | The documented route required authentication. | +| Alibaba Model Studio Token Plan | Yes | Not applicable | Not tested | The dedicated Beijing plan route required its plan key; no catalog endpoint is assumed. | +| Tencent Cloud Token Plan | Yes | Not applicable | Not tested | The mainland China personal-plan route required its `sk-tp-...` key; no catalog endpoint is assumed. | + +Gemini is not listed as a switcher preset because it is available through Copilot's native model catalog. + +## Automated verification + +The repository quality gate covers argument parsing, built-in provider normalization, model ranking, secret handling, model-catalog timeout and authentication policy, Windows command injection, standalone Copilot binary discovery, offline mode, and Responses API selection. + +Run: + +```sh +npm ci +npm run check +npm run test:coverage +npm audit --omit=dev --audit-level=high +npm pack --dry-run +``` + +Provider catalogs and model availability can change independently of this repository. Re-run authenticated catalog and end-to-end checks before making a release claim for a provider that is not marked end-to-end above. diff --git a/examples/providers.example.json b/examples/providers.example.json index 09ae703..26bc951 100644 --- a/examples/providers.example.json +++ b/examples/providers.example.json @@ -1,4 +1,5 @@ { + "$schema": "../schemas/providers.schema.json", "providers": [ { "id": "chutes", @@ -42,6 +43,30 @@ "catalogModelId": "gpt-4.1", "defaultModel": "deepseek-v4-pro" }, + { + "id": "openrouter", + "aliases": ["or"], + "name": "OpenRouter", + "type": "openai", + "baseUrl": "https://openrouter.ai/api/v1", + "apiKeyEnv": ["OPENROUTER_API_KEY", "COPILOT_OPENROUTER_API_KEY"], + "modelsUrl": "https://openrouter.ai/api/v1/models?supported_parameters=tools&output_modalities=text", + "catalogModelId": "gpt-4.1", + "defaultModel": "openrouter/auto", + "requireToolSupport": true + }, + { + "id": "moonshot", + "aliases": ["kimi", "moonshot-ai", "kimi-ai"], + "name": "Moonshot AI (Kimi)", + "type": "openai", + "baseUrl": "https://api.moonshot.ai/v1", + "apiKeyEnv": ["MOONSHOT_API_KEY", "KIMI_API_KEY", "COPILOT_MOONSHOT_API_KEY"], + "modelsUrl": "https://api.moonshot.ai/v1/models", + "catalogModelId": "gpt-4.1", + "defaultModel": "kimi-k3", + "requireToolSupport": true + }, { "id": "zai", "aliases": ["z-ai", "glm"], @@ -51,7 +76,7 @@ "apiKeyEnv": ["ZAI_API_KEY", "Z_AI_API_KEY", "GLM_API_KEY", "COPILOT_ZAI_API_KEY"], "modelsUrl": "https://api.z.ai/api/coding/paas/v4/models", "catalogModelId": "gpt-4.1", - "defaultModel": "glm-4.7" + "defaultModel": "glm-5.1" }, { "id": "minimax", @@ -63,6 +88,28 @@ "modelsUrl": "https://api.minimax.io/v1/models", "catalogModelId": "gpt-4.1", "defaultModel": "MiniMax-M2.7" + }, + { + "id": "alibaba-token-plan", + "aliases": ["alibaba", "qwen", "bailian", "dashscope", "modelstudio"], + "name": "Alibaba Model Studio Token Plan", + "type": "openai", + "baseUrl": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "apiKeyEnv": ["ALIBABA_TOKEN_PLAN_API_KEY", "DASHSCOPE_TOKEN_PLAN_API_KEY", "BAILIAN_TOKEN_PLAN_API_KEY"], + "catalogModelId": "gpt-4.1", + "defaultModel": "qwen3.7-plus", + "requireToolSupport": true + }, + { + "id": "tencent-token-plan", + "aliases": ["tencent", "tokenhub", "tencent-tokenhub"], + "name": "Tencent Cloud Token Plan", + "type": "openai", + "baseUrl": "https://api.lkeap.cloud.tencent.com/plan/v3", + "apiKeyEnv": ["TENCENT_TOKEN_PLAN_API_KEY", "TOKENHUB_TOKEN_PLAN_API_KEY"], + "catalogModelId": "gpt-4.1", + "defaultModel": "tc-code-latest", + "requireToolSupport": true } ] } diff --git a/package-lock.json b/package-lock.json index 6eb5bbf..1a12fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "copilot-byok-switcher", "version": "0.1.0", "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, "bin": { "copilot-byok": "bin/copilot-byok.mjs" }, @@ -17,7 +20,10 @@ "globals": "^17.8.0" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" + }, + "funding": { + "url": "https://github.com/sponsors/TheStreamCode" } }, "node_modules/@eslint-community/eslint-utils": { @@ -302,7 +308,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -651,7 +656,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/json-buffer": { @@ -809,7 +813,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -839,7 +842,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -852,7 +854,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -885,7 +886,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 50244dd..d738654 100644 --- a/package.json +++ b/package.json @@ -32,17 +32,28 @@ "bin/", "src/", "examples/", + "docs/", + "schemas/", + "CHANGELOG.md", "README.md", + "SECURITY.md", "LICENSE" ], "scripts": { "test": "node --test", - "lint": "eslint ." + "test:coverage": "node --test --experimental-test-coverage", + "lint": "eslint .", + "check": "npm run lint && npm test", + "prepack": "npm run check" }, "engines": { - "node": ">=20" + "node": ">=22.13.0" }, + "funding": "https://github.com/sponsors/TheStreamCode", "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", diff --git a/schemas/providers.schema.json b/schemas/providers.schema.json new file mode 100644 index 0000000..531a92e --- /dev/null +++ b/schemas/providers.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/TheStreamCode/copilot-byok-switcher/main/schemas/providers.schema.json", + "title": "Copilot BYOK Switcher providers", + "type": "object", + "required": ["providers"], + "properties": { + "providers": { + "type": "array", + "items": { "$ref": "#/$defs/provider" } + } + }, + "additionalProperties": true, + "$defs": { + "envName": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "envNames": { + "oneOf": [ + { "$ref": "#/$defs/envName" }, + { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/envName" } + } + ] + }, + "provider": { + "type": "object", + "required": ["name", "type", "baseUrl"], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "name": { "type": "string", "minLength": 1 }, + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + } + }, + "type": { "enum": ["openai", "anthropic", "azure"] }, + "baseUrl": { "type": "string", "format": "uri", "pattern": "^https?://" }, + "apiKeyEnv": { "$ref": "#/$defs/envNames" }, + "bearerTokenEnv": { "$ref": "#/$defs/envNames" }, + "modelsUrl": { "type": "string", "format": "uri", "pattern": "^https?://" }, + "modelsAuth": { "enum": [true, false, "none"] }, + "modelsHeaders": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "modelsTimeoutMs": { + "type": "integer", + "minimum": 10, + "maximum": 300000 + }, + "catalogModelId": { "type": "string", "minLength": 1 }, + "defaultModel": { "type": "string", "minLength": 1 }, + "wireApi": { "enum": ["completions", "responses"] }, + "maxPromptTokens": { "type": "integer", "minimum": 1 }, + "maxOutputTokens": { "type": "integer", "minimum": 1 }, + "requireToolSupport": { "type": "boolean" } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "anthropic" } }, + "required": ["type"] + }, + "then": { "not": { "required": ["wireApi"] } } + } + ] + } + } +} diff --git a/src/args.mjs b/src/args.mjs index f7ada9c..c44b0ff 100644 --- a/src/args.mjs +++ b/src/args.mjs @@ -5,6 +5,8 @@ export function parseArgs(argv) { configPath: null, listModels: false, noModelPrompt: false, + offline: false, + wireApi: null, dryRun: false, help: false, copilotArgs: [], @@ -31,36 +33,36 @@ export function parseArgs(argv) { if (arg === '--provider' || arg === '-P') { index += 1; if (index >= argv.length) throw new Error(`${arg} requires a provider id`); - result.providerName = argv[index]; + result.providerName = requireOptionValue(argv[index], arg, 'a provider id'); continue; } if (arg.startsWith('--provider=')) { - result.providerName = arg.slice('--provider='.length); + result.providerName = requireOptionValue(arg.slice('--provider='.length), '--provider', 'a provider id'); continue; } if (arg === '--model' || arg === '-m') { index += 1; if (index >= argv.length) throw new Error(`${arg} requires a model id`); - result.explicitModel = argv[index]; + result.explicitModel = requireOptionValue(argv[index], arg, 'a model id'); continue; } if (arg.startsWith('--model=')) { - result.explicitModel = arg.slice('--model='.length); + result.explicitModel = requireOptionValue(arg.slice('--model='.length), '--model', 'a model id'); continue; } if (arg === '--config' || arg === '-c') { index += 1; if (index >= argv.length) throw new Error(`${arg} requires a file path`); - result.configPath = argv[index]; + result.configPath = requireOptionValue(argv[index], arg, 'a file path'); continue; } if (arg.startsWith('--config=')) { - result.configPath = arg.slice('--config='.length); + result.configPath = requireOptionValue(arg.slice('--config='.length), '--config', 'a file path'); continue; } @@ -74,6 +76,23 @@ export function parseArgs(argv) { continue; } + if (arg === '--offline') { + result.offline = true; + continue; + } + + if (arg === '--wire-api') { + index += 1; + if (index >= argv.length) throw new Error(`${arg} requires completions or responses`); + result.wireApi = requireWireApi(argv[index], arg); + continue; + } + + if (arg.startsWith('--wire-api=')) { + result.wireApi = requireWireApi(arg.slice('--wire-api='.length), '--wire-api'); + continue; + } + if (arg === '--dry-run') { result.dryRun = true; continue; @@ -82,5 +101,33 @@ export function parseArgs(argv) { result.copilotArgs.push(arg); } + if (result.listModels && result.providerName === 'native') { + throw new Error('--list-models requires a BYOK provider'); + } + + if (result.listModels && result.explicitModel) { + throw new Error('--list-models cannot be combined with --model'); + } + + if (result.providerName === 'native' && (result.offline || result.wireApi)) { + throw new Error('--offline and --wire-api require a BYOK provider'); + } + return result; } + +function requireWireApi(value, option) { + const normalized = requireOptionValue(value, option, 'completions or responses').toLowerCase(); + if (!['completions', 'responses'].includes(normalized)) { + throw new Error(`${option} must be completions or responses`); + } + return normalized; +} + +function requireOptionValue(value, option, expected) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${option} requires ${expected}`); + } + + return value.trim(); +} diff --git a/src/cli.mjs b/src/cli.mjs index c0c4d10..c430b9d 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,12 +1,16 @@ -import { spawn } from 'node:child_process'; import readline from 'node:readline/promises'; +import spawn from 'cross-spawn'; + import { parseArgs } from './args.mjs'; +import { resolveCopilotBin } from './copilot-bin.mjs'; import { findProvider, loadConfig } from './config.mjs'; import { rankModels } from './model-ranking.mjs'; import { buildProviderEnvironment } from './provider-env.mjs'; import { sanitizeCopilotEnvironment } from './process-env.mjs'; +const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 10_000; + export async function main(argv = process.argv.slice(2), io = defaultIo()) { const args = parseArgs(argv); @@ -25,6 +29,9 @@ export async function main(argv = process.argv.slice(2), io = defaultIo()) { const provider = await resolveProvider({ config, requested: args.providerName, io }); if (provider === 'native') { + if (args.offline || args.wireApi) { + throw new Error('--offline and --wire-api require a BYOK provider'); + } const copilotArgs = [...args.copilotArgs]; if (args.explicitModel) copilotArgs.unshift('--model', args.explicitModel); return runCopilot({ copilotArgs, env: {}, io, dryRun: args.dryRun, native: true }); @@ -34,6 +41,10 @@ export async function main(argv = process.argv.slice(2), io = defaultIo()) { throw new Error(`Missing API key for ${provider.name}. Set one of: ${formatEnvNames(provider.apiKeyEnv || provider.bearerTokenEnv)}`); } + if (args.wireApi && provider.type === 'anthropic') { + throw new Error('--wire-api is available only for OpenAI-compatible BYOK providers'); + } + const models = args.explicitModel ? [] : await loadProviderModels(provider, io, { strict: args.listModels }); if (args.listModels) { @@ -42,7 +53,8 @@ export async function main(argv = process.argv.slice(2), io = defaultIo()) { } const wireModel = args.explicitModel || await selectModel({ provider, models, noPrompt: args.noModelPrompt, io }); - const env = buildProviderEnvironment({ provider, wireModel }); + const effectiveProvider = args.wireApi ? { ...provider, wireApi: args.wireApi } : provider; + const env = buildProviderEnvironment({ provider: effectiveProvider, wireModel, offline: args.offline }); return runCopilot({ copilotArgs: args.copilotArgs, env, io, dryRun: args.dryRun, provider, wireModel, config }); } @@ -64,6 +76,8 @@ async function resolveProvider({ config, requested, io }) { return provider; } + if (config.providers.length === 0) return 'native'; + if (!io.stdin.isTTY || !io.stdout.isTTY) return 'native'; io.stdout.write('\nSelect Copilot provider:\n'); @@ -76,7 +90,7 @@ async function resolveProvider({ config, requested, io }) { const answer = await rl.question(`Provider (1-${config.providers.length + 1}) [default: 1 ${config.providers[0].name}]: `); if (!answer.trim()) return config.providers[0]; - const selected = Number.parseInt(answer, 10); + const selected = parseMenuSelection(answer); if (Number.isInteger(selected) && selected >= 1 && selected <= config.providers.length) return config.providers[selected - 1]; if (selected === config.providers.length + 1) return 'native'; @@ -94,8 +108,12 @@ async function resolveProvider({ config, requested, io }) { async function loadProviderModels(provider, io, { strict = false } = {}) { if (!provider.modelsUrl) return provider.defaultModel ? [provider.defaultModel] : []; + const timeoutMs = provider.modelsTimeoutMs || DEFAULT_MODEL_FETCH_TIMEOUT_MS; try { - const response = await fetch(provider.modelsUrl, { headers: providerModelHeaders(provider) }); + const response = await fetch(provider.modelsUrl, { + headers: providerModelHeaders(provider), + signal: AbortSignal.timeout(timeoutMs), + }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); const payload = await response.json(); const ranked = rankModels({ payload, requireToolSupport: provider.requireToolSupport === true }); @@ -103,17 +121,25 @@ async function loadProviderModels(provider, io, { strict = false } = {}) { if (strict && ranked.length === 0) throw new Error(`No models returned by ${provider.modelsUrl}`); return ranked; } catch (error) { - if (strict) throw error; - io.stderr.write(`Warning: could not refresh ${provider.name} models: ${error.message}\n`); + const errorName = error instanceof Error ? error.name : ''; + const errorMessage = error instanceof Error ? error.message : String(error); + const detail = errorName === 'TimeoutError' + ? `request timed out after ${timeoutMs}ms` + : errorMessage; + if (strict) throw new Error(`Could not load ${provider.name} models: ${detail}`, { cause: error }); + io.stderr.write(`Warning: could not refresh ${provider.name} models: ${detail}\n`); return provider.defaultModel ? [provider.defaultModel] : []; } } function providerModelHeaders(provider) { const headers = { ...(provider.modelsHeaders || {}) }; - if (provider.modelsAuth === false) return headers; + if (provider.modelsAuth === false || provider.modelsAuth === 'none') return headers; const token = provider.bearerToken || provider.apiKey; - if (token && provider.modelsAuth !== 'none') headers.Authorization = `Bearer ${token}`; + if (!token) return headers; + + const sameOrigin = new URL(provider.modelsUrl).origin === new URL(provider.baseUrl).origin; + if (sameOrigin || provider.modelsAuth === true) headers.Authorization = `Bearer ${token}`; return headers; } @@ -133,7 +159,7 @@ async function selectModel({ provider, models, noPrompt, io }) { const answer = await rl.question(`Select model (1-${models.length}) [default: 1 ${defaultModel}]: `); if (!answer.trim()) return defaultModel; - const selected = Number.parseInt(answer, 10); + const selected = parseMenuSelection(answer); if (Number.isInteger(selected) && selected >= 1 && selected <= models.length) return models[selected - 1]; io.stderr.write(`Invalid model: ${answer}\n`); } @@ -143,7 +169,7 @@ async function selectModel({ provider, models, noPrompt, io }) { } function runCopilot({ copilotArgs, env, io, dryRun, provider, wireModel, native = false, config = null }) { - const copilotBin = io.env.COPILOT_BIN || 'copilot'; + const copilotBin = resolveCopilotBin({ env: io.env }); if (dryRun) { io.stdout.write(`${JSON.stringify({ @@ -168,13 +194,20 @@ function runCopilot({ copilotArgs, env, io, dryRun, provider, wireModel, native }); } -export function buildCopilotSpawnOptions({ env, ioEnv = process.env, platform = process.platform, config = null }) { +export function buildCopilotSpawnOptions({ env, ioEnv = process.env, config = null }) { return { env: sanitizeCopilotEnvironment(ioEnv, env, collectProviderSecretEnvNames(config)), - shell: platform === 'win32', + shell: false, }; } +function parseMenuSelection(value) { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const selected = Number(trimmed); + return Number.isSafeInteger(selected) ? selected : null; +} + function collectProviderSecretEnvNames(config) { return (config?.providers || []).flatMap((provider) => [ ...asArray(provider.apiKeyEnv), @@ -200,5 +233,5 @@ function formatEnvNames(names) { } function helpText() { - return `copilot-byok - switch GitHub Copilot CLI between native and BYOK providers\n\nUsage:\n copilot-byok [options] [-- Copilot args...]\n\nOptions:\n -P, --provider Provider id or alias\n --native Run GitHub Copilot CLI without BYOK\n -m, --model Provider wire model for BYOK, native model for --native\n -c, --config Provider config JSON path\n --list-models Print ranked models for the selected provider\n --no-model-prompt Use automatic default model\n --dry-run Print command/env without launching Copilot\n -h, --help Show this help\n\nExamples:\n copilot-byok --provider chutes --no-model-prompt\n copilot-byok --provider fireworks --model accounts/fireworks/models/minimax-m2p5 -p "fix the bug"\n copilot-byok --native --model claude-sonnet-4.6\n`; + return `copilot-byok - switch GitHub Copilot CLI between native and BYOK providers\n\nUsage:\n copilot-byok [options] [-- Copilot args...]\n\nOptions:\n -P, --provider Provider id or alias\n --native Run GitHub Copilot CLI without BYOK\n -m, --model Provider wire model for BYOK, native model for --native\n -c, --config Provider config JSON path\n --list-models Print ranked models for the selected provider\n --no-model-prompt Use automatic default model\n --offline Prevent Copilot from contacting GitHub in BYOK mode\n --wire-api BYOK wire API: completions or responses\n --dry-run Print command/env without launching Copilot\n -h, --help Show this help\n\nExamples:\n copilot-byok --provider chutes --no-model-prompt\n copilot-byok --provider openrouter --offline --no-model-prompt\n copilot-byok --provider alibaba-token-plan --wire-api responses --no-model-prompt\n copilot-byok --provider fireworks --model accounts/fireworks/models/minimax-m2p5 -p "fix the bug"\n copilot-byok --native --model claude-sonnet-4.6\n`; } diff --git a/src/config.mjs b/src/config.mjs index e822907..5187d5e 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -2,6 +2,11 @@ import { access, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PROVIDER_TYPES = new Set(['openai', 'anthropic', 'azure']); +const WIRE_APIS = new Set(['completions', 'responses']); + const DEFAULT_PROVIDERS = [ { id: 'chutes', @@ -35,6 +40,30 @@ const DEFAULT_PROVIDERS = [ catalogModelId: 'claude-sonnet-4.6', requireToolSupport: true, }, + { + id: 'openrouter', + aliases: ['or'], + name: 'OpenRouter', + type: 'openai', + baseUrl: 'https://openrouter.ai/api/v1', + apiKeyEnv: ['OPENROUTER_API_KEY', 'COPILOT_OPENROUTER_API_KEY'], + modelsUrl: 'https://openrouter.ai/api/v1/models?supported_parameters=tools&output_modalities=text', + catalogModelId: 'gpt-4.1', + defaultModel: 'openrouter/auto', + requireToolSupport: true, + }, + { + id: 'moonshot', + aliases: ['kimi', 'moonshot-ai', 'kimi-ai'], + name: 'Moonshot AI (Kimi)', + type: 'openai', + baseUrl: 'https://api.moonshot.ai/v1', + apiKeyEnv: ['MOONSHOT_API_KEY', 'KIMI_API_KEY', 'COPILOT_MOONSHOT_API_KEY'], + modelsUrl: 'https://api.moonshot.ai/v1/models', + catalogModelId: 'gpt-4.1', + defaultModel: 'kimi-k3', + requireToolSupport: true, + }, { id: 'deepseek', aliases: ['deepseek-ai'], @@ -55,7 +84,7 @@ const DEFAULT_PROVIDERS = [ apiKeyEnv: ['ZAI_API_KEY', 'Z_AI_API_KEY', 'GLM_API_KEY', 'COPILOT_ZAI_API_KEY'], modelsUrl: 'https://api.z.ai/api/coding/paas/v4/models', catalogModelId: 'gpt-4.1', - defaultModel: 'glm-4.7', + defaultModel: 'glm-5.1', }, { id: 'minimax', @@ -68,6 +97,32 @@ const DEFAULT_PROVIDERS = [ catalogModelId: 'gpt-4.1', defaultModel: 'MiniMax-M2.7', }, + { + id: 'alibaba-token-plan', + aliases: ['alibaba', 'qwen', 'bailian', 'dashscope', 'modelstudio'], + name: 'Alibaba Model Studio Token Plan', + type: 'openai', + baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + apiKeyEnv: [ + 'ALIBABA_TOKEN_PLAN_API_KEY', + 'DASHSCOPE_TOKEN_PLAN_API_KEY', + 'BAILIAN_TOKEN_PLAN_API_KEY', + ], + catalogModelId: 'gpt-4.1', + defaultModel: 'qwen3.7-plus', + requireToolSupport: true, + }, + { + id: 'tencent-token-plan', + aliases: ['tencent', 'tokenhub', 'tencent-tokenhub'], + name: 'Tencent Cloud Token Plan', + type: 'openai', + baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3', + apiKeyEnv: ['TENCENT_TOKEN_PLAN_API_KEY', 'TOKENHUB_TOKEN_PLAN_API_KEY'], + catalogModelId: 'gpt-4.1', + defaultModel: 'tc-code-latest', + requireToolSupport: true, + }, ]; export async function loadConfig({ configPath, env = process.env } = {}) { @@ -82,7 +137,13 @@ export async function loadConfig({ configPath, env = process.env } = {}) { export async function loadConfigFromPath(configPath, env = process.env) { const contents = await readFile(configPath, 'utf8'); - const parsed = JSON.parse(contents); + let parsed; + try { + parsed = JSON.parse(contents); + } catch (error) { + throw new Error(`Invalid JSON in provider config ${configPath}: ${error.message}`, { cause: error }); + } + return normalizeConfig(parsed, env); } @@ -98,40 +159,206 @@ export function findProvider(config, requested) { } function normalizeConfig(config, env) { - const providers = (config.providers || []).map((provider) => ({ - ...normalizeProvider(provider, env), - })); + if (!isRecord(config) || !Array.isArray(config.providers)) { + throw new Error('Provider config must be an object with a providers array'); + } + + const providers = config.providers.map((provider, index) => normalizeProvider(provider, env, index)); + assertUniqueProviderNames(providers); return { ...config, providers }; } -function normalizeProvider(provider, env) { +function normalizeProvider(provider, env, index) { + if (!isRecord(provider)) { + throw new Error(`Provider at index ${index} must be an object`); + } + + const name = requiredString(provider.name, `Provider at index ${index} requires a name`); + const id = provider.id == null ? slug(name) : requiredString(provider.id, `Provider ${name} requires an id`); + if (!PROVIDER_ID_PATTERN.test(id)) { + throw new Error(`Provider id "${id}" may contain only letters, numbers, dots, underscores, and hyphens`); + } + + const type = requiredString(provider.type, `Provider ${name} requires a type`).toLowerCase(); + if (!PROVIDER_TYPES.has(type)) { + throw new Error(`Provider ${name} has unsupported type "${provider.type}"`); + } + + const aliases = normalizeAliases(provider.aliases, name); + const baseUrl = validateHttpUrl(provider.baseUrl, `Provider ${name} baseUrl`); + const modelsUrl = provider.modelsUrl == null + ? undefined + : validateHttpUrl(provider.modelsUrl, `Provider ${name} modelsUrl`); + const apiKeyEnv = normalizeEnvNames(provider.apiKeyEnv, name, 'apiKeyEnv'); + const bearerTokenEnv = normalizeEnvNames(provider.bearerTokenEnv, name, 'bearerTokenEnv'); + const wireApi = provider.wireApi == null + ? undefined + : requiredString(provider.wireApi, `Provider ${name} wireApi must be a non-empty string`).toLowerCase(); + if (provider.apiKey) { - throw new Error(`Inline apiKey is not allowed for provider ${provider.name || provider.id}. Use apiKeyEnv instead.`); + throw new Error(`Inline apiKey is not allowed for provider ${name}. Use apiKeyEnv instead.`); } if (provider.bearerToken) { - throw new Error(`Inline bearerToken is not allowed for provider ${provider.name || provider.id}. Use bearerTokenEnv instead.`); + throw new Error(`Inline bearerToken is not allowed for provider ${name}. Use bearerTokenEnv instead.`); } - rejectSecretModelHeaders(provider); + validateProviderOptions(provider, name, type); + rejectSecretModelHeaders(provider, name); return { ...provider, - id: provider.id || slug(provider.name), - apiKey: readFirstEnv(env, provider.apiKeyEnv), - bearerToken: readFirstEnv(env, provider.bearerTokenEnv), + id, + name, + type, + baseUrl, + ...(modelsUrl ? { modelsUrl } : {}), + ...(aliases ? { aliases } : {}), + ...(apiKeyEnv ? { apiKeyEnv } : {}), + ...(bearerTokenEnv ? { bearerTokenEnv } : {}), + ...(wireApi ? { wireApi } : {}), + apiKey: readFirstEnv(env, apiKeyEnv), + bearerToken: readFirstEnv(env, bearerTokenEnv), }; } -function rejectSecretModelHeaders(provider) { +function validateProviderOptions(provider, name, type) { + for (const field of ['catalogModelId', 'defaultModel']) { + if (provider[field] != null) requiredString(provider[field], `Provider ${name} ${field} must be a non-empty string`); + } + + if (provider.wireApi != null) { + const wireApi = requiredString(provider.wireApi, `Provider ${name} wireApi must be a non-empty string`).toLowerCase(); + if (!WIRE_APIS.has(wireApi)) { + throw new Error(`Provider ${name} wireApi must be "completions" or "responses"`); + } + if (type === 'anthropic') { + throw new Error(`Provider ${name} cannot set wireApi for the Anthropic protocol`); + } + } + + for (const field of ['maxPromptTokens', 'maxOutputTokens']) { + if (provider[field] != null && (!Number.isInteger(provider[field]) || provider[field] <= 0)) { + throw new Error(`Provider ${name} ${field} must be a positive integer`); + } + } + + if (provider.modelsTimeoutMs != null && ( + !Number.isInteger(provider.modelsTimeoutMs) || + provider.modelsTimeoutMs < 10 || + provider.modelsTimeoutMs > 300_000 + )) { + throw new Error(`Provider ${name} modelsTimeoutMs must be an integer between 10 and 300000`); + } + + if (provider.requireToolSupport != null && typeof provider.requireToolSupport !== 'boolean') { + throw new Error(`Provider ${name} requireToolSupport must be a boolean`); + } + + if (provider.modelsAuth != null && ![true, false, 'none'].includes(provider.modelsAuth)) { + throw new Error(`Provider ${name} modelsAuth must be true, false, or "none"`); + } + + if (provider.modelsHeaders != null) { + if (!isRecord(provider.modelsHeaders)) { + throw new Error(`Provider ${name} modelsHeaders must be an object`); + } + + for (const [headerName, value] of Object.entries(provider.modelsHeaders)) { + if (!headerName.trim() || typeof value !== 'string') { + throw new Error(`Provider ${name} modelsHeaders must contain string header values`); + } + } + } +} + +function rejectSecretModelHeaders(provider, name) { const headers = provider.modelsHeaders || {}; - for (const [name, value] of Object.entries(headers)) { - const combined = `${name}: ${value}`; + for (const [headerName, value] of Object.entries(headers)) { + const combined = `${headerName}: ${value}`; if (/authorization|api[-_]?key|token|secret|bearer/i.test(combined)) { - throw new Error(`Secret model headers are not allowed for provider ${provider.name || provider.id}. Use apiKeyEnv or bearerTokenEnv instead.`); + throw new Error(`Secret model headers are not allowed for provider ${name}. Use apiKeyEnv or bearerTokenEnv instead.`); + } + } +} + +function normalizeAliases(value, name) { + if (value == null) return undefined; + if (!Array.isArray(value)) throw new Error(`Provider ${name} aliases must be an array`); + + const aliases = value.map((alias) => requiredString(alias, `Provider ${name} aliases must be non-empty strings`)); + for (const alias of aliases) { + if (!PROVIDER_ID_PATTERN.test(alias)) { + throw new Error(`Provider alias "${alias}" may contain only letters, numbers, dots, underscores, and hyphens`); } } + + return [...new Set(aliases)]; +} + +function normalizeEnvNames(value, providerName, field) { + if (value == null) return undefined; + const values = Array.isArray(value) ? value : [value]; + if (values.length === 0) throw new Error(`Provider ${providerName} ${field} cannot be empty`); + + const normalized = values.map((name) => requiredString( + name, + `Provider ${providerName} ${field} must contain environment variable names` + )); + for (const name of normalized) { + if (!ENV_NAME_PATTERN.test(name)) { + throw new Error(`Provider ${providerName} ${field} contains invalid environment variable name "${name}"`); + } + } + + return Array.isArray(value) ? [...new Set(normalized)] : normalized[0]; +} + +function assertUniqueProviderNames(providers) { + const claimed = new Map(); + for (const provider of providers) { + for (const value of [provider.id, provider.name, ...(provider.aliases || [])]) { + const normalized = value.toLowerCase(); + if (normalized === 'native') { + throw new Error(`Provider ${provider.name} cannot use reserved name "native"`); + } + + const existing = claimed.get(normalized); + if (existing && existing !== provider) { + throw new Error(`Provider identifier "${value}" is used by both ${existing.name} and ${provider.name}`); + } + claimed.set(normalized, provider); + } + } +} + +function validateHttpUrl(value, label) { + const input = requiredString(value, `${label} is required`); + let url; + try { + url = new URL(input); + } catch (error) { + throw new Error(`${label} must be an absolute URL`, { cause: error }); + } + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`${label} must use http or https`); + } + if (url.username || url.password) { + throw new Error(`${label} must not contain embedded credentials`); + } + + return url.href.replace(/\/$/, ''); +} + +function requiredString(value, message) { + if (typeof value !== 'string' || !value.trim()) throw new Error(message); + return value.trim(); +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); } function readFirstEnv(env, names) { diff --git a/src/copilot-bin.mjs b/src/copilot-bin.mjs new file mode 100644 index 0000000..9b39ad9 --- /dev/null +++ b/src/copilot-bin.mjs @@ -0,0 +1,50 @@ +import { accessSync, constants } from 'node:fs'; +import { delimiter, join } from 'node:path'; + +const VSCODE_COPILOT_SHIM_PATTERN = /[\\/]globalStorage[\\/]github\.copilot-chat[\\/]copilotCli[\\/]/i; + +export function resolveCopilotBin({ env = process.env, platform = process.platform } = {}) { + if (typeof env.COPILOT_BIN === 'string' && env.COPILOT_BIN.trim()) { + return env.COPILOT_BIN.trim(); + } + + const pathValue = readEnvironmentValue(env, 'PATH'); + if (!pathValue) return 'copilot'; + + const commandNames = platform === 'win32' + ? ['copilot.cmd', 'copilot.exe', 'copilot.bat', 'copilot'] + : ['copilot']; + const pathDelimiter = platform === 'win32' ? ';' : delimiter; + const candidates = []; + + for (const rawDirectory of pathValue.split(pathDelimiter)) { + const directory = rawDirectory.trim().replace(/^"|"$/g, ''); + if (!directory) continue; + + for (const commandName of commandNames) { + const candidate = join(directory, commandName); + if (isRunnable(candidate, platform)) { + candidates.push(candidate); + break; + } + } + } + + return candidates.find((candidate) => !VSCODE_COPILOT_SHIM_PATTERN.test(candidate)) + || candidates[0] + || 'copilot'; +} + +function readEnvironmentValue(env, requestedName) { + const match = Object.entries(env).find(([name]) => name.toUpperCase() === requestedName); + return typeof match?.[1] === 'string' ? match[1] : ''; +} + +function isRunnable(path, platform) { + try { + accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/src/model-ranking.mjs b/src/model-ranking.mjs index 163e644..46f09f0 100644 --- a/src/model-ranking.mjs +++ b/src/model-ranking.mjs @@ -43,6 +43,7 @@ export function rankModels({ payload, requireToolSupport = false } = {}) { } function getModelItems(payload) { + if (Array.isArray(payload)) return payload; if (!payload || typeof payload !== 'object') return []; return [ ...(Array.isArray(payload.data) ? payload.data : []), @@ -63,9 +64,17 @@ function isUsableModel(model, id, requireToolSupport) { return false; } + if ( + Array.isArray(model.architecture?.input_modalities) && + model.architecture.input_modalities.length > 0 && + !model.architecture.input_modalities.includes('text') + ) { + return false; + } + if (model.supportsServerless === false || model.supports_serverless === false) return false; - if (typeof model.state === 'string' && model.state && model.state !== 'READY') return false; - if (typeof model.status?.code === 'string' && model.status.code && model.status.code !== 'OK') return false; + if (typeof model.state === 'string' && model.state && model.state.toUpperCase() !== 'READY') return false; + if (typeof model.status?.code === 'string' && model.status.code && model.status.code.toUpperCase() !== 'OK') return false; let hasToolMetadata = false; let hasToolSupport = false; @@ -73,14 +82,20 @@ function isUsableModel(model, id, requireToolSupport) { if (Array.isArray(model.supported_features) && model.supported_features.length > 0) { hasToolMetadata = true; hasToolSupport = model.supported_features.includes('tools'); - if (!hasToolSupport) return false; + if (requireToolSupport && !hasToolSupport) return false; + } + + if (Array.isArray(model.supported_parameters) && model.supported_parameters.length > 0) { + hasToolMetadata = true; + hasToolSupport = hasToolSupport || model.supported_parameters.includes('tools'); + if (requireToolSupport && !hasToolSupport) return false; } const hasConversationConfig = Boolean(model.conversationConfig || model.conversation_config); if (model.supportsTools != null || model.supports_tools != null || hasConversationConfig) { hasToolMetadata = true; hasToolSupport = hasToolSupport || model.supportsTools === true || model.supports_tools === true || hasConversationConfig; - if (!hasToolSupport) return false; + if (requireToolSupport && !hasToolSupport) return false; } if (requireToolSupport && hasToolMetadata && !hasToolSupport) return false; diff --git a/src/process-env.mjs b/src/process-env.mjs index 7000337..7c2f622 100644 --- a/src/process-env.mjs +++ b/src/process-env.mjs @@ -1,4 +1,4 @@ -const COPILOT_BYOK_ENV_PATTERN = /^COPILOT_PROVIDER_/; +const COPILOT_BYOK_ENV_PATTERN = /^COPILOT_PROVIDER_/i; const DEFAULT_SECRET_SOURCE_ENV = new Set([ 'CHUTES_API_KEY', 'COPILOT_CHUTES_API_KEY', @@ -16,14 +16,28 @@ const DEFAULT_SECRET_SOURCE_ENV = new Set([ 'COPILOT_ZAI_API_KEY', 'MINIMAX_API_KEY', 'COPILOT_MINIMAX_API_KEY', + 'OPENROUTER_API_KEY', + 'COPILOT_OPENROUTER_API_KEY', + 'MOONSHOT_API_KEY', + 'KIMI_API_KEY', + 'COPILOT_MOONSHOT_API_KEY', + 'ALIBABA_TOKEN_PLAN_API_KEY', + 'DASHSCOPE_TOKEN_PLAN_API_KEY', + 'BAILIAN_TOKEN_PLAN_API_KEY', + 'TENCENT_TOKEN_PLAN_API_KEY', + 'TOKENHUB_TOKEN_PLAN_API_KEY', ]); export function sanitizeCopilotEnvironment(baseEnv, overlay = {}, stripEnvNames = []) { const sanitized = { ...baseEnv }; - const sourceSecrets = new Set([...DEFAULT_SECRET_SOURCE_ENV, ...stripEnvNames].filter(Boolean)); + const sourceSecrets = new Set( + [...DEFAULT_SECRET_SOURCE_ENV, ...stripEnvNames] + .filter((name) => typeof name === 'string' && name) + .map((name) => name.toUpperCase()) + ); for (const key of Object.keys(sanitized)) { - if (COPILOT_BYOK_ENV_PATTERN.test(key) || sourceSecrets.has(key)) { + if (COPILOT_BYOK_ENV_PATTERN.test(key) || sourceSecrets.has(key.toUpperCase())) { delete sanitized[key]; } } diff --git a/src/provider-env.mjs b/src/provider-env.mjs index e2c163a..c126a73 100644 --- a/src/provider-env.mjs +++ b/src/provider-env.mjs @@ -1,12 +1,12 @@ -export function buildProviderEnvironment({ provider, wireModel }) { +export function buildProviderEnvironment({ provider, wireModel, offline = false }) { if (!provider?.type) throw new Error('Provider type is required'); if (!provider?.baseUrl) throw new Error('Provider baseUrl is required'); if (!wireModel) throw new Error('Provider wire model is required'); const env = { + COPILOT_MODEL: provider.catalogModelId || wireModel, COPILOT_PROVIDER_TYPE: provider.type, COPILOT_PROVIDER_BASE_URL: provider.baseUrl, - COPILOT_PROVIDER_MODEL_ID: provider.catalogModelId || wireModel, COPILOT_PROVIDER_WIRE_MODEL: wireModel, }; @@ -30,5 +30,9 @@ export function buildProviderEnvironment({ provider, wireModel }) { env.COPILOT_PROVIDER_MAX_OUTPUT_TOKENS = String(provider.maxOutputTokens); } + if (offline) { + env.COPILOT_OFFLINE = 'true'; + } + return env; } diff --git a/test/args.test.mjs b/test/args.test.mjs index c47ea70..4988c74 100644 --- a/test/args.test.mjs +++ b/test/args.test.mjs @@ -9,17 +9,37 @@ test('parses switcher options without passing them to Copilot', () => { 'chutes', '--model', 'moonshotai/Kimi-K2.6-TEE', - '--list-models', '-p', 'hello', ]); assert.equal(parsed.providerName, 'chutes'); assert.equal(parsed.explicitModel, 'moonshotai/Kimi-K2.6-TEE'); - assert.equal(parsed.listModels, true); + assert.equal(parsed.listModels, false); assert.deepEqual(parsed.copilotArgs, ['-p', 'hello']); }); +test('rejects empty option values and incompatible model listing options', () => { + assert.throws(() => parseArgs(['--provider=']), /requires a provider id/); + assert.throws(() => parseArgs(['--model=']), /requires a model id/); + assert.throws(() => parseArgs(['--config=']), /requires a file path/); + assert.throws(() => parseArgs(['--native', '--list-models']), /requires a BYOK provider/); + assert.throws( + () => parseArgs(['--provider', 'chutes', '--model', 'model', '--list-models']), + /cannot be combined/ + ); + assert.throws(() => parseArgs(['--wire-api=invalid']), /must be completions or responses/); + assert.throws(() => parseArgs(['--native', '--offline']), /require a BYOK provider/); +}); + +test('parses offline mode and a Responses API override', () => { + const parsed = parseArgs(['--provider', 'openrouter', '--offline', '--wire-api', 'RESPONSES']); + + assert.equal(parsed.offline, true); + assert.equal(parsed.wireApi, 'responses'); + assert.deepEqual(parsed.copilotArgs, []); +}); + test('keeps native Copilot --model argument in native mode', () => { const parsed = parseArgs(['--native', '--model', 'claude-sonnet-4.6', '-p', 'hello']); diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f08b133..a2d005b 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Writable } from 'node:stream'; @@ -60,6 +60,10 @@ test('sanitizes provider source key environment variables', () => { COPILOT_ZAI_API_KEY: 'secret', MINIMAX_API_KEY: 'secret', COPILOT_MINIMAX_API_KEY: 'secret', + OPENROUTER_API_KEY: 'secret', + MOONSHOT_API_KEY: 'secret', + ALIBABA_TOKEN_PLAN_API_KEY: 'secret', + TENCENT_TOKEN_PLAN_API_KEY: 'secret', }, {}); assert.equal(env.PATH, '/bin'); @@ -74,26 +78,98 @@ test('sanitizes provider source key environment variables', () => { assert.equal(Object.hasOwn(env, 'COPILOT_ZAI_API_KEY'), false); assert.equal(Object.hasOwn(env, 'MINIMAX_API_KEY'), false); assert.equal(Object.hasOwn(env, 'COPILOT_MINIMAX_API_KEY'), false); + assert.equal(Object.hasOwn(env, 'OPENROUTER_API_KEY'), false); + assert.equal(Object.hasOwn(env, 'MOONSHOT_API_KEY'), false); + assert.equal(Object.hasOwn(env, 'ALIBABA_TOKEN_PLAN_API_KEY'), false); + assert.equal(Object.hasOwn(env, 'TENCENT_TOKEN_PLAN_API_KEY'), false); }); -test('uses Windows shell for command shims', () => { - const options = buildCopilotSpawnOptions({ - env: {}, - platform: 'win32', +test('applies offline and Responses API overrides to the Copilot child environment', async () => { + const output = captureWritable(); + const exitCode = await main([ + '--provider', 'openrouter', + '--model', 'openrouter/auto', + '--offline', + '--wire-api', 'responses', + '--dry-run', + ], { + stdin: { isTTY: false }, + stdout: output, + stderr: captureWritable(), + env: { OPENROUTER_API_KEY: 'secret' }, }); - assert.equal(options.shell, true); + const result = JSON.parse(output.text()); + assert.equal(exitCode, 0); + assert.equal(result.env.COPILOT_OFFLINE, 'true'); + assert.equal(result.env.COPILOT_PROVIDER_WIRE_API, 'responses'); + assert.equal(result.env.COPILOT_PROVIDER_API_KEY, ''); +}); + +test('rejects OpenAI wire API overrides for Anthropic providers', async () => { + await assert.rejects( + () => main([ + '--provider', 'fireworks', + '--model', 'accounts/fireworks/models/example', + '--wire-api', 'responses', + '--dry-run', + ], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: { FIREWORKS_API_KEY: 'secret' }, + }), + /only for OpenAI-compatible/ + ); }); -test('uses direct spawn without shell on Unix-like platforms', () => { - const options = buildCopilotSpawnOptions({ - env: {}, - platform: 'linux', +test('sanitizes provider environment variables case-insensitively', () => { + const env = sanitizeCopilotEnvironment({ + PATH: '/bin', + copilot_provider_api_key: 'secret', + chutes_api_key: 'secret', }); + assert.equal(env.PATH, '/bin'); + assert.equal(Object.hasOwn(env, 'copilot_provider_api_key'), false); + assert.equal(Object.hasOwn(env, 'chutes_api_key'), false); +}); + +test('uses shell-free spawn options on every platform', () => { + const options = buildCopilotSpawnOptions({ env: {} }); + assert.equal(options.shell, false); }); +test('preserves metacharacters when launching a Windows command shim', { + skip: process.platform !== 'win32', +}, async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-shim-')); + const scriptPath = join(dir, 'mock-copilot.mjs'); + const commandPath = join(dir, 'mock-copilot.cmd'); + const outputPath = join(dir, 'args.json'); + const injectedPath = join(dir, 'injected.txt'); + const dangerousArg = `hello & echo injected>${injectedPath}`; + + await writeFile(scriptPath, [ + "import { writeFile } from 'node:fs/promises';", + 'const [outputPath, ...args] = process.argv.slice(2);', + "await writeFile(outputPath, JSON.stringify(args), 'utf8');", + ].join('\n')); + await writeFile(commandPath, `@echo off\r\n"${process.execPath}" "%~dp0mock-copilot.mjs" %*\r\n`); + + const exitCode = await main(['--native', '--', outputPath, dangerousArg], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: { ...process.env, COPILOT_BIN: commandPath }, + }); + + assert.equal(exitCode, 0); + assert.deepEqual(JSON.parse(await readFile(outputPath, 'utf8')), [dangerousArg]); + await assert.rejects(() => readFile(injectedPath, 'utf8'), { code: 'ENOENT' }); +}); + test('list-models fails when provider catalog request fails', async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async () => ({ @@ -117,6 +193,112 @@ test('list-models fails when provider catalog request fails', async () => { } }); +test('does not send provider credentials to a cross-origin model catalog by default', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'safe', + name: 'Safe Provider', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + modelsUrl: 'https://catalog.example.net/models', + apiKeyEnv: 'SAFE_PROVIDER_KEY', + defaultModel: 'safe-model', + }], + })); + + const previousFetch = globalThis.fetch; + let requestOptions; + globalThis.fetch = async (_url, options) => { + requestOptions = options; + return { ok: true, json: async () => ({ data: [{ id: 'safe-model' }] }) }; + }; + + try { + const exitCode = await main(['--config', configPath, '--provider', 'safe', '--list-models'], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: { SAFE_PROVIDER_KEY: 'secret' }, + }); + + assert.equal(exitCode, 0); + assert.equal(Object.hasOwn(requestOptions.headers, 'Authorization'), false); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test('allows explicit authentication for a cross-origin model catalog', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'authenticated', + name: 'Authenticated Catalog', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + modelsUrl: 'https://catalog.example.net/models', + modelsAuth: true, + apiKeyEnv: 'SAFE_PROVIDER_KEY', + }], + })); + + const previousFetch = globalThis.fetch; + let requestOptions; + globalThis.fetch = async (_url, options) => { + requestOptions = options; + return { ok: true, json: async () => ({ data: [{ id: 'safe-model' }] }) }; + }; + + try { + await main(['--config', configPath, '--provider', 'authenticated', '--list-models'], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: { SAFE_PROVIDER_KEY: 'secret' }, + }); + assert.equal(requestOptions.headers.Authorization, 'Bearer secret'); + } finally { + globalThis.fetch = previousFetch; + } +}); + +test('times out model catalog requests', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'slow', + name: 'Slow Provider', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + modelsUrl: 'https://api.example.com/v1/models', + modelsTimeoutMs: 10, + }], + })); + + const previousFetch = globalThis.fetch; + globalThis.fetch = async (_url, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + + try { + await assert.rejects( + () => main(['--config', configPath, '--provider', 'slow', '--list-models'], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: {}, + }), + /timed out after 10ms/ + ); + } finally { + globalThis.fetch = previousFetch; + } +}); + function captureWritable() { let buffer = ''; const stream = new Writable({ diff --git a/test/config.test.mjs b/test/config.test.mjs index adcc677..3edb248 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -29,7 +29,7 @@ test('loads providers from JSON config and resolves apiKeyEnv', async () => { assert.equal(config.providers[0].catalogModelId, 'gpt-4.1'); }); -test('loads DeepSeek, Z.ai, and MiniMax as built-in providers', async () => { +test('loads documented API and token-plan providers as built-ins', async () => { const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-empty-config-')); const config = await loadConfig({ env: { XDG_CONFIG_HOME: dir } }); @@ -48,7 +48,7 @@ test('loads DeepSeek, Z.ai, and MiniMax as built-in providers', async () => { assert.equal(zai.type, 'openai'); assert.equal(zai.baseUrl, 'https://api.z.ai/api/coding/paas/v4'); assert.equal(zai.modelsUrl, 'https://api.z.ai/api/coding/paas/v4/models'); - assert.equal(zai.defaultModel, 'glm-4.7'); + assert.equal(zai.defaultModel, 'glm-5.1'); assert.deepEqual(zai.apiKeyEnv, ['ZAI_API_KEY', 'Z_AI_API_KEY', 'GLM_API_KEY', 'COPILOT_ZAI_API_KEY']); assert.equal(zai.apiKey, null); @@ -61,6 +61,30 @@ test('loads DeepSeek, Z.ai, and MiniMax as built-in providers', async () => { assert.equal(minimax.defaultModel, 'MiniMax-M2.7'); assert.deepEqual(minimax.apiKeyEnv, ['MINIMAX_API_KEY', 'COPILOT_MINIMAX_API_KEY']); assert.equal(minimax.apiKey, null); + + const openrouter = findProvider(config, 'or'); + assert.equal(openrouter.id, 'openrouter'); + assert.equal(openrouter.baseUrl, 'https://openrouter.ai/api/v1'); + assert.match(openrouter.modelsUrl, /supported_parameters=tools/); + assert.equal(openrouter.defaultModel, 'openrouter/auto'); + + const moonshot = findProvider(config, 'kimi'); + assert.equal(moonshot.id, 'moonshot'); + assert.equal(moonshot.baseUrl, 'https://api.moonshot.ai/v1'); + assert.equal(moonshot.modelsUrl, 'https://api.moonshot.ai/v1/models'); + assert.equal(moonshot.defaultModel, 'kimi-k3'); + + const alibaba = findProvider(config, 'qwen'); + assert.equal(alibaba.id, 'alibaba-token-plan'); + assert.equal(alibaba.baseUrl, 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'); + assert.equal(alibaba.modelsUrl, undefined); + assert.equal(alibaba.defaultModel, 'qwen3.7-plus'); + + const tencent = findProvider(config, 'tokenhub'); + assert.equal(tencent.id, 'tencent-token-plan'); + assert.equal(tencent.baseUrl, 'https://api.lkeap.cloud.tencent.com/plan/v3'); + assert.equal(tencent.modelsUrl, undefined); + assert.equal(tencent.defaultModel, 'tc-code-latest'); }); test('rejects inline API keys in provider config', async () => { @@ -104,3 +128,85 @@ test('rejects secret model headers in provider config', async () => { /Secret model headers are not allowed/ ); }); + +test('validates provider URLs, types, environment names, and identifiers', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'invalid provider', + name: 'Invalid', + type: 'unknown', + baseUrl: 'not-a-url', + apiKeyEnv: 'NOT VALID', + }], + })); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), /Provider id/); +}); + +test('rejects ambiguous provider identifiers', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [ + { id: 'one', aliases: ['shared'], name: 'One', type: 'openai', baseUrl: 'https://one.example.com' }, + { id: 'two', aliases: ['shared'], name: 'Two', type: 'openai', baseUrl: 'https://two.example.com' }, + ], + })); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), /used by both One and Two/); +}); + +test('rejects duplicate ids even when providers have the same display name', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [ + { id: 'duplicate', name: 'Same', type: 'openai', baseUrl: 'https://one.example.com' }, + { id: 'duplicate', name: 'Same', type: 'openai', baseUrl: 'https://two.example.com' }, + ], + })); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), /used by both Same and Same/); +}); + +test('validates wire API values', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'invalid-wire-api', + name: 'Invalid Wire API', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + wireApi: 'legacy', + }], + })); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), /completions.*responses/); +}); + +test('rejects an OpenAI wire API override on Anthropic providers', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'anthropic-wire-api', + name: 'Anthropic Wire API', + type: 'anthropic', + baseUrl: 'https://api.example.com', + wireApi: 'responses', + }], + })); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), /Anthropic protocol/); +}); + +test('adds the config path to malformed JSON errors', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, '{broken'); + + await assert.rejects(() => loadConfigFromPath(configPath, {}), new RegExp(configPath.replaceAll('\\', '\\\\'))); +}); diff --git a/test/copilot-bin.test.mjs b/test/copilot-bin.test.mjs new file mode 100644 index 0000000..3a4a4a6 --- /dev/null +++ b/test/copilot-bin.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { resolveCopilotBin } from '../src/copilot-bin.mjs'; + +test('prefers an npm Copilot binary over the stale VS Code shim on Windows', async () => { + const root = await mkdtemp(join(tmpdir(), 'copilot-bin-')); + const shimDir = join(root, 'globalStorage', 'github.copilot-chat', 'copilotCli'); + const npmDir = await mkdtemp(join(tmpdir(), 'copilot-npm-')); + await mkdir(shimDir, { recursive: true }); + await writeFile(join(shimDir, 'copilot.bat'), '@exit /b 1\r\n'); + await writeFile(join(npmDir, 'copilot.cmd'), '@exit /b 0\r\n'); + + const resolved = resolveCopilotBin({ + env: { Path: `${shimDir};${npmDir}` }, + platform: 'win32', + }); + + assert.equal(resolved, join(npmDir, 'copilot.cmd')); +}); + +test('honors COPILOT_BIN and falls back to the command name', () => { + assert.equal(resolveCopilotBin({ env: { COPILOT_BIN: ' custom-copilot ' } }), 'custom-copilot'); + assert.equal(resolveCopilotBin({ env: {}, platform: 'linux' }), 'copilot'); +}); diff --git a/test/model-ranking.test.mjs b/test/model-ranking.test.mjs index a40fdcf..75c2c95 100644 --- a/test/model-ranking.test.mjs +++ b/test/model-ranking.test.mjs @@ -82,3 +82,48 @@ test('does not reject minimal model catalogs when tool metadata is absent', () = assert.deepEqual(models, ['provider/kimi-k2.6', 'provider/deepseek-v4-pro']); }); + +test('honors requireToolSupport only when it is enabled', () => { + const payload = [{ id: 'chat-model', supported_features: ['json_mode'], state: 'ready' }]; + + assert.deepEqual(rankModels({ payload, requireToolSupport: false }), ['chat-model']); + assert.deepEqual(rankModels({ payload, requireToolSupport: true }), []); +}); + +test('accepts array catalogs and case-insensitive ready status values', () => { + const models = rankModels({ + payload: [ + { id: 'model-b', state: 'ready', status: { code: 'ok' } }, + { id: 'model-a', state: 'failed', status: { code: 'ok' } }, + ], + }); + + assert.deepEqual(models, ['model-b']); +}); + +test('understands OpenRouter modalities and tool parameter metadata', () => { + const models = rankModels({ + payload: { + data: [ + { + id: 'vendor/tool-model', + architecture: { input_modalities: ['text'] }, + supported_parameters: ['tools', 'reasoning'], + }, + { + id: 'vendor/image-only', + architecture: { input_modalities: ['image'] }, + supported_parameters: ['tools'], + }, + { + id: 'vendor/chat-only', + architecture: { input_modalities: ['text'] }, + supported_parameters: ['temperature'], + }, + ], + }, + requireToolSupport: true, + }); + + assert.deepEqual(models, ['vendor/tool-model']); +}); diff --git a/test/provider-env.test.mjs b/test/provider-env.test.mjs index 10439be..e13a51a 100644 --- a/test/provider-env.test.mjs +++ b/test/provider-env.test.mjs @@ -17,9 +17,9 @@ test('separates Copilot catalog model from provider wire model', () => { assert.equal(env.COPILOT_PROVIDER_TYPE, 'openai'); assert.equal(env.COPILOT_PROVIDER_BASE_URL, 'https://llm.chutes.ai/v1'); assert.equal(env.COPILOT_PROVIDER_API_KEY, 'secret'); - assert.equal(env.COPILOT_PROVIDER_MODEL_ID, 'gpt-4.1'); + assert.equal(env.COPILOT_MODEL, 'gpt-4.1'); assert.equal(env.COPILOT_PROVIDER_WIRE_MODEL, 'moonshotai/Kimi-K2.6-TEE'); - assert.equal(Object.hasOwn(env, 'COPILOT_MODEL'), false); + assert.equal(Object.hasOwn(env, 'COPILOT_PROVIDER_MODEL_ID'), false); }); test('passes explicit token limits when configured', () => { @@ -38,3 +38,19 @@ test('passes explicit token limits when configured', () => { assert.equal(env.COPILOT_PROVIDER_MAX_PROMPT_TOKENS, '200000'); assert.equal(env.COPILOT_PROVIDER_MAX_OUTPUT_TOKENS, '64000'); }); + +test('enables Copilot offline mode and the configured wire API', () => { + const env = buildProviderEnvironment({ + provider: { + type: 'openai', + baseUrl: 'https://api.example.com/v1', + apiKey: 'secret', + wireApi: 'responses', + }, + wireModel: 'custom-model', + offline: true, + }); + + assert.equal(env.COPILOT_PROVIDER_WIRE_API, 'responses'); + assert.equal(env.COPILOT_OFFLINE, 'true'); +}); From 8083f44b251008eec4781bf7b6b77adf9e29802c Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sat, 1 Aug 2026 00:04:15 +0200 Subject: [PATCH 2/4] Fix Node 22 timeout test --- test/cli.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/cli.test.mjs b/test/cli.test.mjs index a2d005b..ad0f4f1 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -281,7 +281,14 @@ test('times out model catalog requests', async () => { const previousFetch = globalThis.fetch; globalThis.fetch = async (_url, { signal }) => new Promise((resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + const watchdog = setTimeout(() => resolve({ + ok: true, + json: async () => ({ data: [] }), + }), 1_000); + signal.addEventListener('abort', () => { + clearTimeout(watchdog); + reject(signal.reason); + }, { once: true }); }); try { From ace4a71509aa8acf078f3a58e83c759abc5667a6 Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sat, 1 Aug 2026 00:06:12 +0200 Subject: [PATCH 3/4] Avoid logging credential configuration --- src/cli.mjs | 7 +------ test/cli.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/cli.mjs b/src/cli.mjs index c430b9d..42cfb9d 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -38,7 +38,7 @@ export async function main(argv = process.argv.slice(2), io = defaultIo()) { } if (!args.listModels && !provider.apiKey && !provider.bearerToken) { - throw new Error(`Missing API key for ${provider.name}. Set one of: ${formatEnvNames(provider.apiKeyEnv || provider.bearerTokenEnv)}`); + throw new Error(`Missing API key for ${provider.name}. Configure an environment variable listed in the provider's apiKeyEnv or bearerTokenEnv setting.`); } if (args.wireApi && provider.type === 'anthropic') { @@ -227,11 +227,6 @@ function redactEnv(env) { ])); } -function formatEnvNames(names) { - const values = Array.isArray(names) ? names : names ? [names] : []; - return values.join(', ') || 'provider apiKeyEnv'; -} - function helpText() { return `copilot-byok - switch GitHub Copilot CLI between native and BYOK providers\n\nUsage:\n copilot-byok [options] [-- Copilot args...]\n\nOptions:\n -P, --provider Provider id or alias\n --native Run GitHub Copilot CLI without BYOK\n -m, --model Provider wire model for BYOK, native model for --native\n -c, --config Provider config JSON path\n --list-models Print ranked models for the selected provider\n --no-model-prompt Use automatic default model\n --offline Prevent Copilot from contacting GitHub in BYOK mode\n --wire-api BYOK wire API: completions or responses\n --dry-run Print command/env without launching Copilot\n -h, --help Show this help\n\nExamples:\n copilot-byok --provider chutes --no-model-prompt\n copilot-byok --provider openrouter --offline --no-model-prompt\n copilot-byok --provider alibaba-token-plan --wire-api responses --no-model-prompt\n copilot-byok --provider fireworks --model accounts/fireworks/models/minimax-m2p5 -p "fix the bug"\n copilot-byok --native --model claude-sonnet-4.6\n`; } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index ad0f4f1..d862c62 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -26,6 +26,40 @@ test('native mode does not require provider config to parse', async () => { assert.equal(JSON.parse(output.text()).native, true); }); +test('does not expose credential configuration in missing-key errors', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'private-provider', + name: 'Private Provider', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + apiKeyEnv: 'SENSITIVE_CREDENTIAL_SOURCE', + defaultModel: 'private-model', + }], + })); + + await assert.rejects( + () => main([ + '--config', configPath, + '--provider', 'private-provider', + '--model', 'private-model', + '--dry-run', + ], { + stdin: { isTTY: false }, + stdout: captureWritable(), + stderr: captureWritable(), + env: {}, + }), + (error) => { + assert.match(error.message, /Missing API key for Private Provider/); + assert.doesNotMatch(error.message, /SENSITIVE_CREDENTIAL_SOURCE/); + return true; + } + ); +}); + test('sanitizes stale Copilot BYOK env before launching child process', () => { const env = sanitizeCopilotEnvironment({ PATH: '/bin', From dcd2d7a0048b92d37cceef213ef79231144db846 Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sat, 1 Aug 2026 00:09:26 +0200 Subject: [PATCH 4/4] Redact credential environment validation errors --- src/config.mjs | 2 +- test/config.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/config.mjs b/src/config.mjs index 5187d5e..f1d32db 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -308,7 +308,7 @@ function normalizeEnvNames(value, providerName, field) { )); for (const name of normalized) { if (!ENV_NAME_PATTERN.test(name)) { - throw new Error(`Provider ${providerName} ${field} contains invalid environment variable name "${name}"`); + throw new Error(`Provider ${providerName} ${field} contains an invalid environment variable name`); } } diff --git a/test/config.test.mjs b/test/config.test.mjs index 3edb248..660d57b 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -145,6 +145,29 @@ test('validates provider URLs, types, environment names, and identifiers', async await assert.rejects(() => loadConfigFromPath(configPath, {}), /Provider id/); }); +test('does not expose invalid credential environment names', async () => { + const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); + const configPath = join(dir, 'providers.json'); + await writeFile(configPath, JSON.stringify({ + providers: [{ + id: 'private-provider', + name: 'Private Provider', + type: 'openai', + baseUrl: 'https://api.example.com/v1', + apiKeyEnv: 'SENSITIVE CREDENTIAL SOURCE', + }], + })); + + await assert.rejects( + () => loadConfigFromPath(configPath, {}), + (error) => { + assert.match(error.message, /invalid environment variable name/); + assert.doesNotMatch(error.message, /SENSITIVE CREDENTIAL SOURCE/); + return true; + } + ); +}); + test('rejects ambiguous provider identifiers', async () => { const dir = await mkdtemp(join(tmpdir(), 'copilot-byok-')); const configPath = join(dir, 'providers.json');