graphharbor 0.13.0.post2__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.
- graphharbor-0.13.0.post2.dist-info/METADATA +64 -0
- graphharbor-0.13.0.post2.dist-info/RECORD +12 -0
- graphharbor-0.13.0.post2.dist-info/WHEEL +4 -0
- graphharbor-0.13.0.post2.dist-info/entry_points.txt +2 -0
- graphharbor-0.13.0.post2.dist-info/licenses/LICENSE +21 -0
- langhost/__init__.py +10 -0
- langhost/__main__.py +4 -0
- langhost/cli.py +512 -0
- langhost/core_api.py +1351 -0
- langhost/protocol_api.py +324 -0
- langhost/server.py +849 -0
- langhost/streaming.py +317 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: graphharbor
|
|
3
|
+
Version: 0.13.0.post2
|
|
4
|
+
Summary: GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis
|
|
5
|
+
Project-URL: Homepage, https://github.com/ljxpython/graphharbor
|
|
6
|
+
Project-URL: Repository, https://github.com/ljxpython/graphharbor
|
|
7
|
+
Project-URL: Issues, https://github.com/ljxpython/graphharbor/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ljxpython/graphharbor/releases
|
|
9
|
+
Author-email: Mohankumar Ramachandran <mail@mohanram.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent-protocol,agent-server,agents,ai-agents,cli,langchain,langgraph,langgraph-api,langgraph-sdk,langsmith,llm,open-source,postgres,redis,self-hosted,uvicorn
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: click>=8.1.7
|
|
29
|
+
Requires-Dist: graphharbor-runtime==0.13.0.post2
|
|
30
|
+
Requires-Dist: langgraph-cli<0.5,>=0.4.0
|
|
31
|
+
Requires-Dist: pyfiglet>=1.0.0
|
|
32
|
+
Requires-Dist: python-dotenv>=0.8.0
|
|
33
|
+
Requires-Dist: uvicorn[standard]>=0.51.0
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# graphharbor
|
|
37
|
+
|
|
38
|
+
Minimal CLI for running the self-hosted GraphHarbor Agent Server on the open
|
|
39
|
+
[`graphharbor-runtime`](https://pypi.org/project/graphharbor-runtime/) backend
|
|
40
|
+
(Postgres + Redis).
|
|
41
|
+
|
|
42
|
+
## Command
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
graphharbor serve [OPTIONS]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Requires a `langgraph.json` in the working directory (same format as
|
|
49
|
+
[`langgraph-cli`](https://github.com/langchain-ai/langgraph/tree/main/libs/cli))
|
|
50
|
+
and `DATABASE_URI` / `REDIS_URI` (see repo `.env.example`). Bring your own
|
|
51
|
+
PostgreSQL and Redis reachable from the host.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cp .env.example .env
|
|
55
|
+
# Start PostgreSQL and Redis with your host's service manager.
|
|
56
|
+
|
|
57
|
+
# Dev (hot reload)
|
|
58
|
+
graphharbor serve --reload -c langgraph.json
|
|
59
|
+
|
|
60
|
+
# Prod (multi-process)
|
|
61
|
+
graphharbor serve --host 0.0.0.0 --workers 4 -c langgraph.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run `graphharbor serve --help` for the full option list.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
langhost/__init__.py,sha256=A3vk6inbe5N-v0ti0JoQoSAPMyiMy4YuCDYNOhiEz54,275
|
|
2
|
+
langhost/__main__.py,sha256=Z_8g0qzCSaEiYYX5KPZ_0Q3YHN83PaSNYPW1op38meM,67
|
|
3
|
+
langhost/cli.py,sha256=Bc2oL8aaCBdZSwN4zu-xKf9aPGzYAWflRlRMR_qWlkY,16664
|
|
4
|
+
langhost/core_api.py,sha256=Uf73zDohE6V6YHcznVbxUEF2FAis78l8FPUneuf42EE,52111
|
|
5
|
+
langhost/protocol_api.py,sha256=IXpKNMqhpPozSCN9BIGOzVz57gMoocKSwMZDqjhkTxM,13381
|
|
6
|
+
langhost/server.py,sha256=Kts1cyNpZh9v_DjrOgANZp37cCqyFDFZlHEkLL6b5tY,35730
|
|
7
|
+
langhost/streaming.py,sha256=a6g31tZ-MQ8m01Cl8s7pj6EyoypL1Gf9XAn_pfO6tKQ,11898
|
|
8
|
+
graphharbor-0.13.0.post2.dist-info/METADATA,sha256=lOZJgRe0mGr1SuCCi1xkbXvtDkeRQRY1SvY-Qou86Ks,2503
|
|
9
|
+
graphharbor-0.13.0.post2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
graphharbor-0.13.0.post2.dist-info/entry_points.txt,sha256=hM5uFwJylE-2OVnqAus0QZSFkYsvtVCoUkE9Mb3us-c,49
|
|
11
|
+
graphharbor-0.13.0.post2.dist-info/licenses/LICENSE,sha256=VOzjTuqsyEVtwd7ylP1eoLjPAAvdB6iC9a7JBGFJUgE,1080
|
|
12
|
+
graphharbor-0.13.0.post2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohankumar Ramachandran
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
langhost/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""GraphHarbor CLI for a self-hosted LangGraph Agent Server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("graphharbor")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
langhost/__main__.py
ADDED
langhost/cli.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""GraphHarbor CLI — config loading plus the self-owned Agent Server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
from langgraph_cli.config import validate_config_file
|
|
16
|
+
from langgraph_cli.constants import DEFAULT_CONFIG
|
|
17
|
+
from pyfiglet import figlet_format
|
|
18
|
+
|
|
19
|
+
from langgraph_runtime_pg.graph_registry import resolve_config_base_dir
|
|
20
|
+
from langhost import __version__
|
|
21
|
+
from langhost.server import run_server
|
|
22
|
+
|
|
23
|
+
# Always the open Postgres+Redis runtime.
|
|
24
|
+
RUNTIME_EDITION = "pg"
|
|
25
|
+
DEFAULT_PORT = 31296
|
|
26
|
+
LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
|
|
27
|
+
DEFAULT_STUDIO_ORIGIN = "https://smith.langchain.com"
|
|
28
|
+
|
|
29
|
+
# Upstream run_server always logs an inmem-oriented welcome; replace it for GraphHarbor.
|
|
30
|
+
_UPSTREAM_WELCOME_MARKER = "This in-memory server is designed for development and testing"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_port(host: str, port: int | None) -> int:
|
|
34
|
+
"""Return the requested port or the first free port, without private API imports."""
|
|
35
|
+
requested = port if port is not None else DEFAULT_PORT
|
|
36
|
+
with socket.socket(
|
|
37
|
+
socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM
|
|
38
|
+
) as sock:
|
|
39
|
+
try:
|
|
40
|
+
sock.bind((host, requested))
|
|
41
|
+
except OSError:
|
|
42
|
+
sock.bind((host, 0))
|
|
43
|
+
return int(sock.getsockname()[1])
|
|
44
|
+
return requested
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _display_host(host: str) -> str:
|
|
48
|
+
if host in {"0.0.0.0", "::"}:
|
|
49
|
+
return "127.0.0.1"
|
|
50
|
+
if ":" in host and not host.startswith("["):
|
|
51
|
+
return f"[{host}]"
|
|
52
|
+
return host
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _server_base_url(
|
|
56
|
+
host: str,
|
|
57
|
+
port: int,
|
|
58
|
+
*,
|
|
59
|
+
ssl: bool,
|
|
60
|
+
mount_prefix: str | None,
|
|
61
|
+
) -> str:
|
|
62
|
+
scheme = "https" if ssl else "http"
|
|
63
|
+
url = f"{scheme}://{_display_host(host)}:{port}"
|
|
64
|
+
if mount_prefix:
|
|
65
|
+
url += mount_prefix
|
|
66
|
+
return url
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _langhost_welcome(
|
|
70
|
+
*,
|
|
71
|
+
host: str,
|
|
72
|
+
port: int,
|
|
73
|
+
ssl: bool,
|
|
74
|
+
studio_origin: str | None,
|
|
75
|
+
mount_prefix: str | None,
|
|
76
|
+
) -> str:
|
|
77
|
+
api_url = _server_base_url(host, port, ssl=ssl, mount_prefix=mount_prefix)
|
|
78
|
+
origin = (studio_origin or DEFAULT_STUDIO_ORIGIN).rstrip("/")
|
|
79
|
+
studio_url = f"{origin}/studio/?baseUrl={api_url}"
|
|
80
|
+
agent_chat_url = f"https://agentchat.vercel.app/?apiUrl={api_url}&assistantId=agent"
|
|
81
|
+
title = figlet_format("GraphHarbor", font="standard").rstrip()
|
|
82
|
+
repo_url = "https://github.com/ljxpython/graphharbor"
|
|
83
|
+
return f"""
|
|
84
|
+
|
|
85
|
+
{title}
|
|
86
|
+
|
|
87
|
+
- 🚀 API: \033[36m{api_url}\033[0m
|
|
88
|
+
- 🎨 Studio UI: \033[36m{studio_url}\033[0m
|
|
89
|
+
- 📚 API Docs: \033[36m{api_url}/docs\033[0m
|
|
90
|
+
- 💬 Agent Chat UI: \033[36m{agent_chat_url}\033[0m
|
|
91
|
+
|
|
92
|
+
\033[1;33m★\033[0m If GraphHarbor helps you, a \033[1;33mGitHub star\033[0m keeps the project alive:
|
|
93
|
+
\033[36m{repo_url}\033[0m
|
|
94
|
+
|
|
95
|
+
Self-hosted LangGraph Agent Server with PostgreSQL + Redis.
|
|
96
|
+
GraphHarbor {__version__}
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _ReplaceWelcomeBanner(logging.Filter):
|
|
102
|
+
"""Swap the upstream inmem welcome for a prebuilt GraphHarbor banner."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, welcome: str) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
self._welcome = welcome
|
|
107
|
+
|
|
108
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
109
|
+
if _UPSTREAM_WELCOME_MARKER in record.getMessage():
|
|
110
|
+
record.msg = self._welcome
|
|
111
|
+
record.args = ()
|
|
112
|
+
return (
|
|
113
|
+
True # NOSONAR — logging.Filter keeps all records; we only rewrite the welcome banner
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_serve_options(
|
|
118
|
+
reload: bool,
|
|
119
|
+
workers: int,
|
|
120
|
+
ssl_certfile: pathlib.Path | None,
|
|
121
|
+
ssl_keyfile: pathlib.Path | None,
|
|
122
|
+
tunnel: bool,
|
|
123
|
+
wait_for_client: bool,
|
|
124
|
+
debug_port: int | None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Validate mutually exclusive CLI options, raising UsageError on conflicts."""
|
|
127
|
+
if reload and workers > 1:
|
|
128
|
+
raise click.UsageError("Cannot combine --reload with --workers > 1.")
|
|
129
|
+
if (ssl_certfile is None) != (ssl_keyfile is None):
|
|
130
|
+
raise click.UsageError("Both --ssl-certfile and --ssl-keyfile are required for HTTPS.")
|
|
131
|
+
if ssl_certfile and ssl_keyfile and tunnel:
|
|
132
|
+
raise click.UsageError("Cannot combine --tunnel with SSL options.")
|
|
133
|
+
if wait_for_client and debug_port is None:
|
|
134
|
+
raise click.UsageError("--wait-for-client requires --debug-port.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _prepare_serve_env(
|
|
138
|
+
env_file: pathlib.Path | None,
|
|
139
|
+
database_uri: str | None,
|
|
140
|
+
redis_uri: str | None,
|
|
141
|
+
n_jobs_per_worker: int | None,
|
|
142
|
+
) -> tuple[str, str, int | None]:
|
|
143
|
+
"""Load dotenv and resolve database/redis URIs. Returns (db_uri, redis_uri, n_jobs)."""
|
|
144
|
+
load_dotenv(env_file or ".env", override=False)
|
|
145
|
+
os.environ["LANGGRAPH_RUNTIME_EDITION"] = RUNTIME_EDITION
|
|
146
|
+
|
|
147
|
+
database_uri = database_uri or os.environ.get("DATABASE_URI")
|
|
148
|
+
redis_uri = redis_uri or os.environ.get("REDIS_URI")
|
|
149
|
+
if not database_uri:
|
|
150
|
+
raise click.UsageError(
|
|
151
|
+
"DATABASE_URI is required. Please set it in the environment or pass it to the command via --database-uri."
|
|
152
|
+
)
|
|
153
|
+
if not redis_uri:
|
|
154
|
+
raise click.UsageError(
|
|
155
|
+
"REDIS_URI is required. Please set it in the environment or pass it to the command via --redis-uri."
|
|
156
|
+
)
|
|
157
|
+
if n_jobs_per_worker is None:
|
|
158
|
+
raw = os.environ.get("N_JOBS_PER_WORKER")
|
|
159
|
+
if raw:
|
|
160
|
+
n_jobs_per_worker = int(raw)
|
|
161
|
+
return database_uri, redis_uri, n_jobs_per_worker
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _build_uvicorn_kwargs(workers: int) -> dict[str, Any]:
|
|
165
|
+
"""Build extra kwargs passed to uvicorn."""
|
|
166
|
+
uvicorn_kwargs: dict[str, Any] = {}
|
|
167
|
+
if workers > 1:
|
|
168
|
+
uvicorn_kwargs["workers"] = workers
|
|
169
|
+
return uvicorn_kwargs
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_mount_prefix(config_json: dict[str, Any]) -> tuple[dict | None, str | None]:
|
|
173
|
+
"""Extract http config and resolve mount prefix from config/env."""
|
|
174
|
+
http_cfg = config_json.get("http")
|
|
175
|
+
mount_prefix = None
|
|
176
|
+
if isinstance(http_cfg, dict):
|
|
177
|
+
mount_prefix = http_cfg.get("mount_prefix")
|
|
178
|
+
mount_prefix = (
|
|
179
|
+
os.environ.get("MOUNT_PREFIX") or os.environ.get("LANGGRAPH_MOUNT_PREFIX") or mount_prefix
|
|
180
|
+
)
|
|
181
|
+
return http_cfg, mount_prefix
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@click.group()
|
|
185
|
+
@click.version_option(version=__version__, prog_name="graphharbor")
|
|
186
|
+
def cli() -> None:
|
|
187
|
+
"""Self-hosted LangGraph Agent Server on PostgreSQL + Redis."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@cli.command(
|
|
191
|
+
"serve",
|
|
192
|
+
help=(
|
|
193
|
+
"Run the Agent Server (LANGGRAPH_RUNTIME_EDITION=pg). "
|
|
194
|
+
"Use --reload for local development; --workers for production."
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--host",
|
|
199
|
+
default="127.0.0.1",
|
|
200
|
+
show_default=True,
|
|
201
|
+
help=(
|
|
202
|
+
"Bind address. Prefer 127.0.0.1 for local work; use 0.0.0.0 only on "
|
|
203
|
+
"trusted networks / behind a reverse proxy."
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--port",
|
|
208
|
+
"-p",
|
|
209
|
+
default=DEFAULT_PORT,
|
|
210
|
+
show_default=True,
|
|
211
|
+
type=int,
|
|
212
|
+
help="Port to bind the Agent Server to.",
|
|
213
|
+
)
|
|
214
|
+
@click.option(
|
|
215
|
+
"--config",
|
|
216
|
+
"-c",
|
|
217
|
+
default=DEFAULT_CONFIG,
|
|
218
|
+
show_default=True,
|
|
219
|
+
type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
|
|
220
|
+
help="Path to langgraph.json (graphs, deps, env, auth, http, …).",
|
|
221
|
+
)
|
|
222
|
+
@click.option(
|
|
223
|
+
"--env-file",
|
|
224
|
+
type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
|
|
225
|
+
default=None,
|
|
226
|
+
help="Optional dotenv file to load before starting (default: .env if present).",
|
|
227
|
+
)
|
|
228
|
+
@click.option(
|
|
229
|
+
"--database-uri",
|
|
230
|
+
default=None,
|
|
231
|
+
help="Postgres URI (overrides DATABASE_URI). Required unless set in the environment.",
|
|
232
|
+
)
|
|
233
|
+
@click.option(
|
|
234
|
+
"--redis-uri",
|
|
235
|
+
default=None,
|
|
236
|
+
help="Redis URI (overrides REDIS_URI). Required unless set in the environment.",
|
|
237
|
+
)
|
|
238
|
+
@click.option(
|
|
239
|
+
"--reload",
|
|
240
|
+
is_flag=True,
|
|
241
|
+
help="Auto-reload on code changes (dev). Incompatible with --workers > 1.",
|
|
242
|
+
)
|
|
243
|
+
@click.option(
|
|
244
|
+
"--reload-include",
|
|
245
|
+
"reload_includes",
|
|
246
|
+
multiple=True,
|
|
247
|
+
help="Glob(s) to watch when --reload is set (repeatable).",
|
|
248
|
+
)
|
|
249
|
+
@click.option(
|
|
250
|
+
"--reload-exclude",
|
|
251
|
+
"reload_excludes",
|
|
252
|
+
multiple=True,
|
|
253
|
+
help="Glob(s) to ignore when --reload is set (repeatable).",
|
|
254
|
+
)
|
|
255
|
+
@click.option(
|
|
256
|
+
"--workers",
|
|
257
|
+
default=1,
|
|
258
|
+
show_default=True,
|
|
259
|
+
type=click.IntRange(min=1),
|
|
260
|
+
help="Uvicorn worker processes (prod). Incompatible with --reload.",
|
|
261
|
+
)
|
|
262
|
+
@click.option(
|
|
263
|
+
"--n-jobs-per-worker",
|
|
264
|
+
default=None,
|
|
265
|
+
type=int,
|
|
266
|
+
help=(
|
|
267
|
+
"Max concurrent background jobs per server process "
|
|
268
|
+
"(sets N_JOBS_PER_WORKER; default inside run_server is 1)."
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
@click.option(
|
|
272
|
+
"--browser/--no-browser",
|
|
273
|
+
default=False,
|
|
274
|
+
help="Open LangSmith Studio in the browser when the server is ready.",
|
|
275
|
+
)
|
|
276
|
+
@click.option(
|
|
277
|
+
"--studio-url",
|
|
278
|
+
default=None,
|
|
279
|
+
help="LangSmith Studio base URL (default: https://smith.langchain.com).",
|
|
280
|
+
)
|
|
281
|
+
@click.option(
|
|
282
|
+
"--tunnel",
|
|
283
|
+
is_flag=True,
|
|
284
|
+
help="Expose the server via a Cloudflare tunnel (remote Studio access).",
|
|
285
|
+
)
|
|
286
|
+
@click.option(
|
|
287
|
+
"--debug-port",
|
|
288
|
+
default=None,
|
|
289
|
+
type=int,
|
|
290
|
+
help="Listen for a remote debugger (debugpy) on this port.",
|
|
291
|
+
)
|
|
292
|
+
@click.option(
|
|
293
|
+
"--wait-for-client",
|
|
294
|
+
is_flag=True,
|
|
295
|
+
help="With --debug-port, block until a debugger attaches.",
|
|
296
|
+
)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--allow-blocking",
|
|
299
|
+
is_flag=True,
|
|
300
|
+
help="Do not raise on synchronous/blocking I/O in graph code.",
|
|
301
|
+
)
|
|
302
|
+
@click.option(
|
|
303
|
+
"--server-log-level",
|
|
304
|
+
default="INFO",
|
|
305
|
+
show_default=True,
|
|
306
|
+
type=click.Choice(LOG_LEVELS, case_sensitive=False),
|
|
307
|
+
help="Log level for uvicorn / GraphHarbor Agent Server.",
|
|
308
|
+
)
|
|
309
|
+
@click.option(
|
|
310
|
+
"--ssl-certfile",
|
|
311
|
+
type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
|
|
312
|
+
default=None,
|
|
313
|
+
help="TLS certificate file (serve over HTTPS).",
|
|
314
|
+
)
|
|
315
|
+
@click.option(
|
|
316
|
+
"--ssl-keyfile",
|
|
317
|
+
type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
|
|
318
|
+
default=None,
|
|
319
|
+
help="TLS private key file (serve over HTTPS).",
|
|
320
|
+
)
|
|
321
|
+
def serve(
|
|
322
|
+
host: str, # NOSONAR - Click command signature intentionally wide
|
|
323
|
+
port: int, # NOSONAR - Click command signature intentionally wide
|
|
324
|
+
config: pathlib.Path,
|
|
325
|
+
env_file: pathlib.Path | None,
|
|
326
|
+
database_uri: str | None,
|
|
327
|
+
redis_uri: str | None,
|
|
328
|
+
reload: bool,
|
|
329
|
+
reload_includes: tuple[str, ...],
|
|
330
|
+
reload_excludes: tuple[str, ...],
|
|
331
|
+
workers: int,
|
|
332
|
+
n_jobs_per_worker: int | None,
|
|
333
|
+
browser: bool,
|
|
334
|
+
studio_url: str | None,
|
|
335
|
+
tunnel: bool,
|
|
336
|
+
debug_port: int | None,
|
|
337
|
+
wait_for_client: bool,
|
|
338
|
+
allow_blocking: bool,
|
|
339
|
+
server_log_level: str,
|
|
340
|
+
ssl_certfile: pathlib.Path | None,
|
|
341
|
+
ssl_keyfile: pathlib.Path | None,
|
|
342
|
+
) -> None:
|
|
343
|
+
_validate_serve_options(
|
|
344
|
+
reload, workers, ssl_certfile, ssl_keyfile, tunnel, wait_for_client, debug_port
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
config_json = validate_config_file(config)
|
|
348
|
+
if config_json.get("node_version"):
|
|
349
|
+
raise click.UsageError(
|
|
350
|
+
"JS graphs are not supported by langhost. Remove the 'node_version' field from the config."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
cwd = pathlib.Path.cwd()
|
|
354
|
+
config_base_dir = resolve_config_base_dir(config, cast(dict[str, Any], config_json))
|
|
355
|
+
config_env = config_json.get("env")
|
|
356
|
+
if env_file is None and isinstance(config_env, str):
|
|
357
|
+
load_dotenv(config_base_dir / config_env, override=False)
|
|
358
|
+
database_uri, redis_uri, n_jobs_per_worker = _prepare_serve_env(
|
|
359
|
+
env_file, database_uri, redis_uri, n_jobs_per_worker
|
|
360
|
+
)
|
|
361
|
+
for import_root in (cwd, config_base_dir):
|
|
362
|
+
if str(import_root) not in sys.path:
|
|
363
|
+
sys.path.append(str(import_root))
|
|
364
|
+
for dep in config_json.get("dependencies", []):
|
|
365
|
+
dep_path = cwd / dep
|
|
366
|
+
if dep_path.is_dir() and dep_path.exists():
|
|
367
|
+
sys.path.append(str(dep_path))
|
|
368
|
+
|
|
369
|
+
includes: Sequence[str] | None = list(reload_includes) or None
|
|
370
|
+
excludes: Sequence[str] | None = list(reload_excludes) or None
|
|
371
|
+
uvicorn_kwargs = _build_uvicorn_kwargs(workers)
|
|
372
|
+
http_cfg, mount_prefix = _resolve_mount_prefix(cast(dict[str, Any], config_json))
|
|
373
|
+
|
|
374
|
+
port = _resolve_port(host, port)
|
|
375
|
+
welcome = _langhost_welcome(
|
|
376
|
+
host=host,
|
|
377
|
+
port=port,
|
|
378
|
+
ssl=ssl_certfile is not None and ssl_keyfile is not None,
|
|
379
|
+
studio_origin=studio_url,
|
|
380
|
+
mount_prefix=mount_prefix,
|
|
381
|
+
)
|
|
382
|
+
api_cli_logger = logging.getLogger("graphharbor.cli")
|
|
383
|
+
banner_filter = _ReplaceWelcomeBanner(welcome)
|
|
384
|
+
api_cli_logger.addFilter(banner_filter)
|
|
385
|
+
try:
|
|
386
|
+
run_server(
|
|
387
|
+
host,
|
|
388
|
+
port,
|
|
389
|
+
reload,
|
|
390
|
+
config_json.get("graphs", {}),
|
|
391
|
+
n_jobs_per_worker=n_jobs_per_worker,
|
|
392
|
+
open_browser=browser,
|
|
393
|
+
tunnel=tunnel,
|
|
394
|
+
debug_port=debug_port,
|
|
395
|
+
wait_for_client=wait_for_client,
|
|
396
|
+
env=config_json.get("env"),
|
|
397
|
+
reload_includes=includes,
|
|
398
|
+
reload_excludes=excludes,
|
|
399
|
+
store=config_json.get("store"),
|
|
400
|
+
auth=config_json.get("auth"),
|
|
401
|
+
http=http_cfg,
|
|
402
|
+
ui=config_json.get("ui"),
|
|
403
|
+
ui_config=config_json.get("ui_config"),
|
|
404
|
+
webhooks=config_json.get("webhooks"),
|
|
405
|
+
checkpointer=config_json.get("checkpointer"),
|
|
406
|
+
studio_url=studio_url,
|
|
407
|
+
disable_persistence=config_json.get("disable_persistence", False),
|
|
408
|
+
allow_blocking=allow_blocking,
|
|
409
|
+
server_level=server_log_level,
|
|
410
|
+
ssl_certfile=ssl_certfile,
|
|
411
|
+
ssl_keyfile=ssl_keyfile,
|
|
412
|
+
runtime_edition=RUNTIME_EDITION,
|
|
413
|
+
__database_uri__=database_uri,
|
|
414
|
+
__redis_uri__=redis_uri,
|
|
415
|
+
__migrations_path__=None,
|
|
416
|
+
config=config_json,
|
|
417
|
+
base_dir=config_base_dir,
|
|
418
|
+
**uvicorn_kwargs,
|
|
419
|
+
)
|
|
420
|
+
finally:
|
|
421
|
+
api_cli_logger.removeFilter(banner_filter)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@cli.command("migrate", help="Apply the explicit PostgreSQL schema migration job.")
|
|
425
|
+
@click.argument(
|
|
426
|
+
"migration_action",
|
|
427
|
+
type=click.Choice(["upgrade", "current", "history", "stamp", "downgrade"]),
|
|
428
|
+
default="upgrade",
|
|
429
|
+
)
|
|
430
|
+
@click.argument("revision", required=False)
|
|
431
|
+
def migrate_command(migration_action: str, revision: str | None) -> None:
|
|
432
|
+
"""Run migrations separately from API startup."""
|
|
433
|
+
from langgraph_runtime_pg import migrate
|
|
434
|
+
|
|
435
|
+
if migration_action == "upgrade":
|
|
436
|
+
click.echo(f"upgraded to {migrate.upgrade_head()}")
|
|
437
|
+
elif migration_action == "stamp":
|
|
438
|
+
migrate.stamp_head()
|
|
439
|
+
click.echo("stamped head")
|
|
440
|
+
elif migration_action == "current":
|
|
441
|
+
migrate.current()
|
|
442
|
+
elif migration_action == "history":
|
|
443
|
+
migrate.history()
|
|
444
|
+
else:
|
|
445
|
+
migrate.downgrade(revision or "-1")
|
|
446
|
+
click.echo(f"downgraded to {revision or '-1'}")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@cli.command("worker", help="Run the GraphHarbor worker against host PostgreSQL and Redis.")
|
|
450
|
+
@click.option(
|
|
451
|
+
"--config",
|
|
452
|
+
"config_path",
|
|
453
|
+
default=DEFAULT_CONFIG,
|
|
454
|
+
show_default=True,
|
|
455
|
+
type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
|
|
456
|
+
help="Path to langgraph.json used for graph discovery.",
|
|
457
|
+
)
|
|
458
|
+
@click.option("--env-file", type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path))
|
|
459
|
+
@click.option("--database-uri", default=None)
|
|
460
|
+
@click.option("--redis-uri", default=None)
|
|
461
|
+
@click.option("--n-jobs-per-worker", default=1, type=click.IntRange(min=1), show_default=True)
|
|
462
|
+
@click.option(
|
|
463
|
+
"--compatibility-spike",
|
|
464
|
+
is_flag=True,
|
|
465
|
+
help="Use the legacy private worker only for comparison tests; never use in production.",
|
|
466
|
+
)
|
|
467
|
+
def worker_command(
|
|
468
|
+
config_path: pathlib.Path,
|
|
469
|
+
env_file: pathlib.Path | None,
|
|
470
|
+
database_uri: str | None,
|
|
471
|
+
redis_uri: str | None,
|
|
472
|
+
n_jobs_per_worker: int,
|
|
473
|
+
compatibility_spike: bool,
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Start only the queue worker; migrations remain an explicit command."""
|
|
476
|
+
worker_config = validate_config_file(config_path)
|
|
477
|
+
worker_base_dir = resolve_config_base_dir(config_path, cast(dict[str, Any], worker_config))
|
|
478
|
+
worker_env = worker_config.get("env")
|
|
479
|
+
if env_file is None and isinstance(worker_env, str):
|
|
480
|
+
load_dotenv(worker_base_dir / worker_env, override=False)
|
|
481
|
+
database_uri, redis_uri, _ = _prepare_serve_env(
|
|
482
|
+
env_file, database_uri, redis_uri, n_jobs_per_worker
|
|
483
|
+
)
|
|
484
|
+
os.environ["DATABASE_URI"] = database_uri
|
|
485
|
+
os.environ["REDIS_URI"] = redis_uri
|
|
486
|
+
os.environ["GRAPHHARBOR_COMPATIBILITY_SPIKE"] = "1" if compatibility_spike else "0"
|
|
487
|
+
|
|
488
|
+
if compatibility_spike:
|
|
489
|
+
from langgraph_runtime_pg.production import lifespan
|
|
490
|
+
from langgraph_runtime_pg.queue import queue
|
|
491
|
+
|
|
492
|
+
async def _run_compatibility() -> None:
|
|
493
|
+
async with lifespan():
|
|
494
|
+
await queue()
|
|
495
|
+
|
|
496
|
+
import asyncio
|
|
497
|
+
|
|
498
|
+
asyncio.run(_run_compatibility())
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
async def _run() -> None:
|
|
502
|
+
from langgraph_runtime_pg.production_worker import run_worker
|
|
503
|
+
|
|
504
|
+
await run_worker(config_path)
|
|
505
|
+
|
|
506
|
+
import asyncio
|
|
507
|
+
|
|
508
|
+
asyncio.run(_run())
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
cli()
|