agentic-api 0.7.0__py3-none-macosx_11_0_arm64.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.
- agentic_api/__init__.py +4 -0
- agentic_api/__main__.py +11 -0
- agentic_api/binary.py +90 -0
- agentic_api/cli.py +223 -0
- agentic_api/compatibility.py +1 -0
- agentic_api/diagnostics.py +171 -0
- agentic_api/launcher.py +179 -0
- agentic_api/process.py +226 -0
- agentic_api/version.py +28 -0
- agentic_api-0.7.0.data/scripts/agentic +0 -0
- agentic_api-0.7.0.data/scripts/agentic-api +5 -0
- agentic_api-0.7.0.data/scripts/agentic-server +0 -0
- agentic_api-0.7.0.dist-info/METADATA +8 -0
- agentic_api-0.7.0.dist-info/RECORD +17 -0
- agentic_api-0.7.0.dist-info/WHEEL +4 -0
- agentic_api-0.7.0.dist-info/licenses/LICENSE +201 -0
- agentic_api-0.7.0.dist-info/sboms/agentic-server.cyclonedx.json +14839 -0
agentic_api/__init__.py
ADDED
agentic_api/__main__.py
ADDED
agentic_api/binary.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import sysconfig
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REMEDIATION_MESSAGE = "Reinstall agentic-api for this platform"
|
|
12
|
+
BINARY_VERSION_TIMEOUT_S = 5.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PackagedBinaryNotFoundError(FileNotFoundError):
|
|
16
|
+
"""Raised when a packaged executable cannot be located."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PackagedBinaryVersionError(RuntimeError):
|
|
20
|
+
"""Raised when a packaged executable cannot report its version."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_packaged_binary(name: str) -> Path:
|
|
24
|
+
for candidate in _candidate_paths(name, include_ambient_path=False):
|
|
25
|
+
if _is_executable_file(candidate):
|
|
26
|
+
return candidate
|
|
27
|
+
raise PackagedBinaryNotFoundError(f"{name} not found; {REMEDIATION_MESSAGE}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_active_environment_executable(name: str) -> Path:
|
|
31
|
+
for candidate in _candidate_paths(name, include_ambient_path=False):
|
|
32
|
+
if _is_executable_file(candidate):
|
|
33
|
+
return candidate
|
|
34
|
+
raise FileNotFoundError(f"{name} executable not found in the active environment")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_binary_version(path: Path) -> str:
|
|
38
|
+
try:
|
|
39
|
+
completed = subprocess.run(
|
|
40
|
+
[str(path), "--version"],
|
|
41
|
+
check=True,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=BINARY_VERSION_TIMEOUT_S,
|
|
45
|
+
)
|
|
46
|
+
except OSError as error: # pragma: no cover - exercised via unit tests.
|
|
47
|
+
raise PackagedBinaryVersionError(f"unable to launch {path}: {error.strerror or error}") from error
|
|
48
|
+
except subprocess.TimeoutExpired as error:
|
|
49
|
+
raise PackagedBinaryVersionError(f"{path} timed out while reporting its version") from error
|
|
50
|
+
except subprocess.CalledProcessError as error:
|
|
51
|
+
raise PackagedBinaryVersionError(
|
|
52
|
+
f"{path} exited with status {error.returncode} while reporting its version"
|
|
53
|
+
) from error
|
|
54
|
+
|
|
55
|
+
output = (completed.stdout or completed.stderr).strip()
|
|
56
|
+
if not output:
|
|
57
|
+
raise PackagedBinaryVersionError(f"{path} did not report a version")
|
|
58
|
+
return output.splitlines()[0].strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _candidate_paths(name: str, *, include_ambient_path: bool = True) -> list[Path]:
|
|
62
|
+
candidates: list[Path] = []
|
|
63
|
+
|
|
64
|
+
if sys.argv and sys.argv[0]:
|
|
65
|
+
candidates.append(Path(sys.argv[0]).resolve().parent / name)
|
|
66
|
+
|
|
67
|
+
scripts_dir = sysconfig.get_path("scripts")
|
|
68
|
+
if scripts_dir:
|
|
69
|
+
candidates.append(Path(scripts_dir) / name)
|
|
70
|
+
|
|
71
|
+
candidates.append(Path(sys.executable).resolve().parent / name)
|
|
72
|
+
|
|
73
|
+
if include_ambient_path:
|
|
74
|
+
which_path = shutil.which(name)
|
|
75
|
+
if which_path:
|
|
76
|
+
candidates.append(Path(which_path))
|
|
77
|
+
|
|
78
|
+
unique_candidates: list[Path] = []
|
|
79
|
+
seen: set[Path] = set()
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
resolved = candidate.resolve(strict=False)
|
|
82
|
+
if resolved in seen:
|
|
83
|
+
continue
|
|
84
|
+
seen.add(resolved)
|
|
85
|
+
unique_candidates.append(candidate)
|
|
86
|
+
return unique_candidates
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_executable_file(path: Path) -> bool:
|
|
90
|
+
return path.is_file() and os.access(path, os.X_OK)
|
agentic_api/cli.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import math
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from agentic_api import __version__
|
|
12
|
+
from agentic_api.diagnostics import doctor
|
|
13
|
+
from agentic_api.version import version_report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
17
|
+
DEFAULT_PORT = 9000
|
|
18
|
+
DEFAULT_STARTUP_TIMEOUT_S = 600.0
|
|
19
|
+
DEFAULT_SHUTDOWN_TIMEOUT_S = 10.0
|
|
20
|
+
DEFAULT_VLLM_PORT = 8000
|
|
21
|
+
DEFAULT_GATEWAY_API_KEY_ENV = "OPENAI_API_KEY"
|
|
22
|
+
DEFAULT_VLLM_API_KEY_ENV = "AGENTIC_VLLM_API_KEY"
|
|
23
|
+
RESERVED_VLLM_FLAGS = {"--host", "--port", "--api-key"}
|
|
24
|
+
INCOMPATIBLE_VLLM_FLAGS = {"--uds"}
|
|
25
|
+
MAX_TIMEOUT_S = 86_400.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ServeOptions:
|
|
30
|
+
mode: str
|
|
31
|
+
model: str | None
|
|
32
|
+
vllm_base_url: str | None
|
|
33
|
+
host: str
|
|
34
|
+
port: int
|
|
35
|
+
startup_timeout_s: float
|
|
36
|
+
shutdown_timeout_s: float
|
|
37
|
+
vllm_port: int
|
|
38
|
+
gateway_api_key_env: str
|
|
39
|
+
vllm_api_key_env: str
|
|
40
|
+
vllm_args: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _AgenticArgumentParser(argparse.ArgumentParser):
|
|
44
|
+
def parse_args(self, args: Sequence[str] | None = None, namespace: argparse.Namespace | None = None) -> argparse.Namespace:
|
|
45
|
+
parsed = super().parse_args(args=args, namespace=namespace)
|
|
46
|
+
command = getattr(parsed, "command", None)
|
|
47
|
+
|
|
48
|
+
if command == "serve":
|
|
49
|
+
parsed.options = _build_serve_options(self, parsed)
|
|
50
|
+
|
|
51
|
+
return parsed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
55
|
+
parser = _AgenticArgumentParser(
|
|
56
|
+
prog="agentic-api",
|
|
57
|
+
description="Python launcher for packaged Agentic API binaries",
|
|
58
|
+
allow_abbrev=False,
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
61
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
62
|
+
|
|
63
|
+
serve_parser = subparsers.add_parser(
|
|
64
|
+
"serve", help="Launch Agentic API in local or remote mode", allow_abbrev=False
|
|
65
|
+
)
|
|
66
|
+
serve_parser.add_argument("--model")
|
|
67
|
+
serve_parser.add_argument("--vllm-base-url")
|
|
68
|
+
serve_parser.add_argument("--host", default=DEFAULT_HOST)
|
|
69
|
+
serve_parser.add_argument("--port", type=_tcp_port, default=DEFAULT_PORT)
|
|
70
|
+
serve_parser.add_argument("--startup-timeout-s", type=_bounded_timeout, default=DEFAULT_STARTUP_TIMEOUT_S)
|
|
71
|
+
serve_parser.add_argument("--shutdown-timeout-s", type=_bounded_timeout, default=DEFAULT_SHUTDOWN_TIMEOUT_S)
|
|
72
|
+
serve_parser.add_argument("--vllm-port", type=_tcp_port, default=DEFAULT_VLLM_PORT)
|
|
73
|
+
serve_parser.add_argument("--gateway-api-key-env", default=DEFAULT_GATEWAY_API_KEY_ENV)
|
|
74
|
+
serve_parser.add_argument("--vllm-api-key-env", default=DEFAULT_VLLM_API_KEY_ENV)
|
|
75
|
+
serve_parser.add_argument("vllm_args", nargs=argparse.REMAINDER)
|
|
76
|
+
|
|
77
|
+
doctor_parser = subparsers.add_parser(
|
|
78
|
+
"doctor", help="Report packaged binary and compatibility diagnostics", allow_abbrev=False
|
|
79
|
+
)
|
|
80
|
+
doctor_parser.add_argument("--mode", choices=("local", "remote"))
|
|
81
|
+
doctor_parser.add_argument(
|
|
82
|
+
"--json", action="store_true", dest="json_output", help="Emit a machine-readable JSON report"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
subparsers.add_parser("version", help="Print package and packaged binary versions")
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
90
|
+
parser = build_parser()
|
|
91
|
+
try:
|
|
92
|
+
namespace = parser.parse_args(argv)
|
|
93
|
+
except SystemExit as error:
|
|
94
|
+
code = error.code
|
|
95
|
+
return code if isinstance(code, int) else 1
|
|
96
|
+
|
|
97
|
+
if namespace.command == "version":
|
|
98
|
+
try:
|
|
99
|
+
print(version_report())
|
|
100
|
+
return 0
|
|
101
|
+
except (FileNotFoundError, RuntimeError) as error:
|
|
102
|
+
print(str(error), file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
if namespace.command == "doctor":
|
|
106
|
+
return doctor(namespace.mode, json_output=namespace.json_output)
|
|
107
|
+
|
|
108
|
+
if namespace.command == "serve":
|
|
109
|
+
try:
|
|
110
|
+
from agentic_api.launcher import run_serve
|
|
111
|
+
except ModuleNotFoundError as error:
|
|
112
|
+
if error.name != "agentic_api.launcher":
|
|
113
|
+
raise
|
|
114
|
+
print(
|
|
115
|
+
"agentic-api serve is unavailable because the Python launcher is missing; "
|
|
116
|
+
"reinstall agentic-api for this platform.",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
return run_serve(namespace.options)
|
|
122
|
+
|
|
123
|
+
parser.error(f"unknown command: {namespace.command}")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _build_serve_options(parser: argparse.ArgumentParser, namespace: argparse.Namespace) -> ServeOptions:
|
|
128
|
+
if namespace.model is not None and not namespace.model.strip():
|
|
129
|
+
parser.error("--model must not be empty")
|
|
130
|
+
|
|
131
|
+
source_count = int(bool(namespace.model)) + int(bool(namespace.vllm_base_url))
|
|
132
|
+
if source_count != 1:
|
|
133
|
+
parser.error("exactly one of --model or --vllm-base-url is required")
|
|
134
|
+
|
|
135
|
+
vllm_args = _normalize_vllm_args(namespace.vllm_args)
|
|
136
|
+
reserved_flag = _reserved_vllm_flag(vllm_args)
|
|
137
|
+
if reserved_flag is not None:
|
|
138
|
+
parser.error(f"{reserved_flag} is reserved for the launcher and cannot be forwarded after --")
|
|
139
|
+
|
|
140
|
+
vllm_base_url = None
|
|
141
|
+
mode = "local"
|
|
142
|
+
if namespace.vllm_base_url is not None:
|
|
143
|
+
vllm_base_url = _normalize_base_url(parser, namespace.vllm_base_url)
|
|
144
|
+
mode = "remote"
|
|
145
|
+
|
|
146
|
+
if mode == "remote" and vllm_args:
|
|
147
|
+
parser.error("vLLM passthrough arguments are only supported with --model (local mode)")
|
|
148
|
+
|
|
149
|
+
return ServeOptions(
|
|
150
|
+
mode=mode,
|
|
151
|
+
model=namespace.model,
|
|
152
|
+
vllm_base_url=vllm_base_url,
|
|
153
|
+
host=namespace.host,
|
|
154
|
+
port=namespace.port,
|
|
155
|
+
startup_timeout_s=namespace.startup_timeout_s,
|
|
156
|
+
shutdown_timeout_s=namespace.shutdown_timeout_s,
|
|
157
|
+
vllm_port=namespace.vllm_port,
|
|
158
|
+
gateway_api_key_env=namespace.gateway_api_key_env,
|
|
159
|
+
vllm_api_key_env=namespace.vllm_api_key_env,
|
|
160
|
+
vllm_args=vllm_args,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalize_vllm_args(values: Sequence[str]) -> list[str]:
|
|
165
|
+
if values and values[0] == "--":
|
|
166
|
+
return list(values[1:])
|
|
167
|
+
return list(values)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reserved_vllm_flag(values: Sequence[str]) -> str | None:
|
|
171
|
+
for value in values:
|
|
172
|
+
if not value.startswith("--"):
|
|
173
|
+
continue
|
|
174
|
+
option_name = value.partition("=")[0].replace("_", "-")
|
|
175
|
+
if len(option_name) > 2 and any(
|
|
176
|
+
reserved.startswith(option_name) for reserved in RESERVED_VLLM_FLAGS | INCOMPATIBLE_VLLM_FLAGS
|
|
177
|
+
):
|
|
178
|
+
return option_name
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _tcp_port(value: str) -> int:
|
|
183
|
+
try:
|
|
184
|
+
port = int(value)
|
|
185
|
+
except ValueError as error:
|
|
186
|
+
raise argparse.ArgumentTypeError("must be between 1 and 65535") from error
|
|
187
|
+
if not 1 <= port <= 65_535:
|
|
188
|
+
raise argparse.ArgumentTypeError("must be between 1 and 65535")
|
|
189
|
+
return port
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _bounded_timeout(value: str) -> float:
|
|
193
|
+
try:
|
|
194
|
+
timeout = float(value)
|
|
195
|
+
except ValueError as error:
|
|
196
|
+
raise argparse.ArgumentTypeError(
|
|
197
|
+
f"must be finite and greater than 0, up to {MAX_TIMEOUT_S:g} seconds"
|
|
198
|
+
) from error
|
|
199
|
+
if not math.isfinite(timeout) or not 0 < timeout <= MAX_TIMEOUT_S:
|
|
200
|
+
raise argparse.ArgumentTypeError(f"must be finite and greater than 0, up to {MAX_TIMEOUT_S:g} seconds")
|
|
201
|
+
return timeout
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _normalize_base_url(parser: argparse.ArgumentParser, value: str) -> str:
|
|
205
|
+
if any(character.isspace() for character in value):
|
|
206
|
+
parser.error("--vllm-base-url must not contain whitespace")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
parsed = urlparse(value)
|
|
210
|
+
hostname = parsed.hostname
|
|
211
|
+
port = parsed.port
|
|
212
|
+
except ValueError as error:
|
|
213
|
+
parser.error(f"--vllm-base-url is malformed: {error}")
|
|
214
|
+
|
|
215
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not hostname:
|
|
216
|
+
parser.error("--vllm-base-url must be an http:// or https:// base URL")
|
|
217
|
+
if parsed.username is not None or parsed.password is not None:
|
|
218
|
+
parser.error("--vllm-base-url must not contain credentials; use an environment variable for API keys")
|
|
219
|
+
if port is not None and not 1 <= port <= 65_535:
|
|
220
|
+
parser.error("--vllm-base-url port must be between 1 and 65535")
|
|
221
|
+
if parsed.query or parsed.fragment:
|
|
222
|
+
parser.error("--vllm-base-url must not include a query string or fragment")
|
|
223
|
+
return value.rstrip("/")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SUPPORTED_VLLM_VERSION = "0.11.0"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version as metadata_version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from agentic_api import __version__
|
|
11
|
+
from agentic_api.binary import (
|
|
12
|
+
PackagedBinaryNotFoundError,
|
|
13
|
+
PackagedBinaryVersionError,
|
|
14
|
+
find_active_environment_executable,
|
|
15
|
+
find_packaged_binary,
|
|
16
|
+
read_binary_version,
|
|
17
|
+
)
|
|
18
|
+
from agentic_api.compatibility import SUPPORTED_VLLM_VERSION
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class DoctorReport:
|
|
23
|
+
python_version: str
|
|
24
|
+
platform_summary: str
|
|
25
|
+
package_version: str
|
|
26
|
+
rust_binary_path: str
|
|
27
|
+
rust_binary_executable: bool
|
|
28
|
+
rust_binary_version: str
|
|
29
|
+
supported_vllm_version: str
|
|
30
|
+
installed_vllm_version: str
|
|
31
|
+
vllm_executable_path: str
|
|
32
|
+
local_ok: bool
|
|
33
|
+
remote_ok: bool
|
|
34
|
+
local_message: str
|
|
35
|
+
remote_message: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def doctor(mode: str | None, *, json_output: bool = False) -> int:
|
|
39
|
+
report = collect_doctor_report()
|
|
40
|
+
print(render_doctor_report(report, mode, json_output=json_output))
|
|
41
|
+
|
|
42
|
+
if mode == "local":
|
|
43
|
+
return 0 if report.local_ok else 1
|
|
44
|
+
if mode == "remote":
|
|
45
|
+
return 0 if report.remote_ok else 1
|
|
46
|
+
return 0 if report.remote_ok else 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def collect_doctor_report() -> DoctorReport:
|
|
50
|
+
rust_binary_path, rust_binary_executable, rust_binary_version = _rust_binary_details()
|
|
51
|
+
package_version = _package_version("agentic-api")
|
|
52
|
+
installed_vllm_version = _package_version("vllm")
|
|
53
|
+
try:
|
|
54
|
+
vllm_executable_path = str(find_active_environment_executable("vllm"))
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
vllm_executable_path = "not found"
|
|
57
|
+
local_runtime_ok, local_message = _local_health(
|
|
58
|
+
installed_vllm_version,
|
|
59
|
+
vllm_executable_path,
|
|
60
|
+
platform_name=platform.system(),
|
|
61
|
+
)
|
|
62
|
+
remote_ok = rust_binary_executable and not rust_binary_version.startswith("error:")
|
|
63
|
+
remote_message = _remote_health_message(rust_binary_executable, rust_binary_version)
|
|
64
|
+
if local_runtime_ok and not remote_ok:
|
|
65
|
+
local_message = remote_message
|
|
66
|
+
|
|
67
|
+
return DoctorReport(
|
|
68
|
+
python_version=platform.python_version(),
|
|
69
|
+
platform_summary=f"{platform.system()} {platform.machine()}",
|
|
70
|
+
package_version=package_version,
|
|
71
|
+
rust_binary_path=rust_binary_path,
|
|
72
|
+
rust_binary_executable=rust_binary_executable,
|
|
73
|
+
rust_binary_version=rust_binary_version,
|
|
74
|
+
supported_vllm_version=SUPPORTED_VLLM_VERSION,
|
|
75
|
+
installed_vllm_version=installed_vllm_version,
|
|
76
|
+
vllm_executable_path=vllm_executable_path,
|
|
77
|
+
local_ok=local_runtime_ok and remote_ok,
|
|
78
|
+
remote_ok=remote_ok,
|
|
79
|
+
local_message=local_message,
|
|
80
|
+
remote_message=remote_message,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_doctor_report(report: DoctorReport, mode: str | None, *, json_output: bool = False) -> str:
|
|
85
|
+
if json_output:
|
|
86
|
+
payload = asdict(report)
|
|
87
|
+
payload["selected_mode"] = mode or "all"
|
|
88
|
+
return json.dumps(payload, sort_keys=True)
|
|
89
|
+
|
|
90
|
+
local_health = "ok" if report.local_ok else "unavailable"
|
|
91
|
+
remote_health = "ok" if report.remote_ok else "unavailable"
|
|
92
|
+
|
|
93
|
+
lines = [
|
|
94
|
+
f"Selected mode: {mode or 'all'}",
|
|
95
|
+
f"Python version: {report.python_version}",
|
|
96
|
+
f"Platform: {report.platform_summary}",
|
|
97
|
+
f"agentic-api version: {report.package_version}",
|
|
98
|
+
f"Rust binary path: {report.rust_binary_path}",
|
|
99
|
+
f"Rust binary executable: {'yes' if report.rust_binary_executable else 'no'}",
|
|
100
|
+
f"Rust binary version: {report.rust_binary_version}",
|
|
101
|
+
f"Supported vLLM version: {report.supported_vllm_version}",
|
|
102
|
+
f"Installed vLLM version: {report.installed_vllm_version}",
|
|
103
|
+
f"vLLM executable path: {report.vllm_executable_path}",
|
|
104
|
+
f"Local mode health: {local_health}",
|
|
105
|
+
f"Local mode details: {report.local_message}",
|
|
106
|
+
f"Remote mode health: {remote_health}",
|
|
107
|
+
f"Remote mode details: {report.remote_message}",
|
|
108
|
+
]
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _package_version(name: str) -> str:
|
|
113
|
+
try:
|
|
114
|
+
return metadata_version(name)
|
|
115
|
+
except PackageNotFoundError:
|
|
116
|
+
return "not installed"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rust_binary_details() -> tuple[str, bool, str]:
|
|
120
|
+
try:
|
|
121
|
+
path = find_packaged_binary("agentic-server")
|
|
122
|
+
except PackagedBinaryNotFoundError as error:
|
|
123
|
+
return ("not found", False, f"error: {error}")
|
|
124
|
+
|
|
125
|
+
executable = path.is_file() and os.access(path, os.X_OK)
|
|
126
|
+
try:
|
|
127
|
+
version = read_binary_version(path)
|
|
128
|
+
except PackagedBinaryVersionError as error:
|
|
129
|
+
version = f"error: {error}"
|
|
130
|
+
return (str(path), executable, version)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _local_health(
|
|
134
|
+
installed_vllm_version: str,
|
|
135
|
+
vllm_executable_path: str,
|
|
136
|
+
*,
|
|
137
|
+
platform_name: str,
|
|
138
|
+
) -> tuple[bool, str]:
|
|
139
|
+
if platform_name != "Linux":
|
|
140
|
+
return (
|
|
141
|
+
False,
|
|
142
|
+
"Local mode is currently supported only on Linux because the [local] extra installs vLLM only on Linux. "
|
|
143
|
+
"Use remote mode on this platform, or run local mode on Linux.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
install_hint = (
|
|
147
|
+
"Install the agentic-api wheel artifact with its local extra: "
|
|
148
|
+
'`uv pip install "agentic-api[local] @ file:///path/to/agentic_api-PLATFORM.whl"`.'
|
|
149
|
+
)
|
|
150
|
+
if installed_vllm_version == "not installed":
|
|
151
|
+
return (False, install_hint)
|
|
152
|
+
if installed_vllm_version != SUPPORTED_VLLM_VERSION:
|
|
153
|
+
return (
|
|
154
|
+
False,
|
|
155
|
+
f"Installed vLLM does not match the tested version {SUPPORTED_VLLM_VERSION}. {install_hint}",
|
|
156
|
+
)
|
|
157
|
+
if vllm_executable_path == "not found":
|
|
158
|
+
return (
|
|
159
|
+
False,
|
|
160
|
+
f"The installed vLLM package does not provide a `vllm` executable in the active Python environment. "
|
|
161
|
+
f"{install_hint}",
|
|
162
|
+
)
|
|
163
|
+
return (True, "The tested local vLLM package and executable are available.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _remote_health_message(rust_binary_executable: bool, rust_binary_version: str) -> str:
|
|
167
|
+
if not rust_binary_executable:
|
|
168
|
+
return "The packaged Rust gateway executable is missing or not executable."
|
|
169
|
+
if rust_binary_version.startswith("error:"):
|
|
170
|
+
return "The packaged Rust gateway executable could not report its version."
|
|
171
|
+
return "The packaged Rust gateway executable is available; remote mode does not require vLLM."
|
agentic_api/launcher.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from agentic_api.binary import (
|
|
10
|
+
PackagedBinaryNotFoundError,
|
|
11
|
+
find_active_environment_executable,
|
|
12
|
+
find_packaged_binary,
|
|
13
|
+
)
|
|
14
|
+
from agentic_api.cli import ServeOptions
|
|
15
|
+
from agentic_api.compatibility import SUPPORTED_VLLM_VERSION
|
|
16
|
+
from agentic_api.process import ChildResult, ProcessSupervisor, ShutdownRequested, wait_for_vllm_ready
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
READY_INTERVAL_S = 2.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_serve(options: ServeOptions) -> int:
|
|
23
|
+
supervisor = ProcessSupervisor()
|
|
24
|
+
signal_exit_code: int | None = None
|
|
25
|
+
previous_handlers: list[tuple[int, object]] = []
|
|
26
|
+
|
|
27
|
+
def begin_shutdown(signum: int, _frame: object) -> None:
|
|
28
|
+
nonlocal signal_exit_code
|
|
29
|
+
if signal_exit_code is None:
|
|
30
|
+
signal_exit_code = _signal_exit_code(signum)
|
|
31
|
+
supervisor.request_shutdown(signum)
|
|
32
|
+
|
|
33
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
34
|
+
previous_handlers.append((signum, signal.signal(signum, begin_shutdown)))
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
if options.mode == "local":
|
|
38
|
+
result = _run_local_mode(supervisor, options)
|
|
39
|
+
elif options.mode == "remote":
|
|
40
|
+
result = _run_remote_mode(supervisor, options)
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"unsupported serve mode: {options.mode}")
|
|
43
|
+
|
|
44
|
+
if signal_exit_code is not None:
|
|
45
|
+
return signal_exit_code
|
|
46
|
+
return _normalize_exit_code(result)
|
|
47
|
+
except ShutdownRequested:
|
|
48
|
+
if signal_exit_code is not None:
|
|
49
|
+
return signal_exit_code
|
|
50
|
+
raise
|
|
51
|
+
except KeyboardInterrupt:
|
|
52
|
+
signal_exit_code = _signal_exit_code(signal.SIGINT)
|
|
53
|
+
supervisor.request_shutdown(signal.SIGINT)
|
|
54
|
+
return signal_exit_code
|
|
55
|
+
except (OSError, PackagedBinaryNotFoundError, RuntimeError, ValueError) as error:
|
|
56
|
+
if signal_exit_code is not None:
|
|
57
|
+
return signal_exit_code
|
|
58
|
+
print(str(error), file=sys.stderr)
|
|
59
|
+
return 1
|
|
60
|
+
finally:
|
|
61
|
+
supervisor.terminate_all(options.shutdown_timeout_s)
|
|
62
|
+
for signum, previous in previous_handlers:
|
|
63
|
+
signal.signal(signum, previous)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_local_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> ChildResult:
|
|
67
|
+
if options.model is None:
|
|
68
|
+
raise ValueError("--model is required in local mode")
|
|
69
|
+
if sys.platform != "linux":
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
"agentic-api local mode is currently supported only on Linux; "
|
|
72
|
+
"use remote mode on this platform"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
rust_binary = find_packaged_binary("agentic-server")
|
|
76
|
+
vllm_path = find_active_environment_executable("vllm")
|
|
77
|
+
installed_version = _installed_vllm_version()
|
|
78
|
+
if installed_version != SUPPORTED_VLLM_VERSION:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"agentic-api local mode requires vllm=={SUPPORTED_VLLM_VERSION}; found {installed_version}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
vllm_api_key = os.environ.get(options.vllm_api_key_env)
|
|
84
|
+
vllm_url = f"http://127.0.0.1:{options.vllm_port}"
|
|
85
|
+
|
|
86
|
+
vllm_process = supervisor.start(
|
|
87
|
+
[
|
|
88
|
+
str(vllm_path),
|
|
89
|
+
"serve",
|
|
90
|
+
options.model,
|
|
91
|
+
*options.vllm_args,
|
|
92
|
+
"--host",
|
|
93
|
+
"127.0.0.1",
|
|
94
|
+
"--port",
|
|
95
|
+
str(options.vllm_port),
|
|
96
|
+
],
|
|
97
|
+
_vllm_environment(vllm_api_key),
|
|
98
|
+
)
|
|
99
|
+
wait_for_vllm_ready(
|
|
100
|
+
base_url=vllm_url,
|
|
101
|
+
api_key=vllm_api_key,
|
|
102
|
+
process=vllm_process,
|
|
103
|
+
timeout=options.startup_timeout_s,
|
|
104
|
+
interval=READY_INTERVAL_S,
|
|
105
|
+
shutdown_requested=supervisor.shutdown_requested,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
supervisor.start(
|
|
109
|
+
_rust_command(options, vllm_url, binary=rust_binary),
|
|
110
|
+
_rust_environment(options.gateway_api_key_env, vllm_api_key),
|
|
111
|
+
)
|
|
112
|
+
return supervisor.wait_for_failure()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_remote_mode(supervisor: ProcessSupervisor, options: ServeOptions) -> ChildResult:
|
|
116
|
+
if options.vllm_base_url is None:
|
|
117
|
+
raise ValueError("--vllm-base-url is required in remote mode")
|
|
118
|
+
|
|
119
|
+
supervisor.start(
|
|
120
|
+
_rust_command(options, options.vllm_base_url),
|
|
121
|
+
_rust_environment(options.gateway_api_key_env, None),
|
|
122
|
+
)
|
|
123
|
+
return supervisor.wait_for_failure()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _rust_command(options: ServeOptions, upstream_base_url: str, *, binary: Path | None = None) -> list[str]:
|
|
127
|
+
binary = binary or find_packaged_binary("agentic-server")
|
|
128
|
+
return [
|
|
129
|
+
str(binary),
|
|
130
|
+
"--llm-api-base",
|
|
131
|
+
upstream_base_url,
|
|
132
|
+
"--llm-ready-timeout-s",
|
|
133
|
+
str(options.startup_timeout_s),
|
|
134
|
+
"--gateway-host",
|
|
135
|
+
options.host,
|
|
136
|
+
"--gateway-port",
|
|
137
|
+
str(options.port),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _rust_environment(gateway_api_key_env: str, api_key_override: str | None) -> dict[str, str]:
|
|
142
|
+
env = os.environ.copy()
|
|
143
|
+
gateway_api_key = api_key_override if api_key_override is not None else os.environ.get(gateway_api_key_env)
|
|
144
|
+
if gateway_api_key is not None:
|
|
145
|
+
env["OPENAI_API_KEY"] = gateway_api_key
|
|
146
|
+
elif gateway_api_key_env != "OPENAI_API_KEY":
|
|
147
|
+
env.pop("OPENAI_API_KEY", None)
|
|
148
|
+
return env
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _vllm_environment(api_key: str | None) -> dict[str, str]:
|
|
152
|
+
env = os.environ.copy()
|
|
153
|
+
if api_key is not None:
|
|
154
|
+
env["VLLM_API_KEY"] = api_key
|
|
155
|
+
else:
|
|
156
|
+
env.pop("VLLM_API_KEY", None)
|
|
157
|
+
return env
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _installed_vllm_version() -> str:
|
|
161
|
+
try:
|
|
162
|
+
return version("vllm")
|
|
163
|
+
except PackageNotFoundError as error:
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
f"agentic-api local mode requires vllm=={SUPPORTED_VLLM_VERSION}; install the [local] extra first"
|
|
166
|
+
) from error
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_exit_code(result: ChildResult) -> int:
|
|
170
|
+
if result.returncode == 0:
|
|
171
|
+
print(f"{result.name} exited unexpectedly with status 0", file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
if result.returncode >= 0:
|
|
174
|
+
return result.returncode
|
|
175
|
+
return _signal_exit_code(-result.returncode)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _signal_exit_code(signum: int) -> int:
|
|
179
|
+
return 128 + int(signum)
|