From f5a4edc6325d8ec7d9047f13eafc05c5d6b867b7 Mon Sep 17 00:00:00 2001 From: BANADDA MUBARAKA <83466862+BANADDA@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:34:38 +0300 Subject: [PATCH] mtensor: Microtensor Bridge points Claude Code, Codex, OpenCode, Cursor and VS Code at Microtensor, with snapshots, status, off and restore --- .github/workflows/ci.yml | 28 +++ .gitignore | 1 - LICENSE | 8 + README.md | 83 ++++++- microtensor_cli/__init__.py | 1 + microtensor_cli/__main__.py | 3 + microtensor_cli/api.py | 87 +++++++ microtensor_cli/app.py | 222 ++++++++++++++++++ microtensor_cli/bridge/__init__.py | 0 microtensor_cli/bridge/base.py | 132 +++++++++++ microtensor_cli/bridge/editors/__init__.py | 0 microtensor_cli/bridge/editors/running.py | 25 ++ microtensor_cli/bridge/editors/safestorage.py | 118 ++++++++++ microtensor_cli/bridge/editors/vscdb.py | 53 +++++ microtensor_cli/bridge/harnesses/__init__.py | 12 + .../bridge/harnesses/claude_code.py | 129 ++++++++++ microtensor_cli/bridge/harnesses/codex.py | 120 ++++++++++ microtensor_cli/bridge/harnesses/cursor.py | 189 +++++++++++++++ microtensor_cli/bridge/harnesses/opencode.py | 122 ++++++++++ microtensor_cli/bridge/harnesses/vscode.py | 137 +++++++++++ microtensor_cli/bridge/service.py | 186 +++++++++++++++ microtensor_cli/bridge/snapshot.py | 110 +++++++++ microtensor_cli/catalogue.py | 123 ++++++++++ microtensor_cli/config.py | 112 +++++++++ microtensor_cli/errors.py | 2 + microtensor_cli/files.py | 55 +++++ pyproject.toml | 54 +++++ tests/__init__.py | 0 tests/conftest.py | 84 +++++++ tests/test_bridge.py | 142 +++++++++++ tests/test_safestorage.py | 50 ++++ 31 files changed, 2380 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 microtensor_cli/__init__.py create mode 100644 microtensor_cli/__main__.py create mode 100644 microtensor_cli/api.py create mode 100644 microtensor_cli/app.py create mode 100644 microtensor_cli/bridge/__init__.py create mode 100644 microtensor_cli/bridge/base.py create mode 100644 microtensor_cli/bridge/editors/__init__.py create mode 100644 microtensor_cli/bridge/editors/running.py create mode 100644 microtensor_cli/bridge/editors/safestorage.py create mode 100644 microtensor_cli/bridge/editors/vscdb.py create mode 100644 microtensor_cli/bridge/harnesses/__init__.py create mode 100644 microtensor_cli/bridge/harnesses/claude_code.py create mode 100644 microtensor_cli/bridge/harnesses/codex.py create mode 100644 microtensor_cli/bridge/harnesses/cursor.py create mode 100644 microtensor_cli/bridge/harnesses/opencode.py create mode 100644 microtensor_cli/bridge/harnesses/vscode.py create mode 100644 microtensor_cli/bridge/service.py create mode 100644 microtensor_cli/bridge/snapshot.py create mode 100644 microtensor_cli/catalogue.py create mode 100644 microtensor_cli/config.py create mode 100644 microtensor_cli/errors.py create mode 100644 microtensor_cli/files.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_bridge.py create mode 100644 tests/test_safestorage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b26d496 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - run: pip install ".[dev]" + - run: ruff check . + - run: mypy microtensor_cli + - run: pytest -q diff --git a/.gitignore b/.gitignore index b1d6167..a7906c2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ dist/ .ruff_cache/ .mypy_cache/ .env -*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ab6a7a --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +Copyright (c) 2026 Microtensor. All rights reserved. + +This software is licensed to you solely for use with the Microtensor API, under +the Microtensor terms of service (https://www.microtensor.cloud/terms). +Redistribution, sublicensing or use with any other service is not permitted +without written permission from Microtensor. Models served through Microtensor +remain licensed under their own terms. It is provided as is, without warranty +of any kind. diff --git a/README.md b/README.md index 4d18326..09552e7 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,80 @@ # microtensor-cli -The mt command for Microtensor customers. +The `mtensor` command for Microtensor customers. Its first job is **Microtensor Bridge**: one command points a coding tool you already use at Microtensor models, and one command puts it back. -A light package with no miner or validator dependencies. It talks to -api.microtensor.cloud with an API key and covers the registry (pull a certified -artifact and verify it against its certificate), batch jobs, evaluations and -key management. +## Install -The subnet package plugs its miner and validator commands into this one, so a -single mt command serves both audiences. + uv tool install microtensor-cli + +or + + pipx install microtensor-cli + +Python 3.10 or newer. + +## Sign in + +Create a key at https://www.microtensor.cloud/console/keys, then: + + mtensor login + +The key is saved in `~/.mtensor/credentials.json`, readable only by you. `MICROTENSOR_API_KEY` in the environment works too. + +## Bridge a tool + + mtensor bridge claude-code + mtensor bridge codex + mtensor bridge opencode + mtensor bridge cursor + mtensor bridge vscode + +Options for every tool: + +| Option | What it does | +|---|---| +| `--model ` | The model to use. Default `mt/auto`, which picks a model for each request. `mtensor models` lists the choices. | +| `--key ` | Use a different key, for example one key per tool so usage is split by key. | +| `--dry-run` | Print what would change and write nothing. | +| `--force` | Write even when the tool is running, or when no general model is served yet. | + +Before the first change, Bridge saves a snapshot of everything it is about to touch in `~/.mtensor/bridge//snapshot.json`. + +## What each tool gets + +| Tool | File it edits | What changes | +|---|---|---| +| Claude Code | `~/.claude/settings.json` | `env.ANTHROPIC_BASE_URL`, `env.ANTHROPIC_AUTH_TOKEN`, a tool header, the model and every model tier, and a model picker of Microtensor models. Start a new session afterwards. | +| Codex | `~/.codex/config.toml` | A `[model_providers.microtensor]` table using the Responses API, `model_provider` and `model`. Restart Codex afterwards. | +| OpenCode | `~/.config/opencode/opencode.json` | A `provider.microtensor` block with the models and `model`. Restart OpenCode afterwards. | +| Cursor | Cursor's `state.vscdb` | The OpenAI base URL override, your key in Cursor's own encrypted store, and the Microtensor models. Quit Cursor first. | +| VS Code | `chatLanguageModels.json` and VS Code's `state.vscdb` | A custom endpoint provider with the Microtensor models, and your key in VS Code's secret storage. Quit VS Code first. | + +Limits worth knowing: + +- Claude Code sends every request to Microtensor while bridged, background tasks included, so every tier points at the chosen model. +- Cursor routes custom key requests through its own servers, uses them for chat only, and some agent features refuse custom keys. Tab completion stays on Cursor. +- VS Code uses Microtensor in Chat; inline suggestions stay on Copilot. +- On Linux, Cursor and VS Code keys are stored the way those editors store them without a keyring: obfuscated, not encrypted. + +## Check and undo + + mtensor bridge status + mtensor bridge off claude-code + mtensor bridge restore + +- `status` shows each tool, whether it is bridged and to which model, and its requests once the service records them. +- `off ` removes what Bridge added and puts back the values you had before, keeping any other change you made since. +- `restore` writes every saved snapshot back exactly as it was before the first bridge. + +## Models + + mtensor models + +Lists what Microtensor serves: general models, which coding tools need, and certified specialists, which answer one task each. Until a general model is served, `mtensor bridge` refuses and says so, because the tool would get an error on every request; `--force` writes the settings anyway. + +## Development + + pip install -e ".[dev]" + ruff check . && mypy microtensor_cli && pytest -q + +Docs: https://www.microtensor.cloud/docs/api/getting-started/bridge diff --git a/microtensor_cli/__init__.py b/microtensor_cli/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/microtensor_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/microtensor_cli/__main__.py b/microtensor_cli/__main__.py new file mode 100644 index 0000000..481fd37 --- /dev/null +++ b/microtensor_cli/__main__.py @@ -0,0 +1,3 @@ +from microtensor_cli.app import main + +raise SystemExit(main()) diff --git a/microtensor_cli/api.py b/microtensor_cli/api.py new file mode 100644 index 0000000..7644bb2 --- /dev/null +++ b/microtensor_cli/api.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import platform +from typing import Any + +import httpx + +from microtensor_cli import __version__ +from microtensor_cli.catalogue import Model, parse_list +from microtensor_cli.config import CONSOLE_KEYS_URL, Credentials +from microtensor_cli.errors import CliError + +TIMEOUT = 20.0 +REFUSED = ( + "the key was refused by {base}; create one at " + CONSOLE_KEYS_URL + " and run: mtensor login" +) + + +class Api: + def __init__(self, credentials: Credentials, *, client: httpx.Client | None = None) -> None: + self.credentials = credentials + self._client = client or httpx.Client(timeout=TIMEOUT) + + def _headers(self, tool: str = "") -> dict[str, str]: + found = { + "authorization": f"Bearer {self.credentials.key}", + "accept": "application/json", + "user-agent": f"mtensor/{__version__} ({platform.system().lower()})", + } + if tool: + found["x-microtensor-tool"] = tool + return found + + def _url(self, path: str) -> str: + return f"{self.credentials.base_url}{path}" + + def _send(self, method: str, path: str, *, tool: str = "", body: Any = None) -> httpx.Response: + try: + return self._client.request( + method, self._url(path), headers=self._headers(tool), json=body + ) + except httpx.HTTPError as exc: + raise CliError( + f"could not reach {self.credentials.base_url} ({exc.__class__.__name__})" + ) from exc + + def _refused(self, answer: httpx.Response) -> None: + if answer.status_code in (401, 403): + raise CliError(REFUSED.format(base=self.credentials.base_url)) + + def models(self) -> list[Model]: + answer = self._send("GET", "/v1/models") + self._refused(answer) + if answer.status_code != 200: + raise CliError(f"listing models failed with {answer.status_code}") + return parse_list(answer.json()) + + def register(self, tool: str, model: str, machine: str) -> bool: + body = { + "tool": tool, + "model": model, + "host": machine, + "os": platform.system().lower(), + "client": f"mtensor/{__version__}", + } + try: + answer = self._send("POST", "/v1/bridge/tools", tool=tool, body=body) + except CliError: + return False + return answer.status_code in (200, 201) + + def unregister(self, tool: str, machine: str) -> bool: + try: + answer = self._send("DELETE", f"/v1/bridge/tools/{tool}?host={machine}", tool=tool) + except CliError: + return False + return answer.status_code in (200, 204) + + def bridges(self) -> list[dict[str, Any]] | None: + try: + answer = self._send("GET", "/v1/bridge/tools") + except CliError: + return None + if answer.status_code != 200: + return None + found = answer.json().get("tools") + return [dict(t) for t in found] if isinstance(found, list) else None diff --git a/microtensor_cli/app.py b/microtensor_cli/app.py new file mode 100644 index 0000000..588ae0e --- /dev/null +++ b/microtensor_cli/app.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import getpass +import json +import os +import sys +from collections.abc import Sequence +from dataclasses import asdict + +from microtensor_cli import __version__ +from microtensor_cli.api import Api +from microtensor_cli.bridge import service +from microtensor_cli.bridge.harnesses import HARNESSES +from microtensor_cli.catalogue import context_label +from microtensor_cli.config import ( + CONSOLE_KEYS_URL, + DEFAULT_BASE_URL, + ENV_BASE, + KEY_PREFIX, + Credentials, + clear_credentials, + load_credentials, + normal_base, + save_credentials, +) +from microtensor_cli.errors import CliError + +SIGN_IN = f"sign in first with: mtensor login (create a key at {CONSOLE_KEYS_URL})" + + +def say(line: str) -> None: + print(line) + + +def _credentials(key: str = "") -> Credentials: + held = load_credentials() + if key: + base = held.base_url if held else normal_base(os.environ.get(ENV_BASE) or DEFAULT_BASE_URL) + return Credentials(key=key.strip(), base_url=base, source="--key") + if held is None: + raise CliError(SIGN_IN) + return held + + +def _table(rows: Sequence[Sequence[str]]) -> None: + widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] + for row in rows: + say(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)).rstrip()) + + +def login(args: argparse.Namespace) -> int: + key = (args.key or os.environ.get("MICROTENSOR_API_KEY") or "").strip() + if not key: + key = getpass.getpass(f"Microtensor API key ({KEY_PREFIX}...): ").strip() + if not key.startswith(KEY_PREFIX): + raise CliError(f"that does not look like a Microtensor key; keys start with {KEY_PREFIX}") + base = normal_base(args.base_url or os.environ.get(ENV_BASE) or DEFAULT_BASE_URL) + models = Api(Credentials(key=key, base_url=base, source="login")).models() + path = save_credentials(key, base) + say(f"Signed in. The key is saved in {path}, readable only by you.") + say( + f"{len(models)} models are listed. " + "Next: mtensor bridge , for example mtensor bridge claude-code" + ) + return 0 + + +def logout(_: argparse.Namespace) -> int: + say("Signed out." if clear_credentials() else "No saved key to remove.") + return 0 + + +def models(args: argparse.Namespace) -> int: + found = Api(_credentials()).models() + if args.json: + say(json.dumps([asdict(m) for m in found], indent=2)) + return 0 + rows = [["MODEL", "KIND", "CONTEXT", "TOOLS", "PRICE PER 1M (IN/OUT)", "SERVED NOW"]] + for m in found: + price = ( + f"${m.usd_in_per_mtok:g} / ${m.usd_out_per_mtok:g}" + if m.usd_in_per_mtok is not None and m.usd_out_per_mtok is not None + else "-" + ) + rows.append( + [ + m.id, + m.kind, + context_label(m.context) if m.context else "-", + "yes" if m.tool_calling else "no", + price, + "yes" if m.live else "no", + ] + ) + _table(rows) + if not any(m.general for m in found): + say( + "No general model is served yet; coding tools need one. " + "Certified specialists answer one task each." + ) + return 0 + + +def bridge(args: argparse.Namespace) -> int: + action = args.action + if action == "status": + return bridge_status(args) + if action == "off": + held = load_credentials() + service.off(args.tool, api=Api(held) if held else None, force=args.force, say=say) + return 0 + if action == "restore": + held = load_credentials() + service.restore(api=Api(held) if held else None, force=args.force, say=say) + return 0 + credentials = _credentials(args.key or "") + service.bridge( + action, + credentials=credentials, + api=Api(credentials), + model=args.model or "", + dry_run=args.dry_run, + force=args.force, + say=say, + ) + return 0 + + +def bridge_status(args: argparse.Namespace) -> int: + held = load_credentials() + rows = service.status(api=Api(held) if held else None) + if args.json: + say(json.dumps([r.view() for r in rows], indent=2)) + return 0 + table = [["TOOL", "STATE", "MODEL", "REQUESTS 30D", "LAST REQUEST", "SETTINGS"]] + for r in rows: + table.append( + [ + r.tool, + "bridged" if r.bridged else "not bridged", + r.model or "-", + "-" if r.requests is None else str(r.requests), + r.last_seen or "-", + r.where, + ] + ) + _table(table) + if held is None: + say(SIGN_IN) + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + prog="mtensor", + description=( + "Microtensor from the command line. Bridge your coding tools to Microtensor models." + ), + ) + root.add_argument("--version", action="version", version=f"mtensor {__version__}") + commands = root.add_subparsers(dest="command", required=True, metavar="command") + + signin = commands.add_parser("login", help="save an API key for this machine") + signin.add_argument("--key", help="the key; asked for when left out") + signin.add_argument("--base-url", help=f"the API host (default {DEFAULT_BASE_URL})") + signin.set_defaults(run=login) + + signout = commands.add_parser("logout", help="remove the saved key") + signout.set_defaults(run=logout) + + listing = commands.add_parser("models", help="list the models Microtensor serves") + listing.add_argument("--json", action="store_true", help="print JSON") + listing.set_defaults(run=models) + + bridging = commands.add_parser( + "bridge", + help="point a coding tool at Microtensor, check it, or undo it", + description=( + "Point a coding tool at Microtensor. Each tool's settings are saved first, " + "so off and restore put them back." + ), + ) + actions = bridging.add_subparsers( + dest="action", required=True, metavar="{tool,status,off,restore}" + ) + for name, h in HARNESSES.items(): + tool = actions.add_parser(name, help=f"bridge {h.title}") + tool.add_argument("--model", help="the model to use (default mt/auto)") + tool.add_argument( + "--key", help="use this key instead of the saved one, for example a key per tool" + ) + tool.add_argument( + "--dry-run", action="store_true", help="show what would change and write nothing" + ) + tool.add_argument( + "--force", + action="store_true", + help="write even if the tool is running or no general model is served", + ) + check = actions.add_parser("status", help="which tools are bridged and to which model") + check.add_argument("--json", action="store_true", help="print JSON") + undo = actions.add_parser("off", help="put one tool back on its own settings") + undo.add_argument("tool", choices=list(HARNESSES)) + undo.add_argument("--force", action="store_true", help="write even if the tool is running") + back = actions.add_parser("restore", help="restore every tool from its saved snapshot") + back.add_argument("--force", action="store_true", help="write even if a tool is running") + bridging.set_defaults(run=bridge) + return root + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + code: int = args.run(args) + return code + except CliError as exc: + print(f"mtensor: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("", file=sys.stderr) + return 130 diff --git a/microtensor_cli/bridge/__init__.py b/microtensor_cli/bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microtensor_cli/bridge/base.py b/microtensor_cli/bridge/base.py new file mode 100644 index 0000000..df68d27 --- /dev/null +++ b/microtensor_cli/bridge/base.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import copy +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + +from microtensor_cli.catalogue import Model +from microtensor_cli.files import dump_json + +MISSING: Any = object() +TOOL_HEADER = "X-Microtensor-Tool" + + +@dataclass(slots=True) +class Capture: + files: dict[str, bytes | None] = field(default_factory=dict) + rows: dict[str, dict[str, str | None]] = field(default_factory=dict) + + def file(self, path: Path) -> bytes | None: + return self.files.get(str(path)) + + def row(self, db: Path, key: str) -> str | None: + return self.rows.get(str(db), {}).get(key) + + +@dataclass(frozen=True, slots=True) +class Targets: + files: tuple[Path, ...] = () + rows: tuple[tuple[Path, tuple[str, ...]], ...] = () + + +@dataclass(frozen=True, slots=True) +class Context: + key: str + base_url: str + model: Model + models: tuple[Model, ...] + + +@dataclass(slots=True) +class Change: + files: dict[Path, bytes | None] = field(default_factory=dict) + rows: dict[Path, dict[str, str | None]] = field(default_factory=dict) + private: set[Path] = field(default_factory=set) + changed: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class State: + bridged: bool + model: str + where: str + + +class Harness(Protocol): + name: str + title: str + after: str + processes: Mapping[str, str] + + def installed(self) -> str: ... + + def targets(self) -> Targets: ... + + def apply(self, current: Capture, ctx: Context) -> Change: ... + + def revert(self, current: Capture, snapshot: Capture) -> Change: ... + + def state(self, current: Capture) -> State: ... + + +def get_path(doc: Any, path: Sequence[str]) -> Any: + found = doc + for part in path: + if not isinstance(found, dict) or part not in found: + return MISSING + found = found[part] + return found + + +def set_path(doc: dict[str, Any], path: Sequence[str], value: Any) -> None: + found = doc + for part in path[:-1]: + nxt = found.get(part) + if not isinstance(nxt, dict): + nxt = {} + found[part] = nxt + found = nxt + found[path[-1]] = value + + +def del_path(doc: dict[str, Any], path: Sequence[str]) -> None: + parent = get_path(doc, path[:-1]) if len(path) > 1 else doc + if isinstance(parent, dict): + parent.pop(path[-1], None) + + +def restore_paths( + now: dict[str, Any], then: dict[str, Any], paths: Iterable[Sequence[str]] +) -> None: + for path in paths: + old = get_path(then, path) + if old is MISSING: + del_path(now, path) + else: + set_path(now, path, copy.deepcopy(old)) + + +def prune(now: dict[str, Any], then: dict[str, Any], parents: Iterable[Sequence[str]]) -> None: + for path in parents: + held = get_path(now, path) + if held == {} and get_path(then, path) is MISSING: + del_path(now, path) + + +def finish_json(now: Any, then: Any, then_raw: bytes | None, empty: Any) -> bytes | None: + if now == then: + return then_raw + if then_raw is None and now == empty: + return None + return dump_json(now) + + +def model_rows(models: Sequence[Model]) -> list[str]: + return [m.id for m in models] + + +def as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} diff --git a/microtensor_cli/bridge/editors/__init__.py b/microtensor_cli/bridge/editors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/microtensor_cli/bridge/editors/running.py b/microtensor_cli/bridge/editors/running.py new file mode 100644 index 0000000..8cbdfad --- /dev/null +++ b/microtensor_cli/bridge/editors/running.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Mapping + + +def running(processes: Mapping[str, str]) -> bool: + name = processes.get(sys.platform) or processes.get("linux", "") + if not name: + return False + try: + if sys.platform == "win32": + found = subprocess.run( + ["tasklist", "/FI", f"IMAGENAME eq {name}", "/NH", "/FO", "CSV"], + capture_output=True, + text=True, + check=False, + timeout=20, + ) + return f'"{name.lower()}"' in found.stdout.lower() + found = subprocess.run(["pgrep", "-x", name], capture_output=True, check=False, timeout=20) + except (OSError, subprocess.TimeoutExpired): + return False + return found.returncode == 0 diff --git a/microtensor_cli/bridge/editors/safestorage.py b/microtensor_cli/bridge/editors/safestorage.py new file mode 100644 index 0000000..970bb7f --- /dev/null +++ b/microtensor_cli/bridge/editors/safestorage.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +VERSION = b"v10" +SALT = b"saltysalt" +IV = b" " * 16 +MAC_ITERATIONS = 1003 +LINUX_ITERATIONS = 1 +LINUX_PASSWORD = b"peanuts" +DPAPI_PREFIX = b"DPAPI" +NONCE_BYTES = 12 + + +class SafeStorageError(RuntimeError): + pass + + +def cbc_key(password: bytes, iterations: int) -> bytes: + kdf = PBKDF2HMAC(algorithm=hashes.SHA1(), length=16, salt=SALT, iterations=iterations) + return kdf.derive(password) + + +def cbc_encrypt(password: bytes, iterations: int, plaintext: bytes) -> bytes: + padder = padding.PKCS7(128).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher(algorithms.AES(cbc_key(password, iterations)), modes.CBC(IV)).encryptor() + return VERSION + encryptor.update(padded) + encryptor.finalize() + + +def gcm_encrypt(key: bytes, plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + return VERSION + nonce + AESGCM(key).encrypt(nonce, plaintext, None) + + +def _mac_password(app: str) -> bytes: + try: + found = subprocess.run( + ["security", "find-generic-password", "-w", "-s", f"{app} Safe Storage"], + capture_output=True, + text=True, + check=False, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SafeStorageError(f"the macOS keychain could not be read ({exc})") from exc + if found.returncode != 0 or not found.stdout.strip(): + raise SafeStorageError( + f"no '{app} Safe Storage' entry in the keychain; open {app} once first" + ) + return found.stdout.strip().encode("utf-8") + + +def _local_state_key(local_state: Path) -> bytes: + try: + held = json.loads(local_state.read_text(encoding="utf-8")) + encoded = held["os_crypt"]["encrypted_key"] + except (OSError, ValueError, KeyError, TypeError) as exc: + raise SafeStorageError( + f"{local_state} has no encryption key; open the editor once first" + ) from exc + blob = base64.b64decode(encoded) + if not blob.startswith(DPAPI_PREFIX): + raise SafeStorageError(f"{local_state} uses a key format this version cannot read") + return _unprotect(blob[len(DPAPI_PREFIX) :]) + + +if sys.platform == "win32": + import ctypes + from ctypes import wintypes + + class _Blob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))] + + def _unprotect(data: bytes) -> bytes: + buffer = ctypes.create_string_buffer(data, len(data)) + source = _Blob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))) + target = _Blob() + if not ctypes.windll.crypt32.CryptUnprotectData( + ctypes.byref(source), None, None, None, None, 0, ctypes.byref(target) + ): + raise SafeStorageError("Windows refused to unlock the editor's encryption key") + try: + return ctypes.string_at(target.pbData, target.cbData) + finally: + ctypes.windll.kernel32.LocalFree(target.pbData) + +else: + + def _unprotect(data: bytes) -> bytes: + raise SafeStorageError("the editor key is protected by Windows and cannot be read here") + + +def encrypt(app: str, local_state: Path, plaintext: str) -> tuple[bytes, str]: + data = plaintext.encode("utf-8") + if sys.platform == "win32": + return gcm_encrypt(_local_state_key(local_state), data), "" + if sys.platform == "darwin": + return cbc_encrypt(_mac_password(app), MAC_ITERATIONS, data), "" + note = ( + f"On Linux the key is stored the way {app} stores it without a keyring: obfuscated, " + "not encrypted. Anyone who can read your home directory can read it." + ) + return cbc_encrypt(LINUX_PASSWORD, LINUX_ITERATIONS, data), note + + +def buffer_json(blob: bytes) -> str: + return json.dumps({"type": "Buffer", "data": list(blob)}, separators=(",", ":")) diff --git a/microtensor_cli/bridge/editors/vscdb.py b/microtensor_cli/bridge/editors/vscdb.py new file mode 100644 index 0000000..00560c0 --- /dev/null +++ b/microtensor_cli/bridge/editors/vscdb.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sqlite3 +from collections.abc import Mapping, Sequence +from contextlib import closing +from pathlib import Path + +from microtensor_cli.errors import CliError + +TABLE = "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)" + + +def _text(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bytes): + return value.decode("utf-8") + return str(value) + + +def read_rows(db: Path, keys: Sequence[str]) -> dict[str, str | None]: + found: dict[str, str | None] = dict.fromkeys(keys) + if not db.exists(): + return found + try: + with closing(sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True)) as conn: + for key in keys: + row = conn.execute("SELECT value FROM ItemTable WHERE key = ?", (key,)).fetchone() + found[key] = _text(row[0]) if row else None + except sqlite3.OperationalError as exc: + if "no such table" in str(exc): + return found + raise CliError(f"could not read {db} ({exc})") from exc + return found + + +def write_rows(db: Path, rows: Mapping[str, str | None]) -> None: + if not rows: + return + db.parent.mkdir(parents=True, exist_ok=True) + try: + with closing(sqlite3.connect(db, timeout=5)) as conn: + conn.execute(TABLE) + for key, value in rows.items(): + if value is None: + conn.execute("DELETE FROM ItemTable WHERE key = ?", (key,)) + else: + conn.execute( + "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)", (key, value) + ) + conn.commit() + except sqlite3.OperationalError as exc: + raise CliError(f"could not write {db} ({exc}); quit the editor and try again") from exc diff --git a/microtensor_cli/bridge/harnesses/__init__.py b/microtensor_cli/bridge/harnesses/__init__.py new file mode 100644 index 0000000..7da62d5 --- /dev/null +++ b/microtensor_cli/bridge/harnesses/__init__.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from microtensor_cli.bridge.base import Harness +from microtensor_cli.bridge.harnesses.claude_code import ClaudeCode +from microtensor_cli.bridge.harnesses.codex import Codex +from microtensor_cli.bridge.harnesses.cursor import Cursor +from microtensor_cli.bridge.harnesses.opencode import OpenCode +from microtensor_cli.bridge.harnesses.vscode import VSCode + +HARNESSES: dict[str, Harness] = { + h.name: h for h in (ClaudeCode(), Codex(), OpenCode(), Cursor(), VSCode()) +} diff --git a/microtensor_cli/bridge/harnesses/claude_code.py b/microtensor_cli/bridge/harnesses/claude_code.py new file mode 100644 index 0000000..a2ad45f --- /dev/null +++ b/microtensor_cli/bridge/harnesses/claude_code.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from microtensor_cli.bridge.base import ( + TOOL_HEADER, + Capture, + Change, + Context, + State, + Targets, + as_dict, + finish_json, + prune, + restore_paths, +) +from microtensor_cli.catalogue import LONG_CONTEXT, Model +from microtensor_cli.config import user_home +from microtensor_cli.errors import CliError +from microtensor_cli.files import dump_json, load_json + +NAME = "claude-code" +TIERS = ( + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", +) +ENV_KEYS = ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_CUSTOM_HEADERS", *TIERS) +TOP_KEYS = ("model", "modelPicker", "apiKeyHelper") +HEADER = f"{TOOL_HEADER}: {NAME}" + + +def settings_path() -> Path: + found = os.environ.get("CLAUDE_CONFIG_DIR") + root = Path(found) if found else user_home() / ".claude" + return root / "settings.json" + + +def picker_id(model: Model) -> str: + return f"{model.id}[1m]" if model.context >= LONG_CONTEXT else model.id + + +def _doc(raw: bytes | None, path: Path) -> dict[str, Any]: + found = load_json(raw, path, empty={}) + if not isinstance(found, dict): + raise CliError(f"{path} does not hold a JSON object, so it was left untouched") + return found + + +class ClaudeCode: + name = NAME + title = "Claude Code" + after = "Start a new Claude Code session to use it." + processes: Mapping[str, str] = MappingProxyType({}) + + def installed(self) -> str: + return "" + + def targets(self) -> Targets: + return Targets(files=(settings_path(),)) + + def apply(self, current: Capture, ctx: Context) -> Change: + path = settings_path() + doc = _doc(current.file(path), path) + env = doc.get("env") + if not isinstance(env, dict): + env = {} + doc["env"] = env + chosen = picker_id(ctx.model) + env["ANTHROPIC_BASE_URL"] = ctx.base_url + env["ANTHROPIC_AUTH_TOKEN"] = ctx.key + env["ANTHROPIC_CUSTOM_HEADERS"] = HEADER + for var in TIERS: + env[var] = chosen + doc["model"] = chosen + helper = doc.pop("apiKeyHelper", None) + doc["modelPicker"] = { + "replaceBuiltInOptions": True, + "options": [ + {"model": picker_id(m), "label": m.label, "description": m.describe()} + for m in ctx.models + ], + } + change = Change() + change.files[path] = dump_json(doc) + change.private.add(path) + change.changed = [ + f"{path}: env.ANTHROPIC_BASE_URL, env.ANTHROPIC_AUTH_TOKEN, " + "env.ANTHROPIC_CUSTOM_HEADERS", + f"{path}: model and every model tier set to {chosen}", + f"{path}: modelPicker lists {len(ctx.models)} Microtensor models", + ] + if helper is not None: + change.changed.append(f"{path}: apiKeyHelper removed while bridged") + change.notes.append( + "Claude Code sends every request to Microtensor while bridged, including " + "background tasks, so all tiers point at the chosen model." + ) + return change + + def revert(self, current: Capture, snapshot: Capture) -> Change: + path = settings_path() + then_raw = snapshot.file(path) + then = _doc(then_raw, path) + now = _doc(current.file(path), path) + restore_paths(now, then, [("env", k) for k in ENV_KEYS]) + restore_paths(now, then, [(k,) for k in TOP_KEYS]) + prune(now, then, [("env",)]) + change = Change() + change.files[path] = finish_json(now, then, then_raw, {}) + change.changed = [f"{path}: Microtensor settings removed, your earlier values put back"] + return change + + def state(self, current: Capture) -> State: + path = settings_path() + try: + doc = _doc(current.file(path), path) + except CliError: + return State(bridged=False, model="", where=str(path)) + env = as_dict(doc.get("env")) + bridged = TOOL_HEADER in str(env.get("ANTHROPIC_CUSTOM_HEADERS", "")) + model = str(doc.get("model", "")).removesuffix("[1m]") if bridged else "" + return State(bridged=bridged, model=model, where=str(path)) diff --git a/microtensor_cli/bridge/harnesses/codex.py b/microtensor_cli/bridge/harnesses/codex.py new file mode 100644 index 0000000..e1ce731 --- /dev/null +++ b/microtensor_cli/bridge/harnesses/codex.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import copy +import os +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import tomlkit +from tomlkit.exceptions import TOMLKitError +from tomlkit.toml_document import TOMLDocument + +from microtensor_cli.bridge.base import TOOL_HEADER, Capture, Change, Context, State, Targets +from microtensor_cli.config import user_home +from microtensor_cli.errors import CliError + +NAME = "codex" +PROVIDER = "microtensor" +TOP_KEYS = ("model_provider", "model") + + +def config_path() -> Path: + found = os.environ.get("CODEX_HOME") + root = Path(found) if found else user_home() / ".codex" + return root / "config.toml" + + +def _doc(raw: bytes | None, path: Path) -> TOMLDocument: + try: + return tomlkit.parse((raw or b"").decode("utf-8-sig")) + except (UnicodeDecodeError, TOMLKitError) as exc: + raise CliError(f"{path} is not valid TOML ({exc}), so it was left untouched") from exc + + +def _plain(doc: TOMLDocument) -> dict[str, Any]: + return dict(doc.unwrap()) + + +class Codex: + name = NAME + title = "Codex" + after = ( + "Restart Codex to use it. Resume older sessions with: " + "codex resume -c model_provider=microtensor" + ) + processes: Mapping[str, str] = MappingProxyType({}) + + def installed(self) -> str: + return "" + + def targets(self) -> Targets: + return Targets(files=(config_path(),)) + + def apply(self, current: Capture, ctx: Context) -> Change: + path = config_path() + doc = _doc(current.file(path), path) + doc["model_provider"] = PROVIDER + doc["model"] = ctx.model.id + providers = doc.get("model_providers") + if providers is None: + providers = tomlkit.table(is_super_table=True) + doc["model_providers"] = providers + entry = tomlkit.table() + entry["name"] = "Microtensor" + entry["base_url"] = f"{ctx.base_url}/v1" + entry["wire_api"] = "responses" + headers = tomlkit.inline_table() + headers["x-api-key"] = ctx.key + headers[TOOL_HEADER] = NAME + entry["http_headers"] = headers + providers[PROVIDER] = entry + change = Change() + change.files[path] = tomlkit.dumps(doc).encode("utf-8") + change.private.add(path) + change.changed = [ + f"{path}: model_provider = {PROVIDER}, model = {ctx.model.id}", + f"{path}: [model_providers.{PROVIDER}] with the Responses API and your key", + ] + return change + + def revert(self, current: Capture, snapshot: Capture) -> Change: + path = config_path() + then_raw = snapshot.file(path) + then = _doc(then_raw, path) + now = _doc(current.file(path), path) + for key in TOP_KEYS: + if key in then: + now[key] = copy.deepcopy(then[key]) + elif key in now: + del now[key] + then_providers = then.get("model_providers") + now_providers = now.get("model_providers") + if now_providers is not None: + if then_providers is not None and PROVIDER in then_providers: + now_providers[PROVIDER] = copy.deepcopy(then_providers[PROVIDER]) + elif PROVIDER in now_providers: + del now_providers[PROVIDER] + if not now_providers and then_providers is None: + del now["model_providers"] + change = Change() + if _plain(now) == _plain(then): + change.files[path] = then_raw + elif then_raw is None and not _plain(now): + change.files[path] = None + else: + change.files[path] = tomlkit.dumps(now).encode("utf-8") + change.changed = [f"{path}: Microtensor provider removed, your earlier values put back"] + return change + + def state(self, current: Capture) -> State: + path = config_path() + try: + doc = _doc(current.file(path), path) + except CliError: + return State(bridged=False, model="", where=str(path)) + bridged = doc.get("model_provider") == PROVIDER + return State( + bridged=bridged, model=str(doc.get("model", "")) if bridged else "", where=str(path) + ) diff --git a/microtensor_cli/bridge/harnesses/cursor.py b/microtensor_cli/bridge/harnesses/cursor.py new file mode 100644 index 0000000..4bb7184 --- /dev/null +++ b/microtensor_cli/bridge/harnesses/cursor.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import copy +import json +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from microtensor_cli.bridge.base import ( + MISSING, + Capture, + Change, + Context, + State, + Targets, + as_dict, + get_path, +) +from microtensor_cli.bridge.editors.safestorage import SafeStorageError, buffer_json, encrypt +from microtensor_cli.config import app_data + +NAME = "cursor" +APP_KEY = ( + "src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl" + ".persistentStorage.applicationUser" +) +SEALED_ROW = "secret://cursorAuth/openAIKey" +LEGACY_KEY = "cursorAuth/openAIKey" +MARKER = "mtensorAddedModels" +LISTS = ("userAddedModels", "modelOverrideEnabled") +DEFAULT_MODE = "composer" +TOP_KEYS = ("openAIBaseUrl", "useOpenAIKey") + + +def user_data() -> Path: + return app_data() / "Cursor" + + +def db_path() -> Path: + return user_data() / "User" / "globalStorage" / "state.vscdb" + + +def local_state() -> Path: + return user_data() / "Local State" + + +def _blob(value: str | None) -> dict[str, Any]: + if not value: + return {} + try: + found = json.loads(value) + except json.JSONDecodeError: + return {} + return found if isinstance(found, dict) else {} + + +def _compact(doc: dict[str, Any]) -> str: + return json.dumps(doc, separators=(",", ":"), ensure_ascii=False) + + +class Cursor: + name = NAME + title = "Cursor" + after = "Open Cursor and pick a Microtensor model in the model menu." + processes: Mapping[str, str] = MappingProxyType( + {"win32": "Cursor.exe", "darwin": "Cursor", "linux": "cursor"} + ) + + def installed(self) -> str: + return ( + "" + if user_data().exists() + else f"Cursor does not seem to be installed here (no {user_data()})" + ) + + def targets(self) -> Targets: + return Targets(rows=((db_path(), (APP_KEY, SEALED_ROW, LEGACY_KEY)),)) + + def apply(self, current: Capture, ctx: Context) -> Change: + db = db_path() + blob = _blob(current.row(db, APP_KEY)) + ids = [m.id for m in ctx.models] + added = [str(i) for i in blob.get(MARKER) or []] + ai = blob.get("aiSettings") + if not isinstance(ai, dict): + ai = {} + blob["aiSettings"] = ai + for field in LISTS: + kept = [x for x in ai.get(field) or [] if x not in added] + ai[field] = kept + [i for i in ids if i not in kept] + blob[MARKER] = ids + configs = ai.get("modelConfig") + if not isinstance(configs, dict): + configs = {} + ai["modelConfig"] = configs + for mode in {*configs, DEFAULT_MODE}: + held = as_dict(configs.get(mode)) + configs[mode] = { + **held, + "modelName": ctx.model.id, + "selectedModels": [{"modelId": ctx.model.id, "parameters": []}], + } + blob["openAIBaseUrl"] = f"{ctx.base_url}/v1" + blob["useOpenAIKey"] = True + rows: dict[str, str | None] = {APP_KEY: _compact(blob)} + change = Change() + try: + sealed, note = encrypt("Cursor", local_state(), ctx.key) + rows[SEALED_ROW] = buffer_json(sealed) + if note: + change.notes.append(note) + except SafeStorageError as exc: + rows[LEGACY_KEY] = ctx.key + change.notes.append( + f"Cursor's key store could not be written ({exc}). If Cursor asks for an " + "OpenAI API key, paste your Microtensor key in Settings, Models." + ) + change.rows[db] = rows + change.changed = [ + f"{db}: OpenAI base URL override set to {ctx.base_url}/v1 with your key", + f"{db}: {len(ids)} Microtensor models added, {ctx.model.id} selected", + ] + change.notes.append( + "Cursor sends custom key requests through its own servers, uses them for chat " + "only, and some agent features refuse custom keys. Tab completion stays on Cursor." + ) + return change + + def revert(self, current: Capture, snapshot: Capture) -> Change: + db = db_path() + then_value = snapshot.row(db, APP_KEY) + then = _blob(then_value) + now = _blob(current.row(db, APP_KEY)) + added = {str(i) for i in now.get(MARKER) or []} + for key in TOP_KEYS: + if key in then: + now[key] = copy.deepcopy(then[key]) + else: + now.pop(key, None) + now.pop(MARKER, None) + ai = now.get("aiSettings") + then_ai = as_dict(then.get("aiSettings")) + if isinstance(ai, dict): + for field in LISTS: + before = then_ai.get(field) or [] + kept = [x for x in ai.get(field) or [] if x not in added or x in before] + if kept or field in then_ai: + ai[field] = kept + else: + ai.pop(field, None) + configs = ai.get("modelConfig") + then_configs = as_dict(then_ai.get("modelConfig")) + if isinstance(configs, dict): + for mode in list(configs): + if mode in then_configs: + configs[mode] = copy.deepcopy(then_configs[mode]) + else: + del configs[mode] + if not configs and get_path(then, ("aiSettings", "modelConfig")) is MISSING: + ai.pop("modelConfig", None) + if not ai and "aiSettings" not in then: + now.pop("aiSettings", None) + if now == then: + value = then_value + elif then_value is None and not now: + value = None + else: + value = _compact(now) + change = Change() + change.rows[db] = { + APP_KEY: value, + SEALED_ROW: snapshot.row(db, SEALED_ROW), + LEGACY_KEY: snapshot.row(db, LEGACY_KEY), + } + change.changed = [ + f"{db}: base URL, key and Microtensor models removed, your settings put back" + ] + return change + + def state(self, current: Capture) -> State: + db = db_path() + blob = _blob(current.row(db, APP_KEY)) + bridged = MARKER in blob + model = "" + if bridged: + found = get_path(blob, ("aiSettings", "modelConfig", DEFAULT_MODE, "modelName")) + model = "" if found is MISSING else str(found) + return State(bridged=bridged, model=model, where=str(db)) diff --git a/microtensor_cli/bridge/harnesses/opencode.py b/microtensor_cli/bridge/harnesses/opencode.py new file mode 100644 index 0000000..2d244d7 --- /dev/null +++ b/microtensor_cli/bridge/harnesses/opencode.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from microtensor_cli.bridge.base import ( + TOOL_HEADER, + Capture, + Change, + Context, + State, + Targets, + as_dict, + finish_json, + prune, + restore_paths, +) +from microtensor_cli.catalogue import DEFAULT_CONTEXT, DEFAULT_OUTPUT, Model +from microtensor_cli.config import xdg_config +from microtensor_cli.errors import CliError +from microtensor_cli.files import dump_json, load_json + +NAME = "opencode" +PROVIDER = "microtensor" +SCHEMA = "https://opencode.ai/config.json" + + +def config_path() -> Path: + return xdg_config() / "opencode" / "opencode.json" + + +def _doc(raw: bytes | None, path: Path) -> dict[str, Any]: + found = load_json(raw, path, empty={}) + if not isinstance(found, dict): + raise CliError(f"{path} does not hold a JSON object, so it was left untouched") + return found + + +def _model(m: Model) -> dict[str, Any]: + entry: dict[str, Any] = { + "name": m.label, + "limit": { + "context": m.context or DEFAULT_CONTEXT, + "output": m.max_output or DEFAULT_OUTPUT, + }, + "tool_call": m.tool_calling, + } + if m.vision: + entry["modalities"] = {"input": ["text", "image"], "output": ["text"]} + return entry + + +class OpenCode: + name = NAME + title = "OpenCode" + after = "Restart OpenCode and pick a model with /models." + processes: Mapping[str, str] = MappingProxyType({}) + + def installed(self) -> str: + return "" + + def targets(self) -> Targets: + return Targets(files=(config_path(),)) + + def apply(self, current: Capture, ctx: Context) -> Change: + path = config_path() + raw = current.file(path) + doc = _doc(raw, path) + if raw is None: + doc["$schema"] = SCHEMA + provider = doc.get("provider") + if not isinstance(provider, dict): + provider = {} + doc["provider"] = provider + provider[PROVIDER] = { + "npm": "@ai-sdk/openai-compatible", + "name": "Microtensor", + "options": { + "baseURL": f"{ctx.base_url}/v1", + "apiKey": ctx.key, + "headers": {TOOL_HEADER: NAME}, + }, + "models": {m.id: _model(m) for m in ctx.models}, + } + doc["model"] = f"{PROVIDER}/{ctx.model.id}" + change = Change() + change.files[path] = dump_json(doc) + change.private.add(path) + change.changed = [ + f"{path}: provider.{PROVIDER} with {len(ctx.models)} models and your key", + f"{path}: model = {PROVIDER}/{ctx.model.id}", + ] + if (path.parent / "opencode.jsonc").exists(): + change.notes.append( + f"{path.parent / 'opencode.jsonc'} also exists; OpenCode merges both files." + ) + return change + + def revert(self, current: Capture, snapshot: Capture) -> Change: + path = config_path() + then_raw = snapshot.file(path) + then = _doc(then_raw, path) + now = _doc(current.file(path), path) + restore_paths(now, then, [("model",), ("provider", PROVIDER), ("$schema",)]) + prune(now, then, [("provider",)]) + change = Change() + change.files[path] = finish_json(now, then, then_raw, {}) + change.changed = [f"{path}: Microtensor provider removed, your earlier values put back"] + return change + + def state(self, current: Capture) -> State: + path = config_path() + try: + doc = _doc(current.file(path), path) + except CliError: + return State(bridged=False, model="", where=str(path)) + provider = as_dict(doc.get("provider")) + bridged = PROVIDER in provider + model = str(doc.get("model", "")).removeprefix(f"{PROVIDER}/") if bridged else "" + return State(bridged=bridged, model=model, where=str(path)) diff --git a/microtensor_cli/bridge/harnesses/vscode.py b/microtensor_cli/bridge/harnesses/vscode.py new file mode 100644 index 0000000..ea16947 --- /dev/null +++ b/microtensor_cli/bridge/harnesses/vscode.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any + +from microtensor_cli.bridge.base import ( + TOOL_HEADER, + Capture, + Change, + Context, + State, + Targets, + finish_json, +) +from microtensor_cli.bridge.editors.safestorage import SafeStorageError, buffer_json, encrypt +from microtensor_cli.catalogue import DEFAULT_CONTEXT, DEFAULT_OUTPUT +from microtensor_cli.config import app_data +from microtensor_cli.errors import CliError +from microtensor_cli.files import dump_json, load_json + +NAME = "vscode" +VAULT_ID = "chat.lm.secret.microtensor" +SEALED_ROW = f"secret://{VAULT_ID}" +INPUT = "${input:" + VAULT_ID + "}" + + +def user_data() -> Path: + return app_data() / "Code" + + +def models_path() -> Path: + return user_data() / "User" / "chatLanguageModels.json" + + +def db_path() -> Path: + return user_data() / "User" / "globalStorage" / "state.vscdb" + + +def local_state() -> Path: + return user_data() / "Local State" + + +def _ours(entry: Any) -> bool: + return isinstance(entry, dict) and entry.get("apiKey") == INPUT + + +def _doc(raw: bytes | None, path: Path) -> list[Any]: + found = load_json(raw, path, empty=[]) + if not isinstance(found, list): + raise CliError(f"{path} does not hold a JSON list, so it was left untouched") + return found + + +class VSCode: + name = NAME + title = "VS Code" + after = "Open VS Code, then pick a Microtensor model in the Chat model picker." + processes: Mapping[str, str] = MappingProxyType( + {"win32": "Code.exe", "darwin": "Code", "linux": "code"} + ) + + def installed(self) -> str: + return ( + "" + if user_data().exists() + else f"VS Code does not seem to be installed here (no {user_data()})" + ) + + def targets(self) -> Targets: + return Targets(files=(models_path(),), rows=((db_path(), (SEALED_ROW,)),)) + + def apply(self, current: Capture, ctx: Context) -> Change: + path = models_path() + doc = [e for e in _doc(current.file(path), path) if not _ours(e)] + doc.append( + { + "name": "Microtensor", + "vendor": "customendpoint", + "apiType": "chat-completions", + "apiKey": INPUT, + "models": [ + { + "id": m.id, + "name": m.label, + "url": f"{ctx.base_url}/v1/chat/completions", + "toolCalling": m.tool_calling, + "vision": m.vision, + "maxInputTokens": m.context or DEFAULT_CONTEXT, + "maxOutputTokens": m.max_output or DEFAULT_OUTPUT, + "requestHeaders": {TOOL_HEADER: NAME}, + } + for m in ctx.models + ], + } + ) + change = Change() + change.files[path] = dump_json(doc) + change.changed = [f"{path}: a Microtensor provider with {len(ctx.models)} models"] + try: + sealed, note = encrypt("Code", local_state(), ctx.key) + change.rows[db_path()] = {SEALED_ROW: buffer_json(sealed)} + change.changed.append(f"{db_path()}: your key in VS Code's secret storage") + if note: + change.notes.append(note) + except SafeStorageError as exc: + change.notes.append( + f"VS Code's secret storage could not be written ({exc}). VS Code asks for the " + "API key the first time you pick a Microtensor model; paste your key there." + ) + change.notes.append("Inline suggestions stay on Copilot; Microtensor answers in Chat.") + return change + + def revert(self, current: Capture, snapshot: Capture) -> Change: + path = models_path() + then_raw = snapshot.file(path) + then = _doc(then_raw, path) + now = [e for e in _doc(current.file(path), path) if not _ours(e)] + now.extend(e for e in then if _ours(e)) + change = Change() + change.files[path] = finish_json(now, then, then_raw, []) + change.rows[db_path()] = {SEALED_ROW: snapshot.row(db_path(), SEALED_ROW)} + change.changed = [f"{path}: Microtensor provider removed, your models put back"] + return change + + def state(self, current: Capture) -> State: + path = models_path() + try: + doc = _doc(current.file(path), path) + except CliError: + return State(bridged=False, model="", where=str(path)) + ours = next((e for e in doc if _ours(e)), None) + model = "" + if ours and ours.get("models"): + model = str(ours["models"][0].get("id", "")) + return State(bridged=ours is not None, model=model, where=str(path)) diff --git a/microtensor_cli/bridge/service.py b/microtensor_cli/bridge/service.py new file mode 100644 index 0000000..ed0bdfc --- /dev/null +++ b/microtensor_cli/bridge/service.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +from microtensor_cli.api import Api +from microtensor_cli.bridge import snapshot +from microtensor_cli.bridge.base import Context, Harness +from microtensor_cli.bridge.editors.running import running +from microtensor_cli.bridge.harnesses import HARNESSES +from microtensor_cli.catalogue import AUTO, Model, find, general, offered +from microtensor_cli.config import Credentials, machine_id, masked +from microtensor_cli.errors import CliError + +Say = Callable[[str], None] +RUNNING = ( + "{title} is running. Quit it first so it does not overwrite the change when it exits, " + "then run the command again (or pass --force)." +) +NO_GENERAL = ( + "No general model is served yet, so {title} would get an error on every request. " + "Run `mtensor models` to see what is served, or pass --force to write the settings anyway." +) + + +def harness(name: str) -> Harness: + found = HARNESSES.get(name) + if found is None: + raise CliError(f"unknown tool {name}; choose one of {', '.join(HARNESSES)}") + return found + + +def _quit_check(h: Harness, force: bool) -> None: + if h.processes and not force and running(h.processes): + raise CliError(RUNNING.format(title=h.title)) + + +def choose( + models: Sequence[Model], wanted: str, title: str, force: bool +) -> tuple[Model, list[Model]]: + menu = offered(models) + if not general(models) and not force: + raise CliError(NO_GENERAL.format(title=title)) + chosen = find(menu, wanted or AUTO) or find(models, wanted) + if chosen is None: + names = ", ".join(m.id for m in menu) + raise CliError(f"{wanted} is not served; choose one of {names}") + return chosen, menu + + +def bridge( + name: str, + *, + credentials: Credentials, + api: Api, + model: str, + dry_run: bool, + force: bool, + say: Say, +) -> None: + h = harness(name) + missing = h.installed() + if missing: + raise CliError(missing) + _quit_check(h, force or dry_run) + chosen, menu = choose(api.models(), model, h.title, force) + current = snapshot.capture(h.targets()) + ctx = Context( + key=credentials.key, base_url=credentials.base_url, model=chosen, models=tuple(menu) + ) + change = h.apply(current, ctx) + if dry_run: + say(f"Dry run: nothing was written. Bridging {h.title} would change:") + for line in change.changed: + say(f" {line}") + for note in change.notes: + say(f"Note: {note}") + return + held, fresh = snapshot.take(name, current) + snapshot.write(change) + registered = api.register(name, chosen.id, machine_id()) + say(f"Bridged {h.title} to Microtensor.") + say(f" Model {chosen.id}") + say(f" Key {masked(credentials.key)}") + for line in change.changed: + say(f" Changed {line}") + say(f" Snapshot {held}" + ("" if fresh else " (kept from the first bridge)")) + if not registered: + say(" Usage per tool usage appears in the console once the service records bridges") + for note in change.notes: + say(f"Note: {note}") + say(h.after) + say(f"Undo with: mtensor bridge off {name}") + + +def off(name: str, *, api: Api | None, force: bool, say: Say) -> None: + h = harness(name) + held = snapshot.load(name) + current = snapshot.capture(h.targets()) + if held is None: + if h.state(current).bridged: + raise CliError( + f"{h.title} looks bridged but no snapshot was saved on this machine; " + f"edit {h.state(current).where} by hand" + ) + say(f"{h.title} is not bridged.") + return + _quit_check(h, force) + change = h.revert(current, held) + snapshot.write(change) + snapshot.drop(name) + if api is not None: + api.unregister(name, machine_id()) + say(f"{h.title} is back on its own settings.") + for line in change.changed: + say(f" {line}") + say(h.after.replace("to use it", "to pick up the change")) + + +def restore(*, api: Api | None, force: bool, say: Say) -> None: + tools = snapshot.saved() + if not tools: + say("No snapshot is saved, so there is nothing to restore.") + return + for name in tools: + h = harness(name) + if h.processes and not force and running(h.processes): + say(f"Skipped {h.title}: it is running. Quit it and run mtensor bridge restore again.") + continue + held = snapshot.load(name) + if held is None: + continue + snapshot.write(snapshot.restore_exact(held)) + snapshot.drop(name) + if api is not None: + api.unregister(name, machine_id()) + say(f"Restored {h.title} exactly as it was before the first bridge.") + + +@dataclass(frozen=True, slots=True) +class Row: + tool: str + title: str + bridged: bool + model: str + where: str + snapshot: bool + last_seen: str + requests: int | None + + def view(self) -> dict[str, Any]: + return { + "tool": self.tool, + "title": self.title, + "bridged": self.bridged, + "model": self.model, + "config": self.where, + "snapshot": self.snapshot, + "last_seen_at": self.last_seen, + "requests_30d": self.requests, + } + + +def status(*, api: Api | None) -> list[Row]: + remote = api.bridges() if api is not None else None + by_tool = {str(r.get("tool")): r for r in remote or []} + saved = set(snapshot.saved()) + rows: list[Row] = [] + for name, h in HARNESSES.items(): + state = h.state(snapshot.capture(h.targets())) + server = by_tool.get(name, {}) + requests = server.get("requests_30d") + rows.append( + Row( + tool=name, + title=h.title, + bridged=state.bridged, + model=state.model, + where=state.where, + snapshot=name in saved, + last_seen=str(server.get("last_seen_at") or ""), + requests=int(requests) if isinstance(requests, int) else None, + ) + ) + return rows diff --git a/microtensor_cli/bridge/snapshot.py b/microtensor_cli/bridge/snapshot.py new file mode 100644 index 0000000..3937f40 --- /dev/null +++ b/microtensor_cli/bridge/snapshot.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import base64 +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from microtensor_cli.bridge.base import Capture, Change, Targets +from microtensor_cli.bridge.editors.vscdb import read_rows, write_rows +from microtensor_cli.config import home +from microtensor_cli.errors import CliError +from microtensor_cli.files import atomic_write, read_bytes, remove + + +def directory() -> Path: + return home() / "bridge" + + +def path_for(tool: str) -> Path: + return directory() / tool / "snapshot.json" + + +def capture(targets: Targets) -> Capture: + found = Capture() + for path in targets.files: + found.files[str(path)] = read_bytes(path) + for db, keys in targets.rows: + found.rows[str(db)] = read_rows(db, keys) + return found + + +def _encode(found: Capture) -> dict[str, Any]: + return { + "files": { + p: None if raw is None else base64.b64encode(raw).decode("ascii") + for p, raw in found.files.items() + }, + "rows": found.rows, + } + + +def _decode(body: dict[str, Any]) -> Capture: + files = { + str(p): None if raw is None else base64.b64decode(str(raw)) + for p, raw in (body.get("files") or {}).items() + } + rows = { + str(db): {str(k): None if v is None else str(v) for k, v in (held or {}).items()} + for db, held in (body.get("rows") or {}).items() + } + return Capture(files=files, rows=rows) + + +def load(tool: str) -> Capture | None: + raw = read_bytes(path_for(tool)) + if raw is None: + return None + try: + return _decode(json.loads(raw.decode("utf-8"))) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise CliError(f"the snapshot {path_for(tool)} is damaged ({exc})") from exc + + +def take(tool: str, found: Capture) -> tuple[Path, bool]: + target = path_for(tool) + if target.exists(): + return target, False + body = { + "tool": tool, + "taken_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + **_encode(found), + } + atomic_write(target, (json.dumps(body, indent=2) + "\n").encode("utf-8"), private=True) + return target, True + + +def drop(tool: str) -> None: + target = path_for(tool) + remove(target) + try: + target.parent.rmdir() + except OSError: + return + + +def saved() -> list[str]: + root = directory() + if not root.exists(): + return [] + return sorted(p.parent.name for p in root.glob("*/snapshot.json")) + + +def write(change: Change) -> None: + for path, data in change.files.items(): + if data is None: + remove(path) + else: + atomic_write(path, data, private=path in change.private) + for db, rows in change.rows.items(): + write_rows(db, rows) + + +def restore_exact(found: Capture) -> Change: + change = Change() + for name, raw in found.files.items(): + change.files[Path(name)] = raw + for db, rows in found.rows.items(): + change.rows[Path(db)] = dict(rows) + return change diff --git a/microtensor_cli/catalogue.py b/microtensor_cli/catalogue.py new file mode 100644 index 0000000..e90ae37 --- /dev/null +++ b/microtensor_cli/catalogue.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +AUTO = "mt/auto" +GENERAL = "general" +CERTIFIED = "certified" +LONG_CONTEXT = 1_000_000 +DEFAULT_CONTEXT = 131_072 +DEFAULT_OUTPUT = 8_192 + + +@dataclass(frozen=True, slots=True) +class Model: + id: str + kind: str + title: str + context: int + max_output: int + tool_calling: bool + vision: bool + usd_in_per_mtok: float | None + usd_out_per_mtok: float | None + live: bool + + @property + def general(self) -> bool: + return self.kind == GENERAL + + @property + def label(self) -> str: + return self.title or self.id + + def describe(self) -> str: + parts = ["General model" if self.general else "Certified specialist"] + if self.context: + parts.append(f"{context_label(self.context)} context") + if self.tool_calling: + parts.append("tools") + if self.vision: + parts.append("images") + if self.usd_in_per_mtok is not None and self.usd_out_per_mtok is not None: + parts.append( + f"${self.usd_in_per_mtok:g} in, ${self.usd_out_per_mtok:g} out per 1M tokens" + ) + if not self.live: + parts.append("not served right now") + return ", ".join(parts) + + +def context_label(tokens: int) -> str: + if tokens >= LONG_CONTEXT: + return f"{tokens / LONG_CONTEXT:g}M" + if tokens >= 1000: + return f"{round(tokens / 1024)}K" + return str(tokens) + + +def _number(value: Any) -> float | None: + try: + return None if value is None else float(value) + except (TypeError, ValueError): + return None + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def parse(entry: Mapping[str, Any]) -> Model: + model_id = str(entry.get("id", "")) + prices = _mapping(entry.get("prices")) + modalities = [str(m) for m in entry.get("modalities") or []] + capacity = _mapping(entry.get("capacity")) + kind = str(entry.get("kind") or (CERTIFIED if model_id != AUTO else "router")) + return Model( + id=model_id, + kind=kind, + title=str(entry.get("title") or ""), + context=int(entry.get("context") or 0), + max_output=int(entry.get("max_output") or 0), + tool_calling=bool(entry.get("tool_calling", False)), + vision="image" in modalities, + usd_in_per_mtok=_number(prices.get("usd_in_per_mtok")), + usd_out_per_mtok=_number(prices.get("usd_out_per_mtok")), + live=bool(capacity.get("live", False)), + ) + + +def parse_list(payload: Mapping[str, Any]) -> list[Model]: + return [parse(entry) for entry in payload.get("data") or [] if isinstance(entry, Mapping)] + + +def general(models: Sequence[Model]) -> list[Model]: + return [m for m in models if m.general] + + +def find(models: Sequence[Model], model_id: str) -> Model | None: + return next((m for m in models if m.id == model_id), None) + + +def auto_model(models: Sequence[Model]) -> Model: + found = general(models) + context = max((m.context for m in found), default=DEFAULT_CONTEXT) or DEFAULT_CONTEXT + output = max((m.max_output for m in found), default=DEFAULT_OUTPUT) or DEFAULT_OUTPUT + return Model( + id=AUTO, + kind="router", + title="Microtensor auto", + context=context, + max_output=output, + tool_calling=any(m.tool_calling for m in found) or not found, + vision=any(m.vision for m in found), + usd_in_per_mtok=None, + usd_out_per_mtok=None, + live=any(m.live for m in found), + ) + + +def offered(models: Sequence[Model]) -> list[Model]: + return [auto_model(models), *general(models)] diff --git a/microtensor_cli/config.py b/microtensor_cli/config.py new file mode 100644 index 0000000..e51579d --- /dev/null +++ b/microtensor_cli/config.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +import os +import sys +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from microtensor_cli.files import atomic_write, read_bytes, remove + +DEFAULT_BASE_URL = "https://api.microtensor.cloud" +CONSOLE_KEYS_URL = "https://www.microtensor.cloud/console/keys" +ENV_HOME = "MTENSOR_HOME" +ENV_USER_HOME = "MTENSOR_USER_HOME" +ENV_KEY = "MICROTENSOR_API_KEY" +ENV_BASE = "MTENSOR_BASE_URL" +KEY_PREFIX = "mt_sk_" + + +@dataclass(frozen=True, slots=True) +class Credentials: + key: str + base_url: str + source: str + + +def home() -> Path: + found = os.environ.get(ENV_HOME) + return Path(found) if found else Path.home() / ".mtensor" + + +def user_home() -> Path: + found = os.environ.get(ENV_USER_HOME) + return Path(found) if found else Path.home() + + +def app_data() -> Path: + if sys.platform == "win32": + found = os.environ.get("APPDATA") + return Path(found) if found else user_home() / "AppData" / "Roaming" + if sys.platform == "darwin": + return user_home() / "Library" / "Application Support" + found = os.environ.get("XDG_CONFIG_HOME") + return Path(found) if found else user_home() / ".config" + + +def xdg_config() -> Path: + found = os.environ.get("XDG_CONFIG_HOME") + return Path(found) if found else user_home() / ".config" + + +def credentials_path() -> Path: + return home() / "credentials.json" + + +def normal_base(url: str) -> str: + cleaned = url.strip().rstrip("/") + return cleaned[:-3] if cleaned.endswith("/v1") else cleaned + + +def load_credentials() -> Credentials | None: + from_env = os.environ.get(ENV_KEY, "").strip() + base = normal_base(os.environ.get(ENV_BASE, "") or DEFAULT_BASE_URL) + raw = read_bytes(credentials_path()) + if raw: + try: + held = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + held = {} + if not os.environ.get(ENV_BASE) and held.get("base_url"): + base = normal_base(str(held["base_url"])) + if not from_env and held.get("key"): + return Credentials(key=str(held["key"]), base_url=base, source=str(credentials_path())) + if from_env: + return Credentials(key=from_env, base_url=base, source=ENV_KEY) + return None + + +def save_credentials(key: str, base_url: str) -> Path: + path = credentials_path() + body = { + "key": key, + "base_url": normal_base(base_url), + "saved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + atomic_write(path, (json.dumps(body, indent=2) + "\n").encode("utf-8"), private=True) + return path + + +def clear_credentials() -> bool: + path = credentials_path() + existed = path.exists() + remove(path) + return existed + + +def machine_id() -> str: + path = home() / "machine" + raw = read_bytes(path) + if raw and raw.strip(): + return raw.decode("utf-8").strip() + made = uuid.uuid4().hex[:16] + atomic_write(path, made.encode("utf-8"), private=True) + return made + + +def masked(key: str) -> str: + if len(key) <= 12: + return KEY_PREFIX + "..." + return f"{key[:8]}...{key[-4:]}" diff --git a/microtensor_cli/errors.py b/microtensor_cli/errors.py new file mode 100644 index 0000000..4aceba3 --- /dev/null +++ b/microtensor_cli/errors.py @@ -0,0 +1,2 @@ +class CliError(RuntimeError): + pass diff --git a/microtensor_cli/files.py b/microtensor_cli/files.py new file mode 100644 index 0000000..d2e9d3a --- /dev/null +++ b/microtensor_cli/files.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from microtensor_cli.errors import CliError + + +def read_bytes(path: Path) -> bytes | None: + try: + return path.read_bytes() + except FileNotFoundError: + return None + + +def atomic_write(path: Path, data: bytes, *, private: bool = False) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + mode = 0o600 if private else (path.stat().st_mode & 0o777 if path.exists() else None) + handle, temp = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent)) + try: + with os.fdopen(handle, "wb") as out: + out.write(data) + if mode is not None: + os.chmod(temp, mode) + os.replace(temp, path) + except BaseException: + if os.path.exists(temp): + os.unlink(temp) + raise + + +def remove(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + return + + +def load_json(raw: bytes | None, path: Path, *, empty: Any) -> Any: + if raw is None or not raw.strip(): + return empty + try: + return json.loads(raw.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CliError( + f"{path} is not plain JSON ({exc.__class__.__name__}), so it was left untouched; " + "remove comments or trailing commas and run the command again" + ) from exc + + +def dump_json(doc: Any) -> bytes: + return (json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode("utf-8") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ebd73c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "microtensor-cli" +version = "0.1.0" +description = "The mtensor command for Microtensor customers: bridge your coding tools to Microtensor models" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27", + "tomlkit>=0.12", + "cryptography>=42", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.6", + "mypy>=1.11", +] + +[project.scripts] +mtensor = "microtensor_cli.app:main" + +[project.urls] +Homepage = "https://www.microtensor.cloud" +Documentation = "https://www.microtensor.cloud/docs/api/getting-started/bridge" +Repository = "https://github.com/microtensor-io/microtensor-cli" + +[tool.setuptools.packages.find] +include = ["microtensor_cli*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "C4", "RUF", "S"] +ignore = ["S101", "S603", "S607"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S"] +"microtensor_cli/bridge/editors/safestorage.py" = ["S303"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unused_ignores = false + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..21bada5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from microtensor_cli.catalogue import Model, parse_list +from microtensor_cli.config import Credentials + +KEY = "mt_sk_test_0123456789abcdef" +BASE = "https://api.example.test" + +GENERAL = { + "id": "glm-4.6-awq", + "kind": "general", + "title": "GLM 4.6", + "context": 131072, + "max_output": 16384, + "tool_calling": True, + "modalities": ["text"], + "prices": {"usd_in_per_mtok": 0.35, "usd_out_per_mtok": 1.4}, + "capacity": {"operators": 2, "live": True}, +} +LONG = { + **GENERAL, + "id": "kimi-k3", + "title": "Kimi K3", + "context": 1040000, + "modalities": ["text", "image"], +} +CERTIFIED = {"id": "mt/sql_gen_s1", "kind": "certified", "title": "SQL", "capacity": {"live": True}} +AUTO = {"id": "mt/auto", "capacity": {"live": True}} + + +class FakeApi: + def __init__(self, entries: list[dict[str, Any]] | None = None) -> None: + self.entries = entries if entries is not None else [AUTO, CERTIFIED, GENERAL, LONG] + self.registered: list[tuple[str, str]] = [] + self.unregistered: list[str] = [] + + def models(self) -> list[Model]: + return parse_list({"data": self.entries}) + + def register(self, tool: str, model: str, machine: str) -> bool: + self.registered.append((tool, model)) + return True + + def unregister(self, tool: str, machine: str) -> bool: + self.unregistered.append(tool) + return True + + def bridges(self) -> list[dict[str, Any]] | None: + return [ + {"tool": t, "model": m, "last_seen_at": "2026-10-16T10:00:00Z", "requests_30d": 3} + for t, m in self.registered + ] + + +@pytest.fixture +def homes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + user = tmp_path / "user" + user.mkdir() + monkeypatch.setenv("MTENSOR_HOME", str(tmp_path / "mtensor")) + monkeypatch.setenv("MTENSOR_USER_HOME", str(user)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(user / ".config")) + monkeypatch.setenv("APPDATA", str(user / "AppData")) + for name in ("CLAUDE_CONFIG_DIR", "CODEX_HOME", "MICROTENSOR_API_KEY", "MTENSOR_BASE_URL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr("microtensor_cli.bridge.service.running", lambda processes: False) + monkeypatch.setattr( + "microtensor_cli.bridge.harnesses.cursor.encrypt", + lambda app, state, key: (b"v10sealed", ""), + ) + monkeypatch.setattr( + "microtensor_cli.bridge.harnesses.vscode.encrypt", + lambda app, state, key: (b"v10sealed", ""), + ) + return user + + +@pytest.fixture +def credentials() -> Credentials: + return Credentials(key=KEY, base_url=BASE, source="test") diff --git a/tests/test_bridge.py b/tests/test_bridge.py new file mode 100644 index 0000000..1cd4a1b --- /dev/null +++ b/tests/test_bridge.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +import tomlkit + +from microtensor_cli.bridge import service, snapshot +from microtensor_cli.bridge.harnesses import claude_code, codex, cursor, opencode, vscode +from microtensor_cli.config import Credentials +from microtensor_cli.errors import CliError +from tests.conftest import AUTO, BASE, CERTIFIED, KEY, FakeApi + + +def run(name: str, credentials: Credentials, api: FakeApi, **extra: object) -> list[str]: + lines: list[str] = [] + service.bridge( + name, + credentials=credentials, + api=api, + model=str(extra.get("model", "")), + dry_run=bool(extra.get("dry_run", False)), + force=bool(extra.get("force", False)), + say=lines.append, + ) + return lines + + +def test_claude_code_round_trip(homes: Path, credentials: Credentials) -> None: + path = claude_code.settings_path() + path.parent.mkdir(parents=True) + original = b'{\n "theme": "dark",\n "apiKeyHelper": "/bin/key",\n "env": {"OTHER": "1"}\n}\n' + path.write_bytes(original) + api = FakeApi() + run("claude-code", credentials, api) + doc = json.loads(path.read_text()) + assert doc["theme"] == "dark" and doc["env"]["OTHER"] == "1" and "apiKeyHelper" not in doc + assert doc["env"]["ANTHROPIC_BASE_URL"] == BASE and doc["env"]["ANTHROPIC_AUTH_TOKEN"] == KEY + assert ( + doc["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "mt/auto[1m]" + and doc["model"] == "mt/auto[1m]" + ) + picker = [o["model"] for o in doc["modelPicker"]["options"]] + assert picker == ["mt/auto[1m]", "glm-4.6-awq", "kimi-k3[1m]"] + assert api.registered == [("claude-code", "mt/auto")] + rows = {r.tool: r for r in service.status(api=api)} + assert rows["claude-code"].bridged and rows["claude-code"].model == "mt/auto" + assert rows["claude-code"].requests == 3 and not rows["codex"].bridged + service.off("claude-code", api=api, force=False, say=lambda _: None) + assert path.read_bytes() == original + assert snapshot.saved() == [] and api.unregistered == ["claude-code"] + + +def test_codex_keeps_other_tables_and_restores_bytes(homes: Path, credentials: Credentials) -> None: + path = codex.config_path() + path.parent.mkdir(parents=True) + original = b'model = "gpt-5"\n\n[mcp_servers.docs]\ncommand = "npx"\nargs = ["docs-mcp"]\n' + path.write_bytes(original) + run("codex", credentials, FakeApi(), model="glm-4.6-awq") + doc = tomlkit.parse(path.read_text()).unwrap() + assert doc["model_provider"] == "microtensor" and doc["model"] == "glm-4.6-awq" + provider = doc["model_providers"]["microtensor"] + assert provider["base_url"] == f"{BASE}/v1" and provider["wire_api"] == "responses" + assert provider["http_headers"] == {"x-api-key": KEY, "X-Microtensor-Tool": "codex"} + assert doc["mcp_servers"]["docs"]["command"] == "npx" + service.off("codex", api=None, force=False, say=lambda _: None) + assert path.read_bytes() == original + + +def test_opencode_new_file_is_removed_on_off_and_restore_is_exact( + homes: Path, credentials: Credentials +) -> None: + path = opencode.config_path() + run("opencode", credentials, FakeApi()) + doc = json.loads(path.read_text()) + assert doc["model"] == "microtensor/mt/auto" + assert doc["provider"]["microtensor"]["options"]["baseURL"] == f"{BASE}/v1" + assert doc["provider"]["microtensor"]["models"]["kimi-k3"]["modalities"]["input"] == [ + "text", + "image", + ] + service.off("opencode", api=None, force=False, say=lambda _: None) + assert not path.exists() + claude = claude_code.settings_path() + run("claude-code", credentials, FakeApi()) + claude.write_text(claude.read_text().replace('"model"', '"theme": "light", "model"')) + service.restore(api=None, force=False, say=lambda _: None) + assert not claude.exists() and snapshot.saved() == [] + + +def test_editors_write_rows_and_put_them_back(homes: Path, credentials: Credentials) -> None: + db = cursor.db_path() + db.parent.mkdir(parents=True) + blob = { + "aiSettings": { + "userAddedModels": ["gpt-x"], + "modelConfig": {"composer": {"modelName": "gpt-x"}}, + } + } + original = json.dumps(blob, separators=(",", ":")) + with closing(sqlite3.connect(db)) as conn: + conn.execute("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)") + conn.execute("INSERT INTO ItemTable VALUES (?, ?)", (cursor.APP_KEY, original)) + conn.commit() + run("cursor", credentials, FakeApi()) + rows = snapshot.capture(cursor.Cursor().targets()).rows[str(db)] + now = json.loads(rows[cursor.APP_KEY] or "{}") + assert now["openAIBaseUrl"] == f"{BASE}/v1" and now["useOpenAIKey"] is True + assert now["aiSettings"]["userAddedModels"] == ["gpt-x", "mt/auto", "glm-4.6-awq", "kimi-k3"] + assert now["aiSettings"]["modelConfig"]["composer"]["modelName"] == "mt/auto" + assert json.loads(rows[cursor.SEALED_ROW] or "{}")["data"][:3] == list(b"v10") + service.off("cursor", api=None, force=False, say=lambda _: None) + back = snapshot.capture(cursor.Cursor().targets()).rows[str(db)] + assert back[cursor.APP_KEY] == original and back[cursor.SEALED_ROW] is None + + models = vscode.models_path() + models.parent.mkdir(parents=True) + mine = [{"name": "Mine", "vendor": "customendpoint", "apiKey": "${input:mine}", "models": []}] + models.write_text(json.dumps(mine)) + before = models.read_bytes() + run("vscode", credentials, FakeApi()) + providers = json.loads(models.read_text()) + assert [p["name"] for p in providers] == ["Mine", "Microtensor"] + assert providers[1]["models"][0]["url"] == f"{BASE}/v1/chat/completions" + service.off("vscode", api=None, force=False, say=lambda _: None) + assert models.read_bytes() == before + + +def test_refuses_without_a_general_model_unless_forced( + homes: Path, credentials: Credentials +) -> None: + api = FakeApi([AUTO, CERTIFIED]) + with pytest.raises(CliError, match="No general model is served yet"): + run("claude-code", credentials, api) + assert not claude_code.settings_path().exists() + lines = run("claude-code", credentials, api, dry_run=True, force=True) + assert lines[0].startswith("Dry run") and not claude_code.settings_path().exists() + with pytest.raises(CliError, match="not served"): + run("claude-code", credentials, FakeApi(), model="nope") diff --git a/tests/test_safestorage.py b/tests/test_safestorage.py new file mode 100644 index 0000000..ab8efa0 --- /dev/null +++ b/tests/test_safestorage.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +import os + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from microtensor_cli.bridge.editors.safestorage import ( + IV, + LINUX_ITERATIONS, + LINUX_PASSWORD, + MAC_ITERATIONS, + buffer_json, + cbc_encrypt, + cbc_key, + gcm_encrypt, +) + + +def _cbc_decrypt(password: bytes, iterations: int, blob: bytes) -> bytes: + assert blob[:3] == b"v10" + decryptor = Cipher(algorithms.AES(cbc_key(password, iterations)), modes.CBC(IV)).decryptor() + padded = decryptor.update(blob[3:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + +def test_cbc_formats_decrypt_like_chromium() -> None: + secret = b"mt_sk_test_0123456789abcdef" + assert ( + _cbc_decrypt( + LINUX_PASSWORD, LINUX_ITERATIONS, cbc_encrypt(LINUX_PASSWORD, LINUX_ITERATIONS, secret) + ) + == secret + ) + assert ( + _cbc_decrypt(b"keychain", MAC_ITERATIONS, cbc_encrypt(b"keychain", MAC_ITERATIONS, secret)) + == secret + ) + + +def test_gcm_layout_and_buffer_json() -> None: + key = os.urandom(32) + blob = gcm_encrypt(key, b"mt_sk_x") + assert blob[:3] == b"v10" and len(blob) == 3 + 12 + len(b"mt_sk_x") + 16 + assert AESGCM(key).decrypt(blob[3:15], blob[15:], None) == b"mt_sk_x" + held = json.loads(buffer_json(b"v10")) + assert held == {"type": "Buffer", "data": [118, 49, 48]}