From 76f7f6227c5e032c11c31bcbf10a1a08a5f4254f Mon Sep 17 00:00:00 2001 From: BeLazy167 Date: Fri, 21 Aug 2026 01:00:46 -0500 Subject: [PATCH 1/6] feat: isolate qBraid account profiles --- .audit/qbraid-code-org-profiles.tsv | 28 + .github/workflows/ci.yml | 17 +- README.md | 235 ++++---- ...-gateway-capabilities-and-organizations.md | 217 +++++++ doctor.ps1 | 25 +- install.ps1 | 408 ++++++++++--- install.sh | 534 ++++++++++++----- qbraid-code | 538 +++++++++++++----- qbraid-code.cmd | 135 +---- qbraid-launch.ps1 | 350 ++++++++++++ qbraid-proxy.ps1 | 58 +- statusline.ps1 | 98 ++-- statusline.sh | 83 ++- tests/context-policy.sh | 114 ++++ tests/extractors.sh | 14 +- tests/install-profiles.sh | 140 +++++ tests/migration.sh | 126 ++++ tests/profiles.sh | 193 +++++++ tests/statusline.sh | 46 +- tests/windows-profiles.ps1 | 112 ++++ 20 files changed, 2704 insertions(+), 767 deletions(-) create mode 100644 .audit/qbraid-code-org-profiles.tsv create mode 100644 docs/research/2026-08-20-gateway-capabilities-and-organizations.md create mode 100644 qbraid-launch.ps1 create mode 100755 tests/context-policy.sh create mode 100755 tests/install-profiles.sh create mode 100755 tests/migration.sh create mode 100755 tests/profiles.sh create mode 100644 tests/windows-profiles.ps1 diff --git a/.audit/qbraid-code-org-profiles.tsv b/.audit/qbraid-code-org-profiles.tsv new file mode 100644 index 0000000..1d4523b --- /dev/null +++ b/.audit/qbraid-code-org-profiles.tsv @@ -0,0 +1,28 @@ +ts phase decision why evidence result +2026-08-21T03:17:06Z frame Started from a clean origin/main worktree The current checkout was a merged feature branch and would mix unrelated history origin/main 2427263; git status clean feature branch +2026-08-21T03:17:06Z research Used named account profiles and a model capability catalog as the leading design candidates Read-only architecture scout found one key drives several unsynchronized artifacts and model facts are scattered agent message arch-history; docs/research design exploration open +2026-08-21T03:17:12Z frame Defined the run exit predicate The unattended run needs an objective stop condition session todo and native checks predicate defined +2026-08-21T03:19:05Z baseline Ran every local CI-equivalent shell check before changes A clean baseline separates existing failures from regressions shellcheck; bash -n; tests/statusline.sh; tests/extractors.sh; tests/model-routing.sh; install.sh --help 24 behavior tests and all checks green +2026-08-21T03:19:05Z brand Chose the dark-mode qBraid violet for terminal branding and kept credit warning colors The published brand spends one violet accent on chrome and permits separate state colors /tmp/qbraid-brand.md from https://vessa.design/brand/qbraid brand facts captured +2026-08-21T03:22:17Z architecture Opened the visual architecture report and chose account profiles as the first deepening Account cross-talk can charge and display different organizations; profiles also give model state a stable owner /var/folders/1v/_4ktp36n22d72l98hgy_xj3w0000gn/T/architecture-review-20260820-222213.html report opened; selection implicit from user request +2026-08-21T03:25:27Z probe Kept Opus adaptive thinking blocked pending an API fix A live production request returned HTTP 400 because qbraid-api changed adaptive to enabled redacted curl probe; qbraid-api origin/staging 1942979c reproduced exact failure +2026-08-21T03:25:27Z probe Confirmed Sol accepts Claude Code default thinking through CLIProxyAPI when tool count is under 128 The first probe failed on the documented 128-tool limit; strict MCP isolated thinking compatibility /tmp/probe-sol-thinking.sh; CLIProxyAPI 7.2.135 Sol returned OK over translated /v1/messages +2026-08-21T03:28:45Z impact Treat qbraid-api and qbraid-code as a coordinated contract despite an empty impact route The mapper does not index qbraid-code; zero consumers is a coverage gap, not safety /tmp/qbraid-impact-ai-messages.json; repos.json qbraid-code note required deploy order will be explicit +2026-08-21T03:28:50Z research Captured primary-source constraints in the repository research note Claude Code has process-wide context policy, no organization field in status JSON, and no in-process credential switch docs/research/2026-08-20-gateway-capabilities-and-organizations.md research complete +2026-08-21T03:30:07Z prototype Removed legacy --global gateway env from the viable profile design A fake gateway proved user settings env overrides process env and would silently select the wrong profile /tmp/fake_anthropic_server.py; captured settings-model request settings precedence reproduced +2026-08-21T03:30:07Z prototype Included adaptive display and effort in the API fix contract Claude Code sent adaptive display omitted and output_config effort high; qbraid-api currently drops both /tmp/fake-anthropic-requests.jsonl wire shape captured +2026-08-21T03:34:09Z design Selected per-profile directories with an atomic active pointer It isolates secrets, proxies, ports, and caches without a daemon or registry parser /tmp/qbraid-code-selected-architecture.md arena dropouts documented; selected +2026-08-21T03:35:24Z api-fix Preserved adaptive type, display, and effort at the Bedrock provider boundary Live Claude Code payload and SDK serialization proved all three fields are meaningful qbraid-api thinking + validation tests focused tests and typecheck green +2026-08-21T05:37:53Z security Removed plain-Claude --global mode Project settings can override only the base URL and exfiltrate a user-settings bearer Claude 2.1.238 fake-gateway precedence probe; contested review installer rejects --global and removes legacy gateway env +2026-08-21T05:37:53Z architecture Replaced mutable profile directories with atomic metadata generations A crash between in-place writes mixed credentials, labels, models, and proxy state failed-update fixture with rotated key and disabled proxy current pointer changes only after a complete generation +2026-08-21T05:37:53Z security Moved keys to OS vaults with a Linux 0600 fallback Status hooks and Claude tools must never receive the reusable upstream key Keychain/Credential Locker guidance; secret-free snapshot tests Claude receives only a random per-launch loopback bearer +2026-08-21T05:37:53Z lifecycle Replaced shared profile proxies with per-launch owned proxies Refcounts and shared ports created teardown, update, and Terminal Services races real CLIProxyAPI launch; occupied-port fixture; contested review runtime config is unlinked after health and process stops on exit +2026-08-21T05:37:53Z compatibility Disabled gateway model discovery and required relaunch for model changes Claude Code context policy is process-wide while gateway rows span 200k to 1m context-policy fixtures and wire probe each process has one conservative capability policy +2026-08-21T05:44:21Z lifecycle Kept the private runtime config until proxy shutdown CLIProxyAPI starts its file watcher after the first healthy response and exits if the config was already unlinked CLIProxyAPI 6.8.40 delayed watcher repro; watcher-lifetime fixture supersedes early-unlink result; cleanup removes the private runtime directory +2026-08-21T05:51:13Z security Pruned superseded generations and credentials after atomic commit Profile updates already reject live sessions, so retaining old keys only enlarged exposure rotated-key fixture checks old generation and secret removal only current generation remains recoverable +2026-08-21T05:51:13Z migration Scrubbed plaintext legacy env after vault migration Running sessions already hold launch credentials and status needs only label/cache files migration fixture checks non-secret root state remains idempotent migration leaves no root token duplicate +2026-08-21T05:51:13Z portability Preferred Linux Secret Service and retained a 0600 headless fallback Desktop Linux provides an OS credential store but minimal servers may not fake secret-tool migration and failure fallback fixtures secret-service backend selected only after a successful store +2026-08-21T05:51:13Z identity Rendered a shortened verified organization-ID tag beside local labels API-key auth often lacks an authoritative organization name organization-id provenance file and status fixture local text stays marked and gains verified account evidence +2026-08-21T05:52:35Z validation Kept proxy healthy after delayed watcher initialization First health can precede CLIProxyAPI watcher startup real CLIProxyAPI delayed second /v1/models returned HTTP 200; runtime removed on exit per-launch config lifetime and cleanup verified +2026-08-21T05:55:21Z migration Deferred legacy plaintext scrub until successful generation commit Immediate scrub broke the old launcher when later installation steps failed pre-commit migration fixture retains root token; post-commit helper scrubs it supersedes immediate-scrub timing while preserving final no-duplicate state +2026-08-21T05:58:51Z migration Removed legacy secret-bearing proxy config after safe handoff The old singleton proxy template embedded the upstream key twice migration fixture covers inactive cleanup, live watcher preservation, and dead-owner scavenging new launchers remove residue once no legacy proxy watches it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da26946..1e0bc1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: run: brew install shellcheck - name: shellcheck - run: shellcheck -S warning install.sh qbraid-code statusline.sh tests/statusline.sh tests/extractors.sh tests/model-routing.sh + run: shellcheck -S warning install.sh qbraid-code statusline.sh tests/*.sh - name: syntax run: | @@ -26,14 +26,9 @@ jobs: bash -n qbraid-code bash -n statusline.sh - - name: statusline tests - run: tests/statusline.sh - - - name: extractor tests - run: tests/extractors.sh - - - name: model routing tests - run: tests/model-routing.sh + - name: shell tests + run: | + for test in tests/*.sh; do bash "$test"; done - name: installer --help does not touch the machine run: bash install.sh --help @@ -62,6 +57,10 @@ jobs: "parsed $($f.Name)" } + - name: Windows profile tests + shell: pwsh + run: tests/windows-profiles.ps1 + - name: batch launcher is CRLF and BOM-free shell: pwsh run: | diff --git a/README.md b/README.md index 642ce04..1dc3610 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # qbraid-code -Claude Code, powered by the **qBraid AI gateway**. One command installs everything -and leaves you in a working session: Claude Code itself, your qBraid credentials, -a live credit statusline, and the qBraid MCP tools. +Claude Code, powered by the **qBraid AI gateway**. -Your own `claude` command stays untouched unless you ask for `--global`. +The installer adds Claude Code, qBraid API-key profiles, a credit statusline, +GPT translation, and the qBraid MCP. It needs no Node.js or administrator rights. ## Install @@ -14,105 +13,140 @@ Your own `claude` command stays untouched unless you ask for `--global`. curl -fsSL https://qbraid.com/code.sh | bash ``` -**Windows** (PowerShell) +**Windows PowerShell** ```powershell irm https://qbraid.com/code.ps1 | iex ``` -That is all. The installer: +The first install creates the `default` profile. Add a named profile with the +same installer. -- installs **Claude Code** if you do not have it — a native binary, no Node.js and no administrator rights -- finds your **qBraid API key**, or opens the page where you can copy one -- shows you which **organization** the key belongs to and asks you to confirm it -- lets you **choose a default model** from the list the gateway is actually serving -- registers the **qBraid MCP** and signs you in -- installs a **statusline** showing your remaining qBraid credits -- runs a **real request** to prove it works before it says "ready" +```bash +curl -fsSL https://qbraid.com/code.sh | bash -s -- --profile research +``` + +```powershell +& ([scriptblock]::Create((irm https://qbraid.com/code.ps1))) -Profile research +``` + +Set `QBRAID_CODE_PROFILE_LABEL` to show a different readable label. The +installer uses an authenticated organization name when the API returns one. +Otherwise it marks this label as local and adds a shortened verified +organization-ID tag. -Re-running it is safe. +Existing singleton installs migrate to `profiles/default` once. Migration does +not overwrite an existing profile. The old files remain available to sessions +that were already running. -## Use +## Use profiles ```bash -qbraid-code # start a session -qbraid-code -p "explain this" # ask one question and exit -qbraid-code --doctor # check your setup +qbraid-code # use the active profile +qbraid-code --profile research # bind this session to research +qbraid-code --profiles # list profiles +qbraid-code --use-profile research # select future sessions +qbraid-code --doctor # check the bound profile ``` -Every other flag goes straight through to `claude`, so `-c`, `--model`, -`--allowedTools` and the rest behave normally. +`--profile` must be the first option. The launcher removes it before forwarding +the remaining arguments to Claude Code. -### GPT models +Each launch resolves one immutable metadata generation. Profile updates stage a +complete generation and atomically switch `current`. Failed updates leave the +previous generation and secret reference intact. + +macOS stores keys in Keychain. Windows stores them in Credential Locker. Linux +uses Secret Service through `secret-tool` when available. Headless Linux falls +back to a documented private `0600` file. +Claude receives only a random per-launch loopback token. The status snapshot +contains no API key. + +A profile slug belongs to one organization. Use a new slug when a key belongs +to another organization. Existing sessions stay pinned to their generation. + +Every resume requires `--allow-profile-resume`. Confirm the intended account +with `--profile NAME --allow-profile-resume --resume ...`. + +The launcher excludes project and local Claude settings. Their hooks can read +process credentials or replace the gateway. User settings still load. Gateway +credentials are pinned through higher-precedence CLI settings. + +The statusline renders the account beside its balance. + +```text +qbraid-code ⎇ main │ Claude Opus 5 │ C13 █░░░░░ │ qBraid Research Lab · 4281 credits +``` + +The `qBraid` word uses the qBraid violet accent. Warning and low-credit colors +remain semantic. +Unverified profile labels include `(local)`. Their account segment also includes +a shortened verified organization ID. Credit snapshots show `stale` after five +minutes without a successful launch refresh. -The gateway also serves OpenAI GPT models, and `qbraid-code` can use them: +### GPT models ```bash qbraid-code --model gpt-5.6-sol qbraid-code --model gpt-5.4-mini -p "explain this error" ``` -Every model — Claude and GPT — is served through one local endpoint: a small -translation proxy (CLIProxyAPI, loopback only, started on demand). Claude -models pass through to the gateway untouched; GPT models are translated to its -OpenAI-compatible surface. Because it is one endpoint, `/model ` works -for **any** of the models mid-session, e.g. `/model gpt-5.6-sol`. +CLIProxyAPI runs on loopback only. Claude models pass through unchanged. GPT +models use the gateway OpenAI-compatible surface. Each launch owns a random +local bearer, runtime port, and proxy process. The proxy stops with the session. +Its private runtime config is removed during launch cleanup. -If the proxy is missing, Claude models automatically fall back to the gateway -directly — they can never break because of it. It shuts down by itself when -you quit the session that started it; `qbraid-code --stop` stops it by hand. +GPT models accept at most 128 tools. Use `--strict-mcp-config` when many MCP +servers would exceed that limit. -One caveat: GPT models accept at most 128 tools, so with many MCP servers add -`--strict-mcp-config`. +### Context windows -A session looks like this: +The installer stores exact model IDs and context limits in each profile's +`models.tsv`. Verified built-in fallbacks cover Sol, Opus, Haiku, and the +GPT-5.4 family. Unknown models stay at the conservative 200,000-token policy. -``` -qbraid-code ⎇ main │ Claude Opus 5 │ C13 █░░░░░ │ 4281 credits -``` +One-million-token models use Claude Code's `[1m]` model marker. Sol's +1,050,000-token window is advertised conservatively as 1,000,000 tokens. +Other gateway models use `CLAUDE_CODE_MAX_CONTEXT_TOKENS` with their catalog +value. -Folder and branch, the model you are talking to, how much of the context window -is used, and what you have left to spend. +Context policy belongs to the Claude Code process. Gateway picker discovery is +disabled because its rows can mix incompatible context classes. Relaunch with +`--model` to change models. This keeps accounting accurate. -### Take over the plain `claude` command +### Thinking -If qBraid is the only backend you use, install with `--global` and plain `claude` -will use it too: +Claude Code sends adaptive thinking, display policy, and effort for current +Opus models. qbraid-api must preserve those fields at the Bedrock boundary. +Deploy the coordinated qbraid-api adaptive-thinking fix before this version. -```bash -curl -fsSL https://qbraid.com/code.sh | bash -s -- --global -``` +The older global thinking-disable workaround is gone. A live Sol probe also +succeeded with Claude Code's default thinking when MCP tools were excluded. The +128-tool limit is independent of thinking. -```powershell -& ([scriptblock]::Create((irm https://qbraid.com/code.ps1))) -Global -``` +### Plain `claude` + +The installer leaves plain `claude` untouched. Legacy qBraid gateway variables +are removed from user settings during migration. `--global` is rejected because +a project setting can replace the base URL and exfiltrate a reusable key. ## How it works -Claude Code speaks the Anthropic Messages API. So does the qBraid gateway — it -exposes an Anthropic-compatible surface at `/api/v1/ai/v1/messages` (the double -`v1` is deliberate, so `ANTHROPIC_BASE_URL` works with no client changes). +Claude Code sends Anthropic Messages requests to the qBraid gateway. The local +proxy translates only the GPT routes. +```text +qbraid-code ── loopback CLIProxyAPI + ├─ Claude passthrough ── qBraid gateway + └─ GPT translation ──── qBraid gateway ``` -qbraid-code - │ ANTHROPIC_BASE_URL=https://api-v2.qbraid.com/api/v1/ai - │ ANTHROPIC_AUTH_TOKEN=qbr_... - ▼ -Claude Code ──Anthropic Messages──► qBraid AI gateway ──► Claude on Bedrock -``` - -**There is no proxy and no daemon.** Nothing runs in the background, nothing -listens on a port. The launcher sets four environment variables and execs -`claude`. -Requests are billed against your qBraid credits at the usual rate -(100 credits = $1). +Requests use qBraid credits at the usual rate. One hundred credits equal one US +dollar. -## The qBraid MCP +## qBraid MCP -The MCP endpoint at `mcp.qbraid.com/mcp` uses OAuth, not API keys, so it needs a -browser sign-in that your API key cannot do for you. The installer runs it during -setup, while you are still there. If you skipped it: +The MCP endpoint uses OAuth rather than API keys. Sign in with a browser. ```bash claude mcp login qbraid @@ -120,61 +154,46 @@ claude mcp login qbraid ## Layout -| Path | What | +| Path | Purpose | |---|---| -| `~/.qbraid-code/env` | your key, gateway URL and default model (mode `600`) | -| `~/.qbraid-code/statusline.sh` | statusline script (`statusline.ps1` on Windows) | -| `~/.qbraid-code/credits.cache` | last known credit balance, refreshed every 60s | -| `~/.qbraid-code/credits.attempt` | when a refresh was last tried, so failures back off | -| `~/.qbraid-code/proxy-config.yaml` | GPT translation proxy config (mode `600`, holds your key) | -| `~/.qbraid-code/proxy.key` | loopback bearer for the proxy | -| `~/.qbraid-code/proxy.log` | proxy output | -| `~/.local/bin/qbraid-code` | the launcher (`qbraid-code.cmd` on Windows) | -| `~/.claude/settings.json` | statusline wiring, plus gateway env with `--global` | +| `~/.qbraid-code/active-profile` | Future-session profile pointer | +| `~/.qbraid-code/profiles//current` | Atomic metadata-generation pointer | +| `~/.qbraid-code/profiles//generations/*/env` | URLs, model, secret reference, and proxy binary | +| `~/.qbraid-code/profiles//generations/*/label*` | Readable label and verified/local provenance | +| `~/.qbraid-code/profiles//generations/*/models.tsv` | Exact model context facts | +| `~/.qbraid-code/secrets/*` | Linux-only private key fallback | +| `~/.qbraid-code/runtime.*` | Short-lived proxy state | +| `~/.qbraid-code/session.*` | Short-lived non-secret status snapshot | +| `~/.qbraid-code/statusline.sh` | Unix statusline adapter | +| `~/.local/bin/qbraid-code` | Unix launcher | + +Windows uses the same layout under `%USERPROFILE%\.qbraid-code`. ## Troubleshooting -Start with `qbraid-code --doctor`. It reports whether Claude Code is installed, -whether your key still works, your credit balance, whether the gateway is -reachable, and whether the MCP is registered. - -**`key: REJECTED`** — your API key was deleted or expired. Make a new one at -[account.qbraid.com/account/api-keys](https://account.qbraid.com/account/api-keys) -and re-run the installer. - -**`qbraid-code: command not found`** — `~/.local/bin` is not on your `PATH`. The -installer prints the line to add. On Windows, open a new terminal first. +Start with `qbraid-code --doctor`. -**Wrong organization** — credits come from the organization the key belongs to. -Create a key under the organization you want, then re-run the installer. +**Rejected key.** Create a key at +[account.qbraid.com/account/api-keys](https://account.qbraid.com/account/api-keys), +then update the affected profile. -**Statusline did not appear** — when `python3` is unavailable (no Xcode Command -Line Tools on macOS, or no `python3` on Linux) and `~/.claude/settings.json` -already exists, the installer cannot merge JSON safely and skips this step. It -prints the snippet to add by hand. +**Wrong account label.** Reinstall with `QBRAID_CODE_PROFILE_LABEL` set to a +readable local name. API-key authentication does not always expose an +authoritative organization name. -## Trust +**GPT proxy unavailable.** Re-run the installer for that profile. Claude models +continue to use the gateway directly. -The installer is served from `qbraid.com` but its source of truth is the `main` -branch of this repository, fetched at request time with no pin and no signature. -Anyone who can push here can change what a `curl | bash` runs, which is the -normal trade-off for a one-line installer that must stay current. Two things -follow from that: branch protection on `main` is load-bearing, and if you would -rather read before you run, fetch the script and inspect it first: - -```bash -curl -fsSL https://qbraid.com/code.sh -o install.sh -less install.sh -bash install.sh -``` +**Statusline missing.** Add the printed `statusLine` entry to +`~/.claude/settings.json`. The Unix installer does not rewrite existing JSON +without a real parser. ## Uninstall ```bash qbraid-code --stop -rm -rf ~/.qbraid-code ~/.local/bin/qbraid-code +rm -rf ~/.qbraid-code ~/.local/bin/qbraid-code ~/.local/bin/qbraid-code.home claude mcp remove qbraid ``` -Then remove the `statusLine` entry (and the `env` block, if you used `--global`) -from `~/.claude/settings.json`. +Remove the qBraid `statusLine` from `~/.claude/settings.json`. diff --git a/docs/research/2026-08-20-gateway-capabilities-and-organizations.md b/docs/research/2026-08-20-gateway-capabilities-and-organizations.md new file mode 100644 index 0000000..92a55a7 --- /dev/null +++ b/docs/research/2026-08-20-gateway-capabilities-and-organizations.md @@ -0,0 +1,217 @@ +# Gateway capabilities, organizations, and multi-key switching + +Research date: 2026-08-20; sources rechecked 2026-08-21 UTC. Claude Code tested locally at v2.1.238. qBraid source citations are pinned to `qbraid-api` commit `ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f` and `qbraid-account` commit `db748ee78f3c9bdabe08ecee90b53bbbb129eb85`. + +## Question + +How should qbraid-code safely support one Anthropic-compatible Claude Code entry point across qBraid's OpenAI-style GPT/Sol models and Claude Opus models, expose the active qBraid organization beside credits, and switch explicitly among API keys bound to different organizations? + +This report does not repeat the picker mechanics already established in [2026-08-20-model-picker.md](./2026-08-20-model-picker.md). That report proves the custom-row and `/v1/models` discovery options, including Claude Code's `claude|anthropic` ID filter. + +## Executive verdict + +1. **Keep one local proxy as the protocol boundary, not as the source of Claude Code's process-wide policy.** It must route per model, translate Anthropic requests/responses for OpenAI-style models, preserve Claude behavior for Opus, stream SSE without long silent gaps, count tokens, and emit Anthropic-compatible errors. qBraid's own direct Anthropic endpoint is intentionally Claude/Bedrock-only; its OpenAI models use different surfaces because mixing provider wire formats breaks tool-call round-tripping ([qBraid AI Gateway docs](https://docs.qbraid.com/v2/ai/integrations/ai-gateway.md), [qBraid source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/docs/AI-PROXY.md#L16-L26)). +2. **Treat context and thinking as capabilities, not model-name guesses.** Claude Code's gateway discovery reads only `id` and `display_name`; it does not discover context size or reasoning support. Worse, it treats an unknown gateway alias as a current Claude model and can send `thinking: {"type":"adaptive"}` ([gateway protocol](https://code.claude.com/docs/en/llm-gateway-protocol#feature-pass-through), [model configuration](https://code.claude.com/docs/en/model-config#correct-the-window-for-a-gateway-or-custom-model-id)). The proxy therefore needs a per-model capability manifest, while each Claude Code process needs a conservative or exact context policy. +3. **Do not hot-swap organizations inside a running Claude Code process.** Credential environment variables are process-scoped, settings-file `env` values can override shell values, and `apiKeyHelper` caches for five minutes or until a 401. Claude Code exposes no documented immediate “switch gateway credential” operation ([gateway connection](https://code.claude.com/docs/en/llm-gateway-connect#set-the-base-url-and-credential), [authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence)). qbraid-code should own named profiles and start a new process/session for an explicit profile switch. +4. **The status line cannot discover organization identity from Claude Code.** Its JSON includes model, context, cost, session, effort, and thinking fields, but no account, credential, or organization field ([status-line schema](https://code.claude.com/docs/en/statusline#available-data)). qbraid-code must supply a non-secret organization/profile label and cached credit snapshot out of band. +5. **Store keys in an OS secret store; migrate metadata, not secrets.** Keep profile name, organization ID/label, model policy, and secret reference in a portable manifest. Keep API keys in Keychain, Credential Locker, or Secret Service, with a `0600` file only as a documented headless fallback ([Apple Keychain](https://developer.apple.com/documentation/security/keychain-services), [Windows Credential Locker](https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker), [Secret Service](https://specifications.freedesktop.org/secret-service-spec/latest/)). + +## Evidence by topic + +### 1. Claude Code's gateway contract and capability assumptions + +#### Protocol, model selection, and discovery + +`ANTHROPIC_BASE_URL` selects the Anthropic Messages wire format. The required inference endpoint is `/v1/messages`; `/v1/messages/count_tokens` is optional. Inference must stream SSE, keep-alive pings must survive, and Claude Code aborts a custom-base-URL stream after 300 seconds of silence by default ([gateway protocol: API formats and streaming](https://code.claude.com/docs/en/llm-gateway-protocol#api-formats)). This makes buffering or “translate after the full answer” unsafe, especially during long reasoning pauses. + +Model selection is client-side: `--model`, `/model`, settings, and model environment variables select an ID that Claude Code sends in `model`. Gateway discovery is startup-only, requests `GET /v1/models?limit=1000`, and reads only each entry's `id` and optional `display_name` ([gateway discovery](https://code.claude.com/docs/en/llm-gateway-protocol#model-discovery)). It carries no context-window or capability schema. See the existing [model-picker report](./2026-08-20-model-picker.md) for discovery filtering, aliases, caching, and picker integration. + +qBraid documents separate OpenAI and Anthropic surfaces: `/chat/completions`, `/responses`, and `/models` for OpenAI clients; `/v1/messages` and `/v1/messages/count_tokens` for Anthropic clients ([qBraid AI Gateway](https://docs.qbraid.com/v2/ai/integrations/ai-gateway.md#endpoints)). First-party source explicitly provider-locks the surfaces because routing Claude onto Responses or GPT onto Anthropic wire “silently breaks tool-call round-tripping” ([qBraid source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/docs/AI-PROXY.md#L16-L26)). Therefore qbraid-code, not the remote Anthropic endpoint, must translate Claude Code traffic for GPT/Sol models. + +qBraid's OpenAI `/models` response currently includes private `_qbraid` metadata such as `maxTokens`, capabilities, aliases, provider, and supported endpoints ([serializer](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/models/models.routes.ts#L45-L82), [registry](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/shared/models.registry.ts#L83-L84)). Its Anthropic `/v1/models` shape contains only `id`, `created_at`, `display_name`, and `type`, and includes only models supported on the Messages surface ([source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/models/anthropic-models.service.ts#L10-L55)). `_qbraid` is useful first-party evidence for a proxy capability catalog, but it is not in qBraid's published OpenAPI contract. + +#### Context windows and token counting + +Claude Code has no per-model context metadata handshake. For an unrecognized gateway ID, `CLAUDE_CODE_MAX_CONTEXT_TOKENS` declares the window Claude Code should assume; otherwise it compacts at its inferred window. Recognized `claude-*` IDs and `[1m]` IDs have special rules, so aliases are not neutral ([custom-model window rules](https://code.claude.com/docs/en/model-config#correct-the-window-for-a-gateway-or-custom-model-id)). A single environment value applies to the process, so one process cannot represent several unknown models with different exact windows. + +Anthropic currently documents 1M-token windows for Claude Opus 4.6 and later, while other Claude models may use 200K. All request content and generated output, including thinking, count toward the window ([Anthropic context windows](https://platform.claude.com/docs/en/build-with-claude/context-windows#context-window-sizes-by-model)). The actual qBraid catalog is more granular: the pinned registry declares 1,050,000 for GPT-5.6 variants, 400,000 for several other GPT variants, 1,000,000 for current Opus/Sonnet entries, and 200,000 for Haiku ([qBraid registry](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/shared/models.registry.ts#L102-L159), [later entries](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/shared/models.registry.ts#L166-L375)). Those values can change and should be refreshed, versioned, and fail closed when absent. + +Claude Code prefers `/v1/messages/count_tokens`, but falls back to an inference request when the endpoint is absent ([gateway protocol](https://code.claude.com/docs/en/llm-gateway-protocol#optional-endpoints-and-startup-traffic)). Anthropic calls token counts estimates that can differ slightly from actual usage and recommends counting against the exact target model ([Anthropic token counting](https://platform.claude.com/docs/en/build-with-claude/token-counting#how-to-count-message-tokens)). qBraid's pinned implementation is also explicitly an estimate: it serializes Anthropic content, uses `cl100k_base`, and applies a calibration factor rather than calling the provider tokenizer ([qBraid source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/messages/count-tokens.util.ts#L1-L27)). Context policy therefore needs headroom and must still handle authoritative upstream overflow errors. + +#### Extended and adaptive thinking + +Claude Code sends gateway requests as if the endpoint were Anthropic. For Claude 4.6 and later it can send `thinking: {"type":"adaptive"}`; unknown gateway aliases are treated as current models that receive that field. Capability declarations through `ANTHROPIC_DEFAULT_*_MODEL_SUPPORTED_CAPABILITIES` do not affect an `ANTHROPIC_BASE_URL` gateway ([gateway feature pass-through](https://code.claude.com/docs/en/llm-gateway-protocol#feature-pass-through)). A non-Claude model behind the gateway must therefore not trust Claude Code's model detection. + +Thinking behavior differs materially by Claude generation. Fixed `thinking.type: "enabled"` uses `budget_tokens`, requires at least 1,024, and is rejected by Claude 4.7 and later; adaptive thinking uses `thinking.type: "adaptive"` and `output_config.effort`, may skip thinking, and interleaves around tools automatically ([extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking), [adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking)). `MAX_THINKING_TOKENS` controls fixed budgets; nonzero values are ignored on adaptive models unless adaptive thinking is disabled where supported. `CLAUDE_CODE_DISABLE_THINKING=1` omits the field for compatibility, while `MAX_THINKING_TOKENS=0` also omits it on third-party providers ([Claude Code environment variables](https://code.claude.com/docs/en/env-vars)). + +The safe division is: + +- The **proxy** classifies the target model, passes valid Claude thinking fields through, translates effort/reasoning only where the OpenAI-style model and qBraid surface support an equivalent, and removes unsupported fields otherwise. +- A **process bound to non-Claude-only models** may set `CLAUDE_CODE_DISABLE_THINKING=1` as a compatibility backstop. +- A **mixed-model process** cannot use that backstop without also disabling Claude thinking. It needs proxy normalization and a conservative shared client policy. + +#### Errors and retries + +Claude Code retries some rejected capabilities and disables them for the conversation. It can recover from rejections of `thinking`, thinking signatures, and mid-conversation system messages. It does not automatically retry every context-management or tool-schema rejection. Its retry matching depends on the upstream error text, so an Anthropic upstream's error body must be forwarded unmodified ([automatic retry](https://code.claude.com/docs/en/llm-gateway-protocol#automatic-retry-and-error-forwarding)). + +For a non-Anthropic upstream, the proxy must instead translate deliberately into Anthropic status, envelope, request ID, streaming error event, and recognized wording. In particular, Claude Code's context recovery recognizes Anthropic's `400 invalid_request_error` with “prompt is too long”; arbitrary gateway wording can prevent compact-and-retry ([context overflow](https://platform.claude.com/docs/en/build-with-claude/context-windows#context-window-overflow-behavior), [gateway troubleshooting](https://code.claude.com/docs/en/llm-gateway-connect#troubleshoot-gateway-errors)). The proxy must not emit HTTP 200 before provider acceptance when it can avoid doing so; after the first SSE byte, later failures must become Anthropic `event: error` frames. + +### 2. Proxy concerns versus Claude Code process concerns + +| Concern | Normalize in qbraid-code proxy | Configure per Claude Code process | +| --- | --- | --- | +| Model route and wire format | Yes. Claude/Bedrock uses Messages; GPT/Sol uses OpenAI and requires translation. | Select the initial model and limit picker choices to a compatible set. | +| Context truth | Fetch/cache per-model qBraid capability data; enforce actual upstream limit. | Set the exact active window, or the minimum safe window across every selectable model. | +| Token counting | Expose `/v1/messages/count_tokens`; route or estimate per target model; add safety headroom. | No separate counter. Let Claude Code consume the proxy endpoint. | +| Thinking/effort | Pass through for compatible Claude models; map or strip for OpenAI-style models. | Use effort/thinking flags only when the process's whole selectable model set supports them. | +| Tools and system blocks | Preserve Anthropic ordering/signatures for Claude; translate tool IDs and deltas for GPT. | Avoid process-wide experimental features only when the proxy cannot support them. | +| Errors and streaming | Own Anthropic envelopes, request IDs, pings, usage blocks, and context-overflow semantics. | Configure timeouts only; do not rely on client retry to repair bad translations. | +| Model discovery | Serve Claude Code's `/v1/models` shape and aliases. | Enable discovery at launch. See the [picker report](./2026-08-20-model-picker.md). | +| qBraid key | Resolve the selected named profile and inject `X-API-Key` upstream. | Prefer an ephemeral local-proxy token, not the qBraid key itself. | +| Organization and credits | Validate the key's organization; refresh a non-secret status snapshot asynchronously. | Pass only snapshot path/profile label to the status-line process. | + +The unavoidable boundary is client-side compaction: the proxy knows the actual model on every request, but Claude Code decides when to compact from process-visible model/context information. For materially different windows, either launch one process per capability profile or advertise only the minimum common window in a mixed process. + +### 3. Status-line organization identity and credits + +Claude Code pipes JSON to a shell command. The documented fields include model, working directory, cost, context, effort, thinking, session, and version. They do not include account, login, credential source, organization, or arbitrary gateway metadata ([status-line schema](https://code.claude.com/docs/en/statusline#available-data)). Thus the status line cannot derive qBraid identity safely from stdin. + +The status command runs frequently, can be canceled when a newer refresh starts, and Anthropic recommends caching expensive operations ([status-line execution and caching](https://code.claude.com/docs/en/statusline#how-status-lines-work), [examples](https://code.claude.com/docs/en/statusline#cache-expensive-operations)). It should not make an authenticated network request on every render. + +A safe data path is: + +1. The launcher resolves a named profile and validates its key before starting Claude Code. +2. A trusted qbraid-code component writes an atomic, non-secret snapshot containing profile label, organization ID, organization display name if verified, spendable credits, and `updated_at`. +3. The status line combines Claude's stdin model/context fields with that snapshot. It never reads the API key. +4. Credits refresh asynchronously and retain the last known value with a visible stale marker after failures. + +Display the organization ID when no verified name exists; a user-supplied profile label may accompany it but must not masquerade as server-verified identity. Label the balance precisely. qBraid's current balance source returns the authenticated organization member's `qbraidCredits`, not necessarily the organization's central wallet pool ([balance controller](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/billing/credits/credits.controller.ts#L234-L315), [organization credit model](https://docs.qbraid.com/v2/account/organizations/credits.md)). “Spendable credits” is safer than “organization wallet.” + +### 4. Credentials, precedence, and explicit switching + +Documented Claude Code precedence is cloud-provider credentials, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_API_KEY`, `apiKeyHelper`, OAuth token, Anthropic profiles/federation, then saved `/login` credentials ([authentication precedence](https://code.claude.com/docs/en/authentication#authentication-precedence)). A gateway credential overrides a saved Claude login; unsetting it returns to the saved login ([gateway conflicts](https://code.claude.com/docs/en/llm-gateway-connect#conflicts-with-an-existing-login)). + +Shell exports apply only to that terminal and child processes. A Claude settings-file `env` value wins over the same shell variable, which can silently defeat a wrapper's selected profile ([gateway connection](https://code.claude.com/docs/en/llm-gateway-connect#set-the-base-url-and-credential)). qbraid-code should therefore avoid persisting qBraid keys in `~/.claude/settings.json` and should remove or isolate stale gateway `env` entries. + +`apiKeyHelper` is for dynamic or rotating credentials. Claude Code caches its output for five minutes by default and reruns it after a 401; no documented command immediately invalidates that cache for an organization switch ([gateway helper](https://code.claude.com/docs/en/llm-gateway-connect#rotate-credentials-with-apikeyhelper)). A helper can technically return a different key later, but that is not an atomic, user-visible switch. Requests already in flight, status data, model policy, and billing attribution can straddle organizations. + +Claude Code supports side-by-side configuration roots through `CLAUDE_CONFIG_DIR`, explicitly citing multiple accounts as a use case ([environment variables](https://code.claude.com/docs/en/env-vars)). qbraid-code can use one config root per named profile when isolation of settings, caches, transcripts, or remembered API-key approvals matters. The qBraid profile switch should still create a new process. Existing sessions should remain bound to the profile recorded at launch. + +**Documented fact:** one process has one resolved active credential at a time. `/login` can change OAuth identity in-session, and a helper can refresh a credential, but Claude Code documents no multi-qBraid-key profile picker or immediate helper-cache invalidation. + +**Recommendation:** `profile use` changes only future launches. `profile switch` starts a new Claude Code process and normally a new conversation. Resuming a transcript under another organization should require an explicit warning because one logical conversation would span two billing and audit contexts. + +### 5. qBraid key ownership, organization identity, and balances + +qBraid's public account documentation says API keys grant access to credits/resources, are shown once, and must be stored like passwords ([API key guide](https://docs.qbraid.com/v2/account/api-keys.md)). qBraid's account UI is workspace-scoped, with one default workspace plus organizations and an explicit workspace switcher ([account overview](https://docs.qbraid.com/v2/account/overview.md#qbraid-default-vs-organizations)). + +Pinned first-party source is more precise: + +- Every API-key record carries `userId`, `organizationId`, and `orgUserId` ([API-key model](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/apikey/model.ts#L6-L15)). Authentication sets `currentOrganizationId` from the key, so the key establishes the billing tenant ([API-key strategy](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/shared/services/auth/strategies/apikey-strategy.service.ts#L184-L205)). The AI proxy likewise states that API keys bill the user and organization bound to the credential ([AI proxy contract](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/docs/AI-PROXY.md#L42-L52)). +- `GET /users/verify` returns the authenticated `organizationId`, user identity, roles, permissions, and subscription tier, but no organization display name ([controller](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/user/core/user.controller.ts#L160-L209)). +- `GET /billing/credits/balance` returns `qbraidCredits`, `awsCredits`, `autoRecharge`, `organizationId`, and `userId` for the authenticated member in that organization ([route](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/billing/credits/credits.routes.ts#L52-L58)). +- The qBraid account source tells users that API keys are unique to each organization they belong to ([account UI](https://github.com/qBraid/qbraid-account/blob/db748ee78f3c9bdabe08ecee90b53bbbb129eb85/src/components/organizations/org-api-keys-section.tsx#L194-L204)). + +The public REST OpenAPI currently documents only devices and jobs; it contains no account, organization, API-key, billing, or credit-balance path ([public OpenAPI](https://docs.qbraid.com/openapi-v2.json)). The public AI Gateway docs support `GET /quota`, but describe it as LLM subscription state and remaining quota, not organization identity or general credit balance ([AI Gateway quota](https://docs.qbraid.com/v2/ai/integrations/ai-gateway.md#check-your-quota)). The CLI publicly offers `qbraid account credits`, but its endpoint/schema is not documented ([CLI reference](https://docs.qbraid.com/v2/cli/api-reference/qbraid_account.md)). + +**Gap:** current first-party source proves internal contracts, but there is no public, versioned key-auth endpoint that returns `{organization: {id, name}, spendableCredits}`. qbraid-code should not guess an organization name from browser state, email domain, key name, or locally cached history. Prefer formalizing a supported endpoint or extending `/ai/quota`; until then, pin/test the internal contract and show organization ID plus an explicitly user-defined profile label. + +### 6. Security, storage, and migration + +qBraid says keys are shown once, rotation invalidates the old value immediately, and keys should live in environment variables or a secrets manager rather than source control ([API key guide](https://docs.qbraid.com/v2/account/api-keys.md)). Claude Code itself uses macOS Keychain, a `0600` credentials file on Linux, and a user-profile ACL-protected file on Windows ([Claude credential management](https://code.claude.com/docs/en/authentication#credential-management)). + +For qbraid-code profiles: + +- **macOS:** store each key as a Keychain item. Apple describes Keychain as an encrypted database for small secrets ([Apple](https://developer.apple.com/documentation/security/keychain-services)). +- **Windows:** store each key by stable profile/resource name in Credential Locker. Microsoft documents multiple credentials per app and secure retrieval ([Microsoft](https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker)). +- **Linux desktop:** use the Secret Service API when available ([freedesktop specification](https://specifications.freedesktop.org/secret-service-spec/latest/)). For headless systems without a secret service, use a separate `0600` secret file, fail on broader permissions, and never place it inside a repository. + +Keep a versioned non-secret manifest separate from the vault. It may contain profile ID, display label, verified organization ID/name and verification time, default model, context/thinking policy, and the vault lookup key. It must not contain the qBraid key. + +Migration should export/import only that manifest. The destination re-prompts for each key or creates/rotates keys through qBraid. This avoids pretending Keychain, Credential Locker, Secret Service, and file ACLs are portable. It also prevents a profile archive from becoming a reusable bearer-secret bundle. + +The Claude process should ideally receive only a short-lived random credential for the loopback proxy. The proxy retrieves the selected qBraid key from the vault and injects `X-API-Key` upstream. This reduces exposure through Claude settings, status-line scripts, child shells, hooks, process listings, crash logs, and project configuration. Bind the proxy to loopback and scope the local token to one launch/profile. + +## Facts vs recommendations + +| Topic | Documented or source-backed fact | Recommendation for qbraid-code | +| --- | --- | --- | +| Gateway capabilities | Unknown aliases can receive adaptive-thinking fields; discovery has no context/capability metadata ([protocol](https://code.claude.com/docs/en/llm-gateway-protocol#feature-pass-through)). | Maintain a per-model capability manifest and normalize every request at the proxy. | +| Context | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` is process-wide for unknown IDs ([model config](https://code.claude.com/docs/en/model-config#correct-the-window-for-a-gateway-or-custom-model-id)). | Bind materially different windows to separate processes, or use the minimum selectable window. | +| Token count | Counts are estimates; qBraid's current counter is calibrated `cl100k_base` ([Anthropic](https://platform.claude.com/docs/en/build-with-claude/token-counting), [qBraid](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/messages/count-tokens.util.ts#L1-L27)). | Add headroom and preserve recognized overflow errors for recovery. | +| Thinking | Claude and OpenAI-style models accept different reasoning controls ([Anthropic thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking)). | Translate only supported semantics; otherwise omit. Never infer from a friendly model name alone. | +| Status line | No organization/account field exists ([schema](https://code.claude.com/docs/en/statusline#available-data)). | Read a non-secret qbraid-code snapshot; do not give the script the key. | +| Credential switching | Environment credentials are process-scoped; helper output is cached ([gateway docs](https://code.claude.com/docs/en/llm-gateway-connect)). | Named profiles; switching starts a new process/session. | +| Key ownership | A qBraid key is bound to one user/org membership ([source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/apikey/model.ts#L6-L15)). | Validate and record the organization at profile creation; never let “active browser org” override it. | +| Org name and credits API | Internal first-party routes exist, but no public versioned account-context schema exists ([OpenAPI](https://docs.qbraid.com/openapi-v2.json)). | Formalize one endpoint; until then show verified ID and mark cached/internal data clearly. | +| Secret storage | qBraid keys are password-equivalent and shown once ([qBraid](https://docs.qbraid.com/v2/account/api-keys.md)). | Use OS vaults; plaintext `0600` only as headless fallback. | +| Migration | OS secret stores differ; qBraid rotation immediately revokes the old key ([qBraid](https://docs.qbraid.com/v2/account/api-keys.md#rotate-a-key)). | Export metadata only; re-provision secrets on the destination. | + +## Architectural implications + +A safe design has four explicit ownership layers: + +1. **Profile manager:** owns named qBraid profiles. Each profile maps a non-secret label to a vault reference, verified organization identity, default model, and compatibility policy. “Current profile” affects future launches only. +2. **Launch coordinator:** resolves one profile, validates the key/organization, obtains model capabilities, chooses exact or conservative context/thinking environment, creates a per-launch local token, and starts both proxy and Claude Code. It does not persist the qBraid key in Claude settings. +3. **Protocol normalizer:** owns upstream credentials, model routing, Anthropic↔OpenAI translation, thinking/effort normalization, token counting, usage translation, SSE pings, and error semantics. It rejects unknown capabilities instead of silently dropping behavior that changes answers or billing. +4. **Status adapter:** reads Claude's documented stdin plus a qbraid-code snapshot. Suggested semantics are `profile · verified org name (short ID) · spendable credits · stale age`, with no secret access. + +Session rules follow from those boundaries: + +- Allow `/model` switching within one process only when every advertised model fits the process's declared context and compatibility envelope. Otherwise require a new launch. +- A key/profile switch always starts a new process. Keep the old process pinned to its original organization until it exits. +- Do not silently resume the same transcript under another organization. Require an explicit cross-organization resume action and preserve both profile IDs in audit metadata. +- Cache the qBraid catalog and status snapshot with timestamps, but revalidate on launch. If capability metadata is missing, choose the smaller safe window and disable unsupported optional fields rather than assuming Claude behavior. +- Separate **LLM quota** from **spendable qBraid credits**. `/quota` and `/billing/credits/balance` describe different resources and should not share one unlabeled number. + +## Risks/open questions + +1. **Public contract gap:** which qBraid endpoint will be supported for key-authenticated organization display name and spendable balance? Current source is usable internally but not public/versioned. +2. **Balance semantics:** confirm whether the desired status number is the member wallet spendable by that key, the central organization wallet, remaining monthly AI quota, or all three. +3. **Capability schema ownership:** should qBraid formally publish `_qbraid.maxTokens`, reasoning modes, maximum output, tool limits, and supported surfaces on `/models`? Claude Code will not consume them, but qbraid-code can. +4. **Mixed-process context:** if mid-session GPT↔Opus switching remains a requirement, accept the minimum common context or add an explicit relaunch workflow. There is no exact per-model context discovery channel in Claude Code today. +5. **Thinking fidelity:** define model-by-model mappings among Claude adaptive/fixed thinking, OpenAI reasoning effort, and models with no equivalent. “Drop and continue” can materially change quality and cost. +6. **Tokenizer drift:** qBraid's current count endpoint is deliberately approximate. Establish a safety margin and tests against actual provider usage for every new model family. +7. **Long reasoning streams:** verify the proxy emits pings during provider silence and preserves thinking signatures/tool block ordering for Claude models. +8. **Error conformance:** regression-test exact pre-stream HTTP errors and post-stream SSE error frames. A cosmetically wrapped error can disable Claude Code's recovery. +9. **Headless Linux storage:** define behavior when no Secret Service is running. The fallback must fail closed on weak permissions and support noninteractive secret injection without writing the key into Claude settings. +10. **Windows vault roaming:** Credential Locker may roam some credentials and has platform limits. Decide whether qbraid-code disables roaming expectations and always treats destination enrollment as explicit. +11. **Local proxy trust:** an ephemeral loopback token reduces exposure but does not defeat a same-user attacker. Bind narrowly, rotate per launch, avoid diagnostic secret output, and shut down when the session ends. +12. **Claude Code release drift:** beta headers and body fields change across releases. Keep the proxy's Anthropic request handling open to new fields, then gate/translate them per upstream capability ([gateway open-list rule](https://code.claude.com/docs/en/llm-gateway-protocol#forward-as-open-lists)). + +## Sources + +Accessed 2026-08-21 unless a pinned commit is shown. + +### Claude Code and Anthropic + +- [Gateway protocol reference](https://code.claude.com/docs/en/llm-gateway-protocol) +- [Connect Claude Code to an LLM gateway](https://code.claude.com/docs/en/llm-gateway-connect) +- [Model configuration](https://code.claude.com/docs/en/model-config) +- [Environment variables](https://code.claude.com/docs/en/env-vars) +- [Authentication](https://code.claude.com/docs/en/authentication) +- [Status line](https://code.claude.com/docs/en/statusline) +- [Context windows](https://platform.claude.com/docs/en/build-with-claude/context-windows) +- [Token counting](https://platform.claude.com/docs/en/build-with-claude/token-counting) +- [Extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) +- [Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking) + +### qBraid + +- [AI Gateway](https://docs.qbraid.com/v2/ai/integrations/ai-gateway.md) +- [Account overview](https://docs.qbraid.com/v2/account/overview.md) +- [API keys](https://docs.qbraid.com/v2/account/api-keys.md) +- [Organization credits](https://docs.qbraid.com/v2/account/organizations/credits.md) +- [CLI account commands](https://docs.qbraid.com/v2/cli/api-reference/qbraid_account.md) +- [Public Runtime OpenAPI](https://docs.qbraid.com/openapi-v2.json) +- [AI proxy contract, pinned source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/docs/AI-PROXY.md) +- [Model registry, pinned source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/ai/shared/models.registry.ts) +- [API-key model and auth strategy, pinned source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/apikey/model.ts) +- [User verification, pinned source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/user/core/user.controller.ts) +- [Credit balance route, pinned source](https://github.com/qBraid/qbraid-api/blob/ba86f9419fe68e6ef02d37d8ce500597d1e1eb4f/src/features/billing/credits/credits.routes.ts) + +### Secret storage + +- [Apple Keychain Services](https://developer.apple.com/documentation/security/keychain-services) +- [Microsoft Credential Locker](https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker) +- [freedesktop Secret Service API](https://specifications.freedesktop.org/secret-service-spec/latest/) diff --git a/doctor.ps1 b/doctor.ps1 index c91489a..c41a086 100644 --- a/doctor.ps1 +++ b/doctor.ps1 @@ -5,9 +5,19 @@ # No Set-StrictMode: this command exists to report a broken setup, so a missing # field must print a diagnosis rather than a PowerShell exception. $ErrorActionPreference = 'Continue' +$null = [Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] +$null = [Windows.Security.Credentials.PasswordCredential,Windows.Security.Credentials,ContentType=WindowsRuntime] $HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } -$envPath = Join-Path $HomeDir 'env' +$ProfileDir = $env:QBRAID_CODE_PROFILE_HOME +if (-not $ProfileDir) { + $profile = $env:QBRAID_CODE_PROFILE + if (-not $profile -and (Test-Path (Join-Path $HomeDir 'active-profile'))) { $profile = (Get-Content (Join-Path $HomeDir 'active-profile') -Raw).Trim() } + if (-not $profile) { $profile = 'default' } + $ProfileDir = Join-Path (Join-Path $HomeDir 'profiles') $profile + if (-not (Test-Path (Join-Path $ProfileDir 'env')) -and $profile -eq 'default') { $ProfileDir = $HomeDir } +} +$envPath = Join-Path $ProfileDir 'env' if (-not (Test-Path $envPath)) { Write-Host "qbraid-code: not installed - no $envPath" @@ -22,6 +32,14 @@ foreach ($line in Get-Content $envPath) { $apiBase = $settings['QBRAID_CODE_API_BASE'] $baseUrl = $settings['QBRAID_CODE_BASE_URL'] $token = $settings['QBRAID_CODE_TOKEN'] +if (-not $token -and $settings['QBRAID_CODE_SECRET_BACKEND'] -eq 'credential-locker') { + try { + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($settings['QBRAID_CODE_SECRET_REF'], $env:USERNAME) + $credential.RetrievePassword() + $token = $credential.Password + } catch { } +} $model = $settings['QBRAID_CODE_MODEL'] if (Get-Command claude -ErrorAction SilentlyContinue) { @@ -75,8 +93,7 @@ Write-Host "model: $model" $proxyBin = $settings['QBRAID_CODE_PROXY_BIN'] if ($proxyBin -and (Test-Path $proxyBin)) { - $st = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $HomeDir 'qbraid-proxy.ps1') status 2>$null - Write-Host "gpt: proxy $st" + Write-Host 'proxy: installed (starts once per launch)' } else { - Write-Host 'gpt: NOT AVAILABLE - re-run the installer to add GPT models' + Write-Host 'proxy: NOT AVAILABLE - re-run the installer' } diff --git a/install.ps1 b/install.ps1 index c3d073c..872c325 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,9 +3,9 @@ qbraid-code installer for Windows — Claude Code, powered by the qBraid AI gateway. .DESCRIPTION - There is no proxy and no daemon. The qBraid gateway speaks the Anthropic - Messages API natively at /api/v1/ai/v1/messages, so Claude Code talks to it - directly through ANTHROPIC_BASE_URL. + Claude models use the qBraid Anthropic Messages surface directly. GPT models + use an on-demand loopback translation proxy. Each named profile owns its + credential, proxy port, model facts, and credit cache. Everything this writes lives in %USERPROFILE%\.qbraid-code and %USERPROFILE%\.local\bin. Re-running is safe. @@ -14,15 +14,22 @@ irm https://qbraid.com/code.ps1 | iex .EXAMPLE - & ([scriptblock]::Create((irm https://qbraid.com/code.ps1))) -Global #> param( - [switch]$Global + [switch]$Global, + [string]$Profile = '' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$null = [Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] +$null = [Windows.Security.Credentials.PasswordCredential,Windows.Security.Credentials,ContentType=WindowsRuntime] +if ($Global) { throw '-Global was removed because project settings can exfiltrate its credential. Use qbraid-code.' } $ProgressPreference = 'SilentlyContinue' +$ActivateProfile = $true +$ProfileStage = $null +$SecretStaged = $false +$SecretRef = $null $GatewayHost = 'api-v2.qbraid.com' $ApiBase = "https://$GatewayHost/api/v1" @@ -34,9 +41,10 @@ $SiteBase = 'https://qbraid.com/code' $RawBase = 'https://raw.githubusercontent.com/qBraid/qbraid-code/main' $GhContents = '/repos/qBraid/qbraid-code/contents' -$HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } -$BinDir = if ($env:QBRAID_CODE_BIN_DIR) { $env:QBRAID_CODE_BIN_DIR } else { Join-Path $env:USERPROFILE '.local\bin' } -$ClaudeDir = Join-Path $env:USERPROFILE '.claude' +$HomeDir = if ($env:QBRAID_CODE_HOME) { $env:QBRAID_CODE_HOME } else { Join-Path $env:USERPROFILE '.qbraid-code' } +$ProfilesDir = Join-Path $HomeDir 'profiles' +$BinDir = if ($env:QBRAID_CODE_BIN_DIR) { $env:QBRAID_CODE_BIN_DIR } else { Join-Path $env:USERPROFILE '.local\bin' } +$ClaudeDir = Join-Path $env:USERPROFILE '.claude' $Settings = Join-Path $ClaudeDir 'settings.json' $ClaudeJson = Join-Path $env:USERPROFILE '.claude.json' @@ -70,6 +78,21 @@ function Confirm-Step { return ($reply -eq 'y' -or $reply -eq 'yes') } +function Write-RawText { + param([string]$Path, [string]$Text) + if ($Path.EndsWith('.cmd')) { + $Text = ($Text -replace "`r`n", "`n") -replace "`n", "`r`n" + } + [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) +} + +function Read-PidFile { + param([string]$Path) + $value = 0 + try { if (Test-Path $Path) { [void][int]::TryParse((Get-Content $Path -Raw).Trim(), [ref]$value) } } catch { } + return $value +} + # ---------------------------------------------------------------- 1. platform if (-not [Environment]::Is64BitOperatingSystem) { @@ -77,7 +100,147 @@ if (-not [Environment]::Is64BitOperatingSystem) { } Say "Platform: windows/$($env:PROCESSOR_ARCHITECTURE.ToLower())" -New-Item -ItemType Directory -Force -Path $HomeDir, $BinDir, $ClaudeDir | Out-Null +New-Item -ItemType Directory -Force -Path $HomeDir, $ProfilesDir, $BinDir, $ClaudeDir | Out-Null +if ($PSBoundParameters.ContainsKey('Profile') -and -not $Profile) { Die 'invalid empty profile' } +$InstallMutex = New-Object Threading.Mutex($false, "Local\qbraid-code-installer-$($env:USERNAME)") +if (-not $InstallMutex.WaitOne(0)) { Die 'another qbraid-code installer is running.' } +try { $InstallLockHandle = [IO.File]::Open((Join-Path $HomeDir '.install-lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } +catch [IO.IOException] { Die 'another qbraid-code installer is running.' } +$profileMutex = $null +$updateHandle = $null +try { +if (-not $Profile) { + $activePath = Join-Path $HomeDir 'active-profile' + if (Test-Path $activePath) { $Profile = (Get-Content $activePath -Raw).Trim() } +} +if (-not $Profile) { $Profile = 'default' } +if ($Profile -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$') { Die "invalid profile '$Profile'" } +$ProfileRoot = Join-Path $ProfilesDir $Profile +$ProfileDir = $ProfileRoot +$legacyEnv = Join-Path $HomeDir 'env' +$defaultDir = Join-Path $ProfilesDir 'default' +if ((Test-Path $legacyEnv) -and -not (Test-Path $defaultDir)) { + $migrationDir = Join-Path $ProfilesDir (".default.migrate.$PID.$([guid]::NewGuid().ToString('N'))") + New-Item -ItemType Directory -Force -Path $migrationDir | Out-Null + $legacyLines = @(Get-Content $legacyEnv) + $legacyToken = '' + $safeLines = @($legacyLines | Where-Object { if ($_ -match '^QBRAID_CODE_TOKEN=(.*)$') { $legacyToken = $Matches[1]; $false } else { $true } }) + if ($legacyToken) { + $secretRef = 'qbraid-code:default' + try { + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { $old = $vault.Retrieve($secretRef, $env:USERNAME); $vault.Remove($old) } catch { } + $credential = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList $secretRef, $env:USERNAME, $legacyToken + $vault.Add($credential) + } catch { Die 'could not migrate the default key into Windows Credential Locker' } + $safeLines += 'QBRAID_CODE_SECRET_BACKEND=credential-locker' + $safeLines += "QBRAID_CODE_SECRET_REF=$secretRef" + } + Write-RawText (Join-Path $migrationDir 'env') (($safeLines -join "`n") + "`n") + foreach ($name in @('label','label-source','models.tsv','credits.cache','credits.updated','organization-id')) { + $source = Join-Path $HomeDir $name + if (Test-Path $source) { Copy-Item $source (Join-Path $migrationDir $name) -Recurse } + } + if (-not (Test-Path (Join-Path $migrationDir 'label'))) { Write-RawText (Join-Path $migrationDir 'label') "default`n" } + if (-not (Test-Path (Join-Path $migrationDir 'label-source'))) { Write-RawText (Join-Path $migrationDir 'label-source') "local`n" } + Move-Item $migrationDir $defaultDir +} +function Remove-LegacyPlaintextToken { + if (-not (Test-Path $defaultDir)) { return } + if (Test-Path $legacyEnv) { + $legacyLines = @(Get-Content $legacyEnv) + if (@($legacyLines | Where-Object { $_ -match '^QBRAID_CODE_TOKEN=' }).Count -gt 0) { + $lines = @($legacyLines | Where-Object { $_ -notmatch '^QBRAID_CODE_TOKEN=' }) + $cleanEnv = "$legacyEnv.clean.$PID.$([guid]::NewGuid().ToString('N'))" + Write-RawText $cleanEnv (($lines -join "`n") + "`n") + Move-Item $cleanEnv $legacyEnv -Force + } + } + $legacyConfig = Join-Path $HomeDir 'proxy-config.yaml' + $legacyPidPath = Join-Path $HomeDir 'proxy.pid' + $keepConfig = $false + if (Test-Path $legacyPidPath) { + $legacyPid = Read-PidFile $legacyPidPath + $legacyProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$legacyPid" -ErrorAction SilentlyContinue + $keepConfig = $legacyProcess -and $legacyProcess.CommandLine -and $legacyProcess.CommandLine.Contains($legacyConfig) + } + if (-not $keepConfig) { Remove-Item $legacyConfig -Force -ErrorAction SilentlyContinue } +} +New-Item -ItemType Directory -Force -Path $ProfileRoot | Out-Null +$currentPath = Join-Path $ProfileRoot 'current' +if (Test-Path $currentPath) { + $currentGeneration = (Get-Content $currentPath -Raw).Trim() + if (-not $currentGeneration -or $currentGeneration.Contains('/') -or $currentGeneration.Contains('\') -or $currentGeneration.StartsWith('.')) { Die "profile '$Profile' has an invalid generation pointer" } + $candidateProfile = Join-Path (Join-Path $ProfileRoot 'generations') $currentGeneration + if (-not (Test-Path (Join-Path $candidateProfile 'env'))) { Die "profile '$Profile' generation is incomplete" } + $ProfileDir = $candidateProfile +} +foreach ($privateDir in @(Get-ChildItem $ProfilesDir -Directory -ErrorAction SilentlyContinue)) { + try { + $privateAcl = Get-Acl $privateDir.FullName + $privateAcl.SetAccessRuleProtection($true, $false) + $privateAcl.Access | ForEach-Object { $privateAcl.RemoveAccessRule($_) | Out-Null } + $privateAcl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $env:USERNAME, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -Path $privateDir.FullName -AclObject $privateAcl + } catch { Die "could not restrict profile permissions at $($privateDir.FullName)" } +} +$profileMutex = New-Object Threading.Mutex($false, "Local\qbraid-code-profile-$($env:USERNAME)-$Profile") +if (-not $profileMutex.WaitOne(10000)) { Die "profile '$Profile' is busy" } +try { + $updatePath = Join-Path $ProfileRoot '.update-lock' + try { $updateHandle = [IO.File]::Open($updatePath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } + catch [IO.IOException] { Die "profile '$Profile' has a running session. Update it after that session exits." } + $sessionUsers = Join-Path $ProfileRoot 'session-users' + New-Item -ItemType Directory -Force -Path $sessionUsers | Out-Null + Get-ChildItem $sessionUsers -File -ErrorAction SilentlyContinue | ForEach-Object { + if (Get-Process -Id ([int]$_.Name) -ErrorAction SilentlyContinue) { Die "profile '$Profile' has a running session. Update it after that session exits." } + Remove-Item $_.FullName -Force + } +} finally { $profileMutex.ReleaseMutex() } +$stalePidPath = Join-Path $ProfileDir 'proxy.pid' +if (Test-Path $stalePidPath) { + $stalePid = Read-PidFile $stalePidPath + $staleProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$stalePid" -ErrorAction SilentlyContinue + if ($staleProcess -and $staleProcess.CommandLine -and $staleProcess.CommandLine.Contains((Join-Path $ProfileDir 'proxy-config.yaml'))) { + Stop-Process -Id $stalePid -Force -ErrorAction SilentlyContinue + } + Remove-Item $stalePidPath -Force -ErrorAction SilentlyContinue +} +$ProxyPort = 8320 +$existingEnv = Join-Path $ProfileDir 'env' +if (Test-Path $existingEnv) { + foreach ($line in Get-Content $existingEnv) { + if ($line -match '^QBRAID_CODE_PROXY_PORT=(\d+)$') { $ProxyPort = [int]$Matches[1]; break } + } +} else { + $used = @{} + Get-ChildItem $ProfilesDir -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $candidateEnv = Join-Path $_.FullName 'env' + if (Test-Path $candidateEnv) { + foreach ($line in Get-Content $candidateEnv) { + if ($line -match '^QBRAID_CODE_PROXY_PORT=(\d+)$') { $used[[int]$Matches[1]] = $true } + } + } + } + while ($used.ContainsKey($ProxyPort)) { $ProxyPort++ } +} +$globalProfilePath = Join-Path $HomeDir 'global-profile' +if (Test-Path $Settings) { + try { + $legacySettings = Get-Content $Settings -Raw | ConvertFrom-Json + $legacyEnv = Get-Prop $legacySettings 'env' + $legacyBase = Get-Prop $legacyEnv 'ANTHROPIC_BASE_URL' + if ($legacyBase -like '*api-v2.qbraid.com*') { + foreach ($key in @('ANTHROPIC_BASE_URL','ANTHROPIC_AUTH_TOKEN','ANTHROPIC_MODEL','ANTHROPIC_SMALL_FAST_MODEL','QBRAID_CODE_PROFILE','QBRAID_CODE_HOME')) { + $legacyEnv.PSObject.Properties.Remove($key) + } + Write-RawText $Settings ($legacySettings | ConvertTo-Json -Depth 20) + Warn 'removed unsafe legacy plain-Claude gateway settings; use qbraid-code' + } + } catch { Die "cannot safely remove legacy gateway settings from $Settings" } +} +Remove-Item $globalProfilePath -Force -ErrorAction SilentlyContinue # ------------------------------------------------------------ 2. claude code @@ -122,10 +285,63 @@ function Read-QbraidrcKey { return $null } +function Get-ProfileSecret { + $existingEnv = Join-Path $ProfileDir 'env' + if (-not (Test-Path $existingEnv)) { return $null } + $secretRef = '' + foreach ($line in Get-Content $existingEnv) { if ($line -match '^QBRAID_CODE_SECRET_REF=(.*)$') { $secretRef = $Matches[1] } } + if (-not $secretRef) { + foreach ($line in Get-Content $existingEnv) { if ($line -match '^QBRAID_CODE_TOKEN=(.*)$') { return $Matches[1] } } + return $null + } + try { + $vault = New-Object Windows.Security.Credentials.PasswordVault + $credential = $vault.Retrieve($secretRef, $env:USERNAME) + $credential.RetrievePassword() + return $credential.Password + } catch { return $null } +} + +function Set-ProfileSecret { + param([string]$Key) + $script:SecretRef = "qbraid-code:${Profile}:$generation" + try { + $vault = New-Object Windows.Security.Credentials.PasswordVault + try { $old = $vault.Retrieve($script:SecretRef, $env:USERNAME); $vault.Remove($old) } catch { } + $credential = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList $script:SecretRef, $env:USERNAME, $Key + $vault.Add($credential) + $script:SecretStaged = $true + } catch { Die 'could not store the profile key in Windows Credential Locker' } +} + +function Remove-OldProfileGenerations { + param([string]$CurrentGeneration) + $generationsPath = Join-Path $ProfileRoot 'generations' + $flatEnv = Join-Path $ProfileRoot 'env' + if (Test-Path $flatEnv) { + $flatSettings = Get-EnvMap $flatEnv + $flatRef = $flatSettings['QBRAID_CODE_SECRET_REF'] + if ($flatSettings['QBRAID_CODE_SECRET_BACKEND'] -eq 'credential-locker' -and $flatRef -eq "qbraid-code:$Profile") { + try { $flatVault = New-Object Windows.Security.Credentials.PasswordVault; $flatCredential = $flatVault.Retrieve($flatRef, $env:USERNAME); $flatVault.Remove($flatCredential) } catch { } + } + foreach ($flatName in @('env','proxy-template.yaml','label','label-source','models.tsv','credits.cache','credits.updated','organization-id')) { Remove-Item (Join-Path $ProfileRoot $flatName) -Force -ErrorAction SilentlyContinue } + } + Get-ChildItem $generationsPath -Directory -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.Name -eq $CurrentGeneration -or $_.Name.StartsWith('.stage.')) { return } + $oldSettings = Get-EnvMap (Join-Path $_.FullName 'env') + $oldRef = $oldSettings['QBRAID_CODE_SECRET_REF'] + if ($oldSettings['QBRAID_CODE_SECRET_BACKEND'] -eq 'credential-locker' -and $oldRef -and $oldRef.StartsWith("qbraid-code:${Profile}:")) { + try { $oldVault = New-Object Windows.Security.Credentials.PasswordVault; $oldCredential = $oldVault.Retrieve($oldRef, $env:USERNAME); $oldVault.Remove($oldCredential) } catch { } + } + Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } +} + Say 'qBraid account' $ApiKey = $env:QBRAID_API_KEY $Balance = $null $KeySource = 'QBRAID_API_KEY' +if (-not $ApiKey) { $ApiKey = Get-ProfileSecret; if ($ApiKey) { $KeySource = 'profile secret store' } } if (-not $ApiKey) { $candidate = Read-QbraidrcKey @@ -207,6 +423,9 @@ if (-not (Confirm-Step 'Is this the right organization?' 'y')) { exit 1 } Ok 'organization confirmed' +$orgIdPath = Join-Path $ProfileDir 'organization-id' +$oldOrgId = if (Test-Path $orgIdPath) { (Get-Content $orgIdPath -Raw).Trim() } else { '' } +if ($oldOrgId -and (-not $OrgId -or $oldOrgId -ne $OrgId)) { Die "profile '$Profile' belongs to another organization. Use a new profile name." } # ------------------------------------------------------------- 5. model choice @@ -248,15 +467,47 @@ Ok "default model: $Model" # ---------------------------------------------------------------- 6. env file +$generation = "$(Get-Date -Format yyyyMMddHHmmss).$PID.$([guid]::NewGuid().ToString('N').Substring(0,8))" +$generationsDir = Join-Path $ProfileRoot 'generations' +New-Item -ItemType Directory -Force -Path $generationsDir | Out-Null +$ProfileStage = Join-Path $generationsDir (".stage.$PID.$([guid]::NewGuid().ToString('N'))") +New-Item -ItemType Directory -Path $ProfileStage | Out-Null +Set-ProfileSecret $ApiKey +$ProfileDir = $ProfileStage $envLines = @( "QBRAID_CODE_BASE_URL=$GatewayUrl", "QBRAID_CODE_API_BASE=$ApiBase", - "QBRAID_CODE_TOKEN=$ApiKey", - "QBRAID_CODE_MODEL=$Model" + "QBRAID_CODE_MODEL=$Model", + "QBRAID_CODE_SECRET_BACKEND=credential-locker", + "QBRAID_CODE_SECRET_REF=$SecretRef" ) -$envPath = Join-Path $HomeDir 'env' +$envPath = Join-Path $ProfileDir 'env' +Remove-Item (Join-Path $ProfileDir 'proxy-config.yaml'), (Join-Path $ProfileDir 'proxy-template.yaml'), (Join-Path $ProfileDir 'proxy.key'), (Join-Path $ProfileDir 'proxy-auth') -Recurse -Force -ErrorAction SilentlyContinue Write-RawText $envPath (($envLines -join "`n") + "`n") -Ok "config written to $envPath" +$ProfileLabel = if ($env:QBRAID_CODE_PROFILE_LABEL) { $env:QBRAID_CODE_PROFILE_LABEL } elseif ($OrgName) { $OrgName } else { $Profile } +$ProfileLabel = ($ProfileLabel -replace '[\x00-\x1F\x7F]', '') +if (-not $ProfileLabel -or $ProfileLabel.Length -gt 40) { $ProfileLabel = $Profile } +Write-RawText (Join-Path $ProfileDir 'label') "$ProfileLabel`n" +Write-RawText (Join-Path $ProfileDir 'label-source') "$(if ($OrgName) { 'verified' } else { 'local' })`n" +if ($OrgId) { Write-RawText $orgIdPath "$OrgId`n" } +$modelFacts = @( + "claude-haiku-4-5`t200000", + "claude-opus-4-8`t1000000", + "claude-opus-5`t1000000", + "claude-sonnet-4-6`t1000000", + "gpt-5.4`t400000", + "gpt-5.4-mini`t400000", + "gpt-5.4-nano`t400000", + "gpt-5.6-sol`t1050000" +) +Write-RawText (Join-Path $ProfileDir 'models.tsv') (($modelFacts -join "`n") + "`n") +$creditValue = Get-Prop (Get-Prop $Balance 'data') 'qbraidCredits' +if ($null -ne $creditValue) { + Write-RawText (Join-Path $ProfileDir 'credits.cache') ([string]$creditValue) + Write-RawText (Join-Path $ProfileDir 'credits.updated') ([string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) +} +Remove-Item (Join-Path $ProfileDir 'credits.attempt') -Force -ErrorAction SilentlyContinue +Ok "profile '$Profile' written to $ProfileDir" # ------------------------------------------------- 7. launcher and statusline @@ -270,17 +521,13 @@ if ($PSScriptRoot -and (Test-Path (Join-Path $PSScriptRoot 'qbraid-code.cmd'))) # Set-Content -Encoding UTF8 emits a BOM on Windows PowerShell 5.1, and a BOM # on a .cmd makes cmd.exe fail to parse its first line. Write bytes directly. -function Write-RawText { - param([string]$Path, [string]$Text) - if ($Path.EndsWith('.cmd')) { - $Text = ($Text -replace "`r`n", "`n") -replace "`n", "`r`n" - } - [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) -} - function Fetch-File { param([string]$Name, [string]$Dest) - if ($SrcDir) { Copy-Item (Join-Path $SrcDir $Name) $Dest -Force; return } + if ($SrcDir) { + $source = Join-Path $SrcDir $Name + if ($Dest.EndsWith('.cmd')) { Write-RawText $Dest ([IO.File]::ReadAllText($source)) } else { Copy-Item $source $Dest -Force } + return + } # qbraid.com first: that is the point of the proxy, on networks where # raw.githubusercontent.com is blocked but qbraid.com is not. @@ -306,9 +553,12 @@ function Fetch-File { $ProxyHelperPath = Join-Path $HomeDir 'qbraid-proxy.ps1' $LauncherPath = Join-Path $BinDir 'qbraid-code.cmd' +$LaunchHelperPath = Join-Path $BinDir 'qbraid-launch.ps1' $StatuslinePath = Join-Path $HomeDir 'statusline.ps1' $DoctorPath = Join-Path $HomeDir 'doctor.ps1' Fetch-File 'qbraid-code.cmd' $LauncherPath +Write-RawText (Join-Path $BinDir 'qbraid-code.home') $HomeDir +Fetch-File 'qbraid-launch.ps1' $LaunchHelperPath Ok "launcher installed to $LauncherPath" Fetch-File 'statusline.ps1' $StatuslinePath Ok "statusline installed to $StatuslinePath" @@ -356,42 +606,47 @@ if (Test-Path $existing) { if ($ProxyBin) { $gptModels = @() + $claudeModels = @() try { $list = Invoke-RestMethod -Uri "$GatewayUrl/models" -Headers @{ 'X-API-Key' = $ApiKey } -TimeoutSec 25 + $facts = @{} + foreach ($line in Get-Content (Join-Path $ProfileDir 'models.tsv')) { + if ($line -match '^([^\t]+)\t(\d+)$') { $facts[$Matches[1]] = [int64]$Matches[2] } + } + foreach ($item in @(Get-Prop $list 'data')) { + $id = Get-Prop $item 'id' + $meta = Get-Prop $item '_qbraid' + $context = Get-Prop $meta 'maxTokens' + if ($null -eq $context) { $context = Get-Prop $item 'context_window' } + if ($id -and $context) { $facts[$id] = [int64]$context } + } + $rows = @($facts.Keys | Sort-Object | ForEach-Object { "$_`t$($facts[$_])" }) + Write-RawText (Join-Path $ProfileDir 'models.tsv') (($rows -join "`n") + "`n") $gptModels = @(Get-Prop $list 'data' | ForEach-Object { Get-Prop $_ 'id' } | Where-Object { $_ -like 'gpt-*' }) + $claudeModels = @(Get-Prop $list 'data' | ForEach-Object { Get-Prop $_ 'id' } | Where-Object { $_ -like 'claude-*' }) } catch { } - if ($gptModels.Count -eq 0) { - Warn 'could not list GPT models from the gateway - skipping proxy config' + if (($gptModels.Count + $claudeModels.Count) -eq 0) { + Die 'could not list proxy models from the gateway; the profile was not changed.' } else { - $keyFile = Join-Path $HomeDir 'proxy.key' - if (-not (Test-Path $keyFile)) { - $bytes = New-Object byte[] 24 - [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) - Write-RawText $keyFile (($bytes | ForEach-Object { $_.ToString('x2') }) -join '') - } - $localKey = (Get-Content $keyFile -Raw).Trim() - # One proxy, every model: Claude passthrough + GPT translation, so one - # endpoint lists and serves all of them. - $claudeModels = @(Get-Prop $list 'data' | ForEach-Object { Get-Prop $_ 'id' } | Where-Object { $_ -like 'claude-*' }) + # One proxy, every model: Claude passthrough + GPT translation. $yaml = @() $yaml += '# Generated by the qbraid-code installer. Loopback only.' $yaml += 'host: "127.0.0.1"' - $yaml += 'port: 8320' + $yaml += 'port: __PORT__' $yaml += 'tls:' $yaml += ' enable: false' - $yaml += "auth-dir: `"$($HomeDir -replace '\\','/')/proxy-auth`"" + $yaml += 'auth-dir: "__AUTH_DIR__"' $yaml += 'api-keys:' - $yaml += " - `"$localKey`"" + $yaml += ' - "__LOCAL_KEY__"' $yaml += 'remote-management:' $yaml += ' allow-remote: false' $yaml += ' disable-control-panel: true' $yaml += 'debug: false' - $yaml += '# Model-list cloaking would rewrite ids into reversed pseudo-claude names;' - $yaml += '# our anthropic-compat/ aliases pass the picker filter with readable names.' + $yaml += '# Keep model identifiers stable for explicit --model launches.' $yaml += 'claude-code:' $yaml += ' disable-cloaking-model-list: true' $yaml += 'claude-api-key:' - $yaml += " - api-key: `"$ApiKey`"" + $yaml += ' - api-key: "__QBRAID_KEY__"' $yaml += " base-url: `"$GatewayUrl`"" $yaml += ' models:' foreach ($cm in $claudeModels) { @@ -402,26 +657,32 @@ if ($ProxyBin) { $yaml += ' - name: "qbraid-gateway-gpt"' $yaml += " base-url: `"$GatewayUrl`"" $yaml += ' api-key-entries:' - $yaml += " - api-key: `"$ApiKey`"" + $yaml += ' - api-key: "__QBRAID_KEY__"' $yaml += ' models:' foreach ($gm in $gptModels) { - # Plain alias for the command line, prefixed alias so the /model - # picker's discovery filter (claude|anthropic substring) shows it. $yaml += " - name: `"$gm`"" $yaml += " alias: `"$gm`"" - $yaml += " - name: `"$gm`"" - $yaml += " alias: `"anthropic-compat/$gm`"" } - Write-RawText (Join-Path $HomeDir 'proxy-config.yaml') (($yaml -join "`n") + "`n") - New-Item -ItemType Directory -Force -Path (Join-Path $HomeDir 'proxy-auth') | Out-Null + Write-RawText (Join-Path $ProfileDir 'proxy-template.yaml') (($yaml -join "`n") + "`n") Ok "proxy configured: all $($gptModels.Count + $claudeModels.Count) models on one endpoint (starts on demand)" } } else { - Warn 'CLIProxyAPI unavailable - GPT models will not work; Claude models are unaffected.' + Die 'CLIProxyAPI unavailable; the profile was not changed.' } # Appended after the env file exists. -Add-Content -Path $envPath -Value @("QBRAID_CODE_PROXY_PORT=8320", "QBRAID_CODE_PROXY_BIN=$ProxyBin") +Add-Content -Path $envPath -Value @("QBRAID_CODE_PROXY_PORT=$ProxyPort", "QBRAID_CODE_PROXY_BIN=$ProxyBin") +$finalGeneration = Join-Path $generationsDir $generation +Move-Item $ProfileStage $finalGeneration +$ProfileStage = $null +$currentTmp = Join-Path $ProfileRoot ("current.$PID.tmp") +Write-RawText $currentTmp "$generation`n" +Move-Item $currentTmp $currentPath -Force +$SecretStaged = $false +$ProfileDir = $finalGeneration +Remove-OldProfileGenerations $generation +Remove-LegacyPlaintextToken +Ok "profile '$Profile' metadata committed" # --------------------------------------------------------- 8. first-run flags @@ -452,36 +713,8 @@ if (Test-Path $Settings) { $cfg | Add-Member -NotePropertyName statusLine ` -NotePropertyValue ([pscustomobject]@{ type = 'command'; command = $statusCmd }) -Force -if ($Global) { - $envObj = Get-Prop $cfg 'env' - if ($null -eq $envObj) { $envObj = [pscustomobject]@{} } - $envObj | Add-Member -NotePropertyName ANTHROPIC_BASE_URL -NotePropertyValue $GatewayUrl -Force - $envObj | Add-Member -NotePropertyName ANTHROPIC_AUTH_TOKEN -NotePropertyValue $ApiKey -Force - $envObj | Add-Member -NotePropertyName ANTHROPIC_MODEL -NotePropertyValue $Model -Force - $envObj | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $Model -Force - # The gateway rejects Claude Code's adaptive-thinking parameter; see the - # launcher for the full note. Remove when the gateway accepts "adaptive". - $envObj | Add-Member -NotePropertyName MAX_THINKING_TOKENS -NotePropertyValue '0' -Force - $cfg | Add-Member -NotePropertyName env -NotePropertyValue $envObj -Force -} Write-RawText $Settings ($cfg | ConvertTo-Json -Depth 100) Ok "statusline enabled in $Settings" -if ($Global) { - # settings.json now holds a live credential. Windows has no chmod; restrict - # the ACL to the current user so other accounts on the machine cannot read it. - try { - $acl = Get-Acl $Settings - $acl.SetAccessRuleProtection($true, $false) - $acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) | Out-Null } - $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( - $env:USERNAME, 'FullControl', 'Allow'))) - Set-Acl -Path $Settings -AclObject $acl - } catch { - Warn "could not restrict permissions on $Settings - it contains your API key" - } - Ok 'plain `claude` now uses qBraid too' -} - # ------------------------------------------------------------------- 10. mcp Say 'qBraid MCP' @@ -536,6 +769,9 @@ try { # ---------------------------------------------------------------- 12. finish Write-Host '' +$activeTmp = Join-Path $HomeDir ("active-profile.$PID.$([guid]::NewGuid().ToString('N')).tmp") +Write-RawText $activeTmp "$Profile`n" +Move-Item $activeTmp (Join-Path $HomeDir 'active-profile') -Force Write-Host 'qbraid-code is ready.' -ForegroundColor Green Write-Host '' Write-Host ' Open a new terminal, then run it from any folder:' @@ -544,9 +780,17 @@ Write-Host ' qbraid-code start a session' Write-Host ' qbraid-code -p "..." ask one question and exit' Write-Host ' qbraid-code --doctor check your setup' Write-Host '' -if ($Global) { - Write-Host ' The plain claude command uses qBraid as well.' -} else { - Write-Host ' Your own claude command is untouched.' -} +Write-Host ' Your own claude command is untouched.' Write-Host '' + +} finally { + if ($SecretStaged -and $SecretRef) { + try { $vault = New-Object Windows.Security.Credentials.PasswordVault; $orphan = $vault.Retrieve($SecretRef, $env:USERNAME); $vault.Remove($orphan) } catch { } + } + if ($ProfileStage -and (Test-Path $ProfileStage)) { Remove-Item $ProfileStage -Recurse -Force -ErrorAction SilentlyContinue } + if ($updateHandle) { $updateHandle.Dispose() } + if ($InstallLockHandle) { $InstallLockHandle.Dispose() } + if ($profileMutex) { $profileMutex.Dispose() } + try { $InstallMutex.ReleaseMutex() } catch { } + $InstallMutex.Dispose() +} diff --git a/install.sh b/install.sh index 2a8ee69..702070d 100755 --- a/install.sh +++ b/install.sh @@ -2,13 +2,11 @@ # qbraid-code installer — Claude Code, powered by the qBraid AI gateway. # # curl -fsSL https://qbraid.com/code.sh | bash -# curl -fsSL https://qbraid.com/code.sh | bash -s -- --global # -# There is no proxy and no daemon. The qBraid gateway serves an -# Anthropic-compatible surface, so Claude Code talks to it directly through -# ANTHROPIC_BASE_URL. The double `v1` in /api/v1/ai/v1/messages is deliberate, -# not a typo: Claude Code appends /v1/messages to whatever base URL it is -# given. +# Claude models use the gateway's Anthropic-compatible surface. GPT models +# use an on-demand loopback translation proxy. The double `v1` in +# /api/v1/ai/v1/messages is deliberate because Claude Code appends +# /v1/messages to ANTHROPIC_BASE_URL. # # Everything this writes lives in ~/.qbraid-code and ~/.local/bin. # Re-running is safe. @@ -22,7 +20,8 @@ MCP_NAME="qbraid" # Anthropic Messages API; the gateway serves GPT only on its OpenAI-compat # surface. CLIProxyAPI bridges the two on loopback. Port is qbraid-code's own — # claudeseek and other tools use neighbouring ports. -PROXY_PORT="${QBRAID_CODE_PROXY_PORT:-8320}" +PROXY_PORT_OVERRIDE="${QBRAID_CODE_PROXY_PORT:-}" +PROXY_PORT="" PROXY_REPO="router-for-me/CLIProxyAPI" MCP_URL="https://mcp.qbraid.com/mcp" KEYS_URL="https://account.qbraid.com/account/api-keys" @@ -40,28 +39,153 @@ CLAUDE_DIR="$HOME/.claude" SETTINGS="$CLAUDE_DIR/settings.json" CLAUDE_JSON="$HOME/.claude.json" -GLOBAL=0 -GLOBAL_APPLIED=0 -for arg in "$@"; do - case "$arg" in - --global) GLOBAL=1 ;; +PROFILE="${QBRAID_CODE_PROFILE:-}" +PROFILE_OPTION=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --global) printf 'error: --global was removed because project settings can exfiltrate its credential. Use qbraid-code.\n' >&2; exit 1 ;; + --profile) + [ "$#" -ge 2 ] || { printf 'error: --profile needs a name\n' >&2; exit 1; } + PROFILE_OPTION=1; PROFILE="$2"; shift 2 ;; + --profile=*) PROFILE_OPTION=1; PROFILE="${1#--profile=}"; shift ;; --help|-h) cat <<'EOF' qbraid-code installer - --global also point the plain `claude` command at qBraid - --help show this message + --profile NAME create or update a named qBraid profile + --global removed. Use the isolated qbraid-code wrapper. + --help show this message Environment: - QBRAID_API_KEY use this key instead of prompting - QBRAID_CODE_MODEL use this model instead of prompting - QBRAID_CODE_HOME config directory (default ~/.qbraid-code) - QBRAID_CODE_BIN_DIR install directory (default ~/.local/bin) + QBRAID_API_KEY use this key instead of prompting + QBRAID_CODE_MODEL use this model instead of prompting + QBRAID_CODE_PROFILE_LABEL readable local account label + QBRAID_CODE_HOME config directory (default ~/.qbraid-code) + QBRAID_CODE_BIN_DIR install directory (default ~/.local/bin) EOF exit 0 ;; - *) ;; + *) printf 'error: unknown option: %s\n' "$1" >&2; exit 1 ;; esac done +[ "$PROFILE_OPTION" -eq 0 ] || [ -n "$PROFILE" ] || { printf 'error: invalid empty profile\n' >&2; exit 1; } + +shell_quote() { + printf "'" + printf '%s' "$1" | sed 's/'"'"'/'"'"'\\'"'"''"'"'/g' + printf "'" +} + +json_escape_value() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' +} + +valid_profile_slug() { + local LC_ALL=C + case "${1:-}" in + ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#1}" -le 32 ] +} + +sanitize_profile_label() { # sanitize_profile_label