slice-gateway 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/MANIFEST.in +3 -3
- {slice_gateway-0.2.2/slice_gateway.egg-info → slice_gateway-0.2.3}/PKG-INFO +14 -1
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/PYPI.md +12 -0
- slice_gateway-0.2.3/mcp_server/__init__.py +23 -0
- slice_gateway-0.2.3/mcp_server/__main__.py +21 -0
- slice_gateway-0.2.3/mcp_server/client.py +136 -0
- slice_gateway-0.2.3/mcp_server/config.py +53 -0
- slice_gateway-0.2.3/mcp_server/server.py +91 -0
- slice_gateway-0.2.3/mcp_server/tools.py +323 -0
- slice_gateway-0.2.3/pyproject.toml +46 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3/slice_gateway.egg-info}/PKG-INFO +14 -1
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/slice_gateway.egg-info/SOURCES.txt +6 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/slice_gateway.egg-info/entry_points.txt +1 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/slice_gateway.egg-info/requires.txt +1 -0
- slice_gateway-0.2.3/slice_gateway.egg-info/top_level.txt +2 -0
- slice_gateway-0.2.2/pyproject.toml +0 -39
- slice_gateway-0.2.2/slice_gateway.egg-info/top_level.txt +0 -1
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/LICENSE +0 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/setup.cfg +0 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/slice_cli.py +0 -0
- {slice_gateway-0.2.2 → slice_gateway-0.2.3}/slice_gateway.egg-info/dependency_links.txt +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
# The slice-gateway distribution is the CLI
|
|
2
|
-
#
|
|
1
|
+
# The slice-gateway distribution is the CLI plus the standalone MCP server. Keep the sdist
|
|
2
|
+
# to those two, the PyPI readme, the license, and build metadata. Nothing else from the
|
|
3
|
+
# server repo (mcp_server is kept, so it is not pruned here).
|
|
3
4
|
prune tests
|
|
4
5
|
prune app
|
|
5
6
|
prune infra
|
|
@@ -12,7 +13,6 @@ prune demo
|
|
|
12
13
|
prune scripts
|
|
13
14
|
prune guardrails
|
|
14
15
|
prune docs
|
|
15
|
-
prune mcp_server
|
|
16
16
|
prune gateway
|
|
17
17
|
prune pipeline
|
|
18
18
|
prune rag_store
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slice-gateway
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Command-line client for slice, the self-hosted LLM gateway.
|
|
5
5
|
Author: Jishnu Janardanan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: typer
|
|
17
17
|
Requires-Dist: httpx
|
|
18
|
+
Requires-Dist: mcp<2,>=1.9
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# slice-gateway
|
|
@@ -43,12 +44,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
|
|
|
43
44
|
|
|
44
45
|
Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
|
|
45
46
|
|
|
47
|
+
## Claude Code MCP
|
|
48
|
+
|
|
49
|
+
This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
|
|
50
|
+
|
|
51
|
+
claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
|
|
52
|
+
|
|
53
|
+
It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
|
|
54
|
+
|
|
46
55
|
## The gateway
|
|
47
56
|
|
|
48
57
|
This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
|
|
49
58
|
|
|
50
59
|
Homepage: https://sliceapp.dev
|
|
51
60
|
|
|
61
|
+
## What's new in 0.2.3
|
|
62
|
+
|
|
63
|
+
The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
|
|
64
|
+
|
|
52
65
|
## What's new in 0.2.2
|
|
53
66
|
|
|
54
67
|
`slice login` now ends by printing your dashboard link and opening it in the browser.
|
|
@@ -24,12 +24,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
|
|
|
24
24
|
|
|
25
25
|
Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
|
|
26
26
|
|
|
27
|
+
## Claude Code MCP
|
|
28
|
+
|
|
29
|
+
This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
|
|
30
|
+
|
|
31
|
+
claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
|
|
32
|
+
|
|
33
|
+
It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
|
|
34
|
+
|
|
27
35
|
## The gateway
|
|
28
36
|
|
|
29
37
|
This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
|
|
30
38
|
|
|
31
39
|
Homepage: https://sliceapp.dev
|
|
32
40
|
|
|
41
|
+
## What's new in 0.2.3
|
|
42
|
+
|
|
43
|
+
The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
|
|
44
|
+
|
|
33
45
|
## What's new in 0.2.2
|
|
34
46
|
|
|
35
47
|
`slice login` now ends by printing your dashboard link and opening it in the browser.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""slice MCP server (phase 14).
|
|
2
|
+
|
|
3
|
+
A standalone stdio MCP server that exposes slice's data as tools inside Claude Code.
|
|
4
|
+
It is a *thin adapter* over slice's existing HTTP gateway: it makes short async httpx
|
|
5
|
+
calls to the running gateway and shapes the JSON into compact, human-readable text. It
|
|
6
|
+
holds no gateway logic, no database access, and no business rules of its own: every
|
|
7
|
+
number it reports comes from an endpoint the gateway already serves.
|
|
8
|
+
|
|
9
|
+
Run it as ``python -m mcp_server`` (stdio). Configuration is two env vars, read by
|
|
10
|
+
``mcp_server.config``: ``SLICE_BASE_URL`` (default ``http://localhost:8080``) and
|
|
11
|
+
``SLICE_API_KEY`` (the slice key, sent as ``Authorization: Bearer <key>``, the same way
|
|
12
|
+
the phase-12 gateway auth reads it). With no key set the server still works against a
|
|
13
|
+
gateway running in local/unlocked mode; a call that comes back 401 tells the user to set
|
|
14
|
+
the key.
|
|
15
|
+
|
|
16
|
+
Layout:
|
|
17
|
+
|
|
18
|
+
- ``config``: env-driven settings.
|
|
19
|
+
- ``client``: the httpx wrapper and its typed errors (unreachable / unauthorized / other).
|
|
20
|
+
- ``tools``: the tool functions. Pure ``(SliceClient, args) -> str``; this is the layer
|
|
21
|
+
the tests drive with a mocked gateway. No dependency on the ``mcp`` SDK.
|
|
22
|
+
- ``server``: wires the tool functions into an ``mcp`` ``FastMCP`` server as stdio tools.
|
|
23
|
+
"""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Entry point: ``python -m mcp_server`` or the ``slice-mcp`` console script runs the
|
|
2
|
+
slice MCP server over stdio.
|
|
3
|
+
|
|
4
|
+
Reads ``SLICE_BASE_URL`` / ``SLICE_API_KEY`` from the environment (see
|
|
5
|
+
``mcp_server.config``), builds the FastMCP server, and hands control to the ``mcp`` SDK's
|
|
6
|
+
stdio transport. Claude Code (or any MCP client) speaks to it over stdin/stdout. Exits
|
|
7
|
+
with a one-line message when ``SLICE_API_KEY`` is unset (see ``load_settings``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from mcp_server.config import load_settings
|
|
13
|
+
from mcp_server.server import build_server
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
build_server(load_settings()).run("stdio")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""The httpx wrapper around slice's gateway, with typed errors the tools turn into text.
|
|
2
|
+
|
|
3
|
+
This is the only place that talks to the network. It builds one short-timeout
|
|
4
|
+
``httpx.AsyncClient`` against ``SLICE_BASE_URL``, attaches the slice key on every call,
|
|
5
|
+
and classifies the outcome into three failure kinds the tools can render cleanly:
|
|
6
|
+
|
|
7
|
+
- ``GatewayUnreachable``: the gateway isn't answering (connection refused, DNS, a
|
|
8
|
+
timeout). The tools turn this into "slice gateway not running at <url>", never a stack
|
|
9
|
+
trace. This is by far the most common failure while developing locally.
|
|
10
|
+
- ``GatewayUnauthorized``: the gateway answered 401. The slice key is missing, wrong, or
|
|
11
|
+
revoked; the tools tell the user to set ``SLICE_API_KEY``.
|
|
12
|
+
- ``GatewayError``: any other non-2xx (4xx/5xx). Carries the status and the gateway's own
|
|
13
|
+
error message when it sent one, so a 404/503 surfaces the gateway's words.
|
|
14
|
+
|
|
15
|
+
Nothing here raises a bare httpx exception up to the tools: ``request`` maps them all.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from mcp_server.config import Settings
|
|
26
|
+
|
|
27
|
+
# Short timeouts: this is a local adapter, not a long-running proxy. A gateway that is up
|
|
28
|
+
# answers a read in milliseconds; one that is down should fail fast, not hang Claude Code.
|
|
29
|
+
TIMEOUT = httpx.Timeout(5.0, connect=2.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GatewayUnreachable(Exception):
|
|
33
|
+
"""The gateway could not be reached at all (connection error or timeout)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, base_url: str, detail: str = "") -> None:
|
|
36
|
+
self.base_url = base_url
|
|
37
|
+
self.detail = detail
|
|
38
|
+
super().__init__(f"slice gateway not reachable at {base_url}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GatewayUnauthorized(Exception):
|
|
42
|
+
"""The gateway answered 401: the slice key is missing, invalid, or revoked."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str = "") -> None:
|
|
45
|
+
self.message = message
|
|
46
|
+
super().__init__("unauthorized")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GatewayError(Exception):
|
|
50
|
+
"""Any other non-2xx response from the gateway."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status_code: int, message: str = "") -> None:
|
|
53
|
+
self.status_code = status_code
|
|
54
|
+
self.message = message
|
|
55
|
+
super().__init__(f"gateway returned {status_code}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _error_message(response: httpx.Response) -> str:
|
|
59
|
+
"""Pull the gateway's own error text out of a non-2xx body, in any of its shapes.
|
|
60
|
+
|
|
61
|
+
The gateway speaks three error shapes (see ``app.auth.middleware.error_body``):
|
|
62
|
+
``{"error": {"message": ...}}`` (admin/dashboard), the OpenAI ``{"error": {...}}``,
|
|
63
|
+
and the Anthropic ``{"error": {"type", "message"}}``. All nest the message under
|
|
64
|
+
``error.message``, so one lookup covers them; a non-JSON body falls back to the text.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
body = response.json()
|
|
68
|
+
except (json.JSONDecodeError, ValueError):
|
|
69
|
+
return response.text.strip()[:200]
|
|
70
|
+
if isinstance(body, dict):
|
|
71
|
+
err = body.get("error")
|
|
72
|
+
if isinstance(err, dict) and isinstance(err.get("message"), str):
|
|
73
|
+
return err["message"]
|
|
74
|
+
if isinstance(err, str):
|
|
75
|
+
return err
|
|
76
|
+
if isinstance(body.get("detail"), str):
|
|
77
|
+
return body["detail"]
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SliceClient:
|
|
82
|
+
"""A thin async client for the slice gateway. Owns one httpx client for its lifetime."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
|
85
|
+
self._settings = settings
|
|
86
|
+
self._http = http
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def base_url(self) -> str:
|
|
90
|
+
return self._settings.base_url
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def has_key(self) -> bool:
|
|
94
|
+
return self._settings.api_key is not None
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def build(cls, settings: Settings) -> "SliceClient":
|
|
98
|
+
"""A client with its own httpx.AsyncClient. Close it with ``aclose``."""
|
|
99
|
+
http = httpx.AsyncClient(
|
|
100
|
+
base_url=settings.base_url,
|
|
101
|
+
headers=settings.auth_headers(),
|
|
102
|
+
timeout=TIMEOUT,
|
|
103
|
+
)
|
|
104
|
+
return cls(settings, http)
|
|
105
|
+
|
|
106
|
+
async def aclose(self) -> None:
|
|
107
|
+
await self._http.aclose()
|
|
108
|
+
|
|
109
|
+
async def request(
|
|
110
|
+
self, method: str, path: str, *, params: dict | None = None, json_body: Any = None
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Make one gateway call and return parsed JSON, or raise a typed gateway error.
|
|
113
|
+
|
|
114
|
+
Every transport failure becomes ``GatewayUnreachable``; a 401 becomes
|
|
115
|
+
``GatewayUnauthorized``; any other non-2xx becomes ``GatewayError``. A 2xx with an
|
|
116
|
+
empty or non-JSON body returns ``None`` rather than raising: a few write
|
|
117
|
+
responses carry only a short JSON object, and this stays lenient about the rest.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
response = await self._http.request(method, path, params=params, json=json_body)
|
|
121
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
|
122
|
+
raise GatewayUnreachable(self._settings.base_url, str(exc)) from exc
|
|
123
|
+
except httpx.RequestError as exc: # DNS, protocol, pool: still "can't reach it".
|
|
124
|
+
raise GatewayUnreachable(self._settings.base_url, str(exc)) from exc
|
|
125
|
+
|
|
126
|
+
if response.status_code == 401:
|
|
127
|
+
raise GatewayUnauthorized(_error_message(response))
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
raise GatewayError(response.status_code, _error_message(response))
|
|
130
|
+
|
|
131
|
+
if not response.content:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
return response.json()
|
|
135
|
+
except (json.JSONDecodeError, ValueError):
|
|
136
|
+
return None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Environment-driven configuration for the slice MCP server.
|
|
2
|
+
|
|
3
|
+
Two knobs, both read from the environment at construction time so a test can set them
|
|
4
|
+
with ``monkeypatch.setenv`` and build a fresh ``Settings``:
|
|
5
|
+
|
|
6
|
+
- ``SLICE_BASE_URL``: where the gateway is (default ``https://api.sliceapp.dev``, the
|
|
7
|
+
hosted gateway). Point it at ``http://localhost:8080`` for a self-hosted gateway. A
|
|
8
|
+
trailing slash is trimmed so paths join cleanly.
|
|
9
|
+
- ``SLICE_API_KEY``: the slice key. It rides on every gateway call as
|
|
10
|
+
``Authorization: Bearer <key>``, the header the phase-12 auth middleware reads (see
|
|
11
|
+
``app.auth.keys.bearer_token``). ``Settings.from_env`` leaves it unset when absent (a
|
|
12
|
+
self-hosted gateway in local/unlocked mode needs no key); the ``slice-mcp`` entry point
|
|
13
|
+
calls ``load_settings``, which requires it, since the hosted default has auth on.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.sliceapp.dev"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Settings:
|
|
27
|
+
base_url: str = DEFAULT_BASE_URL
|
|
28
|
+
api_key: str | None = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls, environ: dict | None = None) -> "Settings":
|
|
32
|
+
env = os.environ if environ is None else environ
|
|
33
|
+
base_url = (env.get("SLICE_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
|
|
34
|
+
if not base_url:
|
|
35
|
+
base_url = DEFAULT_BASE_URL
|
|
36
|
+
key = env.get("SLICE_API_KEY")
|
|
37
|
+
key = key.strip() if isinstance(key, str) and key.strip() else None
|
|
38
|
+
return cls(base_url=base_url, api_key=key)
|
|
39
|
+
|
|
40
|
+
def auth_headers(self) -> dict[str, str]:
|
|
41
|
+
"""The Authorization header the gateway expects, or nothing when no key is set."""
|
|
42
|
+
if self.api_key:
|
|
43
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_settings() -> Settings:
|
|
48
|
+
"""Settings for the ``slice-mcp`` entry point. Exits with a one-line message when
|
|
49
|
+
SLICE_API_KEY is unset, since the default hosted gateway requires a slice key."""
|
|
50
|
+
settings = Settings.from_env()
|
|
51
|
+
if settings.api_key is None:
|
|
52
|
+
sys.exit("slice-mcp: set SLICE_API_KEY to your slice key (slk_live_...).")
|
|
53
|
+
return settings
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Wire the slice tools into an ``mcp`` FastMCP stdio server.
|
|
2
|
+
|
|
3
|
+
Thin on purpose: this module only maps the pure tool coroutines in ``mcp_server.tools``
|
|
4
|
+
onto the official SDK's ``@mcp.tool()`` decorators, giving each the user-facing signature
|
|
5
|
+
Claude Code sees (no ``SliceClient`` argument, that is supplied internally from one
|
|
6
|
+
shared, long-lived client). All behavior, formatting, and error handling live in
|
|
7
|
+
``tools``; nothing gateway-specific lives here.
|
|
8
|
+
|
|
9
|
+
The shared ``SliceClient`` is opened when the server starts and closed on shutdown via the
|
|
10
|
+
server's lifespan, so the process holds exactly one httpx client.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from mcp_server import tools
|
|
20
|
+
from mcp_server.client import SliceClient
|
|
21
|
+
from mcp_server.config import Settings
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = (
|
|
24
|
+
"slice, the AI cost gateway. You're connected to a running slice gateway, so when "
|
|
25
|
+
"the user first engages, greet them warmly, mention they're connected to slice, and "
|
|
26
|
+
"offer to show how much of their monthly API budget is left (call get_spend).\n\n"
|
|
27
|
+
"What you can do here: check spend vs budget (get_spend), list/add/delete model-"
|
|
28
|
+
"routing rules (list_rules, add_rule, delete_rule), show recent requests "
|
|
29
|
+
"(get_recent_requests), and show the eval pass rate (get_eval_summary).\n\n"
|
|
30
|
+
"Write safety: add_rule and delete_rule require confirm=true to actually apply. "
|
|
31
|
+
"Without it they only preview the change and leave the gateway untouched."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_server(settings: Settings | None = None) -> FastMCP:
|
|
36
|
+
settings = settings or Settings.from_env()
|
|
37
|
+
# One client for the whole process. httpx builds its connection pool lazily, so
|
|
38
|
+
# constructing it here (before the event loop) is fine; the lifespan closes it.
|
|
39
|
+
client = SliceClient.build(settings)
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_server: FastMCP):
|
|
43
|
+
try:
|
|
44
|
+
yield {}
|
|
45
|
+
finally:
|
|
46
|
+
await client.aclose()
|
|
47
|
+
|
|
48
|
+
mcp = FastMCP(name="slice", instructions=INSTRUCTIONS, lifespan=lifespan)
|
|
49
|
+
|
|
50
|
+
@mcp.tool(description="Current-month slice spend vs budget, with the warn ratio and whether the cap is hit.")
|
|
51
|
+
async def get_spend() -> str:
|
|
52
|
+
return await tools.get_spend(client)
|
|
53
|
+
|
|
54
|
+
@mcp.tool(description="List the current switch (model-routing) rules for the slice account.")
|
|
55
|
+
async def list_rules() -> str:
|
|
56
|
+
return await tools.list_rules(client)
|
|
57
|
+
|
|
58
|
+
@mcp.tool(
|
|
59
|
+
description=(
|
|
60
|
+
"The last N slice requests (model, status, cost, routed-from, cached, time). "
|
|
61
|
+
"N defaults to 10 and is capped at 50."
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
async def get_recent_requests(limit: int = tools.RECENT_DEFAULT) -> str:
|
|
65
|
+
return await tools.get_recent_requests(client, limit)
|
|
66
|
+
|
|
67
|
+
@mcp.tool(description="The slice RAGAS eval pass-rate summary (overall and per model).")
|
|
68
|
+
async def get_eval_summary() -> str:
|
|
69
|
+
return await tools.get_eval_summary(client)
|
|
70
|
+
|
|
71
|
+
@mcp.tool(
|
|
72
|
+
description=(
|
|
73
|
+
"Add a slice switch rule routing from_model to to_model for a team. "
|
|
74
|
+
"Previews the change unless confirm=true is passed, which applies it."
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
async def add_rule(
|
|
78
|
+
team: str, from_model: str, to_model: str, confirm: bool = False
|
|
79
|
+
) -> str:
|
|
80
|
+
return await tools.add_rule(client, team, from_model, to_model, confirm)
|
|
81
|
+
|
|
82
|
+
@mcp.tool(
|
|
83
|
+
description=(
|
|
84
|
+
"Delete a slice switch rule by its id. Previews the deletion unless "
|
|
85
|
+
"confirm=true is passed, which applies it."
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
async def delete_rule(rule_id: int, confirm: bool = False) -> str:
|
|
89
|
+
return await tools.delete_rule(client, rule_id, confirm)
|
|
90
|
+
|
|
91
|
+
return mcp
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The slice MCP tools: pure ``(SliceClient, args) -> str`` coroutines.
|
|
2
|
+
|
|
3
|
+
This module is deliberately free of the ``mcp`` SDK so the tests can drive it against a
|
|
4
|
+
mocked gateway with nothing else in the way. Each function makes one (or, for a confirmed
|
|
5
|
+
write, one) gateway call through ``SliceClient`` and returns compact, human-readable text,
|
|
6
|
+
never a raw JSON dump, and never an unhandled exception. Every gateway failure is caught
|
|
7
|
+
and rendered by ``_gateway_error`` into a plain sentence.
|
|
8
|
+
|
|
9
|
+
Endpoint map (discovered from the existing FastAPI routes, nothing here is invented):
|
|
10
|
+
|
|
11
|
+
- ``get_spend`` → ``GET /dashboard/teams`` (spend vs budget, warn ratio, cap)
|
|
12
|
+
- ``list_rules`` → ``GET /admin/rules`` (the account's switch rules)
|
|
13
|
+
- ``get_recent_requests`` → ``GET /dashboard/recent?limit=`` (latest requests)
|
|
14
|
+
- ``get_eval_summary`` → ``GET /admin/eval/summary`` (RAGAS pass rate)
|
|
15
|
+
- ``add_rule`` → ``POST /admin/rules`` (confirm handshake)
|
|
16
|
+
- ``delete_rule`` → ``DELETE /admin/rules/{id}`` (confirm handshake)
|
|
17
|
+
|
|
18
|
+
Note (flagged for the report): ``/dashboard/recent`` does not expose ``latency_ms``: the
|
|
19
|
+
column exists in the ``requests`` table but the route's SQL and response omit it. Rather
|
|
20
|
+
than change an existing route, ``get_recent_requests`` reports the fields the endpoint
|
|
21
|
+
does return (model, status, cost, routed-from, cached, time) and simply has no latency to
|
|
22
|
+
show. Nothing was added to the gateway.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from mcp_server.client import (
|
|
28
|
+
GatewayError,
|
|
29
|
+
GatewayUnauthorized,
|
|
30
|
+
GatewayUnreachable,
|
|
31
|
+
SliceClient,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Bounds on the ``limit`` a caller may pass to get_recent_requests. Clamped, not rejected:
|
|
35
|
+
# a request for "too many" should still get an answer. The gateway itself caps at 200.
|
|
36
|
+
RECENT_DEFAULT = 10
|
|
37
|
+
RECENT_MAX = 50
|
|
38
|
+
|
|
39
|
+
GatewayFailure = (GatewayUnreachable, GatewayUnauthorized, GatewayError)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- error rendering ---------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _gateway_error(exc: Exception, client: SliceClient) -> str:
|
|
46
|
+
"""One place that turns any gateway failure into a plain, actionable sentence."""
|
|
47
|
+
if isinstance(exc, GatewayUnreachable):
|
|
48
|
+
return (
|
|
49
|
+
f"slice gateway not running at {client.base_url}. "
|
|
50
|
+
"Start the gateway (uvicorn app.main:app) or set SLICE_BASE_URL to point at it."
|
|
51
|
+
)
|
|
52
|
+
if isinstance(exc, GatewayUnauthorized):
|
|
53
|
+
if not client.has_key:
|
|
54
|
+
return (
|
|
55
|
+
"slice gateway returned 401 Unauthorized, but no SLICE_API_KEY is set. "
|
|
56
|
+
"The gateway has auth enabled: set SLICE_API_KEY to your slice key (slk_...)."
|
|
57
|
+
)
|
|
58
|
+
return (
|
|
59
|
+
"slice gateway returned 401 Unauthorized. The slice key in SLICE_API_KEY was "
|
|
60
|
+
"rejected (invalid or revoked): check the value."
|
|
61
|
+
)
|
|
62
|
+
if isinstance(exc, GatewayError):
|
|
63
|
+
detail = f": {exc.message}" if exc.message else ""
|
|
64
|
+
return f"slice gateway returned an error (HTTP {exc.status_code}){detail}."
|
|
65
|
+
return f"slice gateway call failed: {exc}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- formatting helpers ------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _money(value) -> str:
|
|
72
|
+
"""A dollar amount as ``$1.2345``; an em dash for an unknown (null) amount."""
|
|
73
|
+
if value is None:
|
|
74
|
+
return "-"
|
|
75
|
+
try:
|
|
76
|
+
return f"${float(value):.4f}"
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return "-"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pct(value) -> str:
|
|
82
|
+
if value is None:
|
|
83
|
+
return "-"
|
|
84
|
+
try:
|
|
85
|
+
return f"{float(value) * 100:.1f}%"
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return "-"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- read tools --------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def get_spend(client: SliceClient) -> str:
|
|
94
|
+
"""Current-month spend vs budget, the warn ratio, and whether the cap is hit.
|
|
95
|
+
|
|
96
|
+
Reads ``GET /dashboard/teams``, whose ``budget`` block carries the live budget meter
|
|
97
|
+
for the account: recorded spend, the gate-enforced "used" figure, the cap, and dollars
|
|
98
|
+
remaining. The cap is "hit" when nothing remains; the warn threshold is
|
|
99
|
+
``budget_usd * warn_ratio``.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
data = await client.request("GET", "/dashboard/teams")
|
|
103
|
+
except GatewayFailure as exc:
|
|
104
|
+
return _gateway_error(exc, client)
|
|
105
|
+
if not isinstance(data, dict):
|
|
106
|
+
return "slice returned an unexpected response for spend."
|
|
107
|
+
|
|
108
|
+
month = data.get("month", "this month")
|
|
109
|
+
budget_cap = data.get("budget_usd")
|
|
110
|
+
# Phase 25: the cap is per account; the dashboard says whether it is the config default.
|
|
111
|
+
cap_note = " (the default cap, set your own in the dashboard's Settings)" if data.get("budget_default") else ""
|
|
112
|
+
warn_ratio = data.get("warn_ratio")
|
|
113
|
+
meter = data.get("budget") or {}
|
|
114
|
+
|
|
115
|
+
used = meter.get("budget_used_usd")
|
|
116
|
+
remaining = meter.get("remaining_usd")
|
|
117
|
+
spend = meter.get("spend_usd")
|
|
118
|
+
source = meter.get("budget_source", "unknown")
|
|
119
|
+
|
|
120
|
+
cap_hit = remaining is not None and float(remaining) <= 0
|
|
121
|
+
warn = False
|
|
122
|
+
if used is not None and budget_cap not in (None, 0) and warn_ratio is not None:
|
|
123
|
+
try:
|
|
124
|
+
warn = float(used) >= float(budget_cap) * float(warn_ratio)
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
warn = False
|
|
127
|
+
|
|
128
|
+
if cap_hit:
|
|
129
|
+
status = "CAP HIT: budget exhausted, requests are being blocked"
|
|
130
|
+
elif warn:
|
|
131
|
+
status = f"WARNING: over the {_pct(warn_ratio)} warn threshold"
|
|
132
|
+
else:
|
|
133
|
+
status = "OK: under budget"
|
|
134
|
+
|
|
135
|
+
lines = [
|
|
136
|
+
f"slice spend: {month}",
|
|
137
|
+
f" budget used: {_money(used)} of {_money(budget_cap)} (source: {source}){cap_note}",
|
|
138
|
+
f" remaining: {_money(remaining)}",
|
|
139
|
+
f" recorded spend (Postgres): {_money(spend)}",
|
|
140
|
+
f" warn ratio: {_pct(warn_ratio)}",
|
|
141
|
+
f" status: {status}",
|
|
142
|
+
]
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def list_rules(client: SliceClient) -> str:
|
|
147
|
+
"""The account's current switch rules. Reads ``GET /admin/rules``."""
|
|
148
|
+
try:
|
|
149
|
+
data = await client.request("GET", "/admin/rules")
|
|
150
|
+
except GatewayFailure as exc:
|
|
151
|
+
return _gateway_error(exc, client)
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
return "slice returned an unexpected response for rules."
|
|
154
|
+
|
|
155
|
+
rules = data.get("rules") or []
|
|
156
|
+
if not rules:
|
|
157
|
+
return "No switch rules configured for this account."
|
|
158
|
+
|
|
159
|
+
lines = [f"slice switch rules ({len(rules)}):"]
|
|
160
|
+
for rule in rules:
|
|
161
|
+
rid = rule.get("id", "?")
|
|
162
|
+
team = rule.get("team", "?")
|
|
163
|
+
frm = rule.get("from_model", "?")
|
|
164
|
+
to = rule.get("to_model", "?")
|
|
165
|
+
lines.append(f" [#{rid}] team={team}: {frm} → {to}")
|
|
166
|
+
return "\n".join(lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def get_recent_requests(client: SliceClient, limit: int = RECENT_DEFAULT) -> str:
|
|
170
|
+
"""The last N requests (model, status, cost, routed-from, cached, time).
|
|
171
|
+
|
|
172
|
+
Reads ``GET /dashboard/recent?limit=N``. ``limit`` is clamped to [1, 50]. Latency is
|
|
173
|
+
not reported: the endpoint does not expose ``latency_ms`` (see the module note).
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
limit = int(limit)
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
limit = RECENT_DEFAULT
|
|
179
|
+
limit = max(1, min(limit, RECENT_MAX))
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
data = await client.request("GET", "/dashboard/recent", params={"limit": limit})
|
|
183
|
+
except GatewayFailure as exc:
|
|
184
|
+
return _gateway_error(exc, client)
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
return "slice returned an unexpected response for recent requests."
|
|
187
|
+
|
|
188
|
+
requests = data.get("requests") or []
|
|
189
|
+
if not requests:
|
|
190
|
+
return "No requests recorded yet."
|
|
191
|
+
|
|
192
|
+
lines = [f"slice recent requests (latest {len(requests)}):"]
|
|
193
|
+
for req in requests:
|
|
194
|
+
when = req.get("created_at") or "?"
|
|
195
|
+
model = req.get("model") or "?"
|
|
196
|
+
status = req.get("status")
|
|
197
|
+
cost = _money(req.get("cost_usd"))
|
|
198
|
+
routed = req.get("routed_from")
|
|
199
|
+
flags = []
|
|
200
|
+
if routed:
|
|
201
|
+
flags.append(f"routed from {routed}")
|
|
202
|
+
if req.get("cached"):
|
|
203
|
+
flags.append("cached")
|
|
204
|
+
suffix = f" ({', '.join(flags)})" if flags else ""
|
|
205
|
+
lines.append(f" {when} {model} status={status} cost={cost}{suffix}")
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def get_eval_summary(client: SliceClient) -> str:
|
|
210
|
+
"""The RAGAS eval pass rate. Reads ``GET /admin/eval/summary``."""
|
|
211
|
+
try:
|
|
212
|
+
data = await client.request("GET", "/admin/eval/summary")
|
|
213
|
+
except GatewayFailure as exc:
|
|
214
|
+
return _gateway_error(exc, client)
|
|
215
|
+
if not isinstance(data, dict):
|
|
216
|
+
return "slice returned an unexpected response for the eval summary."
|
|
217
|
+
|
|
218
|
+
overall = data.get("overall") or {}
|
|
219
|
+
count = overall.get("count", 0)
|
|
220
|
+
passed = overall.get("passed", 0)
|
|
221
|
+
pass_rate = overall.get("pass_rate")
|
|
222
|
+
|
|
223
|
+
if not count:
|
|
224
|
+
return "No eval scores recorded yet (RAGAS pass rate unavailable)."
|
|
225
|
+
|
|
226
|
+
lines = [
|
|
227
|
+
"slice eval summary (RAGAS):",
|
|
228
|
+
f" overall pass rate: {_pct(pass_rate)} ({passed}/{count} passed)",
|
|
229
|
+
]
|
|
230
|
+
by_model = data.get("by_model") or []
|
|
231
|
+
if by_model:
|
|
232
|
+
lines.append(" by model:")
|
|
233
|
+
for row in by_model:
|
|
234
|
+
model = row.get("model", "?")
|
|
235
|
+
lines.append(
|
|
236
|
+
f" {model}: {_pct(row.get('pass_rate'))} "
|
|
237
|
+
f"({row.get('passed', 0)}/{row.get('count', 0)})"
|
|
238
|
+
)
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- write tools (two-call confirm handshake) --------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _validate_rule(team: str, from_model: str, to_model: str) -> str | None:
|
|
246
|
+
"""Reject a malformed rule up front, before any preview or write. Message or None."""
|
|
247
|
+
for name, value in (("team", team), ("from_model", from_model), ("to_model", to_model)):
|
|
248
|
+
if not isinstance(value, str) or not value.strip():
|
|
249
|
+
return f"Invalid rule: '{name}' is required and must be a non-empty string."
|
|
250
|
+
if from_model.strip() == to_model.strip():
|
|
251
|
+
return "Invalid rule: 'from_model' and 'to_model' must differ."
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def add_rule(
|
|
256
|
+
client: SliceClient,
|
|
257
|
+
team: str,
|
|
258
|
+
from_model: str,
|
|
259
|
+
to_model: str,
|
|
260
|
+
confirm: bool = False,
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Add a switch rule. Previews unless ``confirm=true``; then POSTs ``/admin/rules``.
|
|
263
|
+
|
|
264
|
+
Inputs are validated before anything else, so a bad rule is rejected without a preview
|
|
265
|
+
or a call. With ``confirm`` false/omitted the tool describes exactly what would change
|
|
266
|
+
and does NOT touch the gateway. Only ``confirm=true`` sends the write.
|
|
267
|
+
"""
|
|
268
|
+
error = _validate_rule(team, from_model, to_model)
|
|
269
|
+
if error is not None:
|
|
270
|
+
return error
|
|
271
|
+
|
|
272
|
+
team, from_model, to_model = team.strip(), from_model.strip(), to_model.strip()
|
|
273
|
+
detail = f"team={team}: {from_model} → {to_model}"
|
|
274
|
+
|
|
275
|
+
if not confirm:
|
|
276
|
+
return (
|
|
277
|
+
f"Would add rule: {detail}. "
|
|
278
|
+
"Call again with confirm=true to apply."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
data = await client.request(
|
|
283
|
+
"POST",
|
|
284
|
+
"/admin/rules",
|
|
285
|
+
json_body={"team": team, "from_model": from_model, "to_model": to_model},
|
|
286
|
+
)
|
|
287
|
+
except GatewayFailure as exc:
|
|
288
|
+
return _gateway_error(exc, client)
|
|
289
|
+
|
|
290
|
+
rule = data.get("rule") if isinstance(data, dict) else None
|
|
291
|
+
if isinstance(rule, dict) and rule.get("id") is not None:
|
|
292
|
+
return f"Added rule #{rule['id']}: {detail}."
|
|
293
|
+
return f"Added rule: {detail}."
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def delete_rule(client: SliceClient, rule_id: int, confirm: bool = False) -> str:
|
|
297
|
+
"""Delete a switch rule by id. Previews unless ``confirm=true``; then DELETEs it.
|
|
298
|
+
|
|
299
|
+
The id is validated (a positive integer) before anything else. With ``confirm``
|
|
300
|
+
false/omitted the tool describes what would be deleted and does NOT touch the gateway.
|
|
301
|
+
Only ``confirm=true`` sends the write.
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
rule_id = int(rule_id)
|
|
305
|
+
except (TypeError, ValueError):
|
|
306
|
+
return "Invalid rule id: it must be an integer."
|
|
307
|
+
if rule_id <= 0:
|
|
308
|
+
return "Invalid rule id: it must be a positive integer."
|
|
309
|
+
|
|
310
|
+
if not confirm:
|
|
311
|
+
return (
|
|
312
|
+
f"Would delete rule #{rule_id}. "
|
|
313
|
+
"Call again with confirm=true to apply."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
data = await client.request("DELETE", f"/admin/rules/{rule_id}")
|
|
318
|
+
except GatewayFailure as exc:
|
|
319
|
+
return _gateway_error(exc, client)
|
|
320
|
+
|
|
321
|
+
if isinstance(data, dict) and "deleted" in data:
|
|
322
|
+
return f"Deleted rule #{data['deleted']}."
|
|
323
|
+
return f"Deleted rule #{rule_id}."
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Builds the `slice-gateway` distribution: the slice CLI (slice login / init / use) and
|
|
2
|
+
# the standalone slice MCP server (slice-mcp). The wheel ships the slice_cli.py module and
|
|
3
|
+
# the mcp_server package, and depends on typer, httpx, and mcp only. None of the server
|
|
4
|
+
# stack (fastapi, langgraph, redis, torch, ...) is imported or required, and the app/
|
|
5
|
+
# server package is never pulled in. Inside the repo, `python -m app.cli` still works via
|
|
6
|
+
# the app/cli.py shim and `python -m mcp_server` runs the server from source.
|
|
7
|
+
[build-system]
|
|
8
|
+
requires = ["setuptools>=77"]
|
|
9
|
+
build-backend = "setuptools.build_meta"
|
|
10
|
+
|
|
11
|
+
[project]
|
|
12
|
+
name = "slice-gateway"
|
|
13
|
+
version = "0.2.3"
|
|
14
|
+
description = "Command-line client for slice, the self-hosted LLM gateway."
|
|
15
|
+
readme = "PYPI.md"
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
|
+
license = "MIT"
|
|
18
|
+
authors = [{ name = "Jishnu Janardanan" }]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"typer",
|
|
21
|
+
"httpx",
|
|
22
|
+
"mcp>=1.9,<2",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Environment :: Console",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Topic :: Utilities",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://sliceapp.dev"
|
|
33
|
+
Repository = "https://github.com/jjk30/slice"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
# `slice login`, `slice init`, `slice use <tool>`. See slice_cli.py.
|
|
37
|
+
slice = "slice_cli:main"
|
|
38
|
+
# `slice-mcp`: the stdio MCP server. See mcp_server/__main__.py.
|
|
39
|
+
slice-mcp = "mcp_server.__main__:main"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
# Ship the CLI module and the mcp_server package, and nothing else. Listing py-modules and
|
|
43
|
+
# packages explicitly disables package auto-discovery, so the app/ server package (and
|
|
44
|
+
# every other repo folder) is never pulled into the wheel.
|
|
45
|
+
py-modules = ["slice_cli"]
|
|
46
|
+
packages = ["mcp_server"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slice-gateway
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Command-line client for slice, the self-hosted LLM gateway.
|
|
5
5
|
Author: Jishnu Janardanan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,6 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: typer
|
|
17
17
|
Requires-Dist: httpx
|
|
18
|
+
Requires-Dist: mcp<2,>=1.9
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# slice-gateway
|
|
@@ -43,12 +44,24 @@ This package installs the `slice` command. It logs you in through GitHub, then p
|
|
|
43
44
|
|
|
44
45
|
Run `slice use anthropic`, `slice use openai`, `slice use curl`, or `slice use claude-code` to print the exact lines for your tool.
|
|
45
46
|
|
|
47
|
+
## Claude Code MCP
|
|
48
|
+
|
|
49
|
+
This package also installs `slice-mcp`, a stdio MCP server. Add it to Claude Code with one command:
|
|
50
|
+
|
|
51
|
+
claude mcp add slice -s user -e SLICE_API_KEY=slk_live_... -- slice-mcp
|
|
52
|
+
|
|
53
|
+
It exposes six read and write tools over your slice account: check spend against budget, list recent requests, show the eval pass rate, and list, add, or delete model-routing rules. It talks to the hosted gateway by default; set `SLICE_BASE_URL` for a self-hosted one.
|
|
54
|
+
|
|
46
55
|
## The gateway
|
|
47
56
|
|
|
48
57
|
This is only the client. The slice gateway itself, the server that does the routing, caching, budgets, and dashboards, lives on GitHub at https://github.com/jjk30/slice.
|
|
49
58
|
|
|
50
59
|
Homepage: https://sliceapp.dev
|
|
51
60
|
|
|
61
|
+
## What's new in 0.2.3
|
|
62
|
+
|
|
63
|
+
The package now installs `slice-mcp`, the slice MCP server, so `pipx install slice-gateway` gives you both the CLI and the Claude Code integration. See the Claude Code MCP section above.
|
|
64
|
+
|
|
52
65
|
## What's new in 0.2.2
|
|
53
66
|
|
|
54
67
|
`slice login` now ends by printing your dashboard link and opening it in the browser.
|
|
@@ -3,6 +3,12 @@ MANIFEST.in
|
|
|
3
3
|
PYPI.md
|
|
4
4
|
pyproject.toml
|
|
5
5
|
slice_cli.py
|
|
6
|
+
mcp_server/__init__.py
|
|
7
|
+
mcp_server/__main__.py
|
|
8
|
+
mcp_server/client.py
|
|
9
|
+
mcp_server/config.py
|
|
10
|
+
mcp_server/server.py
|
|
11
|
+
mcp_server/tools.py
|
|
6
12
|
slice_gateway.egg-info/PKG-INFO
|
|
7
13
|
slice_gateway.egg-info/SOURCES.txt
|
|
8
14
|
slice_gateway.egg-info/dependency_links.txt
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# Builds the `slice-gateway` distribution: the slice CLI only (slice login / init / use).
|
|
2
|
-
# The wheel ships a single top-level module, slice_cli.py, and depends on typer and httpx
|
|
3
|
-
# only. None of the server stack (fastapi, langgraph, redis, torch, ...) is imported or
|
|
4
|
-
# required. Inside the repo, `python -m app.cli` still works via the app/cli.py shim.
|
|
5
|
-
[build-system]
|
|
6
|
-
requires = ["setuptools>=77"]
|
|
7
|
-
build-backend = "setuptools.build_meta"
|
|
8
|
-
|
|
9
|
-
[project]
|
|
10
|
-
name = "slice-gateway"
|
|
11
|
-
version = "0.2.2"
|
|
12
|
-
description = "Command-line client for slice, the self-hosted LLM gateway."
|
|
13
|
-
readme = "PYPI.md"
|
|
14
|
-
requires-python = ">=3.11"
|
|
15
|
-
license = "MIT"
|
|
16
|
-
authors = [{ name = "Jishnu Janardanan" }]
|
|
17
|
-
dependencies = [
|
|
18
|
-
"typer",
|
|
19
|
-
"httpx",
|
|
20
|
-
]
|
|
21
|
-
classifiers = [
|
|
22
|
-
"Environment :: Console",
|
|
23
|
-
"Intended Audience :: Developers",
|
|
24
|
-
"Programming Language :: Python :: 3",
|
|
25
|
-
"Topic :: Utilities",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
[project.urls]
|
|
29
|
-
Homepage = "https://sliceapp.dev"
|
|
30
|
-
Repository = "https://github.com/jjk30/slice"
|
|
31
|
-
|
|
32
|
-
[project.scripts]
|
|
33
|
-
# `slice login`, `slice init`, `slice use <tool>`. See slice_cli.py.
|
|
34
|
-
slice = "slice_cli:main"
|
|
35
|
-
|
|
36
|
-
[tool.setuptools]
|
|
37
|
-
# Ship only the CLI module. Setting py-modules disables package auto-discovery, so the
|
|
38
|
-
# app/ server package is never pulled into the wheel.
|
|
39
|
-
py-modules = ["slice_cli"]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
slice_cli
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|