virtual-key-mapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- virtual_key_mapper/__init__.py +3 -0
- virtual_key_mapper/__main__.py +3 -0
- virtual_key_mapper/adapters.py +115 -0
- virtual_key_mapper/auth/__init__.py +1 -0
- virtual_key_mapper/auth/forwarded.py +155 -0
- virtual_key_mapper/auth/litellm_oauth.py +404 -0
- virtual_key_mapper/auth/litellm_sso.py +296 -0
- virtual_key_mapper/cli.py +875 -0
- virtual_key_mapper/errors.py +2 -0
- virtual_key_mapper/gateways/__init__.py +1 -0
- virtual_key_mapper/gateways/litellm.py +92 -0
- virtual_key_mapper/integrations/__init__.py +1 -0
- virtual_key_mapper/integrations/claude.py +371 -0
- virtual_key_mapper/integrations/codex.py +190 -0
- virtual_key_mapper/integrations/common.py +115 -0
- virtual_key_mapper/integrations/opencode.py +320 -0
- virtual_key_mapper/labels.py +742 -0
- virtual_key_mapper/profiles.py +339 -0
- virtual_key_mapper/registry.py +74 -0
- virtual_key_mapper/repositories.py +263 -0
- virtual_key_mapper/resolve.py +238 -0
- virtual_key_mapper/rules.py +297 -0
- virtual_key_mapper/shells.py +116 -0
- virtual_key_mapper/storage.py +234 -0
- virtual_key_mapper/transport.py +130 -0
- virtual_key_mapper-0.1.0.dist-info/METADATA +450 -0
- virtual_key_mapper-0.1.0.dist-info/RECORD +31 -0
- virtual_key_mapper-0.1.0.dist-info/WHEEL +5 -0
- virtual_key_mapper-0.1.0.dist-info/entry_points.txt +2 -0
- virtual_key_mapper-0.1.0.dist-info/licenses/LICENSE +202 -0
- virtual_key_mapper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Interfaces between the vendor-neutral core and its adapters.
|
|
2
|
+
|
|
3
|
+
Auth adapters obtain credentials, gateway adapters know URLs and the outputs labels can fill, and
|
|
4
|
+
integration adapters configure and launch tools. Implementations are listed in ``registry``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Protocol
|
|
12
|
+
|
|
13
|
+
from .labels import Attribution, Choice, LabelConfig, Output
|
|
14
|
+
from .profiles import Profile
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Api(StrEnum):
|
|
18
|
+
"""A wire protocol a tool speaks; a gateway maps each one it supports to a base URL."""
|
|
19
|
+
|
|
20
|
+
ANTHROPIC_MESSAGES = "anthropic-messages"
|
|
21
|
+
OPENAI_CHAT = "openai-chat"
|
|
22
|
+
OPENAI_RESPONSES = "openai-responses"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Credential:
|
|
27
|
+
"""A bearer secret for the gateway; ``notice`` is a nonsecret message for the user, e.g. sign in again soon."""
|
|
28
|
+
|
|
29
|
+
secret: str = field(repr=False)
|
|
30
|
+
expires_at: float | None = None
|
|
31
|
+
user: str | None = None
|
|
32
|
+
notice: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Auth(Protocol):
|
|
36
|
+
"""Obtains credentials for one profile; never opens a browser outside ``login``."""
|
|
37
|
+
|
|
38
|
+
def login(self, notify: Callable[[str], None], **options) -> Credential: ...
|
|
39
|
+
def logout(self, notify: Callable[[str], None]) -> None: ...
|
|
40
|
+
def status(self) -> dict: ...
|
|
41
|
+
def credential(self) -> Credential: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Gateway(Protocol):
|
|
45
|
+
"""Knows a gateway's endpoints and the outputs (headers) labels can fill there.
|
|
46
|
+
|
|
47
|
+
The core renders the headers (``LabelConfig.headers``) from the adapter's outputs, so header names,
|
|
48
|
+
defaults and reserved results stay in the adapter while formats and checks stay in one place.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def endpoint(self, api: Api) -> str:
|
|
52
|
+
"""Return the base URL for ``api``; raise CredentialError if the gateway does not offer it."""
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def outputs() -> tuple[Output, ...]:
|
|
56
|
+
"""The outputs this gateway kind can fill, in header order; static, so profiles validate without a gateway."""
|
|
57
|
+
|
|
58
|
+
def catalogue(self, key: str, credential: Callable[[], Credential], config: LabelConfig) -> list[Choice] | None:
|
|
59
|
+
"""Values the gateway lists for one label key, or None when it cannot list them for this key.
|
|
60
|
+
|
|
61
|
+
``credential`` is called only when the gateway asks for the list, so no sign-in is needed otherwise.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class IntegrationContext:
|
|
67
|
+
"""What an integration may use: the profile, its gateway, a credential and how to call this CLI.
|
|
68
|
+
|
|
69
|
+
``cli`` is the argv prefix that runs this CLI with the same configuration folder; tools that renew
|
|
70
|
+
credentials themselves call ``cli + ("token", "--profile", NAME)`` instead of receiving ``token``.
|
|
71
|
+
``home`` holds the integration's own nonsecret state (what setup changed, for a clean removal).
|
|
72
|
+
``labels`` is the effective label configuration; by default the profile's own labels without central rules.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
profile: Profile
|
|
76
|
+
gateway: Gateway
|
|
77
|
+
token: str = field(repr=False)
|
|
78
|
+
home: Path | None = None
|
|
79
|
+
cli: tuple[str, ...] = (sys.executable, "-I", "-m", "virtual_key_mapper")
|
|
80
|
+
labels: LabelConfig | None = None
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
if self.labels is None:
|
|
84
|
+
object.__setattr__(self, "labels", LabelConfig.build(
|
|
85
|
+
self.gateway.outputs(), profile_labels=self.profile.labels, profile_outputs=self.profile.outputs,
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
def headers(self, attribution: Attribution | None) -> dict[str, str]:
|
|
89
|
+
"""The attribution headers for this context's labels; none without an attribution."""
|
|
90
|
+
return self.labels.headers(attribution) if attribution is not None else {}
|
|
91
|
+
|
|
92
|
+
def cli_command(self, *args: str) -> tuple[str, ...]:
|
|
93
|
+
"""This CLI with the integration's configuration folder, e.g. ``cli_command("token", "--profile", "a")``."""
|
|
94
|
+
return (*self.cli, *(("--home", str(self.home)) if self.home is not None else ()), *args)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Integration(Protocol):
|
|
98
|
+
"""Configures and launches one tool; it only uses its context and the attribution.
|
|
99
|
+
|
|
100
|
+
``attribution`` is the resolved labels (``Attribution``); its headers come from ``context.headers``.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def setup(self, context: IntegrationContext, model: str | None = None, replace: bool = False) -> list[str]:
|
|
104
|
+
"""Write the tool's user-level configuration; return messages for the user."""
|
|
105
|
+
|
|
106
|
+
def remove(self, context: IntegrationContext) -> list[str]:
|
|
107
|
+
"""Undo ``setup``, keeping unrelated configuration."""
|
|
108
|
+
|
|
109
|
+
def command(
|
|
110
|
+
self, context: IntegrationContext, attribution: Attribution | None, args: list[str], model: str | None,
|
|
111
|
+
) -> tuple[list[str], dict[str, str]]: ...
|
|
112
|
+
|
|
113
|
+
def launch(
|
|
114
|
+
self, context: IntegrationContext, attribution: Attribution | None, args: list[str], model: str | None,
|
|
115
|
+
) -> int: ...
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auth adapters: how a profile obtains its gateway credential (see ``registry.AUTH``)."""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The forwarded auth kind: credentials come from the sign-in on the user's local machine.
|
|
2
|
+
|
|
3
|
+
For dev containers and other remote VS Code windows. The Virtual Key Mapper extension running in the
|
|
4
|
+
remote window listens on a local socket and relays each request through VS Code to the companion
|
|
5
|
+
extension on the local machine, which answers with the local CLI (and its OS vault). Only short-lived
|
|
6
|
+
access tokens reach the remote side; the refresh grant never leaves the local vault.
|
|
7
|
+
|
|
8
|
+
Protocol: one JSON line per connection, ``{"version": 1, "operation": "token"|"profile", "profile": NAME}``,
|
|
9
|
+
answered by ``{"ok": true, "result": {...}}`` or ``{"ok": false, "error": "..."}``. A "token" result is
|
|
10
|
+
``{access_token, expires_at, profile, user}``; this module asks only for tokens. The extension asks for
|
|
11
|
+
"profile" to create the remote profile: ``{name, gateway, endpoints, rules, labels, outputs}``, where
|
|
12
|
+
``labels`` and ``outputs`` are the local profile's own definitions (``Profile.definitions()``), not the
|
|
13
|
+
merged ones; the remote CLI fetches the central label rules itself. Labels never travel with tokens: they
|
|
14
|
+
are resolved in the remote window by the remote CLI.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import stat
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Mapping
|
|
24
|
+
|
|
25
|
+
from ..adapters import Credential
|
|
26
|
+
from ..errors import CredentialError
|
|
27
|
+
from ..profiles import Profile, ProfileStore, validate_profile_name
|
|
28
|
+
|
|
29
|
+
AGENT_VARIABLE = "VKM_AGENT"
|
|
30
|
+
TIMEOUT_SECONDS = 120 # The local side may renew the credential first.
|
|
31
|
+
_MAX_ANSWER = 1_048_576
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def settings(values: dict) -> dict:
|
|
35
|
+
"""Optional ``host_profile``: the local profile to use; the local default when absent."""
|
|
36
|
+
if not set(values) <= {"host_profile"}:
|
|
37
|
+
raise CredentialError("Forwarded sign-in takes only --host-profile.")
|
|
38
|
+
if "host_profile" in values:
|
|
39
|
+
validate_profile_name(values["host_profile"])
|
|
40
|
+
return dict(values)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def agent_path(environ: Mapping[str, str] | None = None) -> str:
|
|
44
|
+
"""Where the VS Code extension listens; the same default is computed by the extension."""
|
|
45
|
+
environ = os.environ if environ is None else environ
|
|
46
|
+
if environ.get(AGENT_VARIABLE):
|
|
47
|
+
return environ[AGENT_VARIABLE]
|
|
48
|
+
if environ.get("XDG_RUNTIME_DIR"):
|
|
49
|
+
return str(Path(environ["XDG_RUNTIME_DIR"]) / "vkm" / "agent.sock")
|
|
50
|
+
user = os.getuid() if hasattr(os, "getuid") else os.environ.get("USERNAME", "user")
|
|
51
|
+
return str(Path(tempfile.gettempdir()) / f"vkm-{user}" / "agent.sock")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_owner(path: str) -> None:
|
|
55
|
+
"""Refuse a socket another user could have placed: it must be ours, in a real directory only we (or root) can write."""
|
|
56
|
+
directory, sock = os.lstat(os.path.dirname(path) or "."), os.lstat(path)
|
|
57
|
+
if (
|
|
58
|
+
not stat.S_ISDIR(directory.st_mode) or directory.st_uid not in (os.getuid(), 0) or directory.st_mode & 0o022
|
|
59
|
+
or not stat.S_ISSOCK(sock.st_mode) or sock.st_uid != os.getuid() or sock.st_mode & 0o022
|
|
60
|
+
):
|
|
61
|
+
raise CredentialError(
|
|
62
|
+
f"Refusing the sign-in socket {path}: it must belong to you, in a directory nobody else can write to."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _connect(path: str) -> socket.socket:
|
|
67
|
+
if not hasattr(socket, "AF_UNIX"):
|
|
68
|
+
raise CredentialError("Forwarded sign-in needs a Unix socket; it is meant for Linux dev containers.")
|
|
69
|
+
if hasattr(os, "getuid"):
|
|
70
|
+
_check_owner(path)
|
|
71
|
+
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
72
|
+
connection.settimeout(TIMEOUT_SECONDS)
|
|
73
|
+
connection.connect(path)
|
|
74
|
+
return connection
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def request(operation: str, host_profile: str | None, path: str,
|
|
78
|
+
connect: Callable[[str], socket.socket] | None = None) -> dict:
|
|
79
|
+
"""Ask the local machine through the VS Code extension; raise CredentialError with its message on failure."""
|
|
80
|
+
try:
|
|
81
|
+
connection = (connect or _connect)(path)
|
|
82
|
+
except (OSError, CredentialError) as error:
|
|
83
|
+
if isinstance(error, CredentialError):
|
|
84
|
+
raise
|
|
85
|
+
raise CredentialError(
|
|
86
|
+
"No connection to the sign-in on your local machine. Open this folder in VS Code with the "
|
|
87
|
+
"'Virtual Key Mapper' extension in this container and 'Virtual Key Mapper: local sign-in' in your local VS Code."
|
|
88
|
+
) from None
|
|
89
|
+
with connection:
|
|
90
|
+
try:
|
|
91
|
+
message = {"version": 1, "operation": operation, "profile": host_profile or ""}
|
|
92
|
+
connection.sendall(json.dumps(message).encode() + b"\n")
|
|
93
|
+
raw = b""
|
|
94
|
+
while not raw.endswith(b"\n") and len(raw) <= _MAX_ANSWER:
|
|
95
|
+
chunk = connection.recv(65536)
|
|
96
|
+
if not chunk:
|
|
97
|
+
break
|
|
98
|
+
raw += chunk
|
|
99
|
+
except OSError:
|
|
100
|
+
raise CredentialError("The connection to your local sign-in failed; retry.") from None
|
|
101
|
+
try:
|
|
102
|
+
answer = json.loads(raw)
|
|
103
|
+
except (ValueError, RecursionError):
|
|
104
|
+
raise CredentialError("Your local sign-in sent an unreadable answer; update both VS Code extensions.") from None
|
|
105
|
+
if not isinstance(answer, dict) or answer.get("ok") not in (True, False):
|
|
106
|
+
raise CredentialError("Your local sign-in sent an unexpected answer; update both VS Code extensions.")
|
|
107
|
+
if not answer["ok"]:
|
|
108
|
+
error = answer.get("error")
|
|
109
|
+
raise CredentialError(f"Local sign-in: {error if isinstance(error, str) else 'request failed'}")
|
|
110
|
+
if not isinstance(answer.get("result"), dict):
|
|
111
|
+
raise CredentialError("Your local sign-in sent an unexpected answer; update both VS Code extensions.")
|
|
112
|
+
return answer["result"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Forwarded:
|
|
116
|
+
"""Credentials from the local machine's sign-in, relayed by VS Code; nothing is stored here."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, profile: Profile, store: ProfileStore, secrets: Callable[[], object] | None = None,
|
|
119
|
+
connect: Callable[[str], socket.socket] | None = None) -> None:
|
|
120
|
+
# ``secrets`` (the vault factory) is never called: nothing is stored on this side.
|
|
121
|
+
self.profile = profile
|
|
122
|
+
self.connect = connect
|
|
123
|
+
|
|
124
|
+
def _request(self, operation: str) -> dict:
|
|
125
|
+
return request(operation, self.profile.auth.get("host_profile"), agent_path(), self.connect or _connect)
|
|
126
|
+
|
|
127
|
+
def login(self, notify: Callable[[str], None], **options) -> Credential:
|
|
128
|
+
raise CredentialError(
|
|
129
|
+
f"Profile '{self.profile.name}' uses the sign-in of your local machine: run 'vkm login' "
|
|
130
|
+
"there, in a local terminal."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def logout(self, notify: Callable[[str], None]) -> None:
|
|
134
|
+
raise CredentialError(
|
|
135
|
+
f"Profile '{self.profile.name}' uses the sign-in of your local machine: run 'vkm logout' there."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def status(self) -> dict:
|
|
139
|
+
base = {"profile": self.profile.name, "gateway": self.profile.gateway, "auth": "forwarded", "agent": agent_path()}
|
|
140
|
+
try:
|
|
141
|
+
credential = self.credential()
|
|
142
|
+
except CredentialError as error:
|
|
143
|
+
return {**base, "state": "unavailable", "error": str(error)}
|
|
144
|
+
return {**base, "state": "ready", "user_id": credential.user, "expires_at": credential.expires_at}
|
|
145
|
+
|
|
146
|
+
def credential(self) -> Credential:
|
|
147
|
+
result = self._request("token")
|
|
148
|
+
token, expires_at, user = result.get("access_token"), result.get("expires_at"), result.get("user")
|
|
149
|
+
if (
|
|
150
|
+
not isinstance(token, str) or not token or any(ord(char) < 33 for char in token)
|
|
151
|
+
or not (expires_at is None or type(expires_at) in (int, float))
|
|
152
|
+
or not (user is None or isinstance(user, str))
|
|
153
|
+
):
|
|
154
|
+
raise CredentialError("Your local sign-in returned an invalid token.")
|
|
155
|
+
return Credential(token, expires_at, user)
|