qsp-client 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qsp_client/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """QSP client — relay MCP tools to any OpenAI-compatible local LLM endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import version
7
+
8
+ __version__ = version("qsp-client")
9
+ except Exception:
10
+ __version__ = "0.0.0-dev"
qsp_client/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ """CLI entry point for qsp-client.
2
+
3
+ Supports two modes:
4
+ - Interactive: readline-enabled chat loop
5
+ - Single query: --query "..." for one-shot usage
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from .config import load_config
17
+ from .relay import QSPRelay
18
+
19
+ DEFAULT_CONFIG_PATHS = [
20
+ Path.home() / ".config" / "qsp-client" / "config.json",
21
+ Path.home() / ".qsp-client.json",
22
+ # From when this was qsp-mcp; still read so existing setups keep working.
23
+ Path.home() / ".config" / "qsp-mcp" / "config.json",
24
+ Path.home() / ".qsp-mcp.json",
25
+ ]
26
+
27
+
28
+ def _find_config() -> Path | None:
29
+ """Find the default config file."""
30
+ for path in DEFAULT_CONFIG_PATHS:
31
+ if path.exists():
32
+ return path
33
+ return None
34
+
35
+
36
+ def _parse_args() -> argparse.Namespace:
37
+ parser = argparse.ArgumentParser(
38
+ prog="qsp-client",
39
+ description="QSP — relay MCP tools to any OpenAI-compatible LLM endpoint",
40
+ )
41
+ parser.add_argument(
42
+ "--version", action="version", version=f"qsp-client {__version__}"
43
+ )
44
+ parser.add_argument(
45
+ "--config", "-c",
46
+ type=str,
47
+ default=None,
48
+ help="Path to config file (default: ~/.config/qsp-client/config.json)",
49
+ )
50
+ parser.add_argument(
51
+ "--endpoint", "-e",
52
+ type=str,
53
+ default=None,
54
+ help="LLM endpoint URL (overrides config)",
55
+ )
56
+ parser.add_argument(
57
+ "--api-key", "-k",
58
+ type=str,
59
+ default=None,
60
+ help="API key for the LLM endpoint",
61
+ )
62
+ parser.add_argument(
63
+ "--model", "-m",
64
+ type=str,
65
+ default=None,
66
+ help="Model name (overrides config)",
67
+ )
68
+ parser.add_argument(
69
+ "--profile", "-p",
70
+ type=str,
71
+ default=None,
72
+ help="Tool profile to use (e.g., contest, dx, propagation, full)",
73
+ )
74
+ parser.add_argument(
75
+ "--query", "-q",
76
+ type=str,
77
+ default=None,
78
+ help="Single query mode — ask one question and exit",
79
+ )
80
+ parser.add_argument(
81
+ "--enable-writes",
82
+ action="store_true",
83
+ default=False,
84
+ help="Enable write-capable tools (disabled by default for safety)",
85
+ )
86
+ parser.add_argument(
87
+ "--list-tools",
88
+ action="store_true",
89
+ default=False,
90
+ help="List available tools and exit",
91
+ )
92
+
93
+ return parser.parse_args()
94
+
95
+
96
+ async def _run(args: argparse.Namespace) -> int:
97
+ """Main async entry point."""
98
+ # Find config
99
+ config_path = args.config
100
+ if config_path is None:
101
+ found = _find_config()
102
+ if found:
103
+ config_path = str(found)
104
+
105
+ if config_path is None and args.endpoint is None:
106
+ print(
107
+ "Error: No config file found and no --endpoint specified.\n"
108
+ "Create ~/.config/qsp-client/config.json or use --endpoint.\n"
109
+ "See: https://github.com/qso-graph/qsp-client",
110
+ file=sys.stderr,
111
+ )
112
+ return 1
113
+
114
+ try:
115
+ config = load_config(
116
+ config_path,
117
+ endpoint=args.endpoint,
118
+ api_key=args.api_key,
119
+ model=args.model,
120
+ profile=args.profile,
121
+ enable_writes=args.enable_writes,
122
+ )
123
+ except (FileNotFoundError, ValueError) as e:
124
+ print(f"Config error: {e}", file=sys.stderr)
125
+ return 1
126
+
127
+ if not config.servers:
128
+ print(
129
+ "Error: No MCP servers configured in mcpServers block.",
130
+ file=sys.stderr,
131
+ )
132
+ return 1
133
+
134
+ relay = QSPRelay(config, enable_writes=args.enable_writes)
135
+
136
+ try:
137
+ await relay.start()
138
+ except RuntimeError as e:
139
+ print(f"Startup failed: {e}", file=sys.stderr)
140
+ return 1
141
+
142
+ try:
143
+ # List tools mode
144
+ if args.list_tools:
145
+ print(relay.get_tool_summary())
146
+ for tool in relay._openai_tools:
147
+ func = tool["function"]
148
+ server = relay._tool_server_map.get(func["name"], "?")
149
+ print(f" [{server}] {func['name']}: {func['description'][:80]}")
150
+ return 0
151
+
152
+ # Single query mode
153
+ if args.query:
154
+ response = await relay.query(args.query, [])
155
+ print(response)
156
+ return 0
157
+
158
+ # Interactive mode
159
+ return await _interactive_loop(relay, config.bridge.max_history_turns)
160
+
161
+ finally:
162
+ await relay.stop()
163
+
164
+
165
+ async def _interactive_loop(relay: QSPRelay, max_history: int) -> int:
166
+ """Interactive chat loop with readline support."""
167
+ history: list[dict[str, str]] = []
168
+
169
+ print(
170
+ f"qsp-client {__version__} — type your question, or 'quit' to exit.\n"
171
+ f"Servers:\n{relay.get_tool_summary()}\n"
172
+ )
173
+
174
+ while True:
175
+ try:
176
+ user_input = input("You> ").strip()
177
+ except (EOFError, KeyboardInterrupt):
178
+ print("\n73!")
179
+ break
180
+
181
+ if not user_input:
182
+ continue
183
+
184
+ if user_input.lower() in ("quit", "exit", "q", "73"):
185
+ print("73!")
186
+ break
187
+
188
+ if user_input.lower() == "/tools":
189
+ print(relay.get_tool_summary())
190
+ for tool in relay._openai_tools:
191
+ func = tool["function"]
192
+ server = relay._tool_server_map.get(func["name"], "?")
193
+ print(f" [{server}] {func['name']}")
194
+ continue
195
+
196
+ if user_input.lower() == "/help":
197
+ print(
198
+ "Commands:\n"
199
+ " /tools — list available tools\n"
200
+ " /help — show this help\n"
201
+ " quit — exit (or 73)\n"
202
+ )
203
+ continue
204
+
205
+ try:
206
+ response = await relay.query(user_input, history)
207
+ except Exception as e:
208
+ print(f"[qsp] Error: {type(e).__name__}: {e}", file=sys.stderr)
209
+ continue
210
+
211
+ print(f"\n{response}\n")
212
+
213
+ # Update sliding window history
214
+ history.append({"role": "user", "content": user_input})
215
+ history.append({"role": "assistant", "content": response})
216
+
217
+ # Trim to max_history turns (each turn = user + assistant = 2 messages)
218
+ max_messages = max_history * 2
219
+ if len(history) > max_messages:
220
+ history = history[-max_messages:]
221
+
222
+ return 0
223
+
224
+
225
+ def main() -> None:
226
+ """Entry point for the qsp-client CLI."""
227
+ try:
228
+ exit_code = asyncio.run(_run(_parse_args()))
229
+ except KeyboardInterrupt:
230
+ print("\n73!")
231
+ exit_code = 0
232
+ sys.exit(exit_code)
233
+
234
+
235
+ def main_renamed() -> None:
236
+ """The old `qsp-mcp` command: still works, and says where it went."""
237
+ print("Note: qsp-mcp is now qsp-client; the qsp-mcp command will be removed in a later release.",
238
+ file=sys.stderr)
239
+ main()
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
qsp_client/client.py ADDED
@@ -0,0 +1,130 @@
1
+ """HTTP client for OpenAI-compatible LLM endpoints.
2
+
3
+ Handles chat completions with function calling support.
4
+ Non-streaming only for v1 (streaming buffering is post-v1).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+
15
+ class LLMClient:
16
+ """Client for OpenAI-compatible chat completions API."""
17
+
18
+ def __init__(
19
+ self,
20
+ endpoint: str,
21
+ *,
22
+ api_key: str | None = None,
23
+ model: str = "default",
24
+ max_tokens: int = 2048,
25
+ temperature: float = 0.3,
26
+ timeout: float = 120.0,
27
+ ) -> None:
28
+ self._endpoint = endpoint
29
+ self._model = model
30
+ self._max_tokens = max_tokens
31
+ self._temperature = temperature
32
+
33
+ headers: dict[str, str] = {"Content-Type": "application/json"}
34
+ if api_key:
35
+ headers["Authorization"] = f"Bearer {api_key}"
36
+
37
+ self._http = httpx.Client(
38
+ headers=headers,
39
+ timeout=httpx.Timeout(timeout, connect=10.0),
40
+ )
41
+
42
+ def chat(
43
+ self,
44
+ messages: list[dict[str, Any]],
45
+ tools: list[dict[str, Any]] | None = None,
46
+ *,
47
+ temperature: float | None = None,
48
+ tool_choice: str = "auto",
49
+ ) -> dict[str, Any]:
50
+ """Send a chat completion request.
51
+
52
+ Returns the raw response dict from the LLM endpoint.
53
+ """
54
+ payload: dict[str, Any] = {
55
+ "model": self._model,
56
+ "messages": messages,
57
+ "max_tokens": self._max_tokens,
58
+ "temperature": temperature if temperature is not None else self._temperature,
59
+ }
60
+
61
+ if tools:
62
+ payload["tools"] = tools
63
+ payload["tool_choice"] = tool_choice
64
+
65
+ resp = self._http.post(self._endpoint, json=payload)
66
+ resp.raise_for_status()
67
+
68
+ return resp.json()
69
+
70
+ def close(self) -> None:
71
+ """Close the HTTP client."""
72
+ self._http.close()
73
+
74
+ def __enter__(self) -> LLMClient:
75
+ return self
76
+
77
+ def __exit__(self, *args: Any) -> None:
78
+ self.close()
79
+
80
+
81
+ def extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
82
+ """Extract tool calls from a chat completion response.
83
+
84
+ Returns list of dicts with: id, name, arguments (parsed JSON).
85
+ Returns empty list if no tool calls.
86
+ """
87
+ choices = response.get("choices", [])
88
+ if not choices:
89
+ return []
90
+
91
+ message = choices[0].get("message", {})
92
+ raw_calls = message.get("tool_calls", [])
93
+
94
+ tool_calls = []
95
+ for call in raw_calls:
96
+ func = call.get("function", {})
97
+ args_str = func.get("arguments", "{}")
98
+
99
+ try:
100
+ arguments = json.loads(args_str)
101
+ except json.JSONDecodeError:
102
+ arguments = {"_raw": args_str, "_parse_error": True}
103
+
104
+ tool_calls.append(
105
+ {
106
+ "id": call.get("id", ""),
107
+ "name": func.get("name", ""),
108
+ "arguments": arguments,
109
+ }
110
+ )
111
+
112
+ return tool_calls
113
+
114
+
115
+ def extract_text(response: dict[str, Any]) -> str:
116
+ """Extract text content from a chat completion response."""
117
+ choices = response.get("choices", [])
118
+ if not choices:
119
+ return ""
120
+
121
+ message = choices[0].get("message", {})
122
+ return message.get("content", "") or ""
123
+
124
+
125
+ def get_finish_reason(response: dict[str, Any]) -> str:
126
+ """Get the finish reason from a response."""
127
+ choices = response.get("choices", [])
128
+ if not choices:
129
+ return ""
130
+ return choices[0].get("finish_reason", "")
qsp_client/config.py ADDED
@@ -0,0 +1,147 @@
1
+ """Configuration loader for qsp-client.
2
+
3
+ Config format is Claude Desktop compatible — users can copy their existing
4
+ mcpServers block directly. The 'bridge' section is qsp-client specific.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ @dataclass
16
+ class ServerConfig:
17
+ """MCP server configuration — matches Claude Desktop format."""
18
+
19
+ name: str
20
+ command: str
21
+ args: list[str] = field(default_factory=list)
22
+ env: dict[str, str] = field(default_factory=dict)
23
+
24
+
25
+ @dataclass
26
+ class ProfileConfig:
27
+ """Tool profile — subset of servers with model-specific params."""
28
+
29
+ servers: list[str] | str # list of server names or "*" for all
30
+ temperature: float = 0.3
31
+ system_prompt: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class BridgeConfig:
36
+ """Bridge-specific configuration."""
37
+
38
+ endpoint: str = "http://localhost:8000/v1/chat/completions"
39
+ api_key: str | None = None
40
+ model: str = "default"
41
+ max_tokens: int = 2048
42
+ temperature: float = 0.3
43
+ system_prompt: str = "You are an expert ham radio operator and RF engineer."
44
+ max_history_turns: int = 5
45
+ max_tool_calls_per_turn: int = 5
46
+ tool_timeout_seconds: float = 5.0
47
+ profile: str = "full"
48
+ profiles: dict[str, ProfileConfig] = field(default_factory=dict)
49
+ server_timeouts: dict[str, float] = field(default_factory=dict)
50
+
51
+
52
+ @dataclass
53
+ class Config:
54
+ """Top-level qsp-client configuration."""
55
+
56
+ servers: dict[str, ServerConfig] = field(default_factory=dict)
57
+ bridge: BridgeConfig = field(default_factory=BridgeConfig)
58
+
59
+
60
+ def load_config(
61
+ config_path: str | Path | None = None,
62
+ *,
63
+ endpoint: str | None = None,
64
+ api_key: str | None = None,
65
+ model: str | None = None,
66
+ profile: str | None = None,
67
+ enable_writes: bool = False,
68
+ ) -> Config:
69
+ """Load config from file, with CLI overrides applied on top."""
70
+ if config_path is not None:
71
+ path = Path(config_path)
72
+ if not path.exists():
73
+ raise FileNotFoundError(f"Config file not found: {path}")
74
+ raw = json.loads(path.read_text())
75
+ else:
76
+ raw = {}
77
+
78
+ config = _parse_config(raw)
79
+
80
+ # CLI overrides take precedence
81
+ if endpoint is not None:
82
+ config.bridge.endpoint = endpoint
83
+ if api_key is not None:
84
+ config.bridge.api_key = api_key
85
+ if model is not None:
86
+ config.bridge.model = model
87
+ if profile is not None:
88
+ config.bridge.profile = profile
89
+
90
+ return config
91
+
92
+
93
+ def _parse_config(raw: dict[str, Any]) -> Config:
94
+ """Parse raw JSON dict into typed Config."""
95
+ config = Config()
96
+
97
+ # Parse mcpServers (Claude Desktop compatible)
98
+ for name, server_raw in raw.get("mcpServers", {}).items():
99
+ config.servers[name] = ServerConfig(
100
+ name=name,
101
+ command=server_raw.get("command", ""),
102
+ args=server_raw.get("args", []),
103
+ env=server_raw.get("env", {}),
104
+ )
105
+
106
+ # Parse bridge section
107
+ bridge_raw = raw.get("bridge", {})
108
+ config.bridge = BridgeConfig(
109
+ endpoint=bridge_raw.get("endpoint", config.bridge.endpoint),
110
+ api_key=bridge_raw.get("api_key", config.bridge.api_key),
111
+ model=bridge_raw.get("model", config.bridge.model),
112
+ max_tokens=bridge_raw.get("max_tokens", config.bridge.max_tokens),
113
+ temperature=bridge_raw.get("temperature", config.bridge.temperature),
114
+ system_prompt=bridge_raw.get("system_prompt", config.bridge.system_prompt),
115
+ max_history_turns=bridge_raw.get(
116
+ "max_history_turns", config.bridge.max_history_turns
117
+ ),
118
+ max_tool_calls_per_turn=bridge_raw.get(
119
+ "max_tool_calls_per_turn", config.bridge.max_tool_calls_per_turn
120
+ ),
121
+ tool_timeout_seconds=bridge_raw.get(
122
+ "tool_timeout_seconds", config.bridge.tool_timeout_seconds
123
+ ),
124
+ profile=bridge_raw.get("profile", config.bridge.profile),
125
+ server_timeouts={
126
+ k: float(v) for k, v in bridge_raw.get("server_timeouts", {}).items()
127
+ },
128
+ )
129
+
130
+ # Parse profiles
131
+ for name, prof_raw in bridge_raw.get("profiles", {}).items():
132
+ config.bridge.profiles[name] = ProfileConfig(
133
+ servers=prof_raw.get("servers", "*"),
134
+ temperature=prof_raw.get("temperature", config.bridge.temperature),
135
+ system_prompt=prof_raw.get("system_prompt"),
136
+ )
137
+
138
+ # Ensure "full" profile always exists
139
+ if "full" not in config.bridge.profiles:
140
+ config.bridge.profiles["full"] = ProfileConfig(servers="*")
141
+
142
+ return config
143
+
144
+
145
+ def get_api_key(config: Config) -> str | None:
146
+ """Get API key from config."""
147
+ return config.bridge.api_key