guzli-cli 0.2.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.
- guzli/__init__.py +3 -0
- guzli/__main__.py +4 -0
- guzli/auth.py +182 -0
- guzli/cli.py +84 -0
- guzli/commands/__init__.py +1 -0
- guzli/commands/admin.py +421 -0
- guzli/commands/auth.py +502 -0
- guzli/commands/campaign_launch.py +188 -0
- guzli/commands/campaigns.py +571 -0
- guzli/commands/common.py +42 -0
- guzli/commands/conversations.py +404 -0
- guzli/commands/integrations.py +48 -0
- guzli/commands/telephony.py +274 -0
- guzli/commands/voice_calls.py +270 -0
- guzli/config.py +137 -0
- guzli/config_store.py +218 -0
- guzli/errors.py +47 -0
- guzli/http_client.py +213 -0
- guzli/mcp/__init__.py +1 -0
- guzli/mcp/server.py +320 -0
- guzli/models.py +173 -0
- guzli/oauth_callback.py +126 -0
- guzli/output.py +79 -0
- guzli/runtime.py +159 -0
- guzli/services/__init__.py +29 -0
- guzli/services/api_keys.py +105 -0
- guzli/services/calls.py +48 -0
- guzli/services/campaigns.py +193 -0
- guzli/services/cli_auth.py +103 -0
- guzli/services/conversations.py +112 -0
- guzli/services/integrations.py +18 -0
- guzli/services/replies.py +21 -0
- guzli/services/telephony.py +80 -0
- guzli/services/voice_profiles.py +56 -0
- guzli/services/webhooks.py +169 -0
- guzli/types.py +15 -0
- guzli/validators.py +83 -0
- guzli/workflows/__init__.py +13 -0
- guzli/workflows/campaign_launch.py +501 -0
- guzli_cli-0.2.0.data/data/share/guzli/CHANGELOG.md +16 -0
- guzli_cli-0.2.0.data/data/share/guzli/PROVENANCE.md +23 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY.md +18 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY_AUDIT.md +47 -0
- guzli_cli-0.2.0.data/data/share/guzli/SKILL.md +101 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/compliance-advisory-us.json +15 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/pizza-order-extraction.json +65 -0
- guzli_cli-0.2.0.data/data/share/guzli/references/api.md +245 -0
- guzli_cli-0.2.0.dist-info/METADATA +341 -0
- guzli_cli-0.2.0.dist-info/RECORD +53 -0
- guzli_cli-0.2.0.dist-info/WHEEL +5 -0
- guzli_cli-0.2.0.dist-info/entry_points.txt +3 -0
- guzli_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
- guzli_cli-0.2.0.dist-info/top_level.txt +1 -0
guzli/config_store.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from guzli.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR_ENV = "GUZLI_CONFIG_DIR"
|
|
14
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".guzli"
|
|
15
|
+
CONFIG_FILE_NAME = "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class StoredProfile:
|
|
20
|
+
name: str
|
|
21
|
+
base_url: str | None = None
|
|
22
|
+
api_key: str | None = None
|
|
23
|
+
# Engine-issued access token, or a manually pasted JWT for the legacy workflow.
|
|
24
|
+
bearer_token: str | None = None
|
|
25
|
+
# Opaque refresh handle returned by /auth/cli/oauth/exchange.
|
|
26
|
+
# before bearer_token expires. Rotates on every refresh — must save the new value back.
|
|
27
|
+
refresh_handle: str | None = None
|
|
28
|
+
# ISO-8601 datetime of bearer_token expiry. Used to decide when to call /refresh.
|
|
29
|
+
access_token_expires_at: str | None = None
|
|
30
|
+
# Connected CLI session id from CliOAuthSessionResponse.id. Surfaced for debugging
|
|
31
|
+
# ("which connected client am I?") and so `auth status` can show it.
|
|
32
|
+
cli_session_id: str | None = None
|
|
33
|
+
agent_id: str | None = None
|
|
34
|
+
organization_id: str | None = None
|
|
35
|
+
default_number_pool_id: str | None = None
|
|
36
|
+
timeout_seconds: int | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
data: dict[str, object] = {}
|
|
40
|
+
if self.base_url:
|
|
41
|
+
data["base_url"] = self.base_url
|
|
42
|
+
if self.api_key:
|
|
43
|
+
data["api_key"] = self.api_key
|
|
44
|
+
if self.bearer_token:
|
|
45
|
+
data["bearer_token"] = self.bearer_token
|
|
46
|
+
if self.refresh_handle:
|
|
47
|
+
data["refresh_handle"] = self.refresh_handle
|
|
48
|
+
if self.access_token_expires_at:
|
|
49
|
+
data["access_token_expires_at"] = self.access_token_expires_at
|
|
50
|
+
if self.cli_session_id:
|
|
51
|
+
data["cli_session_id"] = self.cli_session_id
|
|
52
|
+
if self.agent_id:
|
|
53
|
+
data["agent_id"] = self.agent_id
|
|
54
|
+
if self.organization_id:
|
|
55
|
+
data["organization_id"] = self.organization_id
|
|
56
|
+
if self.default_number_pool_id:
|
|
57
|
+
data["default_number_pool_id"] = self.default_number_pool_id
|
|
58
|
+
if self.timeout_seconds is not None:
|
|
59
|
+
data["timeout_seconds"] = self.timeout_seconds
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _config_dir() -> Path:
|
|
64
|
+
override = os.getenv(CONFIG_DIR_ENV)
|
|
65
|
+
return Path(override) if override else DEFAULT_CONFIG_DIR
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _config_path() -> Path:
|
|
69
|
+
return _config_dir() / CONFIG_FILE_NAME
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConfigStore:
|
|
73
|
+
"""Read/write profile credentials at ~/.guzli/config.json."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
76
|
+
self._path = path or _config_path()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def path(self) -> Path:
|
|
80
|
+
return self._path
|
|
81
|
+
|
|
82
|
+
def exists(self) -> bool:
|
|
83
|
+
return self._path.is_file()
|
|
84
|
+
|
|
85
|
+
def load_raw(self) -> dict[str, object]:
|
|
86
|
+
if not self._path.is_file():
|
|
87
|
+
return {"active_profile": None, "profiles": {}}
|
|
88
|
+
try:
|
|
89
|
+
with self._path.open("r", encoding="utf-8") as handle:
|
|
90
|
+
data = json.load(handle)
|
|
91
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
92
|
+
raise ConfigError(f"Could not read {self._path}: {exc}") from exc
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
raise ConfigError(f"{self._path} must contain a JSON object")
|
|
95
|
+
profiles = data.get("profiles")
|
|
96
|
+
if not isinstance(profiles, dict):
|
|
97
|
+
data["profiles"] = {}
|
|
98
|
+
if data.get("active_profile") is not None and not isinstance(data["active_profile"], str):
|
|
99
|
+
data["active_profile"] = None
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
def list_profiles(self) -> list[str]:
|
|
103
|
+
raw = self.load_raw()
|
|
104
|
+
profiles = raw.get("profiles")
|
|
105
|
+
return sorted(profiles.keys()) if isinstance(profiles, dict) else []
|
|
106
|
+
|
|
107
|
+
def active_profile_name(self) -> str | None:
|
|
108
|
+
raw = self.load_raw()
|
|
109
|
+
name = raw.get("active_profile")
|
|
110
|
+
return name if isinstance(name, str) else None
|
|
111
|
+
|
|
112
|
+
def get_profile(self, profile_name: str | None = None) -> StoredProfile | None:
|
|
113
|
+
raw = self.load_raw()
|
|
114
|
+
profiles = raw.get("profiles")
|
|
115
|
+
if not isinstance(profiles, dict):
|
|
116
|
+
return None
|
|
117
|
+
name = profile_name or raw.get("active_profile")
|
|
118
|
+
if not isinstance(name, str) or name not in profiles:
|
|
119
|
+
return None
|
|
120
|
+
entry = profiles[name]
|
|
121
|
+
if not isinstance(entry, Mapping):
|
|
122
|
+
return None
|
|
123
|
+
return StoredProfile(
|
|
124
|
+
name=name,
|
|
125
|
+
base_url=_string_or_none(entry.get("base_url")),
|
|
126
|
+
api_key=_string_or_none(entry.get("api_key")),
|
|
127
|
+
bearer_token=_string_or_none(entry.get("bearer_token")),
|
|
128
|
+
refresh_handle=_string_or_none(entry.get("refresh_handle")),
|
|
129
|
+
access_token_expires_at=_string_or_none(entry.get("access_token_expires_at")),
|
|
130
|
+
cli_session_id=_string_or_none(entry.get("cli_session_id")),
|
|
131
|
+
agent_id=_string_or_none(entry.get("agent_id")),
|
|
132
|
+
organization_id=_string_or_none(entry.get("organization_id")),
|
|
133
|
+
default_number_pool_id=_string_or_none(entry.get("default_number_pool_id")),
|
|
134
|
+
timeout_seconds=_int_or_none(entry.get("timeout_seconds")),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def save_profile(self, profile: StoredProfile, *, set_active: bool = True) -> None:
|
|
138
|
+
raw = self.load_raw()
|
|
139
|
+
profiles = raw.get("profiles")
|
|
140
|
+
if not isinstance(profiles, dict):
|
|
141
|
+
profiles = {}
|
|
142
|
+
profiles[profile.name] = profile.to_dict()
|
|
143
|
+
raw["profiles"] = profiles
|
|
144
|
+
if set_active or not raw.get("active_profile"):
|
|
145
|
+
raw["active_profile"] = profile.name
|
|
146
|
+
self._write(raw)
|
|
147
|
+
|
|
148
|
+
def set_active(self, profile_name: str) -> None:
|
|
149
|
+
raw = self.load_raw()
|
|
150
|
+
profiles = raw.get("profiles")
|
|
151
|
+
if not isinstance(profiles, dict):
|
|
152
|
+
raise ConfigError(f"Unknown profile: {profile_name}")
|
|
153
|
+
if profile_name not in profiles:
|
|
154
|
+
raise ConfigError(f"Unknown profile: {profile_name}")
|
|
155
|
+
raw["active_profile"] = profile_name
|
|
156
|
+
self._write(raw)
|
|
157
|
+
|
|
158
|
+
def delete_profile(self, profile_name: str) -> bool:
|
|
159
|
+
raw = self.load_raw()
|
|
160
|
+
profiles = raw.get("profiles")
|
|
161
|
+
if not isinstance(profiles, dict) or profile_name not in profiles:
|
|
162
|
+
return False
|
|
163
|
+
profiles.pop(profile_name)
|
|
164
|
+
if raw.get("active_profile") == profile_name:
|
|
165
|
+
raw["active_profile"] = next(iter(profiles), None)
|
|
166
|
+
self._write(raw)
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def clear_all(self) -> bool:
|
|
170
|
+
if not self._path.is_file():
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
self._path.unlink()
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
raise ConfigError(f"Could not delete {self._path}: {exc}") from exc
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def _write(self, raw: Mapping[str, object]) -> None:
|
|
179
|
+
directory = self._path.parent
|
|
180
|
+
try:
|
|
181
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
with suppress(OSError):
|
|
183
|
+
os.chmod(directory, stat.S_IRWXU)
|
|
184
|
+
with self._path.open("w", encoding="utf-8") as handle:
|
|
185
|
+
json.dump(raw, handle, indent=2, sort_keys=True)
|
|
186
|
+
handle.write("\n")
|
|
187
|
+
with suppress(OSError):
|
|
188
|
+
os.chmod(self._path, stat.S_IRUSR | stat.S_IWUSR)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise ConfigError(f"Could not write {self._path}: {exc}") from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def mask_secret(value: str | None) -> str:
|
|
194
|
+
if not value:
|
|
195
|
+
return "(unset)"
|
|
196
|
+
if len(value) <= 8:
|
|
197
|
+
return "***"
|
|
198
|
+
return f"{value[:4]}…{value[-4:]}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _string_or_none(value: object) -> str | None:
|
|
202
|
+
if isinstance(value, str) and value.strip():
|
|
203
|
+
return value.strip()
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _int_or_none(value: object) -> int | None:
|
|
208
|
+
if isinstance(value, bool):
|
|
209
|
+
return None
|
|
210
|
+
if isinstance(value, int) and value > 0:
|
|
211
|
+
return value
|
|
212
|
+
if isinstance(value, str):
|
|
213
|
+
try:
|
|
214
|
+
parsed = int(value)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return None
|
|
217
|
+
return parsed if parsed > 0 else None
|
|
218
|
+
return None
|
guzli/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GuzliSkillError(Exception):
|
|
5
|
+
"""Base error for Guzli skill."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(GuzliSkillError):
|
|
9
|
+
"""Raised when required configuration is missing or invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationError(GuzliSkillError):
|
|
13
|
+
"""Raised when input validation fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(GuzliSkillError):
|
|
17
|
+
"""Raised when the API returns a non-success response."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
status_code: int | None = None,
|
|
23
|
+
detail: str | None = None,
|
|
24
|
+
*,
|
|
25
|
+
code: str | None = None,
|
|
26
|
+
request_id: str | None = None,
|
|
27
|
+
retry_after: str | None = None,
|
|
28
|
+
retryable: bool = False,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.detail = detail
|
|
33
|
+
self.code = code
|
|
34
|
+
self.request_id = request_id
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
self.retryable = retryable
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"code": self.code or "api_error",
|
|
41
|
+
"message": str(self),
|
|
42
|
+
"detail": self.detail,
|
|
43
|
+
"status_code": self.status_code,
|
|
44
|
+
"request_id": self.request_id,
|
|
45
|
+
"retry_after": self.retry_after,
|
|
46
|
+
"retryable": self.retryable,
|
|
47
|
+
}
|
guzli/http_client.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from guzli.auth import AuthCapability, AuthSelector
|
|
9
|
+
from guzli.errors import ApiError, ConfigError
|
|
10
|
+
from guzli.types import JsonValue
|
|
11
|
+
from guzli.validators import require_non_empty
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HttpClient:
|
|
16
|
+
base_url: str
|
|
17
|
+
auth_selector: AuthSelector
|
|
18
|
+
timeout_seconds: int
|
|
19
|
+
agent_id: str | None
|
|
20
|
+
# Scriptable credential routes require this as X-Organization-ID.
|
|
21
|
+
organization_id: str | None = None
|
|
22
|
+
_session: Any = field(init=False, repr=False)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
requests = _load_requests()
|
|
26
|
+
from requests.adapters import HTTPAdapter
|
|
27
|
+
from urllib3.util import Retry
|
|
28
|
+
|
|
29
|
+
retry = Retry(
|
|
30
|
+
total=2,
|
|
31
|
+
connect=2,
|
|
32
|
+
read=2,
|
|
33
|
+
status=2,
|
|
34
|
+
backoff_factor=0.25,
|
|
35
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
36
|
+
allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}),
|
|
37
|
+
respect_retry_after_header=True,
|
|
38
|
+
raise_on_status=False,
|
|
39
|
+
)
|
|
40
|
+
self._session = requests.Session()
|
|
41
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
42
|
+
self._session.mount("https://", adapter)
|
|
43
|
+
self._session.mount("http://", adapter)
|
|
44
|
+
|
|
45
|
+
def request_unauthenticated_json(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
path: str,
|
|
49
|
+
*,
|
|
50
|
+
params: Mapping[str, object] | None = None,
|
|
51
|
+
payload: JsonValue | None = None,
|
|
52
|
+
) -> JsonValue:
|
|
53
|
+
"""For endpoints that take their credential in the request body (e.g. the OAuth
|
|
54
|
+
refresh / exchange / logout endpoints take `refresh_handle` in the body and reject
|
|
55
|
+
Authorization headers). Skips auth_selector entirely to avoid bootstrap issues
|
|
56
|
+
when no credential is configured yet (e.g. during `auth login --mode oauth`)."""
|
|
57
|
+
return self._send(method, path, params=params, payload=payload, extra_headers={})
|
|
58
|
+
|
|
59
|
+
def request_json(
|
|
60
|
+
self,
|
|
61
|
+
method: str,
|
|
62
|
+
path: str,
|
|
63
|
+
*,
|
|
64
|
+
params: Mapping[str, object] | None = None,
|
|
65
|
+
payload: JsonValue | None = None,
|
|
66
|
+
require_user: bool = False,
|
|
67
|
+
auth_capability: AuthCapability | None = None,
|
|
68
|
+
require_agent: bool = False,
|
|
69
|
+
require_organization: bool = False,
|
|
70
|
+
) -> JsonValue:
|
|
71
|
+
capability = auth_capability
|
|
72
|
+
if capability is None:
|
|
73
|
+
capability = (
|
|
74
|
+
AuthCapability.BEARER_ONLY if require_user else AuthCapability.API_KEY_OR_BEARER
|
|
75
|
+
)
|
|
76
|
+
auth_headers = self.auth_selector.select(capability).headers()
|
|
77
|
+
extra: dict[str, str] = dict(auth_headers)
|
|
78
|
+
if require_agent:
|
|
79
|
+
agent_id = require_non_empty(self.agent_id, "GUZLI_AGENT_ID")
|
|
80
|
+
extra["x-agent-id"] = agent_id
|
|
81
|
+
elif self.agent_id:
|
|
82
|
+
extra["x-agent-id"] = self.agent_id
|
|
83
|
+
if require_organization:
|
|
84
|
+
org_id = require_non_empty(self.organization_id, "GUZLI_ORGANIZATION_ID")
|
|
85
|
+
extra["x-organization-id"] = org_id
|
|
86
|
+
elif self.organization_id:
|
|
87
|
+
extra["x-organization-id"] = self.organization_id
|
|
88
|
+
return self._send(method, path, params=params, payload=payload, extra_headers=extra)
|
|
89
|
+
|
|
90
|
+
def _send(
|
|
91
|
+
self,
|
|
92
|
+
method: str,
|
|
93
|
+
path: str,
|
|
94
|
+
*,
|
|
95
|
+
params: Mapping[str, object] | None,
|
|
96
|
+
payload: JsonValue | None,
|
|
97
|
+
extra_headers: dict[str, str],
|
|
98
|
+
) -> JsonValue:
|
|
99
|
+
requests = _load_requests()
|
|
100
|
+
request_id = uuid4().hex
|
|
101
|
+
headers: dict[str, str] = {
|
|
102
|
+
"Accept": "application/json",
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"User-Agent": "guzli-cli/0.2",
|
|
105
|
+
"X-Request-ID": request_id,
|
|
106
|
+
}
|
|
107
|
+
headers.update(extra_headers)
|
|
108
|
+
url = _join_url(self.base_url, path)
|
|
109
|
+
try:
|
|
110
|
+
response = self._session.request(
|
|
111
|
+
method=method,
|
|
112
|
+
url=url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
params=_normalize_params(params),
|
|
115
|
+
json=payload,
|
|
116
|
+
timeout=self.timeout_seconds,
|
|
117
|
+
)
|
|
118
|
+
except requests.RequestException as exc:
|
|
119
|
+
raise ApiError(
|
|
120
|
+
"Network error while calling Guzli API",
|
|
121
|
+
code="network_error",
|
|
122
|
+
request_id=request_id,
|
|
123
|
+
retryable=method.upper() in {"GET", "HEAD", "OPTIONS"},
|
|
124
|
+
) from exc
|
|
125
|
+
|
|
126
|
+
if response.ok:
|
|
127
|
+
return _parse_json(response)
|
|
128
|
+
|
|
129
|
+
detail = _extract_error_detail(response)
|
|
130
|
+
response_request_id = (
|
|
131
|
+
response.headers.get("X-Request-ID")
|
|
132
|
+
or response.headers.get("X-Correlation-ID")
|
|
133
|
+
or request_id
|
|
134
|
+
)
|
|
135
|
+
retry_after = response.headers.get("Retry-After")
|
|
136
|
+
error_code = _extract_error_code(response)
|
|
137
|
+
raise ApiError(
|
|
138
|
+
f"Guzli API request failed ({response.status_code})",
|
|
139
|
+
status_code=response.status_code,
|
|
140
|
+
detail=detail,
|
|
141
|
+
code=error_code,
|
|
142
|
+
request_id=response_request_id,
|
|
143
|
+
retry_after=retry_after,
|
|
144
|
+
retryable=response.status_code in {408, 425, 429, 500, 502, 503, 504},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _join_url(base_url: str, path: str) -> str:
|
|
149
|
+
if not base_url:
|
|
150
|
+
raise ConfigError("GUZLI_API_BASE_URL is required")
|
|
151
|
+
base = base_url.rstrip("/")
|
|
152
|
+
suffix = path if path.startswith("/") else f"/{path}"
|
|
153
|
+
return f"{base}{suffix}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_params(
|
|
157
|
+
params: Mapping[str, object] | None,
|
|
158
|
+
) -> dict[str, Iterable[str] | str] | None:
|
|
159
|
+
if not params:
|
|
160
|
+
return None
|
|
161
|
+
normalized: dict[str, Iterable[str] | str] = {}
|
|
162
|
+
for key, value in params.items():
|
|
163
|
+
if value is None:
|
|
164
|
+
continue
|
|
165
|
+
if isinstance(value, list):
|
|
166
|
+
normalized[key] = [str(item) for item in value if item is not None]
|
|
167
|
+
else:
|
|
168
|
+
normalized[key] = str(value)
|
|
169
|
+
return normalized
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_json(response: Any) -> JsonValue:
|
|
173
|
+
if not response.content:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
return response.json()
|
|
177
|
+
except ValueError:
|
|
178
|
+
return response.text
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_error_detail(response: Any) -> str | None:
|
|
182
|
+
try:
|
|
183
|
+
payload = response.json()
|
|
184
|
+
except ValueError:
|
|
185
|
+
payload = None
|
|
186
|
+
if isinstance(payload, dict):
|
|
187
|
+
detail = payload.get("detail") or payload.get("message")
|
|
188
|
+
if isinstance(detail, str):
|
|
189
|
+
return detail
|
|
190
|
+
return response.text
|
|
191
|
+
if isinstance(payload, list):
|
|
192
|
+
return response.text
|
|
193
|
+
return response.text
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_error_code(response: Any) -> str:
|
|
197
|
+
try:
|
|
198
|
+
payload = response.json()
|
|
199
|
+
except ValueError:
|
|
200
|
+
payload = None
|
|
201
|
+
if isinstance(payload, dict):
|
|
202
|
+
code = payload.get("code") or payload.get("error_code")
|
|
203
|
+
if isinstance(code, str) and code.strip():
|
|
204
|
+
return code.strip()
|
|
205
|
+
return f"http_{response.status_code}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _load_requests():
|
|
209
|
+
try:
|
|
210
|
+
import requests
|
|
211
|
+
except ImportError as exc: # pragma: no cover - runtime dependency
|
|
212
|
+
raise ConfigError("requests library not found. Install with: pip install requests") from exc
|
|
213
|
+
return requests
|
guzli/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Guzli Model Context Protocol server package."""
|