deyta-cli 0.1.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.
deyta_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
deyta_cli/cli.py ADDED
@@ -0,0 +1,65 @@
1
+ """Deyta CLI — unified entry point for Deyta's services.
2
+
3
+ Command altitudes:
4
+ - platform/runtime (top-level, never service-prefixed): init, serve, status, stop,
5
+ up, down, login, logout, context.
6
+ - data plane (flat in MVP): namespace/ns, memory, plus ingest/query shorthands.
7
+
8
+ The CLI is a thin HTTP client; all Khora work happens in the `deyta serve` daemon.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import typer
14
+
15
+ from .commands import auth, context, db, init, memory, namespace, serve, update, version
16
+ from .commands import aliases
17
+
18
+ app = typer.Typer(
19
+ help="Deyta — unified CLI for Deyta's services (memory powered by Khora).",
20
+ no_args_is_help=True,
21
+ )
22
+
23
+
24
+ @app.callback()
25
+ def main(
26
+ ctx: typer.Context,
27
+ host: str = typer.Option(
28
+ None,
29
+ "--host",
30
+ help="Target server URL. Overrides DEYTA_HOST and the active context.",
31
+ envvar="DEYTA_HOST",
32
+ ),
33
+ ) -> None:
34
+ """Resolve the target host once and stash it for every command."""
35
+ ctx.obj = {"host": host}
36
+
37
+
38
+ # ---- platform / runtime ---------------------------------------------------- #
39
+ app.command("init")(init.init)
40
+ app.command("serve")(serve.serve)
41
+ app.command("status")(serve.status)
42
+ app.command("stop")(serve.stop)
43
+ app.command("up")(serve.up)
44
+ app.command("down")(serve.down)
45
+ app.command("login")(auth.login)
46
+ app.command("logout")(auth.logout)
47
+ app.command("version")(version.version)
48
+ app.command("update")(update.update)
49
+ app.add_typer(context.app, name="context")
50
+
51
+ # ---- datastores (Docker) --------------------------------------------------- #
52
+ app.add_typer(db.app, name="db")
53
+
54
+ # ---- data plane ------------------------------------------------------------ #
55
+ app.add_typer(namespace.app, name="namespace")
56
+ app.add_typer(namespace.app, name="ns") # alias
57
+ app.add_typer(memory.app, name="memory")
58
+
59
+ # ---- top-level shorthands -------------------------------------------------- #
60
+ app.command("ingest")(aliases.ingest)
61
+ app.command("query")(aliases.query)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ app()
deyta_cli/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """Thin HTTP client to a Deyta daemon (local ``deyta serve`` or, later, cloud).
2
+
3
+ The CLI never imports Khora. Every command builds a request, hands it to this
4
+ client, and renders the response. The base URL is resolved once (``--host`` >
5
+ ``DEYTA_HOST`` > current context > localhost), so pointing the same commands at a
6
+ cloud platform is purely a matter of which context is active.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from . import config
18
+
19
+
20
+ class DeytaClientError(Exception):
21
+ """User-facing client error (connection refused, HTTP error, etc.)."""
22
+
23
+
24
+ class DeytaClient:
25
+ def __init__(self, host: str | None = None, *, timeout: float = 300.0) -> None:
26
+ self.host = config.resolve_host(host).rstrip("/")
27
+ self._timeout = timeout
28
+ token = config.auth_token(config.current_context()["name"])
29
+ self._headers = {"Authorization": f"Bearer {token}"} if token else {}
30
+
31
+ # ---- low-level -------------------------------------------------------- #
32
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
33
+ url = f"{self.host}{path}"
34
+ try:
35
+ with httpx.Client(timeout=self._timeout, headers=self._headers) as c:
36
+ resp = c.request(method, url, **kwargs)
37
+ except httpx.ConnectError as exc:
38
+ raise DeytaClientError(
39
+ f"No Deyta server at {self.host}. Run `deyta serve` first."
40
+ ) from exc
41
+ except httpx.HTTPError as exc:
42
+ raise DeytaClientError(f"Request to {url} failed: {exc}") from exc
43
+ if resp.status_code >= 400:
44
+ raise DeytaClientError(_error_detail(resp))
45
+ return resp.json() if resp.content else None
46
+
47
+ # ---- health ----------------------------------------------------------- #
48
+ def health(self) -> dict[str, Any]:
49
+ return self._request("GET", "/health")
50
+
51
+ def is_up(self) -> bool:
52
+ try:
53
+ self.health()
54
+ return True
55
+ except DeytaClientError:
56
+ return False
57
+
58
+ # ---- namespaces ------------------------------------------------------- #
59
+ def create_namespace(self, name: str) -> dict[str, Any]:
60
+ return self._request("POST", "/namespaces", json={"name": name})
61
+
62
+ def list_namespaces(self) -> list[dict[str, Any]]:
63
+ return self._request("GET", "/namespaces")["namespaces"]
64
+
65
+ def get_namespace(self, namespace_id: str) -> dict[str, Any]:
66
+ return self._request("GET", f"/namespaces/{namespace_id}")
67
+
68
+ def delete_namespace(self, namespace_id: str) -> dict[str, Any]:
69
+ return self._request("DELETE", f"/namespaces/{namespace_id}")
70
+
71
+ # ---- memory ----------------------------------------------------------- #
72
+ def remember(self, payload: dict[str, Any]) -> dict[str, Any]:
73
+ return self._request("POST", "/remember", json=payload)
74
+
75
+ def recall(self, payload: dict[str, Any]) -> dict[str, Any]:
76
+ return self._request("POST", "/recall", json=payload)
77
+
78
+ def forget(self, payload: dict[str, Any]) -> dict[str, Any]:
79
+ return self._request("POST", "/forget", json=payload)
80
+
81
+ def ingest(self, payload: dict[str, Any]) -> Iterator[dict[str, Any]]:
82
+ """Stream ingest progress as Server-Sent Events.
83
+
84
+ Yields ``{"type": "progress", "processed", "total"}`` events followed by a
85
+ single ``{"type": "result", ...}`` (or ``{"type": "error", ...}``) event.
86
+ """
87
+ url = f"{self.host}/ingest"
88
+ try:
89
+ with httpx.Client(timeout=None, headers=self._headers) as c:
90
+ with c.stream("POST", url, json=payload) as resp:
91
+ if resp.status_code >= 400:
92
+ resp.read()
93
+ raise DeytaClientError(_error_detail(resp))
94
+ for line in resp.iter_lines():
95
+ if line.startswith("data: "):
96
+ yield json.loads(line[len("data: ") :])
97
+ except httpx.ConnectError as exc:
98
+ raise DeytaClientError(
99
+ f"No Deyta server at {self.host}. Run `deyta serve` first."
100
+ ) from exc
101
+
102
+
103
+ def _error_detail(resp: httpx.Response) -> str:
104
+ try:
105
+ body = resp.json()
106
+ return str(body.get("detail", body))
107
+ except (json.JSONDecodeError, ValueError):
108
+ return f"HTTP {resp.status_code}: {resp.text[:200]}"
@@ -0,0 +1 @@
1
+ """Typer command groups for the Deyta CLI."""
@@ -0,0 +1,84 @@
1
+ """Shared helpers for command implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import config, docker, render
8
+ from ..client import DeytaClient
9
+
10
+
11
+ def get_host(ctx: typer.Context) -> str | None:
12
+ obj = ctx.obj or {}
13
+ return obj.get("host")
14
+
15
+
16
+ def get_client(ctx: typer.Context) -> DeytaClient:
17
+ return DeytaClient(get_host(ctx))
18
+
19
+
20
+ def require_project(ctx: typer.Context) -> config.ProjectConfig:
21
+ """Load ``deyta.toml`` or exit with guidance."""
22
+ cfg = config.load_project_config()
23
+ if cfg is None:
24
+ render.error("No deyta.toml found. Run `deyta init` first.")
25
+ raise typer.Exit(1)
26
+ return cfg
27
+
28
+
29
+ def resolve_namespace(cfg: config.ProjectConfig, ns_opt: str | None) -> str:
30
+ """Resolve a ``--namespace`` option (or the active namespace) to a UUID."""
31
+ target = ns_opt or cfg.active
32
+ if not target:
33
+ render.error(
34
+ "No namespace selected. Create one with `deyta ns create <name>` "
35
+ "and `deyta ns use <name>`, or pass --namespace."
36
+ )
37
+ raise typer.Exit(1)
38
+ return cfg.resolve_namespace(target)
39
+
40
+
41
+ def resolve_ontology(
42
+ cfg: config.ProjectConfig,
43
+ entity_types: str | None,
44
+ relationship_types: str | None,
45
+ ) -> tuple[list[str], list[str]]:
46
+ """Flags override the project default ontology; otherwise fall back to config."""
47
+ ents = _split(entity_types) if entity_types else cfg.entity_types
48
+ rels = _split(relationship_types) if relationship_types else cfg.relationship_types
49
+ return ents, rels
50
+
51
+
52
+ def _split(csv: str) -> list[str]:
53
+ return [item.strip() for item in csv.split(",") if item.strip()]
54
+
55
+
56
+ def bring_up_datastores() -> None:
57
+ """Start Postgres + Neo4j and wait until healthy, with live progress.
58
+
59
+ Exits with a clear message on Docker errors or if they don't come up in time.
60
+ """
61
+ try:
62
+ render.info("Starting datastores (Postgres + Neo4j)…")
63
+ docker.up(detach=True)
64
+ except docker.DockerError as exc:
65
+ render.error(str(exc))
66
+ raise typer.Exit(1) from exc
67
+
68
+ with render.console.status("[bold]Waiting for datastores…[/]", spinner="dots") as status:
69
+
70
+ def tick(states: dict[str, str]) -> None:
71
+ parts = " ".join(f"{svc}=[bold]{st}[/]" for svc, st in states.items())
72
+ status.update(
73
+ f"[bold]Waiting for datastores to be ready[/] "
74
+ f"— Neo4j can take ~30s {parts}"
75
+ )
76
+
77
+ healthy = docker.wait_healthy(on_tick=tick)
78
+
79
+ if not healthy:
80
+ render.error(
81
+ "Datastores didn't become healthy in time. Check `deyta db status` / `deyta db logs`."
82
+ )
83
+ raise typer.Exit(1)
84
+ render.success("Datastores healthy (postgres :5434, neo4j bolt :7688).")
@@ -0,0 +1,47 @@
1
+ """Top-level shorthands for the hottest memory verbs.
2
+
3
+ `deyta ingest` == `deyta memory ingest`; `deyta query` == `deyta memory recall`.
4
+ These delegate to the same ``run_*`` helpers so behavior never drifts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from . import memory
14
+
15
+
16
+ def ingest(
17
+ ctx: typer.Context,
18
+ path: Path = typer.Argument(..., help="File or directory to ingest."),
19
+ namespace: str = typer.Option(None, "--namespace", "-n"),
20
+ recursive: bool = typer.Option(True, "--recursive/--no-recursive"),
21
+ include: str = typer.Option(None, "--include", help="Glob (e.g. '**/*.md')."),
22
+ entity_types: str = typer.Option(None, "--entity-types"),
23
+ relationship_types: str = typer.Option(None, "--relationship-types"),
24
+ dry_run: bool = typer.Option(False, "--dry-run"),
25
+ ) -> None:
26
+ """Ingest files from a path (alias for `memory ingest`)."""
27
+ memory.run_ingest(
28
+ ctx, path, namespace, recursive, include, entity_types, relationship_types, dry_run
29
+ )
30
+
31
+
32
+ def query(
33
+ ctx: typer.Context,
34
+ text: str = typer.Argument(..., help="Query text."),
35
+ namespace: str = typer.Option(None, "--namespace", "-n"),
36
+ mode: str = typer.Option("hybrid", "--mode", help="hybrid|vector|graph|keyword|all"),
37
+ limit: int = typer.Option(10, "--limit", "-k"),
38
+ min_similarity: float = typer.Option(0.0, "--min-similarity"),
39
+ since: str = typer.Option(None, "--since"),
40
+ until: str = typer.Option(None, "--until"),
41
+ as_json: bool = typer.Option(False, "--json"),
42
+ as_context: bool = typer.Option(False, "--context"),
43
+ ) -> None:
44
+ """Recall from memory (alias for `memory recall`)."""
45
+ memory.run_recall(
46
+ ctx, text, namespace, mode, limit, min_similarity, since, until, as_json, as_context
47
+ )
@@ -0,0 +1,25 @@
1
+ """`deyta login` / `logout` — cloud auth. Stubbed in MVP; the seam is real.
2
+
3
+ When the cloud platform ships, `login` will run a browser OAuth flow, write a token
4
+ to ~/.config/deyta/auth.json, and add a `cloud` context. Today it explains that.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from .. import render
12
+
13
+
14
+ def login() -> None:
15
+ """Authenticate to the Deyta cloud platform (not yet available)."""
16
+ render.info(
17
+ "[yellow]Cloud is not yet available.[/] When it ships, `deyta login` will "
18
+ "authenticate you and add a `cloud` context. For now, run everything locally "
19
+ "with `deyta serve`."
20
+ )
21
+
22
+
23
+ def logout() -> None:
24
+ """Sign out of the Deyta cloud platform (not yet available)."""
25
+ render.info("[yellow]Cloud is not yet available.[/] Nothing to sign out of.")
@@ -0,0 +1,53 @@
1
+ """`deyta context …` — switch the CLI between local and (future) cloud targets.
2
+
3
+ A context is just a named ``host`` (plus a target kind). Switching contexts is the
4
+ persistent local<->cloud toggle; ``--host`` / ``DEYTA_HOST`` override it per-command.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from .. import config, render
12
+
13
+ app = typer.Typer(help="Manage local/cloud contexts.", no_args_is_help=True)
14
+
15
+
16
+ @app.command("list")
17
+ def context_list() -> None:
18
+ """List configured contexts."""
19
+ data = config.load_contexts()
20
+ current = data["current_context"]
21
+ from rich.table import Table
22
+
23
+ table = Table(title="Contexts")
24
+ table.add_column("", style="green", width=1)
25
+ table.add_column("name", style="bold")
26
+ table.add_column("target")
27
+ table.add_column("host", style="dim")
28
+ for name, ctx in data["contexts"].items():
29
+ marker = "●" if name == current else ""
30
+ table.add_row(marker, name, ctx.get("target", "?"), ctx.get("host", ""))
31
+ render.console.print(table)
32
+
33
+
34
+ @app.command("current")
35
+ def context_current() -> None:
36
+ """Show the active context."""
37
+ ctx = config.current_context()
38
+ render.info(f"[bold]{ctx['name']}[/] → {ctx.get('host')} ({ctx.get('target')})")
39
+
40
+
41
+ @app.command("use")
42
+ def context_use(name: str = typer.Argument(..., help="Context name to switch to.")) -> None:
43
+ """Switch the active context."""
44
+ data = config.load_contexts()
45
+ if name not in data["contexts"]:
46
+ render.error(
47
+ f"Unknown context {name!r}. Known: {', '.join(data['contexts'])}. "
48
+ "(Cloud contexts arrive with `deyta login`.)"
49
+ )
50
+ raise typer.Exit(1)
51
+ data["current_context"] = name
52
+ config.save_contexts(data)
53
+ render.success(f"Switched to context {name!r} ({data['contexts'][name].get('host')}).")
@@ -0,0 +1,58 @@
1
+ """`deyta db …` — manage the Docker datastores for the postgres backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import docker, render
8
+ from ._common import bring_up_datastores
9
+
10
+ app = typer.Typer(help="Manage the Postgres + Neo4j datastores (Docker).", no_args_is_help=True)
11
+
12
+
13
+ @app.command("up")
14
+ def db_up(
15
+ detach: bool = typer.Option(True, "--detach/--no-detach", "-d", help="Run detached."),
16
+ ) -> None:
17
+ """Start Postgres + Neo4j via Docker Compose."""
18
+ if detach:
19
+ bring_up_datastores() # starts + waits for health with a live spinner
20
+ return
21
+ try:
22
+ docker.up(detach=False) # foreground: stream logs, no health wait
23
+ except docker.DockerError as exc:
24
+ render.error(str(exc))
25
+ raise typer.Exit(1) from exc
26
+
27
+
28
+ @app.command("down")
29
+ def db_down() -> None:
30
+ """Stop the datastores."""
31
+ try:
32
+ docker.down()
33
+ render.success("Datastores stopped.")
34
+ except docker.DockerError as exc:
35
+ render.error(str(exc))
36
+ raise typer.Exit(1) from exc
37
+
38
+
39
+ @app.command("status")
40
+ def db_status() -> None:
41
+ """Show datastore container status."""
42
+ try:
43
+ render.info(docker.status())
44
+ except docker.DockerError as exc:
45
+ render.error(str(exc))
46
+ raise typer.Exit(1) from exc
47
+
48
+
49
+ @app.command("logs")
50
+ def db_logs(
51
+ follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output."),
52
+ ) -> None:
53
+ """Show datastore logs."""
54
+ try:
55
+ docker.logs(follow=follow)
56
+ except docker.DockerError as exc:
57
+ render.error(str(exc))
58
+ raise typer.Exit(1) from exc
@@ -0,0 +1,148 @@
1
+ """`deyta init` — scaffold a project's deyta.toml interactively."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import questionary
8
+ import typer
9
+
10
+ from .. import config, render
11
+
12
+ # Curated OpenAI model menus (with an escape hatch to type any model id).
13
+ _OTHER = "__other__"
14
+ _EMBEDDING_MODELS = [
15
+ ("text-embedding-3-small (1536 dims, cheapest)", "text-embedding-3-small"),
16
+ ("text-embedding-3-large (3072 dims, best quality)", "text-embedding-3-large"),
17
+ ]
18
+ _LLM_MODELS = [
19
+ ("gpt-4o-mini (fast, cheap — default)", "gpt-4o-mini"),
20
+ ("gpt-4o (higher quality)", "gpt-4o"),
21
+ ("gpt-4.1-mini", "gpt-4.1-mini"),
22
+ ("gpt-4.1", "gpt-4.1"),
23
+ ]
24
+
25
+
26
+ def _pick_model(message: str, options: list[tuple[str, str]], default_value: str) -> str | None:
27
+ choices = [questionary.Choice(label, value=value) for label, value in options]
28
+ choices.append(questionary.Choice("Other (enter a model id)…", value=_OTHER))
29
+ picked = questionary.select(message, choices=choices, default=_default_choice(choices, default_value)).ask()
30
+ if picked is None:
31
+ return None
32
+ if picked == _OTHER:
33
+ return questionary.text("Model id:").ask()
34
+ return picked
35
+
36
+
37
+ def _default_choice(choices: list[questionary.Choice], value: str):
38
+ return next((c for c in choices if c.value == value), None)
39
+
40
+
41
+ def init(
42
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing deyta.toml."),
43
+ ) -> None:
44
+ """Create a deyta.toml in the current directory."""
45
+ root = Path.cwd()
46
+ cfg_path = root / config.PROJECT_CONFIG_NAME
47
+ if cfg_path.exists() and not force:
48
+ render.error(f"{config.PROJECT_CONFIG_NAME} already exists. Use --force to overwrite.")
49
+ raise typer.Exit(1)
50
+
51
+ # 1. Backend ----------------------------------------------------------- #
52
+ backend = questionary.select(
53
+ "Backend:",
54
+ choices=[
55
+ questionary.Choice(
56
+ "Standard (PostgreSQL + pgvector + Neo4j) — Docker",
57
+ value=config.BACKEND_POSTGRES,
58
+ ),
59
+ questionary.Choice(
60
+ "Embedded (SQLite + LanceDB) — no Docker",
61
+ value=config.BACKEND_EMBEDDED,
62
+ ),
63
+ ],
64
+ ).ask()
65
+ if backend is None:
66
+ raise typer.Exit(1)
67
+
68
+ cfg = config.ProjectConfig(root=root, backend=backend)
69
+ if backend == config.BACKEND_EMBEDDED:
70
+ db_path = questionary.text("Embedded DB path:", default="./khora.db").ask()
71
+ cfg.db_path = db_path or "./khora.db"
72
+
73
+ # 2. Models: defaults or customize ------------------------------------ #
74
+ mode = questionary.select(
75
+ "Model configuration:",
76
+ choices=[
77
+ questionary.Choice(
78
+ f"Use defaults (OpenAI · {config.DEFAULT_EMBEDDING_MODEL} · {config.DEFAULT_LLM_MODEL})",
79
+ value="default",
80
+ ),
81
+ questionary.Choice("Customize (choose provider and models)", value="custom"),
82
+ ],
83
+ ).ask()
84
+ if mode is None:
85
+ raise typer.Exit(1)
86
+
87
+ if mode == "custom":
88
+ provider = questionary.select(
89
+ "Model provider:",
90
+ choices=[
91
+ questionary.Choice("OpenAI", value="openai"),
92
+ questionary.Choice(
93
+ "Anthropic (coming soon)", value="anthropic", disabled="not yet supported"
94
+ ),
95
+ ],
96
+ ).ask()
97
+ if provider is None:
98
+ raise typer.Exit(1)
99
+ cfg.provider = provider
100
+
101
+ embedding_model = _pick_model(
102
+ "Embedding model:", _EMBEDDING_MODELS, config.DEFAULT_EMBEDDING_MODEL
103
+ )
104
+ if not embedding_model:
105
+ raise typer.Exit(1)
106
+ cfg.embedding_model = embedding_model
107
+ cfg.embedding_dimension = config.EMBEDDING_DIMENSIONS.get(embedding_model, 1536)
108
+
109
+ llm_model = _pick_model(
110
+ "Retrieval / extraction model:", _LLM_MODELS, config.DEFAULT_LLM_MODEL
111
+ )
112
+ if not llm_model:
113
+ raise typer.Exit(1)
114
+ cfg.llm_model = llm_model
115
+ # else: ProjectConfig already carries the OpenAI defaults.
116
+
117
+ # 3. API key ----------------------------------------------------------- #
118
+ _capture_api_key()
119
+
120
+ # 5. Write ------------------------------------------------------------- #
121
+ cfg.save()
122
+ render.success(f"Wrote {config.PROJECT_CONFIG_NAME}")
123
+ render.info(
124
+ f" backend: [bold]{cfg.backend}[/] · embeddings: [bold]{cfg.embedding_model}[/] "
125
+ f"({cfg.embedding_dimension}d) · model: [bold]{cfg.llm_model}[/]"
126
+ )
127
+ if backend == config.BACKEND_POSTGRES:
128
+ render.info("Next: `deyta up` to start Postgres + Neo4j and the daemon.")
129
+ else:
130
+ render.info("Next: `deyta up` to start the daemon.")
131
+
132
+
133
+ def _capture_api_key() -> None:
134
+ """Store the OpenAI API key as DEYTA_OPENAI_API_KEY in the user-global .env.
135
+
136
+ deyta only ever uses its own namespaced secret — never an ambient
137
+ ``OPENAI_API_KEY`` — so the key is always captured here explicitly.
138
+ """
139
+ key = questionary.password("OpenAI API key (leave blank to set later):").ask()
140
+ if key:
141
+ path = config.global_env_path()
142
+ config.write_env(path, {"DEYTA_OPENAI_API_KEY": key})
143
+ render.success(f"Saved DEYTA_OPENAI_API_KEY to {path} (shared across projects).")
144
+ else:
145
+ render.info(
146
+ "[yellow]![/] No key set. Add DEYTA_OPENAI_API_KEY to "
147
+ f"{config.global_env_path()} before `deyta serve`."
148
+ )