Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ dist/
.ruff_cache/
.mypy_cache/
.env
*.log
8 changes: 8 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 76 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <id>` | The model to use. Default `mt/auto`, which picks a model for each request. `mtensor models` lists the choices. |
| `--key <mt_sk_...>` | 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/<tool>/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 <tool>` 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
1 change: 1 addition & 0 deletions microtensor_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
3 changes: 3 additions & 0 deletions microtensor_cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from microtensor_cli.app import main

raise SystemExit(main())
87 changes: 87 additions & 0 deletions microtensor_cli/api.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading