cbs Virtual Key Mapper
Short-lived keys from your company gateway, labelled with the repository you work in, for every tool you use.
vkm signs you in to your company's gateway once, with its single sign-on. From then on your
tools get short-lived keys from it, and every request carries the labels of the
repository you are working in: key/value pairs such as customer = "acme" and
project = "webshop". Spend is attributed by label without anyone pasting API keys into
config files, and nothing goes out while a required label has no value.
vkm does not depend on a particular gateway or tool. Gateways, sign-in methods and tools
are separate adapters (docs/adapters.md), so new ones can be added
without touching the rest.
✨ What works where
Gateways and sign-in
| Gateway | Sign-in | What the gateway receives |
|---|---|---|
| LiteLLM | Its native CLI single sign-on in the browser, with team choice. The refresh token stays in the OS vault. | The SSO user and team (from the token), the labels as request tags x-litellm-tags (by default one KEY:VALUE tag per label, e.g. customer:acme), and x-litellm-spend-logs-metadata = {"labels": {"customer": "acme"}, "sources": {"customer": "explicit|env|local|file|rules"}, "repository": "github.com/org/repo"} (repository only inside a Git checkout with an origin). |
| LiteLLM, from a VS Code dev container, SSH or WSL window | forwarded: the two VS Code extensions relay short-lived keys from the sign-in on your own computer; the refresh token never leaves it. |
The same. |
More gateways and sign-in methods are adapters (docs/adapters.md).
Tools
| Tool | Connected with | Labels | Key renewal | While a required label has no value |
|---|---|---|---|---|
| Claude Code CLI | vkm setup claude |
per repository, checked before each prompt | ✅ | the prompt is blocked |
| Claude Code for VS Code | vkm setup claude + "claudeCode.disableLoginPrompt": true |
per repository, checked before each prompt | ✅ | the prompt is blocked |
| Codex CLI | vkm setup codex + the shell hook |
of the folder where Codex starts | ✅ | no key is sent; the gateway refuses |
| OpenCode v1 | vkm setup opencode |
per request, of the folder OpenCode runs in | ✅ | the request is blocked |
| OpenCode v2 | vkm run opencode (setup refuses v2) |
fixed at launch | ❌ key from launch time; restart when it expires | as for vkm run |
| GitHub Copilot Chat in VS Code | the Virtual Key Mapper extension | per workspace folder, shown in the status bar | ✅ | blocked, with an "Assign labels" button that runs vkm repo bind |
| any of the above, one launch | vkm run [--label KEY=VALUE]... TOOL |
fixed at launch (or the ones you give) | ✅ (OpenCode v2: ❌) | asks in a terminal for each missing label and saves the answers; blocked without a terminal or outside Git |
| Codex for VS Code | not supported yet | |||
| Copilot inline completions | not possible: they always use GitHub's models |
Platforms
✅ = tested in CI on every push, with the real tools (pinned in ci/agents, plus a weekly
run with their latest releases) against a local test gateway. ☑️ = supported and checked by
hand with the manual checklist.
| Windows | macOS | Linux | Dev container | SSH, WSL | |
|---|---|---|---|---|---|
| Claude Code CLI, Codex CLI, OpenCode v1 | ✅ (interactive Codex ☑️) | ✅ | ✅ | ✅ Claude Code and Codex, ☑️ OpenCode | ☑️ |
| Claude Code for VS Code, Copilot Chat, OpenCode v2 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
| Credential vault | ✅ Credential Manager | ✅ Keychain | ✅ Secret Service (GNOME Keyring), ☑️ KWallet | not needed | not needed |
| Shell hooks | ✅ PowerShell 7, bash; ☑️ Windows PowerShell 5.1 and PCs that block scripts (shell-init pwsh --terminal) |
✅ bash, zsh, fish, PowerShell 7 | ✅ bash, zsh, fish, PowerShell 7 | as on Linux | as on Linux |
| VS Code extensions | ✅ in a VS Code extension host | ✅ in an extension host, with the relay | ✅ in an extension host, with the relay | ✅ relay in a real dev container | ☑️ |
Choosing labels
Each label is resolved on its own, so customer can come from a central rule and project
from a committed file. For each key the first source with a value wins:
| Source | Who sets it | Applies to |
|---|---|---|
vkm run --label KEY=VALUE |
you | that launch |
VKM_LABEL_<KEY> (e.g. VKM_LABEL_CUSTOMER) |
you | every process started with the variable set |
vkm repo bind --label KEY=VALUE |
you | clones of that repository on this computer (same origin, or it as upstream), only for you |
a committed .vkm file (vkm repo bind --shared --label KEY=VALUE writes it at the repository root) |
the team | that folder and everything below it, unless a nearer .vkm exists |
| central rules (a JSON file or URL in the profile) | your organisation | repositories matching a pattern, e.g. github.com/acme-*/** |
Which labels exist and which are required is set by your organisation's central rules document or by your profile (Labels).
🚀 Quick start
Needs Python 3.11 or newer and an OS credential vault.
uv tool install virtual-key-mapper # or: pipx install virtual-key-mapper
Update later with uv tool upgrade virtual-key-mapper (or pipx upgrade virtual-key-mapper).
From a checkout instead:
python -m venv .venv
.venv/bin/python -m pip install -e . # Windows: .venv\Scripts\python.exe -m pip install -e .
vkm is the installed command. If your machine blocks pip-generated .exe launchers (some
Windows policies do), use python -m virtual_key_mapper everywhere instead; it accepts the
same arguments.
# 1. Once per machine: a profile for your gateway and your business-unit team, then sign in.
# If your organisation publishes its label definitions, pass --rules URL instead of --label.
vkm profile add work --gateway https://llm.company.example --team-id YOUR_TEAM_ID \
--label customer --required --description "Who the work is billed to"
vkm login # browser sign-in; choose the same team there
vkm status # "state": "ready"
# 2. Once per machine: connect your tools.
vkm setup claude --model YOUR_CLAUDE_ALIAS
vkm setup codex --model YOUR_CODEX_ALIAS
vkm setup opencode
# 3. Once per repository (unless a central rule or a committed .vkm covers it):
cd ~/src/some-repo
vkm repo bind --label customer=acme # or plain 'vkm repo bind' to be asked
vkm repo show
The gateway address is its root URL (no /v1); the team ID is the exact ID from the gateway,
not its display name. Your sign-in renews itself; sign in again only after logging out, a
long pause or a change of team membership.
The gateway must be served at its host root with LiteLLM's PROXY_BASE_URL set to that URL
(see Gateway requirements).
🧭 Labels
A label is a key with a value, such as customer = "acme" or project = "webshop". What a
label means is up to your organisation; vkm knows the keys, which of them are required,
where their values may come from and how the gateway receives them. The full reference is
docs/labels.md.
Definitions
Labels are defined in your organisation's central rules document (profile add --rules URL|PATH
or profile update NAME --rules ...) and, for keys the document does not define, in your profile:
vkm profile update work --label project --description "Project within the customer"
vkm profile update work --label customer --required --value acme=Acme --value globex=Globex --strict
vkm profile update work --no-label project
vkm labels list # the labels in effect, where each is defined, and notices
One label per call. --required: nothing is sent while it has no value. --value ID[=NAME]:
the choices the picker offers; with --strict only those are accepted. --source NAME
(repeatable) limits where values may come from. Keys are 1-32 lowercase letters, digits, _
or -, starting with a letter. A profile label for a key the central document defines is
refused (or, when the document cannot be read at the time, ignored with a notice): the central
one applies. If no label is defined anywhere, requests carry only the repository, and setup,
run and repo show say so.
Values
For each defined key, the first source with a value wins:
--label KEY=VALUE(forrunandrepo show);- the environment variable
VKM_LABEL_<KEY>(exactly: uppercase,-as_; empty counts as unset; otherVKM_LABEL_*names are ignored); - your personal binding:
vkm repo bind --label KEY=VALUE, stored only for you (repositories.jsonon this computer), keyed by the repository'sorigin/upstreamaddress, so every clone on this computer shares it. It keeps keys you do not name; a different value for a key already bound needs--replace.repo unbindremoves the binding,repo unbind --label KEYone key; - the nearest committed
.vkmfile, written withrepo bind --shared --label KEY=VALUE:# Labels for AI tool requests in this folder and below (vkm). [labels] customer = "acme" project = "webshop"
A file in a parent folder such as~/src/acme/covers all repositories below it, and the nearest file wins as a whole (a nearer file withoutprojectdoes not fall through to a parent's). Inside your home folder the search stops below home, so a file in home or above it never applies (repo bind --sharedrefuses to write one there); for folders outside home, such as/workspacesin a dev container or another drive on Windows, it goes up to the root of the drive; - a central rule (below).
--label KEY= on the command line, "" in a .vkm and null in a rule mean "no value here,
stop searching". --label refuses keys that no definition knows (typos); values for unknown keys
from other sources are not sent, and repo show lists them. Values are 1-256 printable ASCII
characters without ,, surrounding spaces or a leading ! or &.
The central rules document
{
"version": "2.0",
"labels": [
{"key": "customer", "required": true, "description": "Who the work is billed to",
"values": [{"id": "acme", "name": "Acme"}, {"id": "globex", "name": "Globex"}]},
{"key": "project", "description": "Project within the customer"}
],
"outputs": {"litellm": {"tags": {"format": ["${customer}", "${customer}:${project}"]}}},
"rules": [
{"match": "github.com/acme-*/**", "labels": {"customer": "acme"}},
{"match": "**", "labels": {"project": null}}
]
}
Rules are tried in order, each against origin, then upstream; each key takes its value from
the first matching rule that sets it, so a catch-all below can still supply another label.
** spans path segments, */? stay within one. Write the repository as repo show prints it
(lowercase, %20 decoded to a space). outputs are grouped by gateway type, so one document
can serve profiles on different gateways. A URL is cached for an hour; if a fetch fails or a
newly published document is invalid, the last good copy is used.
Required labels
A required label without a value blocks every request with a message starting
Required labels have no value:, naming the keys and the ways to set them. vkm run and
vkm repo bind in a terminal, and "Assign labels" in VS Code, ask for each missing one and save
the answers as your personal binding. The picker offers the label's values, else the
gateway's catalogue, else accepts any valid typed value without signing in (vkm labels values KEY
shows which). vkm repo bind asks only for labels a personal binding (with --shared: a .vkm
file) may set.
repo show tells you each label's value and where it came from.
What the gateway receives
For LiteLLM, by default:
x-litellm-tags: customer:acme,project:webshop
x-litellm-spend-logs-metadata: {"labels":{"customer":"acme","project":"webshop"},"repository":"github.com/acme-shop/web","sources":{"customer":"rules","project":"file"}}
The tags format can be changed in the central document (it wins) or in the profile; the metadata can be switched off:
vkm profile update work --output 'tags=${customer}' --output 'tags=${customer}:${project}'
vkm profile update work --output-off metadata
vkm profile update work --output-default tags
An entry that uses an optional label without a value is left out; $$ is a literal $.
Formats that could make one label's value look like another's are refused, as is any setup in
which a required label reaches no output. The rules are in
docs/labels.md.
Labels are attribution, not access control: enforce allowed values on the gateway if you need
that. Gateway settings that break or leak attribution (tag routing, client-side tag rejection,
header forwarding) and why the picker needs values in the central document are listed in
Gateway requirements.
🧩 Tools
Claude Code (CLI and the official VS Code extension)
vkm setup claude [--model ALIAS] [--replace] [--remove]
Writes ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR): an apiKeyHelper that runs
vkm token, the gateway address, and a prompt hook. Before each prompt the hook
resolves the labels, writes the resulting headers to the repository's .claude/settings.local.json
(excluded from Git) and asks you once to send the prompt again; prompts are blocked while a
required label has no value.
For the VS Code extension also set "claudeCode.disableLoginPrompt": true in your VS Code
user settings. --remove undoes exactly what setup wrote.
Codex CLI
vkm setup codex [--model ALIAS] [--remove]
eval "$(vkm shell-init bash)" # add to ~/.bashrc; also zsh, fish, pwsh
On Windows, add vkm shell-init pwsh | Out-String | Invoke-Expression to your PowerShell
$PROFILE. Where the execution policy blocks scripts (common on company PCs, "running scripts is
disabled on this system"), a profile cannot load; run vkm shell-init pwsh --terminal
instead and paste its command line into Windows Terminal (Settings → your PowerShell profile →
Command line) or its snippet into your VS Code user settings. Check in a new terminal:
Get-Command codex shows Function.
Codex reads the label headers from environment variables that the shell hook sets per directory.
Without them, or while a required label has no value, Codex gets no credential and the gateway refuses the request. The hook also makes
codex start with --no-daemon: Codex's shared background daemon would keep the variables of the
terminal that started it. If one is running from before, stop it once: codex app-server daemon stop.
OpenCode
vkm setup opencode [--model ALIAS] [--remove]
Adds a provider with the gateway's models and a small plugin to
~/.config/opencode/opencode.json; the plugin adds a fresh credential and the folder's
label headers to every request, and blocks requests while a required label has no value. OpenCode v2 is supported only through vkm run opencode.
One-off launches
vkm run [--label KEY=VALUE]... [--model ALIAS] claude|codex|opencode [-- TOOL ARGUMENTS]
Starts the tool with child-only configuration, without changing your setup; each --label
sets that key for that process (--label KEY= for no value), the others resolve as usual. In a
terminal it asks for missing required labels and saves the answers as your personal binding.
GitHub Copilot Chat (VS Code)
Install Virtual Key Mapper for Copilot Chat (cbs-consulting.vkm) from the Extensions view,
the Visual Studio Marketplace.
For editors without the Marketplace (Cursor, VSCodium), install vkm.vsix from the
latest release
(Extensions → ··· → Install from VSIX):
code --install-extension cbs-consulting.vkm
Set vkm.command in your VS Code user settings to how the CLI runs, e.g.
["/path/to/.venv/bin/python", "-m", "virtual_key_mapper"]. Pick a "Virtual Key Mapper"
model in Copilot Chat; the status bar shows the folder's label values (acme · webshop),
missing required labels in warning style, and no labels when none are defined; its tooltip
lists each label with its description and source. If you use VS Code profiles,
install the extension in the profile you work in. Inline completions and next-edit
suggestions always use GitHub's models and are not attributed.
🐳 Dev Containers, SSH and WSL
The container gets short-lived tokens from the sign-in on your own computer; nothing long-lived is stored inside it.
- Local VS Code: install Virtual Key Mapper: local sign-in (
cbs-consulting.vkm-local) and setvkm.localCommandin your user settings to the local CLI command. Containers get tokens only for your local default profile; list others invkm.localProfiles(user settings) to allow them. - In the container: install the CLI, e.g.
uv tool install --python 3.12 virtual-key-mapper, and installcbs-consulting.vkminto the container (VS Code offers "Install in Dev Container"; or list it undercustomizations.vscode.extensionsindevcontainer.json). Reload the window. - The extension creates a container profile that uses your local sign-in
(
vkm profile listshowsforwarded), with your local profile's own label definitions and output overrides and its central rules URL. Do not runloginin the container.
All tools work in the container as described above. Central rules given as a local file cannot
be copied; the extension then warns that the organisation's label definitions are missing in the
container (set them with vkm profile update NAME --rules URL|PATH there). Personal repo bind
bindings made inside a container live in the container.
🗂️ Where things are stored
| What | Where |
|---|---|
| Profiles and their labels, personal label bindings, rules cache, tool state | %LOCALAPPDATA%\virtual-key-mapper or ~/.config/virtual-key-mapper (--home or VKM_HOME to change) |
| Sign-in (access and refresh token) | the OS credential vault only; never in files |
| Claude / Codex / OpenCode configuration | each tool's own user configuration, changed only by setup |
⌨️ Commands
profile add|list|show|use|update, login, status, token [--json], logout,
repo show|bind|unbind|list, labels list|values, setup claude|codex|opencode, run,
shell-init, and claude guard (the hook). vkm COMMAND --help explains each.
📌 Releases
| What | Where | Name |
|---|---|---|
| CLI | PyPI | virtual-key-mapper (command vkm) |
| Copilot Chat extension | Visual Studio Marketplace, vkm.vsix on each GitHub release |
cbs-consulting.vkm |
| Local sign-in extension | Visual Studio Marketplace, vkm-local.vsix on each GitHub release |
cbs-consulting.vkm-local |
All three share one version. Releases are made by the "Release" workflow in the Actions tab, which
- runs the Python and extension tests on the commit to release and builds everything,
- requires a
## VERSION - DATEentry inCHANGELOG.md, - waits for a maintainer to approve the
releaseenvironment, - publishes the package to PyPI (trusted publishing: no token in the repository) and the
GitHub release
vVERSIONwith the changelog entry as notes and the built files attached.
The extensions are then uploaded to the Visual Studio Marketplace by hand, so no store token is
stored anywhere: download vkm.vsix and vkm-local.vsix from the release, open
marketplace.visualstudio.com/manage → publisher
cbs-consulting, and choose ⋯ → Update for each extension (the first time: New extension →
Visual Studio Code). The Marketplace accepts a package only for the publisher named inside it.
Every change goes into the "Unreleased" section of CHANGELOG.md in its pull request; the
"Changelog entry" check fails otherwise, unless the pull request has the label "no changelog".
To prepare a release, set the same version in pyproject.toml, src/virtual_key_mapper/__init__.py
and both package.json files (npm install in vscode-extension/ updates the lockfile), and
rename "Unreleased" to VERSION - DATE; the tests check that they agree. The workflow can be run
again and skips what is already published; its "dry run" option only checks and builds.
🛠️ Contributing
Pull requests need two checks: Test features (all CI jobs on Windows, macOS and Linux) and Changelog entry.
.venv/bin/python -m unittest discover -s tests -v
cd vscode-extension && npm test && npm run test:host # extension unit tests; real VS Code host
cd vscode-extension/local && npm test
Live tests run the real tools against a local fake gateway (no model calls, no secrets). Install the pinned versions and point the tests at them:
cd ci/agents && npm ci && cd ../..
export $(node ci/agents/locate.mjs) # sets VKM_TEST_{CLAUDE,CODEX,OPENCODE}_BINARY
.venv/bin/python -m unittest discover -s tests -v
VKM_TEST_VAULT=1 also runs tests/test_vault_native.py against your real OS vault (uniquely named test entries, removed afterwards).
Locally, missing tools and shells skip their tests; CI requires them. CI runs everything on Windows, macOS and Linux: the Python suite, the live agents and shell hooks, the real OS vault, both extensions in a real VS Code, and a real dev container on Linux; a weekly run tries the newest agent releases. Details and the manual checklist: docs/testing.md. How it works and why: docs/architecture.md; extending it: docs/adapters.md.
📄 License
Copyright © 2026 cbs Corporate Business Solutions Unternehmensberatung GmbH. Licensed under the Apache License 2.0.
LiteLLM is a project of BerriAI. Claude Code is a product of Anthropic, Codex of OpenAI, OpenCode of SST, GitHub Copilot and VS Code of GitHub and Microsoft. This project is not affiliated with or endorsed by any of them.
Metadata
Release files for virtual-key-mapper 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| virtual_key_mapper-0.1.0.tar.gz | 158.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| virtual_key_mapper-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 255.3 kB
Release files / virtual_key_mapper-0.1.0.tar.gz
| Download URL | virtual_key_mapper-0.1.0.tar.gz |
|---|---|
| Size | 158.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
27582022c8dfa7c9afc339d52253bd9f0329e0910fed1b06913efd36bf1add3b
|
|
BLAKE2b-256 checksum How to use checksums |
c2da1c1f9eedd9f66f8b67877f4bb35dc45c30bea3e9c7cca354d9c8ca393518
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / virtual_key_mapper-0.1.0-py3-none-any.whl
| Download URL | virtual_key_mapper-0.1.0-py3-none-any.whl |
|---|---|
| Size | 97.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
87329d40111edf35bb65289721cffc87c87eadf40a8abdf569a67142f46eb364
|
|
BLAKE2b-256 checksum How to use checksums |
5d83a7adf811339d8ef6d4c724c1616bf4b22751322e84ecce7bffc97d5d4cb0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for virtual-key-mapper 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for virtual_key_mapper-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.