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 +10 -0
- qsp_client/cli.py +243 -0
- qsp_client/client.py +130 -0
- qsp_client/config.py +147 -0
- qsp_client/relay.py +365 -0
- qsp_client/schema.py +180 -0
- qsp_client-0.3.0.dist-info/METADATA +168 -0
- qsp_client-0.3.0.dist-info/RECORD +11 -0
- qsp_client-0.3.0.dist-info/WHEEL +4 -0
- qsp_client-0.3.0.dist-info/entry_points.txt +3 -0
- qsp_client-0.3.0.dist-info/licenses/LICENSE +674 -0
qsp_client/__init__.py
ADDED
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
|