From f12b8f9961adf86f322d424e96df4caca56fe5b9 Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 12:24:54 +0530 Subject: [PATCH 1/8] feat: enhance workspace management and vector storage capabilities - Added support for shared Qdrant collections, allowing multiple tenants to utilize a single collection with workspace_id filtering. - Implemented a tenant-isolation guard in Neo4j to prevent cross-tenant data leaks during graph operations. - Enhanced workspace deletion process with soft-delete functionality, including tracking of deletion failures and errors. - Improved error handling in upload routes to manage concurrent uploads and ensure consistent metadata retrieval. - Updated database models and initialization scripts to accommodate new fields for workspace management and deletion tracking. --- alembic.ini | 56 +++++ alembic/README.md | 75 +++++++ alembic/env.py | 94 +++++++++ alembic/script.py.mako | 28 +++ requirements.txt | 1 + scripts/reconcile_orphans.py | 353 ++++++++++++++++++++++++++++++++ src/api/auth.py | 9 +- src/api/build_queue.py | 27 ++- src/api/deps.py | 7 +- src/api/routes.py | 40 +++- src/api/upload_routes.py | 61 +++++- src/api/workspace_routes.py | 263 +++++++++++++++++++++--- src/config.py | 6 + src/graph_builder/builder.py | 14 +- src/graph_builder/embeddings.py | 26 ++- src/infra/db.py | 20 ++ src/infra/db_models.py | 21 ++ src/infra/neo4j_store.py | 78 +++++++ src/infra/qdrant_store.py | 156 +++++++++++--- src/worker/build_worker.py | 200 +++++++++++++++++- 20 files changed, 1454 insertions(+), 81 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README.md create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 scripts/reconcile_orphans.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d6c47f0 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,56 @@ +# Alembic configuration for OpenGraph. +# +# The legacy idempotent ALTER TABLE IF NOT EXISTS bootstrap in +# ``src/infra/db.py::init_db`` still runs on every startup for zero-downtime +# dev ergonomics. Alembic is the strict, versioned migration path for prod: +# +# alembic upgrade head # apply pending migrations +# alembic revision --autogenerate -m "add_foo_column" +# alembic downgrade -1 # rollback one step +# alembic stamp head # mark DB at HEAD without running migrations +# # (first-time adoption on an existing DB) +# +# env.py reads DATABASE_URL from src.config so the same URL drives both the +# runtime engine and the migration engine. + +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README.md b/alembic/README.md new file mode 100644 index 0000000..9094f87 --- /dev/null +++ b/alembic/README.md @@ -0,0 +1,75 @@ +# Alembic migrations + +OpenGraph uses Alembic for versioned schema changes. The legacy idempotent +`ALTER TABLE IF NOT EXISTS` bootstrap in `src/infra/db.py::init_db` still +runs on every startup — that path is fine for dev but not safe for +1M-row alterations in prod. + +## First-time adoption on an existing database + +If Neon already holds the current schema (everything `init_db` creates), +stamp the DB at HEAD so Alembic doesn't try to re-create tables: + +```bash +alembic stamp head +``` + +That writes a row into `alembic_version` and skips over the existing +schema. From there forward, every schema change is a migration. + +## Creating a new migration + +```bash +alembic revision --autogenerate -m "add_my_column" +# review the generated versions/*.py, tweak the batching / defaults +alembic upgrade head +``` + +Autogenerate picks up new/changed columns, indexes and constraints from +`src.infra.db_models.Base.metadata`. It will *not* detect: + +- Server-side DEFAULT changes that aren't part of the ORM default. +- Data backfills (you have to write them by hand in `upgrade()`). +- Check constraint wording changes. + +Always re-read the diff before applying. + +## Safe patterns for 1M-row tables + +Adding a NOT NULL column with a default: + +```python +def upgrade() -> None: + op.add_column("workspaces", sa.Column("plan_tier", sa.String(16), nullable=True)) + op.execute("UPDATE workspaces SET plan_tier = 'trial' WHERE plan_tier IS NULL") + op.alter_column("workspaces", "plan_tier", nullable=False) +``` + +Three small transactions beat one giant ALTER that locks writes for +minutes. + +Adding an index on a hot table: + +```python +def upgrade() -> None: + op.create_index( + "ix_chat_messages_session", + "chat_messages", + ["session_id"], + postgresql_concurrently=True, + ) +``` + +`CONCURRENTLY` keeps writes unblocked; the trade-off is you can't wrap +it in a transaction (Alembic handles this automatically when the flag +is set). + +## Downgrades + +```bash +alembic downgrade -1 +``` + +Write `downgrade()` to drop whatever `upgrade()` added. For destructive +migrations (data deletion, column drops) leave a comment making the +point of no return obvious. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..9363a3b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,94 @@ +"""Alembic environment. + +Reads DATABASE_URL from ``src.config`` so operators don't have to duplicate +secrets between ``.env`` and ``alembic.ini``. Normalises any ``postgres://`` +scheme to psycopg-driver form at migration time; the runtime engine still +uses asyncpg (see ``src/infra/db.py``), but Alembic defaults to the sync +driver so autogenerate can introspect the database schema cheaply. +""" + +from __future__ import annotations + +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Make ``src`` importable when alembic is invoked from repo root. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from src.config import DATABASE_URL # noqa: E402 +from src.infra.db_models import Base # noqa: E402 — registers models on Base.metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def _sync_url(url: str) -> str: + """Rewrite an async-driver URL to the sync psycopg driver for Alembic. + + Alembic's autogenerate path expects a sync engine; runtime async is + handled elsewhere. Strip libpq-only params that asyncpg didn't like but + psycopg handles fine (sslmode etc.) — leave them in the URL. + """ + if not url: + return url + if url.startswith("postgresql+asyncpg://"): + return "postgresql://" + url[len("postgresql+asyncpg://"):] + if url.startswith("postgres://"): + return "postgresql://" + url[len("postgres://"):] + return url + + +_RESOLVED_URL = _sync_url(DATABASE_URL) +if _RESOLVED_URL: + config.set_main_option("sqlalchemy.url", _RESOLVED_URL) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode against a URL, not a live engine.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode against a live connection.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..ee746cf --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/requirements.txt b/requirements.txt index 7d6b433..6c348dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ asyncpg>=0.30.0 PyJWT>=2.8.0 bcrypt>=4.1.0 email-validator>=2.1.0 +alembic>=1.13.0 diff --git a/scripts/reconcile_orphans.py b/scripts/reconcile_orphans.py new file mode 100644 index 0000000..3f2b900 --- /dev/null +++ b/scripts/reconcile_orphans.py @@ -0,0 +1,353 @@ +"""Find and optionally purge storage artifacts that Neon doesn't know about. + +At 1M workspaces, silent divergence between Neon (source of truth for +metadata) and the side stores (Vercel Blob / Qdrant / Memgraph) will leak +storage + cost. This script surfaces every orphan in one pass: + + Vercel Blob: any object under ``{BLOB_STORE_PATH}/{ws_id}/...`` whose + ``ws_id`` is not the id of a live Neon workspace row. + Qdrant: any collection named ``kb-{wid-short}`` whose wid-short + doesn't resolve to a live Neon workspace (legacy mode), + OR in shared-collection mode, any ``workspace_id`` payload + value present in the collection that isn't a live row. + Memgraph: any DISTINCT workspace_id value across KBNode nodes that + isn't a live Neon workspace. + +Dry run by default. Pass ``--delete`` to actually purge — the script will +refuse to delete more than 100 artifacts in a single invocation unless +``--force`` is also passed (guard against a misconfigured env wiping a +healthy cluster). + +Usage: + python -m scripts.reconcile_orphans # dry run, all stores + python -m scripts.reconcile_orphans --only blob # only Blob + python -m scripts.reconcile_orphans --delete # purge (small sets) + python -m scripts.reconcile_orphans --delete --force +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from typing import Iterable + +logger = logging.getLogger("reconcile_orphans") + +_DELETE_CAP = 100 + + +async def _live_workspace_ids() -> set[str]: + """Return the set of live (non-soft-deleted) workspace UUIDs from Neon.""" + from sqlalchemy import select + from src.infra.db import get_session + from src.infra.db_models import Workspace + + async with get_session() as s: + rows = (await s.execute( + select(Workspace.id).where(Workspace.deleted_at.is_(None)) + )).scalars().all() + return {str(wid) for wid in rows} + + +def _short_wid(wid: str) -> str: + # Mirror src.graph_builder.builder._short_wid so collection names line up. + from src.graph_builder.builder import _short_wid as _impl + return _impl(wid) + + +# --------------------------------------------------------------------------- +# Blob reconciliation +# --------------------------------------------------------------------------- + +def _reconcile_blob(live_ids: set[str]) -> list[str]: + """List all blob prefixes under BLOB_STORE_PATH, return orphaned URLs.""" + from src.config import BLOB_READ_WRITE_TOKEN, BLOB_STORE_PATH + if not BLOB_READ_WRITE_TOKEN: + logger.info("Blob: BLOB_READ_WRITE_TOKEN not set — skipping.") + return [] + + import httpx + api_base = "https://blob.vercel-storage.com" + orphans: list[str] = [] + cursor = None + seen = 0 + while True: + qs = f"?prefix={BLOB_STORE_PATH}/&limit=1000" + if cursor: + qs += f"&cursor={cursor}" + resp = httpx.get( + f"{api_base}{qs}", + headers={"authorization": f"Bearer {BLOB_READ_WRITE_TOKEN}"}, + timeout=60, + ) + resp.raise_for_status() + data = resp.json() + for b in data.get("blobs", []): + seen += 1 + path = b.get("pathname", "") + # Expect: "{BLOB_STORE_PATH}/{wid}/{kb_source}/{filename}" + parts = path.split("/") + if len(parts) < 3: + continue + wid_candidate = parts[-3] if path.startswith(BLOB_STORE_PATH + "/") else None + # Guard: the prefix itself may have multiple segments. + prefix_len = len(BLOB_STORE_PATH.split("/")) + if len(parts) <= prefix_len: + continue + wid_candidate = parts[prefix_len] + if wid_candidate and wid_candidate not in live_ids: + url = b.get("url") + if url: + orphans.append(url) + cursor = data.get("cursor") + if not cursor: + break + logger.info("Blob: scanned %d blobs, %d orphans.", seen, len(orphans)) + return orphans + + +def _delete_blob_urls(urls: Iterable[str]) -> int: + """Issue a batched delete against the Vercel Blob REST API.""" + from src.config import BLOB_READ_WRITE_TOKEN + import httpx + api_base = "https://blob.vercel-storage.com" + url_list = list(urls) + if not url_list: + return 0 + resp = httpx.post( + f"{api_base}/delete", + json={"urls": url_list}, + headers={ + "authorization": f"Bearer {BLOB_READ_WRITE_TOKEN}", + "Content-Type": "application/json", + }, + timeout=120, + ) + resp.raise_for_status() + return len(url_list) + + +# --------------------------------------------------------------------------- +# Qdrant reconciliation +# --------------------------------------------------------------------------- + +def _reconcile_qdrant(live_ids: set[str]) -> tuple[list[str], list[str]]: + """Return (orphan_collections, orphan_workspace_ids_in_shared_collection).""" + from src.config import ( + QDRANT_API_KEY, + QDRANT_SHARED_COLLECTION, + QDRANT_URL, + ) + if not (QDRANT_URL and QDRANT_API_KEY): + logger.info("Qdrant: URL/API_KEY not set — skipping.") + return [], [] + + from qdrant_client import QdrantClient + client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=60) + + live_shorts = {_short_wid(wid) for wid in live_ids} + + orphan_collections: list[str] = [] + orphan_ws_ids_shared: list[str] = [] + + for c in client.get_collections().collections: + name = c.name + if name.startswith("kb-") and name != QDRANT_SHARED_COLLECTION: + short = name[len("kb-"):] + if short not in live_shorts: + orphan_collections.append(name) + + if QDRANT_SHARED_COLLECTION: + # Scroll the shared collection, collect distinct workspace_ids seen + # in payloads, and diff against live_ids. Bounded work — we stop + # after seeing each distinct wid once. + seen_ws: set[str] = set() + next_page = None + while True: + points, next_page = client.scroll( + collection_name=QDRANT_SHARED_COLLECTION, + limit=1000, + with_payload=True, + with_vectors=False, + offset=next_page, + ) + for p in points: + ws = (p.payload or {}).get("workspace_id") + if ws: + seen_ws.add(ws) + if next_page is None: + break + orphan_ws_ids_shared = sorted(seen_ws - live_ids) + + logger.info( + "Qdrant: %d orphan collections, %d orphan workspace_ids in shared collection.", + len(orphan_collections), len(orphan_ws_ids_shared), + ) + return orphan_collections, orphan_ws_ids_shared + + +def _delete_qdrant_collections(names: Iterable[str]) -> int: + from src.config import QDRANT_API_KEY, QDRANT_URL + from qdrant_client import QdrantClient + client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=60) + count = 0 + for name in names: + try: + client.delete_collection(name) + count += 1 + except Exception as exc: + logger.warning("Qdrant delete_collection %s failed: %s", name, exc) + return count + + +def _delete_qdrant_shared_ws(ws_ids: Iterable[str]) -> int: + from src.config import QDRANT_API_KEY, QDRANT_SHARED_COLLECTION, QDRANT_URL + from qdrant_client import QdrantClient + from qdrant_client.models import ( + FieldCondition, + Filter, + FilterSelector, + MatchValue, + ) + client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=60) + count = 0 + for wid in ws_ids: + flt = Filter( + must=[FieldCondition(key="workspace_id", match=MatchValue(value=wid))] + ) + try: + client.delete( + collection_name=QDRANT_SHARED_COLLECTION, + points_selector=FilterSelector(filter=flt), + wait=True, + ) + count += 1 + except Exception as exc: + logger.warning("Qdrant filter-delete ws=%s failed: %s", wid, exc) + return count + + +# --------------------------------------------------------------------------- +# Memgraph reconciliation +# --------------------------------------------------------------------------- + +def _reconcile_memgraph(live_ids: set[str]) -> list[str]: + from src.config import USE_MEMGRAPH, USE_NEO4J + if not (USE_MEMGRAPH or USE_NEO4J): + logger.info("Memgraph/Neo4j: no graph backend configured — skipping.") + return [] + + from src.config import MEMGRAPH_PASSWORD, MEMGRAPH_URI, MEMGRAPH_USERNAME + from neo4j import GraphDatabase + driver = GraphDatabase.driver( + MEMGRAPH_URI, auth=(MEMGRAPH_USERNAME, MEMGRAPH_PASSWORD) + ) + try: + with driver.session() as s: + result = s.run( + "MATCH (n:KBNode) RETURN DISTINCT n.workspace_id AS wid" + ) + all_ws = {r["wid"] for r in result if r.get("wid")} + finally: + driver.close() + orphans = sorted(all_ws - live_ids) + logger.info("Memgraph: scanned %d workspace ids, %d orphans.", len(all_ws), len(orphans)) + return orphans + + +def _delete_memgraph_workspace(ws_id: str) -> None: + from src.config import ( + MEMGRAPH_DATABASE, + MEMGRAPH_PASSWORD, + MEMGRAPH_URI, + MEMGRAPH_USERNAME, + ) + from src.infra.memgraph_store import MemgraphGraphStore + store = MemgraphGraphStore( + MEMGRAPH_URI, MEMGRAPH_USERNAME, MEMGRAPH_PASSWORD, MEMGRAPH_DATABASE + ) + store.clear_workspace(ws_id) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def main() -> int: + p = argparse.ArgumentParser(description="Reconcile side-store orphans against Neon.") + p.add_argument("--only", choices=["blob", "qdrant", "memgraph"], help="limit to one store") + p.add_argument("--delete", action="store_true", help="actually purge orphans (dry-run by default)") + p.add_argument("--force", action="store_true", help="allow --delete to purge >100 artifacts") + args = p.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + + live = await _live_workspace_ids() + logger.info("Neon: %d live workspaces.", len(live)) + + report: dict[str, int] = {} + + if args.only in (None, "blob"): + blob_orphans = _reconcile_blob(live) + report["blob_orphans"] = len(blob_orphans) + if args.delete and blob_orphans: + if len(blob_orphans) > _DELETE_CAP and not args.force: + logger.warning( + "Blob: refusing to delete %d orphans without --force " + "(cap=%d).", len(blob_orphans), _DELETE_CAP, + ) + else: + n = _delete_blob_urls(blob_orphans) + logger.info("Blob: purged %d orphan blobs.", n) + + if args.only in (None, "qdrant"): + qdrant_cols, qdrant_ws = _reconcile_qdrant(live) + report["qdrant_orphan_collections"] = len(qdrant_cols) + report["qdrant_orphan_workspace_ids_in_shared"] = len(qdrant_ws) + if args.delete: + if qdrant_cols: + if len(qdrant_cols) > _DELETE_CAP and not args.force: + logger.warning( + "Qdrant collections: refusing to delete %d without --force.", + len(qdrant_cols), + ) + else: + n = _delete_qdrant_collections(qdrant_cols) + logger.info("Qdrant: purged %d orphan collections.", n) + if qdrant_ws: + if len(qdrant_ws) > _DELETE_CAP and not args.force: + logger.warning( + "Qdrant shared-collection ws: refusing to delete %d " + "without --force.", len(qdrant_ws), + ) + else: + n = _delete_qdrant_shared_ws(qdrant_ws) + logger.info("Qdrant: filter-deleted %d orphan workspaces.", n) + + if args.only in (None, "memgraph"): + mg_orphans = _reconcile_memgraph(live) + report["memgraph_orphan_workspaces"] = len(mg_orphans) + if args.delete and mg_orphans: + if len(mg_orphans) > _DELETE_CAP and not args.force: + logger.warning( + "Memgraph: refusing to delete %d orphans without --force.", + len(mg_orphans), + ) + else: + for wid in mg_orphans: + try: + _delete_memgraph_workspace(wid) + except Exception as exc: + logger.warning("Memgraph clear ws=%s failed: %s", wid, exc) + logger.info("Memgraph: cleared %d orphan workspaces.", len(mg_orphans)) + + print("RECONCILE REPORT:") + for k, v in report.items(): + print(f" {k}: {v}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/src/api/auth.py b/src/api/auth.py index 72bab58..9676472 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -22,6 +22,7 @@ from __future__ import annotations import logging +import os import time from datetime import datetime, timedelta, timezone from typing import Any, Optional @@ -127,8 +128,12 @@ def _decode_token(token: str) -> Optional[dict[str, Any]]: # In-proc user cache (sub -> User) # --------------------------------------------------------------------------- -_USER_CACHE_TTL_SECONDS = 300 -_USER_CACHE_MAX = 1024 +_USER_CACHE_TTL_SECONDS = int(os.environ.get("USER_CACHE_TTL_SECONDS", "300")) +# Default bumped from 1024 to 8192 — at 1M users the steady-state concurrent +# set is orders of magnitude smaller (typically low thousands), but if two +# high-traffic tenants push past 1024 the LRU would thrash and re-SELECT +# on every request. 8192 entries ~= 1-2 MB per worker, negligible cost. +_USER_CACHE_MAX = int(os.environ.get("USER_CACHE_MAX", "8192")) _user_cache: dict[str, tuple[float, User]] = {} diff --git a/src/api/build_queue.py b/src/api/build_queue.py index 030bdce..0297a34 100644 --- a/src/api/build_queue.py +++ b/src/api/build_queue.py @@ -250,10 +250,12 @@ async def heartbeat( stage_name: str, percent: int, ) -> None: - """Write a progress + liveness update. Called every ~2s while building. + """Write a progress + liveness update. Called from the worker loop + whenever log_tail / stage / percent have actually changed. - Touches only the fields that change frequently; stage/backends/stats - are written by :func:`record_stage_transition` and :func:`complete`. + Full-row update — for liveness-only ticks (nothing changed since the + last successful heartbeat) use :func:`touch_heartbeat` instead to keep + write volume proportional to genuine progress, not wallclock. """ async with get_session() as s: await s.execute( @@ -270,6 +272,25 @@ async def heartbeat( await s.commit() +async def touch_heartbeat(job_id: str) -> None: + """Bump only ``heartbeat_at`` — proves the worker is still alive without + rewriting log_tail or stage columns. + + At 500 concurrent builds a 10s full-row heartbeat would be 50 writes/s + to the same Postgres table; a touch-only update is a narrower UPDATE + and (because nothing else in the row changes) is cheaper for autovacuum + to ignore. The sweeper checks ``heartbeat_at`` alone to decide if a + worker died, so this is functionally equivalent for zombie detection. + """ + async with get_session() as s: + await s.execute( + update(BuildJobRow) + .where(BuildJobRow.job_id == job_id) + .values(heartbeat_at=datetime.now(timezone.utc)) + ) + await s.commit() + + async def record_stage_transition( job_id: str, stage: int, diff --git a/src/api/deps.py b/src/api/deps.py index 3595ac2..f59ad04 100644 --- a/src/api/deps.py +++ b/src/api/deps.py @@ -58,12 +58,17 @@ async def require_workspace_id( from src.infra.db_models import Workspace async with get_session() as s: ws = (await s.execute( - select(Workspace).where(Workspace.id == uuid.UUID(x_workspace_id)) + select(Workspace).where( + Workspace.id == uuid.UUID(x_workspace_id), + Workspace.deleted_at.is_(None), + ) )).scalar_one_or_none() if ws is None: # 404 (not 403) even though the caller isn't the owner: leaking # "exists but not yours" is strictly worse than leaking "doesn't # exist". This matches how e.g. GitHub responds to foreign repo ids. + # Soft-deleted workspaces also route here so late-arriving + # requests after DELETE return a clean 404. raise HTTPException(status_code=404, detail=f"Workspace {x_workspace_id} not found.") if ws.user_id != user.id: # Intentionally 404 to avoid existence leakage across tenants. diff --git a/src/api/routes.py b/src/api/routes.py index 220f10d..ec41848 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -210,6 +210,12 @@ class QueryResponse(BaseModel): # completes from the per-turn ``chat_metrics_var`` accumulator. Absent # (None) for pre-feature rows — the UI renders "—". usage: Optional[ChatUsage] = None + # True if both the user and assistant turn were written to Neon + # ``chat_messages``. When False the answer was served successfully but + # the server could not record the turn — the UI should surface a subtle + # "history not saved" hint so the user knows to retry if they want a + # durable record. + history_persisted: bool = True class TraverseRequest(BaseModel): @@ -325,16 +331,32 @@ async def query_graph( ) if USE_NEON: - try: - await _persist_chat_turn( - workspace_id=workspace_id, - session_id=session_id, - query=req.query, - resp=resp, - duration_ms=duration_ms, + # Persist before returning, with one quick retry on transient failure + # (connection reset, pool timeout, etc.). If both attempts fail we + # still return the answer but mark ``history_persisted=False`` so the + # UI can surface a warning — losing history silently is worse than + # telling the user to retry. + persisted = False + last_err: Optional[Exception] = None + for _attempt in range(2): + try: + await _persist_chat_turn( + workspace_id=workspace_id, + session_id=session_id, + query=req.query, + resp=resp, + duration_ms=duration_ms, + ) + persisted = True + break + except Exception as exc: + last_err = exc + if not persisted: + logger.warning( + "Neon chat persistence failed after retry for session %s: %s", + session_id, last_err, ) - except Exception as exc: - logger.warning("Neon chat persistence failed: %s", exc) + resp.history_persisted = False record_audit( user.id, "chat.query", diff --git a/src/api/upload_routes.py b/src/api/upload_routes.py index 2117710..bec7b14 100644 --- a/src/api/upload_routes.py +++ b/src/api/upload_routes.py @@ -27,6 +27,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from src.api.auth import require_user from src.api.deps import require_workspace_id @@ -159,7 +160,51 @@ async def _persist_one( blob_url=blob_url, blob_error=None, )) - await s.commit() + try: + await s.commit() + except IntegrityError: + # Two concurrent uploads of the same sha both passed the dedup + # SELECT. The unique constraint ``uq_workspace_files_sha`` just + # rejected the loser. Both produced identical byte content + # (sha matched), so the blob we just uploaded is redundant: + # try to delete it to avoid orphaning a named copy, then + # re-read the winner's row and return it as a duplicate so + # the client sees consistent metadata. + await s.rollback() + _best_effort_blob_delete(blob_url) + r2 = await s.execute( + select(WorkspaceFile).where( + WorkspaceFile.workspace_id == uuid.UUID(workspace_id), + WorkspaceFile.kb_source == kb_source, + WorkspaceFile.sha256 == sha, + ) + ) + winner = r2.scalar_one_or_none() + if winner is None: + # Extremely unlikely — the unique constraint says someone + # has this (ws,kb,sha) tuple, but the winning row isn't + # visible. Surface rather than pretending success. + raise HTTPException( + status_code=500, + detail="Upload raced but winner row not found", + ) + return UploadedFileInfo( + id=winner.id, + kb_source=winner.kb_source, + filename=winner.filename, + size_bytes=winner.size_bytes, + chapters=winner.chapters, + title=winner.title, + sha256=winner.sha256, + blob_url=winner.blob_url or "", + duplicate=True, + ) + except Exception: + # Any other DB failure (connection lost, check constraint, + # etc.) leaves our blob orphaned if we don't compensate. + await s.rollback() + _best_effort_blob_delete(blob_url) + raise await s.refresh(wf) return UploadedFileInfo( @@ -175,6 +220,20 @@ async def _persist_one( ) +def _best_effort_blob_delete(blob_url: str) -> None: + """Delete a just-uploaded blob after a failed DB commit. + + Not durable — if this fails the blob is orphaned and will be cleaned + up by the reconciler job. We log and move on; the caller's error + path already returns a user-visible failure. + """ + try: + from src.infra.blob_loader import delete_blob + delete_blob(blob_url) + except Exception as exc: + logger.warning("Compensating blob delete failed for %s: %s", blob_url, exc) + + @router.post("/kb/upload", response_model=UploadResponse, summary="Upload one or more KB JSON files") async def upload_kb_files( workspace_id: str = Depends(require_workspace_id), diff --git a/src/api/workspace_routes.py b/src/api/workspace_routes.py index fb42c32..c39b29a 100644 --- a/src/api/workspace_routes.py +++ b/src/api/workspace_routes.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from src.api.auth import require_user from src.config import USE_MEMGRAPH, USE_NEON, USE_QDRANT @@ -66,6 +67,12 @@ class WorkspaceSummary(BaseModel): # --------------------------------------------------------------------------- async def _summary_for(workspace: Workspace, session) -> dict: + """Summary for a *single* workspace — two round-trips. + + Used by detail / create / update endpoints that only ever touch one row. + The list endpoint uses the batched ``_summaries_for_many`` instead to + collapse N+1 into three queries total. + """ counts_res = await session.execute( select(WorkspaceFile.kb_source, func.count(WorkspaceFile.id)) .where(WorkspaceFile.workspace_id == workspace.id) @@ -97,6 +104,61 @@ async def _summary_for(workspace: Workspace, session) -> dict: } +async def _summaries_for_many(workspaces: list[Workspace], session) -> list[dict]: + """Batched summary for a list of workspaces — 3 queries, not 2N. + + Collapses the list endpoint's N+1 pattern. Expected shape matches + ``_summary_for`` exactly so the response stays API-compatible. + """ + if not workspaces: + return [] + + ws_ids = [w.id for w in workspaces] + + # 1) File counts by (workspace_id, kb_source) across all workspaces. + counts_res = await session.execute( + select( + WorkspaceFile.workspace_id, + WorkspaceFile.kb_source, + func.count(WorkspaceFile.id), + ) + .where(WorkspaceFile.workspace_id.in_(ws_ids)) + .where(WorkspaceFile.active.is_(True)) + .group_by(WorkspaceFile.workspace_id, WorkspaceFile.kb_source) + ) + counts: dict[uuid.UUID, dict[str, int]] = {} + for wid, kb_src, cnt in counts_res.all(): + counts.setdefault(wid, {"knowledge": 0, "tool": 0})[kb_src] = int(cnt) + + # 2) Latest build per workspace via Postgres DISTINCT ON. + # ``DISTINCT ON (workspace_id)`` + ``ORDER BY workspace_id, created_at DESC`` + # yields the newest row per workspace in one pass. + last_res = await session.execute( + select(BuildJobRow) + .where(BuildJobRow.workspace_id.in_(ws_ids)) + .order_by(BuildJobRow.workspace_id, BuildJobRow.created_at.desc()) + .distinct(BuildJobRow.workspace_id) + ) + latest: dict[uuid.UUID, BuildJobRow] = {r.workspace_id: r for r in last_res.scalars().all()} + + out: list[dict] = [] + for w in workspaces: + fc = counts.get(w.id, {"knowledge": 0, "tool": 0}) + last = latest.get(w.id) + out.append({ + "id": str(w.id), + "name": w.name, + "description": w.description, + "created_at": w.created_at.isoformat(), + "updated_at": w.updated_at.isoformat(), + "file_counts": {"knowledge": fc.get("knowledge", 0), "tool": fc.get("tool", 0)}, + "last_build_at": last.created_at.isoformat() if last else None, + "last_build_status": last.status if last else None, + "stats": last.stats if last else None, + }) + return out + + def _require_neon() -> None: if not USE_NEON: raise HTTPException(status_code=503, detail="Workspaces require DATABASE_URL (Neon).") @@ -106,10 +168,17 @@ async def _load_owned_workspace(session, workspace_id: uuid.UUID, user_id: uuid. """Load a workspace row, raising 404 if it doesn't exist OR isn't owned. Returns 404 (not 403) on ownership mismatch so tenant existence isn't - leaked across accounts. + leaked across accounts. Soft-deleted rows (``deleted_at IS NOT NULL``) + are treated as not-found: the HTTP DELETE handler marks a workspace + deleted synchronously, and the user should stop seeing it the moment + that request returns even while the saga sweeper is still cleaning + side stores. """ ws = (await session.execute( - select(Workspace).where(Workspace.id == workspace_id) + select(Workspace).where( + Workspace.id == workspace_id, + Workspace.deleted_at.is_(None), + ) )).scalar_one_or_none() if ws is None or ws.user_id != user_id: raise HTTPException(status_code=404, detail="Workspace not found.") @@ -126,11 +195,14 @@ async def list_workspaces(user: User = Depends(require_user)): async with get_session() as s: result = await s.execute( select(Workspace) - .where(Workspace.user_id == user.id) + .where( + Workspace.user_id == user.id, + Workspace.deleted_at.is_(None), + ) .order_by(Workspace.updated_at.desc()) ) workspaces = result.scalars().all() - summaries = [await _summary_for(w, s) for w in workspaces] + summaries = await _summaries_for_many(list(workspaces), s) return {"workspaces": summaries} @@ -153,7 +225,17 @@ async def create_workspace(body: WorkspaceCreate, user: User = Depends(require_u ws = Workspace(user_id=user.id, name=body.name, description=body.description) s.add(ws) - await s.commit() + try: + await s.commit() + except IntegrityError: + # Concurrent create with the same (user_id, name) — two requests + # both passed the SELECT check above; the DB unique constraint + # just rejected the second. Surface as 409 instead of 500. + await s.rollback() + raise HTTPException( + status_code=409, + detail=f"Workspace named {body.name!r} already exists.", + ) await s.refresh(ws) summary = await _summary_for(ws, s) @@ -205,13 +287,14 @@ async def update_workspace( return summary -async def delete_workspace_cascade(workspace_id: uuid.UUID) -> None: - """Shared cleanup: purge Memgraph, Qdrant, Vercel Blob, then delete the - Neon row (cascading through FKs). +async def _try_side_store_cleanup(workspace_id: uuid.UUID) -> Optional[str]: + """Run the three side-store purges (Memgraph, Qdrant, Blob). - Exposed as a plain async function (not a FastAPI route) so the anon- - migration script can reuse it without needing to synthesize a fake user. - Best-effort on the side stores — a dead store never blocks the Neon delete. + Returns None on full success, or a human-readable error string on the + first failure. Each call is idempotent so repeated invocations by the + sweeper are safe: clear_workspace issues a DELETE on workspace-scoped + nodes, delete_collection 404 is swallowed, Blob prefix wipe is a + best-effort list-and-delete. """ wid_str = str(workspace_id) @@ -222,40 +305,168 @@ async def delete_workspace_cascade(workspace_id: uuid.UUID) -> None: store = MemgraphGraphStore(MEMGRAPH_URI, MEMGRAPH_USERNAME, MEMGRAPH_PASSWORD, MEMGRAPH_DATABASE) store.clear_workspace(wid_str) except Exception as exc: - logger.warning("Memgraph cleanup for ws=%s failed: %s", wid_str, exc) + return f"Memgraph: {exc}" if USE_QDRANT: try: - from src.config import QDRANT_API_KEY, QDRANT_URL + from src.config import QDRANT_API_KEY, QDRANT_SHARED_COLLECTION, QDRANT_URL from qdrant_client import QdrantClient from src.graph_builder.builder import _short_wid client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=30) - collection = f"kb-{_short_wid(wid_str)}" - try: - client.delete_collection(collection) - except Exception: - pass + if QDRANT_SHARED_COLLECTION: + # Shared-collection mode: only delete this tenant's points; + # leave everyone else alone. + try: + from qdrant_client.models import ( + FieldCondition, + Filter, + FilterSelector, + MatchValue, + ) + flt = Filter( + must=[FieldCondition( + key="workspace_id", match=MatchValue(value=wid_str) + )] + ) + client.delete( + collection_name=QDRANT_SHARED_COLLECTION, + points_selector=FilterSelector(filter=flt), + wait=True, + ) + except Exception as exc: + msg = str(exc).lower() + if "not found" not in msg and "doesn't exist" not in msg: + return f"Qdrant filter-delete: {exc}" + else: + collection = f"kb-{_short_wid(wid_str)}" + try: + client.delete_collection(collection) + except Exception as exc: + # 404 on collection is fine — means an earlier attempt + # already succeeded or the workspace was never built. + # Anything else is a real failure the sweeper retries. + msg = str(exc).lower() + if "not found" not in msg and "doesn't exist" not in msg: + return f"Qdrant delete_collection: {exc}" except Exception as exc: - logger.warning("Qdrant cleanup for ws=%s failed: %s", wid_str, exc) + return f"Qdrant client: {exc}" try: from src.infra.blob_loader import delete_workspace_blobs delete_workspace_blobs(wid_str) except Exception as exc: - logger.warning("Blob cleanup for ws=%s failed: %s", wid_str, exc) + return f"Blob: {exc}" + + return None + + +async def delete_workspace_cascade(workspace_id: uuid.UUID) -> bool: + """Soft-delete a workspace and attempt the side-store cascade. + + Flow: + 1. Set ``deleted_at = now()`` (workspace disappears from all read paths). + 2. Attempt Memgraph / Qdrant / Blob cleanup. + 3. On full success, hard-delete the Neon row (FKs cascade). + 4. On any cleanup failure, keep the soft-deleted row with the error + recorded; ``sweep_pending_deletions`` will retry until success. + + Returns True when the row was hard-deleted. False means the saga is + still pending and the sweeper will pick it up later. + """ + from datetime import datetime, timezone + + async with get_session() as s: + ws = (await s.execute( + select(Workspace).where(Workspace.id == workspace_id) + )).scalar_one_or_none() + if ws is None: + return True # already gone — idempotent + if ws.deleted_at is None: + ws.deleted_at = datetime.now(timezone.utc) + await s.commit() + + error = await _try_side_store_cleanup(workspace_id) async with get_session() as s: ws = (await s.execute( select(Workspace).where(Workspace.id == workspace_id) )).scalar_one_or_none() - if ws is not None: + if ws is None: + return True + if error is None: await s.delete(ws) await s.commit() + return True + ws.deletion_failure_count = (ws.deletion_failure_count or 0) + 1 + ws.deletion_last_error = error[:4096] + await s.commit() + logger.warning("Workspace %s cascade failed (attempt %d): %s", + workspace_id, (ws.deletion_failure_count or 0), error) + return False + + +async def sweep_pending_deletions(max_attempts: int = 20) -> int: + """Retry workspace deletions whose side-store cascade previously failed. + + Picks up any row with ``deleted_at IS NOT NULL`` and retries the + side-store cleanup. Hard-deletes rows that now succeed. Leaves rows + that have exceeded ``max_attempts`` as a permanent "tombstone" for + operator inspection (they still don't appear in the user's UI — the + ``deleted_at`` filter hides them — but the side-store artifacts need + manual review). + + Returns the number of rows hard-deleted on this sweep. + """ + from datetime import datetime, timedelta, timezone + + if not USE_NEON: + return 0 + # Only pick rows whose ``deleted_at`` is at least ~1 minute old, so the + # inline cascade from ``delete_workspace_cascade`` gets first shot and + # the sweeper isn't racing the request handler on the common path. + cutoff = datetime.now(timezone.utc) - timedelta(seconds=60) + + async with get_session() as s: + rows = (await s.execute( + select(Workspace) + .where(Workspace.deleted_at.isnot(None)) + .where(Workspace.deleted_at < cutoff) + .where(Workspace.deletion_failure_count < max_attempts) + .limit(50) + )).scalars().all() + + cleared = 0 + for ws in rows: + error = await _try_side_store_cleanup(ws.id) + async with get_session() as s: + live = (await s.execute( + select(Workspace).where(Workspace.id == ws.id) + )).scalar_one_or_none() + if live is None: + cleared += 1 + continue + if error is None: + await s.delete(live) + await s.commit() + cleared += 1 + else: + live.deletion_failure_count = (live.deletion_failure_count or 0) + 1 + live.deletion_last_error = error[:4096] + await s.commit() + if cleared: + logger.info("sweep_pending_deletions: cleared %d workspace(s)", cleared) + return cleared @router.delete("/workspaces/{workspace_id}", summary="Delete a workspace and all its artifacts") async def delete_workspace(workspace_id: uuid.UUID, user: User = Depends(require_user)): - """Ownership-checked HTTP wrapper around ``delete_workspace_cascade``.""" + """Ownership-checked HTTP wrapper around ``delete_workspace_cascade``. + + Returns 200 with ``pending: true`` if the side-store cleanup didn't + complete in the request; the sweeper finishes the job asynchronously. + The workspace is already hidden from read paths regardless — the user + sees it as gone immediately. + """ _require_neon() async with get_session() as s: ws = await _load_owned_workspace(s, workspace_id, user.id) @@ -268,8 +479,12 @@ async def delete_workspace(workspace_id: uuid.UUID, user: User = Depends(require workspace_id=workspace_id, metadata={"name": name}, ) - await delete_workspace_cascade(workspace_id) - return {"ok": True, "deleted_workspace_id": str(workspace_id)} + done = await delete_workspace_cascade(workspace_id) + return { + "ok": True, + "deleted_workspace_id": str(workspace_id), + "pending": not done, + } @router.get("/workspaces/{workspace_id}/files", summary="List files in a workspace") diff --git a/src/config.py b/src/config.py index 6d92699..eb69795 100644 --- a/src/config.py +++ b/src/config.py @@ -103,6 +103,12 @@ QDRANT_URL: str = os.environ.get("QDRANT_URL", "") QDRANT_API_KEY: str = os.environ.get("QDRANT_API_KEY", "") QDRANT_COLLECTION_NAME: str = os.environ.get("QDRANT_COLLECTION_NAME", "kb-knowledge-graph") +# When set (non-empty), all tenants share this single collection and the +# builder filters reads/deletes by a ``workspace_id`` payload field. When +# empty, the legacy one-collection-per-workspace behaviour is kept. At 1M +# tenants the shared mode is the only option that fits inside Qdrant Cloud's +# per-account collection cap. +QDRANT_SHARED_COLLECTION: str = os.environ.get("QDRANT_SHARED_COLLECTION", "") # Neo4j Aura graph database NEO4J_URI: str = os.environ.get("NEO4J_URI", "") diff --git a/src/graph_builder/builder.py b/src/graph_builder/builder.py index caaa226..59aadfe 100644 --- a/src/graph_builder/builder.py +++ b/src/graph_builder/builder.py @@ -435,16 +435,26 @@ def load(cls, workspace_id: str) -> "KnowledgeGraph": # --- Vector search backend --- pinecone_store = None - qdrant_collection = f"kb-{_short_wid(workspace_id)}" + from src.config import QDRANT_SHARED_COLLECTION as _SHARED + # Query-time collection + workspace_id pair mirrors the write path + # in ``upsert_to_qdrant``: shared mode uses the single env-configured + # collection with a workspace_id filter; legacy mode uses one + # collection per workspace. + qdrant_collection = _SHARED if _SHARED else f"kb-{_short_wid(workspace_id)}" + qdrant_ws = workspace_id if _SHARED else None if USE_QDRANT: - logger.info("Connecting to vector DB (collection %s)", qdrant_collection) + logger.info( + "Connecting to vector DB (collection %s, ws=%s)", + qdrant_collection, qdrant_ws or "", + ) from src.infra.qdrant_store import QdrantVectorStore pinecone_store = QdrantVectorStore( url=QDRANT_URL, api_key=QDRANT_API_KEY, collection_name=qdrant_collection, dimension=EMBEDDING_DIM, + workspace_id=qdrant_ws, ) elif USE_PINECONE: logger.info("Connecting to vector DB for semantic search…") diff --git a/src/graph_builder/embeddings.py b/src/graph_builder/embeddings.py index 38277e8..210af1f 100644 --- a/src/graph_builder/embeddings.py +++ b/src/graph_builder/embeddings.py @@ -301,25 +301,41 @@ def upsert_to_qdrant( self, embeddings: dict[str, list[float]], collection_name: str | None = None, + workspace_id: str | None = None, ) -> Any: """ Upsert all node embeddings into Qdrant Cloud. + When ``workspace_id`` is set AND env ``QDRANT_SHARED_COLLECTION`` + is non-empty, all tenants share one collection and this workspace's + points are filter-scoped. Otherwise the legacy per-collection mode + is used and the caller's ``collection_name`` is the tenant boundary. + Returns the QdrantVectorStore instance. """ + from src.config import QDRANT_SHARED_COLLECTION from src.infra.qdrant_store import QdrantVectorStore node_ids = self._node_ids # Dimension must match what we actually generated (not the configured default) actual_dim = len(next(iter(embeddings.values()))) if embeddings else EMBEDDING_DIM + shared_mode = bool(QDRANT_SHARED_COLLECTION) and workspace_id is not None + if shared_mode: + effective_collection = QDRANT_SHARED_COLLECTION + effective_ws = workspace_id + else: + effective_collection = collection_name or QDRANT_COLLECTION_NAME + effective_ws = None + store = QdrantVectorStore( url=QDRANT_URL, api_key=QDRANT_API_KEY, - collection_name=collection_name or QDRANT_COLLECTION_NAME, + collection_name=effective_collection, dimension=actual_dim, + workspace_id=effective_ws, ) - store.delete_namespace() # clean slate for rebuild + store.delete_namespace() # clean slate for rebuild (filter-delete in shared mode) vectors = [embeddings[nid] for nid in node_ids] metadata = [ @@ -442,7 +458,11 @@ def run_embedding_pipeline( if USE_QDRANT: logger.info("Upserting vectors to vector DB (collection=%s)…", qdrant_collection or "default") - vector_store = pipeline.upsert_to_qdrant(embeddings, collection_name=qdrant_collection) + vector_store = pipeline.upsert_to_qdrant( + embeddings, + collection_name=qdrant_collection, + workspace_id=workspace_id, + ) related_edges = pipeline.build_related_edges(embeddings, remote_store=vector_store) return embeddings, related_edges, vector_store diff --git a/src/infra/db.py b/src/infra/db.py index 330857e..8bef9b7 100644 --- a/src/infra/db.py +++ b/src/infra/db.py @@ -216,6 +216,26 @@ async def init_db() -> None: "ON build_jobs (workspace_id) " "WHERE status IN ('queued','running')" )) + # --- workspaces: delete-saga columns ------------------------------ + # ``deleted_at`` IS NULL means live; any non-null value means the + # cross-store cascade is in progress or has been retried. The HTTP + # DELETE handler sets this column first; a background sweeper + # hard-deletes the row once Memgraph + Qdrant + Blob all confirm + # cleanup. All read paths filter for ``deleted_at IS NULL``. + await conn.execute(text( + "ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ" + )) + await conn.execute(text( + "ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS " + "deletion_failure_count INTEGER NOT NULL DEFAULT 0" + )) + await conn.execute(text( + "ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS deletion_last_error TEXT" + )) + await conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_workspaces_deleted_at " + "ON workspaces (deleted_at) WHERE deleted_at IS NOT NULL" + )) # --- billing_accounts: grandfather existing users ----------------- # First-deploy protection: every ``users`` row that predates billing # would otherwise get a ``plan_tier='trial'`` row on first /me call diff --git a/src/infra/db_models.py b/src/infra/db_models.py index 3b854ef..e0c4d9c 100644 --- a/src/infra/db_models.py +++ b/src/infra/db_models.py @@ -30,6 +30,7 @@ Text, UniqueConstraint, func, + text, ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -96,6 +97,18 @@ class Workspace(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) + # Delete saga. ``DELETE /workspaces/{id}`` sets ``deleted_at`` and kicks + # off the cross-store cascade (Memgraph / Qdrant / Blob). On full success + # the row is hard-deleted. On partial failure the row persists with + # ``deleted_at`` set and ``deletion_failure_count`` incremented; a + # background sweeper retries every minute. All read paths filter + # ``deleted_at IS NULL`` so the user stops seeing the workspace the + # moment the HTTP delete returns. + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deletion_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + deletion_last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) user: Mapped[User] = relationship(back_populates="workspaces") files: Mapped[list["WorkspaceFile"]] = relationship( @@ -105,6 +118,14 @@ class Workspace(Base): __table_args__ = ( UniqueConstraint("user_id", "name", name="uq_workspace_user_name"), Index("ix_workspaces_user_id", "user_id"), + # Partial index keeps the sweeper's scan cheap: only rows currently + # mid-deletion hit the index, so at 1M workspaces the sweeper reads + # ~N-in-flight rows instead of a full table scan. + Index( + "ix_workspaces_deleted_at", + "deleted_at", + postgresql_where=text("deleted_at IS NOT NULL"), + ), ) diff --git a/src/infra/neo4j_store.py b/src/infra/neo4j_store.py index 3b806d4..8c13c56 100644 --- a/src/infra/neo4j_store.py +++ b/src/infra/neo4j_store.py @@ -18,6 +18,8 @@ import json import logging +import os +import re from typing import Any logger = logging.getLogger(__name__) @@ -25,6 +27,82 @@ _NODE_BATCH = 500 _EDGE_BATCH = 500 +# Runtime tenant-isolation guard. Multi-tenant graph correctness depends on +# every read / write / delete carrying a ``workspace_id`` filter. A forgotten +# filter leaks another tenant's graph data. The guard scans each Cypher +# string before execution: statements that read or mutate KBNode data must +# bind either ``$wid`` / ``$workspace_id`` as a parameter and reference it +# in the cypher, or be explicitly marked ``_unscoped_ok=True`` for schema +# operations (CREATE INDEX, SHOW CONSTRAINT, etc.). +# +# Default: enabled in all envs. Failing loud on missed scoping is strictly +# better than a silent cross-tenant leak; set ``GRAPH_FILTER_GUARD=0`` to +# turn it off only if a specific statement demonstrably trips a false +# positive in production and you need to ship the fix. +_GUARD_ENABLED = os.environ.get("GRAPH_FILTER_GUARD", "1") != "0" + +# Commands that touch tenant data. Anything starting with one of these +# keywords must either be scoped or marked ``_unscoped_ok``. +_TENANT_DATA_KEYWORDS = ("MATCH", "MERGE", "CREATE (", "DELETE", "DETACH DELETE") +# Schema / infra statements that legitimately run without a workspace_id. +_SCHEMA_KEYWORDS = ( + "CREATE INDEX", + "CREATE CONSTRAINT", + "DROP INDEX", + "DROP CONSTRAINT", + "SHOW", + "CALL DB.", + "CALL DBMS.", +) + + +def _assert_ws_scoped(cypher: str, params: dict | None) -> None: + """Raise on a Cypher that mutates KBNode data without a workspace_id scope. + + This is a linter, not a proof — a sufficiently obfuscated statement can + still slip through. It catches the common failure mode (someone writing + a new query method and forgetting to add ``WHERE n.workspace_id = $wid``) + which is the actual leakage risk at 1M tenants. + """ + if not _GUARD_ENABLED: + return + stripped = cypher.strip() + upper = stripped.upper() + # Skip schema-only commands. + for kw in _SCHEMA_KEYWORDS: + if upper.startswith(kw): + return + # Only assert on statements that touch tenant data. Literal CREATE + # statements without a relationship pattern (e.g. CREATE INDEX) are + # already caught above. + if not any(re.search(r"(^|\s)" + re.escape(kw), upper) for kw in _TENANT_DATA_KEYWORDS): + return + params = params or {} + has_param = "wid" in params or "workspace_id" in params + mentions_ws = ( + "WORKSPACE_ID" in upper + or "$WID" in upper + or "$WORKSPACE_ID" in upper + ) + if not (has_param and mentions_ws): + raise RuntimeError( + "Graph tenant-isolation guard tripped: cypher touches tenant " + "data without binding workspace_id. First 200 chars: " + + stripped[:200] + ) + + +def _run_scoped(session, cypher: str, **params) -> Any: + """``session.run`` wrapper that enforces the tenant-isolation guard. + + All new read / write paths should funnel through this helper. Existing + callers that pass ``_unscoped_ok=True`` opt out for schema statements. + """ + if params.pop("_unscoped_ok", False): + return session.run(cypher, **params) + _assert_ws_scoped(cypher, params) + return session.run(cypher, **params) + _TYPE_TO_LABEL: dict[str, str] = { "domain": "Domain", diff --git a/src/infra/qdrant_store.py b/src/infra/qdrant_store.py index 76d7ca1..3f68ff5 100644 --- a/src/infra/qdrant_store.py +++ b/src/infra/qdrant_store.py @@ -6,9 +6,22 @@ so it can be dropped into the existing EmbeddingPipeline with a single branch. -All vectors live in a single Qdrant collection (the "namespace" concept -from Pinecone maps to a Qdrant collection, since Qdrant has no nested -namespaces). The collection is created on first connect if absent. +Two collection modes: + +1. **Per-workspace collection (legacy)** — ``workspace_id=None`` at + construction. The whole collection is the tenant boundary; this matched + the old "one collection per kb_id" mental model. Works, but Qdrant Cloud + caps the per-account collection count (roughly the low thousands), so + at 1M workspaces the account hits a hard ceiling. +2. **Shared collection + workspace_id filter (1M-scale mode)** — constructor + receives a ``workspace_id``. Every point carries ``workspace_id`` in its + payload; queries always inject a ``Filter(workspace_id=)`` clause; + ``delete_namespace`` deletes *only* this workspace's points via filter, + leaving the shared collection intact. + +Mode is picked by the caller — ``src/graph_builder/embeddings.py`` passes +``workspace_id`` when env ``QDRANT_SHARED_COLLECTION`` is set. Both modes +coexist so operators can migrate workspace-by-workspace. """ from __future__ import annotations @@ -55,12 +68,18 @@ def __init__( collection_name: str, dimension: int = 4096, metric: str = "cosine", + workspace_id: str | None = None, ) -> None: from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams self._collection = collection_name self._dimension = dimension + # When set, enables shared-collection mode: upsert writes + # ``workspace_id`` into every point's payload and every read/ + # delete operation filters on it. When None, the whole collection + # is the tenant. + self._workspace_id = workspace_id self._client = QdrantClient(url=url, api_key=api_key, timeout=60.0) # Map friendly metric name → Qdrant Distance enum distance_map = { @@ -73,10 +92,43 @@ def __init__( self._distance = distance_map.get(metric.lower(), Distance.COSINE) self._ensure_collection() + self._ensure_workspace_payload_index() logger.info( f"QdrantVectorStore connected: collection='{collection_name}', " - f"dim={dimension}, metric={metric}" + f"dim={dimension}, metric={metric}, ws={workspace_id or ''}" + ) + + # Qdrant filters are fast only when the payload key is indexed. Create + # the index idempotently at connect time so shared-collection queries + # don't scan the whole collection at 100M+ points. + def _ensure_workspace_payload_index(self) -> None: + if self._workspace_id is None: + return + try: + from qdrant_client.models import PayloadSchemaType + self._client.create_payload_index( + collection_name=self._collection, + field_name="workspace_id", + field_schema=PayloadSchemaType.KEYWORD, + ) + except Exception as exc: + # Already-indexed raises — that's the healthy path after the + # first process to ever touch this collection. + msg = str(exc).lower() + if "already" not in msg and "exists" not in msg: + logger.warning( + "Qdrant payload-index create for workspace_id failed: %s", exc, + ) + + def _ws_filter(self): + """Return a Filter that pins reads/deletes to this workspace, or + None when running in legacy per-collection mode.""" + if self._workspace_id is None: + return None + from qdrant_client.models import FieldCondition, Filter, MatchValue + return Filter( + must=[FieldCondition(key="workspace_id", match=MatchValue(value=self._workspace_id))] ) # ------------------------------------------------------------------ @@ -126,26 +178,63 @@ def upsert( from qdrant_client.models import PointStruct total = len(node_ids) + # Shared-collection points disambiguate by both workspace_id and + # node_id: two different tenants with the same node_id must NOT + # collide on Qdrant's point id (hash of node_id). We prefix + # ``{workspace_id}:{node_id}`` for the hash so each tenant's + # vectors live at distinct point ids. + def _pid(nid: str) -> str: + if self._workspace_id is None: + return _safe_point_id(nid) + return _safe_point_id(f"{self._workspace_id}:{nid}") + + extra_payload = {"workspace_id": self._workspace_id} if self._workspace_id else {} + for start in range(0, total, _UPSERT_BATCH): end = min(start + _UPSERT_BATCH, total) points = [ PointStruct( - id=_safe_point_id(node_ids[i]), + id=_pid(node_ids[i]), vector=list(vectors[i]), - payload={**metadata[i], "node_id": node_ids[i]}, + payload={**metadata[i], "node_id": node_ids[i], **extra_payload}, ) for i in range(start, end) ] self._client.upsert(collection_name=self._collection, points=points, wait=True) - logger.info(f"Upserted {total} vectors into Qdrant collection '{self._collection}'.") + logger.info( + f"Upserted {total} vectors into Qdrant collection '{self._collection}'" + + (f" (ws={self._workspace_id})" if self._workspace_id else "") + ) def delete_namespace(self) -> None: - """Delete all vectors in the collection (clean slate before rebuild).""" + """Clean-slate reset for this tenant before a rebuild. + + Per-collection mode: drops and recreates the whole collection. + Shared-collection mode: issues a filter-delete that removes only + this workspace's points. Either way, callers can upsert a fresh + set of vectors afterward without worrying about stale IDs. + """ try: - from qdrant_client.models import Filter - self._client.delete_collection(self._collection) - self._ensure_collection() - logger.info(f"Recreated Qdrant collection '{self._collection}' (clean slate).") + if self._workspace_id is None: + self._client.delete_collection(self._collection) + self._ensure_collection() + logger.info( + f"Recreated Qdrant collection '{self._collection}' (clean slate)." + ) + else: + from qdrant_client.models import FilterSelector + flt = self._ws_filter() + if flt is None: + return + self._client.delete( + collection_name=self._collection, + points_selector=FilterSelector(filter=flt), + wait=True, + ) + logger.info( + f"Deleted workspace {self._workspace_id} vectors from shared " + f"collection '{self._collection}' (clean slate)." + ) except Exception as exc: logger.warning(f"delete_namespace failed: {exc}") @@ -161,25 +250,36 @@ def query( ) -> list[tuple[str, float]]: """Query the collection for the top-k nearest neighbours. + In shared-collection mode, a ``workspace_id`` filter is always + applied so a tenant never sees another tenant's vectors — even if + a caller forgets to pass ``filter=``. + Uses ``query_points`` (qdrant-client >= 1.10). Falls back to ``search`` for older clients. """ + ws_flt = self._ws_filter() query_fn = getattr(self._client, "query_points", None) if query_fn is not None: - resp = query_fn( - collection_name=self._collection, - query=list(vector), - limit=top_k, - with_payload=True, - ) + kwargs = { + "collection_name": self._collection, + "query": list(vector), + "limit": top_k, + "with_payload": True, + } + if ws_flt is not None: + kwargs["query_filter"] = ws_flt + resp = query_fn(**kwargs) points = resp.points else: - points = self._client.search( - collection_name=self._collection, - query_vector=list(vector), - limit=top_k, - with_payload=True, - ) + kwargs = { + "collection_name": self._collection, + "query_vector": list(vector), + "limit": top_k, + "with_payload": True, + } + if ws_flt is not None: + kwargs["query_filter"] = ws_flt + points = self._client.search(**kwargs) return [ (p.payload.get("node_id", str(p.id)) if p.payload else str(p.id), float(p.score)) for p in points @@ -202,8 +302,12 @@ def batch_query( return results def fetch(self, node_ids: list[str]) -> dict[str, list[float]]: - """Fetch raw vectors by node_id.""" - point_ids = [_safe_point_id(nid) for nid in node_ids] + """Fetch raw vectors by node_id. Shared-collection mode prefixes + the workspace_id into the hash to match the upsert path.""" + if self._workspace_id is None: + point_ids = [_safe_point_id(nid) for nid in node_ids] + else: + point_ids = [_safe_point_id(f"{self._workspace_id}:{nid}") for nid in node_ids] resp = self._client.retrieve( collection_name=self._collection, ids=point_ids, diff --git a/src/worker/build_worker.py b/src/worker/build_worker.py index e1567ec..01735ae 100644 --- a/src/worker/build_worker.py +++ b/src/worker/build_worker.py @@ -20,6 +20,7 @@ import asyncio import logging +import os import signal import threading import time @@ -151,28 +152,55 @@ def emit(self, record: logging.LogRecord) -> None: # Heartbeat loop # --------------------------------------------------------------------------- +# Heartbeat cadence. The original 2s loop was visibility-oriented (UI sees +# log_tail update quickly) but at 500 concurrent builds = 250 writes/s to +# a single ``build_jobs`` row-set — enough to starve other writes on small +# Neon plans. Default bumped to 10s (env-tunable) and writes now only go to +# Neon when state has actually changed since the last tick — steady-state +# "still running stage 4" doesn't touch the DB at all. Zombie detection +# timeout stays at 90s so a 10s heartbeat is still ~9x safety margin. +_HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("BUILD_HEARTBEAT_INTERVAL_S", "10")) + + async def _heartbeat_loop( job_id: str, state: _WorkerLogState, stop: asyncio.Event, - interval_seconds: float = 2.0, + interval_seconds: float = _HEARTBEAT_INTERVAL_SECONDS, ) -> None: """Stream log_tail + liveness to Neon every ``interval_seconds``. Runs on the worker's main asyncio loop alongside the ``to_thread`` - build. Exits cleanly when ``stop`` is set. + build. Exits cleanly when ``stop`` is set. Only writes when the + in-memory snapshot has changed since the last successful write — this + caps steady-state DB writes per active build to O(N distinct stages), + not O(uptime/interval). """ + last_snapshot: tuple | None = None try: while not stop.is_set(): try: - log_tail, stage, stage_name, percent = state.snapshot() - await build_queue.heartbeat( - job_id=job_id, - log_tail=log_tail, - stage=stage, - stage_name=stage_name, - percent=percent, - ) + snap = state.snapshot() + # Snapshot is (log_tail_list, stage, stage_name, percent). + # Convert log_tail (mutable) to a tuple for fast compare. + log_tail, stage, stage_name, percent = snap + cmp_key = (tuple(log_tail), stage, stage_name, percent) + if cmp_key != last_snapshot: + await build_queue.heartbeat( + job_id=job_id, + log_tail=log_tail, + stage=stage, + stage_name=stage_name, + percent=percent, + ) + last_snapshot = cmp_key + else: + # Nothing changed — still need the liveness side-effect + # so the sweeper doesn't think we're dead. The cheap + # liveness-only write bumps heartbeat_at without touching + # log_tail / stage columns, so a partial index on + # ``status='running'`` can be exploited. + await build_queue.touch_heartbeat(job_id=job_id) except Exception as exc: # A failed heartbeat is not fatal — the sweeper will pick # up the job if the failure persists past HEARTBEAT_TIMEOUT. @@ -257,6 +285,22 @@ def schedule_stage_update(stage: int, stage_name: str, percent: int) -> None: final_stats = _merge_metrics_into_stats(kg_stats, metrics) domain_snap = _LAST_BUILD_RESULT.get("domain_snapshot") graph_snap = _LAST_BUILD_RESULT.get("graph_snapshot") + + # Cross-store post-build verification. `_run_sync_build` returning + # cleanly means no exception was raised, but an orchestration that + # silently skips a backend (e.g. Qdrant timeout swallowed upstream) + # would still get us here with partial state. Re-check the expected + # invariants; if any fail, treat this like a build failure so the + # UI + sweeper see the correct status and retry semantics. + verification_error = await asyncio.to_thread( + _verify_build_stores, + job.workspace_id, + final_stats, + job.skip_embeddings, + ) + if verification_error: + raise RuntimeError(f"post-build verification failed: {verification_error}") + log_tail, _s, _sn, _p = state.snapshot() state.append(f"[job {job.job_id}] build complete") @@ -340,6 +384,123 @@ def schedule_stage_update(stage: int, stage_name: str, percent: int) -> None: _LAST_BUILD_RESULT: dict = {} +def _verify_build_stores( + workspace_id: str, + stats: Optional[dict], + skip_embeddings: bool, +) -> Optional[str]: + """Assert the side stores actually hold what the build thinks it wrote. + + Runs after ``_run_sync_build`` returns successfully. Returns an error + string if any check fails; returns None if everything lines up. + + Two invariants are cheap to verify and cover the common silent-drift + failure modes: + + - Memgraph node count for the workspace is > 0 (graph actually wrote). + - Qdrant collection has vectors if embeddings were requested (Qdrant + accepted the upsert; wasn't silently skipped by a timeout upstream). + + Runs in a worker thread (``asyncio.to_thread``) so the network calls to + Memgraph + Qdrant don't block the event loop. + """ + from src.config import USE_MEMGRAPH, USE_NEO4J, USE_QDRANT + + expected_nodes = int((stats or {}).get("total_nodes") or 0) + + if USE_MEMGRAPH: + try: + from src.config import ( + MEMGRAPH_DATABASE, + MEMGRAPH_PASSWORD, + MEMGRAPH_URI, + MEMGRAPH_USERNAME, + ) + from src.infra.memgraph_store import MemgraphGraphStore + + store = MemgraphGraphStore( + MEMGRAPH_URI, MEMGRAPH_USERNAME, MEMGRAPH_PASSWORD, MEMGRAPH_DATABASE + ) + ws_stats = store.stats(workspace_id=workspace_id) + actual_nodes = int(ws_stats.get("total_nodes") or 0) + except Exception as exc: + return f"Memgraph verification call failed: {exc}" + if expected_nodes > 0 and actual_nodes == 0: + return ( + f"Memgraph reports 0 nodes for workspace {workspace_id} " + f"but build stats claim {expected_nodes}" + ) + elif USE_NEO4J: + # Same check, Neo4j variant. Kept separate so neither driver is + # imported when the other is configured. + try: + from src.config import NEO4J_DATABASE, NEO4J_PASSWORD, NEO4J_URI, NEO4J_USERNAME + from src.infra.neo4j_store import Neo4jGraphStore + + store = Neo4jGraphStore(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, NEO4J_DATABASE) + ws_stats = store.stats(workspace_id=workspace_id) + actual_nodes = int(ws_stats.get("total_nodes") or 0) + except Exception as exc: + return f"Neo4j verification call failed: {exc}" + if expected_nodes > 0 and actual_nodes == 0: + return ( + f"Neo4j reports 0 nodes for workspace {workspace_id} " + f"but build stats claim {expected_nodes}" + ) + + if USE_QDRANT and not skip_embeddings and expected_nodes > 0: + try: + from src.config import ( + QDRANT_API_KEY, + QDRANT_SHARED_COLLECTION, + QDRANT_URL, + ) + from qdrant_client import QdrantClient + from src.graph_builder.builder import _short_wid + + client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY, timeout=30) + if QDRANT_SHARED_COLLECTION: + # Shared-collection mode: count points filtered by + # workspace_id instead of relying on collection-level total. + try: + from qdrant_client.models import ( + FieldCondition, + Filter, + MatchValue, + ) + flt = Filter(must=[FieldCondition( + key="workspace_id", match=MatchValue(value=workspace_id) + )]) + resp = client.count( + collection_name=QDRANT_SHARED_COLLECTION, + count_filter=flt, + exact=True, + ) + vectors_count = int(getattr(resp, "count", 0) or 0) + collection = QDRANT_SHARED_COLLECTION + except Exception as exc: + return ( + f"Qdrant filtered count on '{QDRANT_SHARED_COLLECTION}' " + f"failed: {exc}" + ) + else: + collection = f"kb-{_short_wid(workspace_id)}" + try: + info = client.get_collection(collection) + vectors_count = int(getattr(info, "points_count", 0) or 0) + except Exception as exc: + return f"Qdrant collection '{collection}' missing after build: {exc}" + except Exception as exc: + return f"Qdrant verification call failed: {exc}" + if vectors_count == 0: + return ( + f"Qdrant collection '{collection}' has 0 points after build " + f"(expected ~{expected_nodes})" + ) + + return None + + async def _debit_build_completion( job: ClaimedJob, stats: dict, @@ -535,6 +696,12 @@ async def worker_main() -> None: # plenty since the sweeper is idempotent (dedupe by workspace_id+date). last_storage_sweep_ts = 0.0 storage_sweep_interval = 30 * 60.0 # 30 minutes + # Workspace-deletion saga retries. Picks up soft-deleted workspaces + # whose Memgraph/Qdrant/Blob cleanup failed on the inline attempt and + # retries the cascade. 60s interval balances "fast enough that orphans + # clear quickly" against "don't hammer side stores during an outage." + last_deletion_sweep_ts = 0.0 + deletion_sweep_interval = 60.0 while not _draining: async with _build_semaphore: @@ -576,6 +743,19 @@ async def worker_main() -> None: logger.warning("storage sweep failed: %s", exc) last_storage_sweep_ts = now + # Workspace-deletion saga retries. Idempotent — if the side stores + # are healthy we clear a few rows per tick; if an upstream is + # throwing we leave rows alone and try again next sweep. + if now - last_deletion_sweep_ts >= deletion_sweep_interval: + try: + from src.api.workspace_routes import sweep_pending_deletions + cleared = await sweep_pending_deletions() + if cleared: + logger.info("deletion sweep cleared %d workspace(s)", cleared) + except Exception as exc: + logger.warning("deletion sweep failed: %s", exc) + last_deletion_sweep_ts = now + try: await asyncio.sleep(CLAIM_POLL_INTERVAL_SECONDS) except asyncio.CancelledError: From 0ffa67f5eb827dd9f10437eaa915af176fff3781 Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 12:34:03 +0530 Subject: [PATCH 2/8] feat: update Alembic environment for async migrations - Refactored Alembic environment to support asyncpg driver for migrations, eliminating the need for a separate sync driver. - Updated URL handling to ensure compatibility with asyncpg, including adjustments for libpq-only parameters. - Introduced an async migration execution function to streamline online migrations using the asyncpg engine. --- alembic/env.py | 74 ++++++++++++++--------- alembic/versions/9b258738389c_baseline.py | 28 +++++++++ 2 files changed, 74 insertions(+), 28 deletions(-) create mode 100644 alembic/versions/9b258738389c_baseline.py diff --git a/alembic/env.py b/alembic/env.py index 9363a3b..054839d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,20 +1,20 @@ """Alembic environment. Reads DATABASE_URL from ``src.config`` so operators don't have to duplicate -secrets between ``.env`` and ``alembic.ini``. Normalises any ``postgres://`` -scheme to psycopg-driver form at migration time; the runtime engine still -uses asyncpg (see ``src/infra/db.py``), but Alembic defaults to the sync -driver so autogenerate can introspect the database schema cheaply. +secrets between ``.env`` and ``alembic.ini``. Runs migrations via the same +asyncpg driver the runtime uses — no second Postgres DBAPI dependency. """ from __future__ import annotations +import asyncio import os import sys from logging.config import fileConfig from alembic import context -from sqlalchemy import engine_from_config, pool +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config # Make ``src`` importable when alembic is invoked from repo root. _HERE = os.path.dirname(os.path.abspath(__file__)) @@ -31,23 +31,20 @@ fileConfig(config.config_file_name) -def _sync_url(url: str) -> str: - """Rewrite an async-driver URL to the sync psycopg driver for Alembic. - - Alembic's autogenerate path expects a sync engine; runtime async is - handled elsewhere. Strip libpq-only params that asyncpg didn't like but - psycopg handles fine (sslmode etc.) — leave them in the URL. - """ +def _async_url(url: str) -> str: + """Ensure the URL uses the asyncpg driver that the runtime engine uses.""" if not url: return url if url.startswith("postgresql+asyncpg://"): - return "postgresql://" + url[len("postgresql+asyncpg://"):] + return url + if url.startswith("postgresql://"): + return "postgresql+asyncpg://" + url[len("postgresql://"):] if url.startswith("postgres://"): - return "postgresql://" + url[len("postgres://"):] + return "postgresql+asyncpg://" + url[len("postgres://"):] return url -_RESOLVED_URL = _sync_url(DATABASE_URL) +_RESOLVED_URL = _async_url(DATABASE_URL) if _RESOLVED_URL: config.set_main_option("sqlalchemy.url", _RESOLVED_URL) @@ -69,23 +66,44 @@ def run_migrations_offline() -> None: context.run_migrations() -def run_migrations_online() -> None: - """Run migrations in 'online' mode against a live connection.""" - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), +def do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online_async() -> None: + """Run migrations in 'online' mode using an asyncpg-backed engine.""" + # asyncpg doesn't understand libpq-only query params. + import re + cfg = config.get_section(config.config_ini_section, {}) + url = cfg.get("sqlalchemy.url", "") + for key in ("sslmode", "channel_binding", "pgbouncer"): + url = re.sub(rf"[?&]{key}=[^&]*", "", url) + if url.endswith("?"): + url = url[:-1] + cfg["sqlalchemy.url"] = url + # Neon requires SSL — match the runtime engine's hint. + connect_args = {"ssl": "require"} if "neon.tech" in url else {} + + connectable = async_engine_from_config( + cfg, prefix="sqlalchemy.", poolclass=pool.NullPool, + connect_args=connect_args, ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=target_metadata, - compare_type=True, - compare_server_default=True, - ) - with context.begin_transaction(): - context.run_migrations() +def run_migrations_online() -> None: + asyncio.run(run_migrations_online_async()) if context.is_offline_mode(): diff --git a/alembic/versions/9b258738389c_baseline.py b/alembic/versions/9b258738389c_baseline.py new file mode 100644 index 0000000..cea7d78 --- /dev/null +++ b/alembic/versions/9b258738389c_baseline.py @@ -0,0 +1,28 @@ +"""baseline + +Revision ID: 9b258738389c +Revises: +Create Date: 2026-04-23 12:30:11.143168 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9b258738389c' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From 8f3f83c9670517f788391952563c497ba9336e7e Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 12:39:57 +0530 Subject: [PATCH 3/8] feat: enhance templates page and template card UI - Removed unused Card component imports to streamline the code. - Updated the search input placeholder for better clarity on search capabilities. - Improved the layout of the templates page with a more organized structure and spacing. - Enhanced loading states with skeleton components for a smoother user experience. - Refined the TemplateCard component to improve accessibility and interaction, including keyboard navigation support. - Adjusted styling for better visual feedback on hover and focus states. --- frontend/app/templates/page.tsx | 66 +++--- .../components/templates/template-card.tsx | 197 +++++++++++------- 2 files changed, 155 insertions(+), 108 deletions(-) diff --git a/frontend/app/templates/page.tsx b/frontend/app/templates/page.tsx index 28b17e6..b1bfd79 100644 --- a/frontend/app/templates/page.tsx +++ b/frontend/app/templates/page.tsx @@ -9,7 +9,6 @@ import { errorMessage } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TemplateCard } from "@/components/templates/template-card"; import { TemplateFormDialog } from "@/components/templates/template-form-dialog"; import { @@ -143,21 +142,23 @@ export default function TemplatesPage() { -
-
+
+
setQuery(e.target.value)} - placeholder="Search templates…" - className="pl-9" + placeholder="Search templates by name, domain, or category…" + className="pl-9 h-10" />
)} + {!templatesQuery.isLoading && ( +

+ {all.length} template{all.length === 1 ? "" : "s"} + {category ? ` in ${category}` : ""} + {query ? ` matching "${query}"` : ""} +

+ )} + {templatesQuery.isLoading ? ( -
- {[0, 1, 2, 3, 4, 5].map((i) => ( - - - - - +
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( +
+ +
+ - - -
- - -
- -
- +
+ + +
))}
) : all.length === 0 ? ( -

- No templates match that search. -

+
+

No templates match that search.

+

+ Try a different keyword or clear the category filter. +

+
) : ( -
+
{all.map((t) => ( >)[name] ?? Icons.Folder; - return ; + return ; } +/** + * OpenRouter-style search result row. + * + * Dense horizontal layout — icon, title + slug, description, meta chips, + * action button. The whole row is clickable (primary action = instantiate); + * hover reveals edit / delete icons for custom templates. Intended to be + * stacked vertically as ``space-y-2`` inside the templates page. + */ export function TemplateCard({ template, onUse, onEdit, onDelete, busy }: Props) { const isCustom = template.source === "custom"; const canEdit = template.editable && Boolean(onEdit); const canDelete = template.editable && Boolean(onDelete); + const identifier = template.slug || `custom · ${template.domain.domain_name || "—"}`; return ( - - -
-
- -
-
-

{template.name}

- {template.slug ? ( -

- {template.slug} -

- ) : ( -

- custom · {template.domain.domain_name || "—"} -

- )} -
-
- {isCustom && ( - - yours - - )} - - {template.category} +
!busy && onUse(template)} + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === " ") && !busy) { + e.preventDefault(); + onUse(template); + } + }} + className={cn( + "group relative flex items-center gap-4 rounded-lg border bg-card px-4 py-3", + "transition-all duration-150 cursor-pointer", + "hover:border-foreground/25 hover:bg-accent/30 hover:shadow-sm", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + busy && "opacity-60 pointer-events-none", + )} + > +
+ +
+ +
+
+

{template.name}

+ + {identifier} + + {isCustom && ( + + yours -
+ )}
- -

+

{template.description}

+
-
- +
+ + {template.category} + +
- {(canEdit || canDelete) && ( -
- {canEdit && ( - - - - - Edit - - )} - {canDelete && ( - - - - - Delete - - )} -
+
+ {(canEdit || canDelete) && ( +
+ {canEdit && ( + + + + + Edit + + )} + {canDelete && ( + + + + + Delete + + )} +
+ )} +
- - + +
+
); } From 766a3a089a44f70c6de631b62647983411e1890e Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 14:05:36 +0530 Subject: [PATCH 4/8] feat: implement developer API key management and enhance OpenAPI documentation - Added new ApiKey model for managing developer API keys, including user and workspace associations. - Updated database initialization to create necessary indexes for efficient key lookups. - Enhanced FastAPI application to include separate routers for developer API key management and public endpoints. - Customized OpenAPI documentation to reflect security requirements for the public API surface. --- src/api/api_key_auth.py | 233 +++++++++++++++++ src/api/ext_models.py | 236 ++++++++++++++++++ src/api/ext_routes.py | 521 +++++++++++++++++++++++++++++++++++++++ src/api/keys_routes.py | 238 ++++++++++++++++++ src/api/query_service.py | 213 ++++++++++++++++ src/api/routes.py | 174 +------------ src/api/server.py | 61 +++++ src/infra/db.py | 9 + src/infra/db_models.py | 67 +++++ src/infra/rate_limit.py | 185 ++++++++++++++ 10 files changed, 1775 insertions(+), 162 deletions(-) create mode 100644 src/api/api_key_auth.py create mode 100644 src/api/ext_models.py create mode 100644 src/api/ext_routes.py create mode 100644 src/api/keys_routes.py create mode 100644 src/api/query_service.py create mode 100644 src/infra/rate_limit.py diff --git a/src/api/api_key_auth.py b/src/api/api_key_auth.py new file mode 100644 index 0000000..f197079 --- /dev/null +++ b/src/api/api_key_auth.py @@ -0,0 +1,233 @@ +""" +Developer API-key authentication for the /api/v1/ext/* surface. + +Layered on top of the JWT auth in ``src.api.auth`` — same bearer header, +different secret format. Keys look like ``og_live_<32-char-base64urlsafe>``; +the ``og_live_`` prefix distinguishes them from JWTs (eyJ…) so the same +``Authorization: Bearer`` header can carry either. + +Hot-path flow: + + Authorization: Bearer og_live_… + ─► _extract_api_key() parse prefix + secret + ─► _cache_get() in-proc LRU (5 min TTL) + ─► _load_key_from_db() fallback Neon SELECT by prefix (partial index) + ─► bcrypt verify constant-time hash check + ─► update last_used_at fire-and-forget, doesn't block the request + ─► return ApiKeyContext user_id, workspace_id, key_id, scopes + +The cache eliminates the bcrypt + DB round-trip after the first use of a +key; a 5-min TTL means a revocation is effective within that window. +""" + +from __future__ import annotations + +import logging +import os +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +import bcrypt +from fastapi import Depends, Header, HTTPException +from sqlalchemy import select, update + +from src.infra.db import fire_and_forget, get_session +from src.infra.db_models import ApiKey + +logger = logging.getLogger(__name__) + +_KEY_MARKER = "og_live_" +_PREFIX_LEN = len(_KEY_MARKER) + 8 # og_live_ + first 8 chars of the secret + + +@dataclass(frozen=True) +class ApiKeyContext: + """The verified identity for a /ext/* request. + + ``workspace_id`` is Optional — an account-scoped key (workspace_id=None + at creation) can target any workspace owned by ``user_id``; the ext + routes still verify ownership on every call. A workspace-scoped key + pins one workspace and any request referencing a different workspace + id returns 404. + """ + key_id: uuid.UUID + user_id: uuid.UUID + workspace_id: Optional[uuid.UUID] + scopes: list[str] + rate_limit_rpm: int + + +# --------------------------------------------------------------------------- +# Cache (prefix -> (issued_at, ApiKeyContext)) +# --------------------------------------------------------------------------- + +_CACHE_TTL_SECONDS = int(os.environ.get("API_KEY_CACHE_TTL_SECONDS", "300")) +# 8192 distinct keys per worker is ample — a user with 100 keys making +# 100 RPS each fits in-cache forever, and cold starts only cost one +# bcrypt verify + SELECT per key. +_CACHE_MAX = int(os.environ.get("API_KEY_CACHE_MAX", "8192")) +_cache: dict[str, tuple[float, ApiKeyContext]] = {} + + +def _cache_get(prefix: str) -> Optional[ApiKeyContext]: + entry = _cache.get(prefix) + if entry is None: + return None + ts, ctx = entry + if time.monotonic() - ts > _CACHE_TTL_SECONDS: + _cache.pop(prefix, None) + return None + return ctx + + +def _cache_put(prefix: str, ctx: ApiKeyContext) -> None: + if len(_cache) >= _CACHE_MAX: + oldest = min(_cache.items(), key=lambda kv: kv[1][0])[0] + _cache.pop(oldest, None) + _cache[prefix] = (time.monotonic(), ctx) + + +def invalidate_api_key_cache(prefix: Optional[str] = None) -> None: + """Drop a single key (or the whole cache) — call on revoke / update.""" + if prefix is None: + _cache.clear() + else: + _cache.pop(prefix, None) + + +# --------------------------------------------------------------------------- +# Header parsing +# --------------------------------------------------------------------------- + +def _extract_api_key(authorization: Optional[str]) -> Optional[str]: + """Pull ``og_live_…`` out of an Authorization: Bearer header. + + Returns None for any header that isn't an API key — including valid + JWTs, which start with ``eyJ`` not ``og_live_``. This lets the same + header carry either credential type and lets the caller decide + which auth dep to invoke. + """ + if not authorization: + return None + if not authorization.lower().startswith("bearer "): + return None + raw = authorization[7:].strip() + if not raw.startswith(_KEY_MARKER): + return None + return raw + + +def _prefix_of(plaintext: str) -> str: + """Slice the first PREFIX_LEN chars — this is the indexed lookup key.""" + return plaintext[:_PREFIX_LEN] + + +# --------------------------------------------------------------------------- +# DB path +# --------------------------------------------------------------------------- + +async def _load_key_from_db(prefix: str) -> Optional[ApiKey]: + """Look up a live (non-revoked) key row by prefix. + + Hits the partial index ``ix_api_keys_prefix_live`` (prefix + revoked_at + IS NULL), so a prefix collision between a live and a revoked key + doesn't slow this down. + """ + async with get_session() as s: + r = (await s.execute( + select(ApiKey).where( + ApiKey.prefix == prefix, + ApiKey.revoked_at.is_(None), + ) + )).scalar_one_or_none() + return r + + +async def _touch_last_used(key_id: uuid.UUID) -> None: + """Update ``last_used_at`` in a background task. Errors are swallowed + — this is telemetry, not correctness.""" + try: + async with get_session() as s: + await s.execute( + update(ApiKey) + .where(ApiKey.id == key_id) + .values(last_used_at=datetime.now(timezone.utc)) + ) + await s.commit() + except Exception as exc: + logger.debug("api_key last_used_at update failed for %s: %s", key_id, exc) + + +# --------------------------------------------------------------------------- +# Public dependency +# --------------------------------------------------------------------------- + +_UNAUTH_DETAIL = "Invalid or missing API key. Use Authorization: Bearer og_live_…" + + +async def require_api_key( + authorization: Optional[str] = Header(default=None), +) -> ApiKeyContext: + """FastAPI dependency — require a valid API key on the request. + + Accepts ``Authorization: Bearer og_live_`` only. Any other + bearer value (JWT, missing header, wrong marker) raises 401 so a + caller wiring this dep in place of ``require_user`` gets a clear + error instead of silent anonymous access. + """ + plaintext = _extract_api_key(authorization) + if plaintext is None: + raise HTTPException(status_code=401, detail=_UNAUTH_DETAIL) + + prefix = _prefix_of(plaintext) + cached = _cache_get(prefix) + if cached is not None: + # Fire-and-forget last_used bump — don't await; don't want to pay + # a round-trip on the hot path. + fire_and_forget(_touch_last_used(cached.key_id)) + return cached + + row = await _load_key_from_db(prefix) + if row is None: + raise HTTPException(status_code=401, detail=_UNAUTH_DETAIL) + + # Constant-time bcrypt compare — plaintext must match the stored hash. + try: + ok = bcrypt.checkpw(plaintext.encode("utf-8"), row.key_hash.encode("utf-8")) + except Exception as exc: + logger.warning("bcrypt check failed for key prefix=%s: %s", prefix, exc) + raise HTTPException(status_code=401, detail=_UNAUTH_DETAIL) + if not ok: + raise HTTPException(status_code=401, detail=_UNAUTH_DETAIL) + + ctx = ApiKeyContext( + key_id=row.id, + user_id=row.user_id, + workspace_id=row.workspace_id, + scopes=list(row.scopes or []), + rate_limit_rpm=int(row.rate_limit_rpm or 60), + ) + _cache_put(prefix, ctx) + fire_and_forget(_touch_last_used(row.id)) + return ctx + + +def require_scope(*required: str): + """Sugar for routes that need a specific scope. Not used in v1 (all + ext routes use the default ``ext:read``), but wired up so new scopes + can be enforced with a single decorator change. + """ + required_set = set(required) + + async def _dep(ctx: ApiKeyContext = Depends(require_api_key)) -> ApiKeyContext: + if not required_set.issubset(set(ctx.scopes)): + raise HTTPException( + status_code=403, + detail=f"API key missing required scope(s): {', '.join(sorted(required_set))}", + ) + return ctx + + return _dep diff --git a/src/api/ext_models.py b/src/api/ext_models.py new file mode 100644 index 0000000..9502d69 --- /dev/null +++ b/src/api/ext_models.py @@ -0,0 +1,236 @@ +""" +Public (third-party-safe) request + response models for /api/v1/ext/*. + +Why a parallel set of models instead of reusing ``QueryResponse``: + + - The internal ``QueryResponse`` carries three ``list[dict[str, Any]]`` + escape fields — ``steps``, ``tools_referenced``, ``knowledge_concepts`` + — plus a ``traversal_path`` of raw internal node IDs. These shapes + change as the LangGraph agent evolves; locking them into an SDK + contract would force breaking changes every time the agent does. + - Third parties shouldn't see internal reasoning steps by default; the + SDK surface is "question in, answer out, plus optional structured + metadata." Debug-mode is opt-in via ``debug=true``. + - We want RFC 7807 problem-details for 4xx/5xx errors so SDK error + handling is predictable across languages. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Query +# --------------------------------------------------------------------------- + +class ExtQueryRequest(BaseModel): + """Public query request. A strict subset of :class:`QueryRequest`.""" + + workspace_id: str = Field(description="Target workspace UUID. Must be owned by the API key's owner.") + query: str = Field(description="Natural-language question for the graph.") + session_id: Optional[str] = Field( + default=None, + description=( + "Continue an existing chat session. Omit to start a new one — the " + "response's ``session_id`` is the handle to reuse on follow-up calls." + ), + ) + llm_model: Optional[str] = Field( + default=None, + description=( + "Override the LLM model for this single call. Must be a valid " + "OpenRouter model id. Falls back to the workspace default when omitted." + ), + ) + debug: bool = Field( + default=False, + description=( + "When true, the response includes the internal agent reasoning " + "steps and traversal path. Shape is not guaranteed stable across " + "releases — intended for debugging integrations." + ), + ) + + +class ExtChatUsage(BaseModel): + """Per-turn LLM token usage. Matches the internal shape one-for-one.""" + llm_prompt_tokens: int = 0 + llm_completion_tokens: int = 0 + llm_total_tokens: int = 0 + llm_calls: int = 0 + model: Optional[str] = None + + +class ExtQueryResponse(BaseModel): + """Public query response. Stable across v1.""" + + session_id: str = Field(description="Handle to continue this conversation; pass on follow-up calls.") + response: str = Field(description="The assistant's answer, as markdown.") + intent: str = Field(description="Classified user intent (e.g. 'how_to', 'lookup').") + kb_focus: str = Field( + description="Which KB the agent focused on: 'knowledge' | 'tool' | 'both'." + ) + extracted_topics: list[str] = Field(default_factory=list) + follow_up_suggestions: list[str] = Field(default_factory=list) + llm_model: Optional[str] = Field( + default=None, + description="The resolved OpenRouter model id that actually answered.", + ) + usage: Optional[ExtChatUsage] = Field( + default=None, + description="Token usage for this turn. Absent when no billable LLM call happened.", + ) + duration_ms: int = Field(description="Server-measured wall time for the turn.") + history_persisted: bool = Field( + default=True, + description=( + "True if the turn was saved to chat history. When false the answer " + "was produced but the DB write failed — retry the request with the " + "same session_id to reconcile." + ), + ) + + # Debug-only fields. Populated when the request had ``debug=true``. + # Shape is ``list[dict[str, Any]]`` deliberately — these fields exist + # for troubleshooting, not for stable downstream consumption. + steps: Optional[list[dict[str, Any]]] = Field( + default=None, + description="Internal agent reasoning steps. Only present when ``debug=true`` was set.", + ) + traversal_path: Optional[list[str]] = Field( + default=None, + description="Node ids the agent visited. Only present when ``debug=true`` was set.", + ) + knowledge_concepts: Optional[list[dict[str, Any]]] = Field( + default=None, + description="Knowledge concepts surfaced mid-reasoning. Only present when ``debug=true`` was set.", + ) + tools_referenced: Optional[list[dict[str, Any]]] = Field( + default=None, + description="Tool nodes referenced mid-reasoning. Only present when ``debug=true`` was set.", + ) + + +# --------------------------------------------------------------------------- +# Graph reads +# --------------------------------------------------------------------------- + +class ExtGraphStats(BaseModel): + total_nodes: int = 0 + total_edges: int = 0 + nodes_by_type: dict[str, int] = Field(default_factory=dict) + edges_by_type: dict[str, int] = Field(default_factory=dict) + backends: dict[str, Any] = Field(default_factory=dict) + + +class ExtSearchResult(BaseModel): + node_id: str + node_type: str + heading: str + kb_source: str + score: float + + +class ExtSearchResponse(BaseModel): + query: str + results: list[ExtSearchResult] + + +class ExtNodeDetail(BaseModel): + node: dict[str, Any] + edges: list[dict[str, Any]] + children: list[dict[str, Any]] + parent: Optional[str] = None + + +class ExtTraverseRequest(BaseModel): + workspace_id: str + node_id: str + max_depth: int = Field(default=3, ge=1, le=10) + edge_types: Optional[list[str]] = None + + +class ExtTraverseResponse(BaseModel): + root_node_id: str + traversal_path: list[str] + nodes: list[dict[str, Any]] + edge_count: int + + +# --------------------------------------------------------------------------- +# History +# --------------------------------------------------------------------------- + +class ExtChatSessionSummary(BaseModel): + session_id: str + title: Optional[str] = None + message_count: int = 0 + created_at: datetime + last_activity_at: datetime + + +class ExtChatSessionsResponse(BaseModel): + sessions: list[ExtChatSessionSummary] + + +class ExtChatMessage(BaseModel): + id: int + role: str + query: Optional[str] = None + response: Optional[dict[str, Any]] = None + intent: Optional[str] = None + kb_focus: Optional[str] = None + created_at: datetime + duration_ms: int = 0 + + +class ExtChatSessionDetail(BaseModel): + session_id: str + workspace_id: str + title: Optional[str] = None + messages: list[ExtChatMessage] + + +class ExtBuildJobSummary(BaseModel): + job_id: str + workspace_id: str + status: str + stage: int + stage_name: str + percent: int + created_at: datetime + finished_at: Optional[datetime] = None + + +class ExtBuildJobsResponse(BaseModel): + builds: list[ExtBuildJobSummary] + + +# --------------------------------------------------------------------------- +# RFC 7807 problem-details error envelope +# --------------------------------------------------------------------------- + +class ProblemDetails(BaseModel): + """RFC 7807 error body used for every 4xx/5xx response on /ext/*. + + Example:: + + { + "type": "https://opengraph.example/errors/rate-limit", + "title": "Too Many Requests", + "status": 429, + "detail": "Rate limit exceeded — 60 requests per minute.", + "request_id": "01HX…", + "retry_after_seconds": 42 + } + """ + type: str = "about:blank" + title: str + status: int + detail: Optional[str] = None + request_id: Optional[str] = None + retry_after_seconds: Optional[int] = None diff --git a/src/api/ext_routes.py b/src/api/ext_routes.py new file mode 100644 index 0000000..9f2d776 --- /dev/null +++ b/src/api/ext_routes.py @@ -0,0 +1,521 @@ +""" +Public developer API at /api/v1/ext/*. + +Auth model: + Authorization: Bearer og_live_ # API key + (JWT tokens are rejected — use the internal /api/v1/* endpoints for + browser sessions.) + +Every route depends on :func:`rate_limit_per_key`, which itself depends on +:func:`require_api_key` — so the dependency chain is: + + HTTP request + ─► require_api_key 401 if bad key + ─► rate_limit_per_key 429 if over quota, otherwise returns ApiKeyContext + ─► _resolve_workspace verifies the caller owns the target workspace + ─► internal helper _get_kg, kg.search, etc. + +Workspace ownership check: the API key either pins a workspace +(``ctx.workspace_id`` set) or allows any workspace the key's ``user_id`` +owns. Either way, the target workspace uuid is resolved once per request +and verified before touching any side store. + +Response shapes live in :mod:`src.api.ext_models`. They deliberately strip +or hide internal-only fields so the SDK surface stays stable as the +LangGraph agent evolves behind the scenes. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select + +from src.api.api_key_auth import ApiKeyContext +from src.api.ext_models import ( + ExtBuildJobSummary, + ExtBuildJobsResponse, + ExtChatMessage, + ExtChatSessionDetail, + ExtChatSessionSummary, + ExtChatSessionsResponse, + ExtChatUsage, + ExtGraphStats, + ExtNodeDetail, + ExtQueryRequest, + ExtQueryResponse, + ExtSearchResponse, + ExtSearchResult, + ExtTraverseRequest, + ExtTraverseResponse, +) +from src.api.query_service import run_query +from src.api.routes import ( + ChatUsage, + QueryRequest, + QueryResponse, + _get_kg, +) +from src.config import USE_NEON +from src.infra.db import get_session +from src.infra.db_models import ( + BuildJobRow, + ChatMessage, + ChatSession, + Workspace, +) +from src.infra.rate_limit import rate_limit_per_key + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ext", tags=["ext"]) + + +# --------------------------------------------------------------------------- +# Workspace resolution — shared by every /ext route that needs a workspace +# --------------------------------------------------------------------------- + +async def _resolve_workspace( + ctx: ApiKeyContext, + requested_workspace_id: Optional[str], +) -> uuid.UUID: + """Pick the target workspace UUID for this request and verify ownership. + + Resolution rules: + - If the key is pinned to a specific workspace (``ctx.workspace_id`` + is not None), the caller may omit the header/body field entirely; + we use the pinned value. If they also pass a different value, + we 404 rather than silently override — avoids subtle surprise. + - If the key is account-scoped (``ctx.workspace_id`` is None), the + caller MUST pass a ``workspace_id`` and we verify it's owned by + ``ctx.user_id``. Non-owned workspaces return 404 (not 403) to + avoid existence leakage — matches the JWT path. + """ + if ctx.workspace_id is not None: + pinned = ctx.workspace_id + if requested_workspace_id and requested_workspace_id.lower() != str(pinned).lower(): + raise HTTPException( + status_code=404, + detail="Workspace not found.", + ) + return pinned + + if not requested_workspace_id: + raise HTTPException( + status_code=400, + detail=( + "workspace_id is required for account-scoped API keys. " + "Pass ``workspace_id`` in the body (POST) or query string (GET)." + ), + ) + + try: + wid = uuid.UUID(requested_workspace_id) + except ValueError: + raise HTTPException(status_code=400, detail="workspace_id must be a UUID.") + + if not USE_NEON: + raise HTTPException( + status_code=503, + detail="Workspace ownership check requires DATABASE_URL (Neon).", + ) + + async with get_session() as s: + owner = (await s.execute( + select(Workspace.user_id).where( + Workspace.id == wid, + Workspace.deleted_at.is_(None), + ) + )).scalar_one_or_none() + if owner is None or owner != ctx.user_id: + raise HTTPException(status_code=404, detail="Workspace not found.") + return wid + + +# --------------------------------------------------------------------------- +# POST /ext/query +# --------------------------------------------------------------------------- + +@router.post("/query", response_model=ExtQueryResponse, summary="Query the knowledge graph") +async def ext_query( + req: ExtQueryRequest, + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtQueryResponse: + """Run the knowledge-graph agent against *workspace_id* and return the + assistant's answer plus light metadata. + + Uses the same pipeline as the internal ``POST /query`` — identical + chat persistence, billing, and audit semantics — so answers are + consistent whether they come from the browser UI or a cron job. + """ + wid = await _resolve_workspace(ctx, req.workspace_id) + + internal_req = QueryRequest( + query=req.query, + session_id=req.session_id, + llm_model=req.llm_model, + ) + internal_resp: QueryResponse = await run_query( + workspace_id=str(wid), + req=internal_req, + owner_user_id=ctx.user_id, + actor_type="api_key", + audit_action="api.query", + audit_metadata_extra={"api_key_id": str(ctx.key_id)}, + billing_source_type="api_key", + billing_reason="chat_api", + ) + + usage = None + if internal_resp.usage is not None: + usage = ExtChatUsage(**internal_resp.usage.model_dump()) + + return ExtQueryResponse( + session_id=internal_resp.session_id or "", + response=internal_resp.response, + intent=internal_resp.intent, + kb_focus=internal_resp.kb_focus, + extracted_topics=internal_resp.extracted_topics, + follow_up_suggestions=internal_resp.follow_up_suggestions, + llm_model=internal_resp.llm_model, + usage=usage, + duration_ms=internal_resp.duration_ms or 0, + history_persisted=internal_resp.history_persisted, + steps=internal_resp.steps if req.debug else None, + traversal_path=internal_resp.traversal_path if req.debug else None, + knowledge_concepts=internal_resp.knowledge_concepts if req.debug else None, + tools_referenced=internal_resp.tools_referenced if req.debug else None, + ) + + +# --------------------------------------------------------------------------- +# GET /ext/graph/stats +# --------------------------------------------------------------------------- + +@router.get("/graph/stats", response_model=ExtGraphStats, summary="Graph statistics") +async def ext_graph_stats( + workspace_id: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtGraphStats: + wid = await _resolve_workspace(ctx, workspace_id) + kg = await _get_kg(str(wid)) + stats = kg.stats() + return ExtGraphStats( + total_nodes=int(stats.get("total_nodes", 0) or 0), + total_edges=int(stats.get("total_edges", 0) or 0), + nodes_by_type=stats.get("nodes_by_type") or {}, + edges_by_type=stats.get("edges_by_type") or {}, + backends=stats.get("backends") or {}, + ) + + +# --------------------------------------------------------------------------- +# GET /ext/graph/search +# --------------------------------------------------------------------------- + +@router.get("/graph/search", response_model=ExtSearchResponse, summary="Hybrid search") +async def ext_graph_search( + q: str = Query(description="Search query text."), + workspace_id: Optional[str] = Query(default=None), + top_k: int = Query(default=10, ge=1, le=50), + node_type: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtSearchResponse: + wid = await _resolve_workspace(ctx, workspace_id) + kg = await _get_kg(str(wid)) + hits = kg.hybrid_search(q, top_k=top_k, node_type=node_type) + results = [] + for h in hits: + node = kg.get_node(h.get("node_id")) + if not node: + continue + results.append(ExtSearchResult( + node_id=node.node_id, + node_type=node.node_type.value, + heading=node.heading, + kb_source=node.kb_source.value, + score=float(h.get("search_score", 0.0) or 0.0), + )) + return ExtSearchResponse(query=q, results=results) + + +# --------------------------------------------------------------------------- +# GET /ext/graph/node/{node_id} +# --------------------------------------------------------------------------- + +@router.get("/graph/node/{node_id}", response_model=ExtNodeDetail, summary="Get node detail") +async def ext_graph_node( + node_id: str, + workspace_id: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtNodeDetail: + wid = await _resolve_workspace(ctx, workspace_id) + kg = await _get_kg(str(wid)) + node = kg.get_node(node_id) + if not node: + raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found.") + return ExtNodeDetail( + node=node.to_dict(), + edges=kg.get_edges(node_id), + children=kg.get_children(node_id), + parent=node.parent_id, + ) + + +# --------------------------------------------------------------------------- +# POST /ext/graph/traverse +# --------------------------------------------------------------------------- + +@router.post("/graph/traverse", response_model=ExtTraverseResponse, summary="BFS traverse") +async def ext_graph_traverse( + req: ExtTraverseRequest, + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtTraverseResponse: + wid = await _resolve_workspace(ctx, req.workspace_id) + kg = await _get_kg(str(wid)) + if req.node_id not in kg.G: + raise HTTPException(status_code=404, detail=f"Node '{req.node_id}' not found.") + path = kg.bfs_traverse( + [req.node_id], + max_depth=req.max_depth, + edge_types=req.edge_types, + max_nodes=60, + ) + nodes = [] + edge_count = 0 + for nid in path: + n = kg.get_node(nid) + if n: + nodes.append(n.to_dict()) + edge_count += len(kg.get_edges(nid)) + return ExtTraverseResponse( + root_node_id=req.node_id, + traversal_path=path, + nodes=nodes, + edge_count=edge_count, + ) + + +# --------------------------------------------------------------------------- +# GET /ext/graph/tree +# --------------------------------------------------------------------------- + +@router.get("/graph/tree", summary="Hierarchical navigation tree") +async def ext_graph_tree( + workspace_id: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> dict: + wid = await _resolve_workspace(ctx, workspace_id) + kg = await _get_kg(str(wid)) + return {"tree": kg.full_tree()} + + +# --------------------------------------------------------------------------- +# GET /ext/graph/tools +# --------------------------------------------------------------------------- + +@router.get("/graph/tools", summary="List extracted tool nodes") +async def ext_graph_tools( + workspace_id: Optional[str] = Query(default=None), + category: Optional[str] = Query(default=None), + provider: Optional[str] = Query(default=None), + search: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> dict: + wid = await _resolve_workspace(ctx, workspace_id) + from src.models.nodes import ToolNode + kg = await _get_kg(str(wid)) + tools = [n.to_dict() for n in kg.nodes.values() if isinstance(n, ToolNode)] + if category: + tools = [t for t in tools if category.lower() in t.get("category", "").lower()] + if provider: + tools = [t for t in tools if provider.lower() in t.get("provider", "").lower()] + if search: + q = search.lower() + tools = [ + t for t in tools + if q in t.get("tool_name", "").lower() + or q in t.get("purpose", "").lower() + ] + return {"total": len(tools), "tools": tools[:limit]} + + +# --------------------------------------------------------------------------- +# GET /ext/graph/chapters +# --------------------------------------------------------------------------- + +@router.get("/graph/chapters", summary="List all chapters") +async def ext_graph_chapters( + workspace_id: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> dict: + wid = await _resolve_workspace(ctx, workspace_id) + from src.models.nodes import ChapterNode + kg = await _get_kg(str(wid)) + chapters = [n.to_dict() for n in kg.nodes.values() if isinstance(n, ChapterNode)] + return {"chapters": chapters} + + +# --------------------------------------------------------------------------- +# GET /ext/history/chats +# --------------------------------------------------------------------------- + +@router.get( + "/history/chats", + response_model=ExtChatSessionsResponse, + summary="List chat sessions", +) +async def ext_history_chats( + workspace_id: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=500), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtChatSessionsResponse: + wid = await _resolve_workspace(ctx, workspace_id) + async with get_session() as s: + rows = (await s.execute( + select(ChatSession) + .where(ChatSession.workspace_id == wid) + .order_by(ChatSession.last_activity_at.desc()) + .limit(limit) + )).scalars().all() + summaries = [] + for r in rows: + count = (await s.execute( + select(ChatMessage) + .where(ChatMessage.session_id == r.session_id) + )).scalars().all() + summaries.append(ExtChatSessionSummary( + session_id=str(r.session_id), + title=r.title, + message_count=len(count), + created_at=r.created_at, + last_activity_at=r.last_activity_at, + )) + return ExtChatSessionsResponse(sessions=summaries) + + +# --------------------------------------------------------------------------- +# GET /ext/history/chats/{session_id} +# --------------------------------------------------------------------------- + +@router.get( + "/history/chats/{session_id}", + response_model=ExtChatSessionDetail, + summary="Full message thread for a chat session", +) +async def ext_history_chat_detail( + session_id: str, + workspace_id: Optional[str] = Query(default=None), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtChatSessionDetail: + wid = await _resolve_workspace(ctx, workspace_id) + try: + sid = uuid.UUID(session_id) + except ValueError: + raise HTTPException(status_code=400, detail="session_id must be a UUID.") + async with get_session() as s: + sess = (await s.execute( + select(ChatSession).where( + ChatSession.session_id == sid, + ChatSession.workspace_id == wid, + ) + )).scalar_one_or_none() + if sess is None: + raise HTTPException(status_code=404, detail="Chat session not found.") + msgs = (await s.execute( + select(ChatMessage) + .where(ChatMessage.session_id == sid) + .order_by(ChatMessage.created_at.asc()) + )).scalars().all() + return ExtChatSessionDetail( + session_id=str(sess.session_id), + workspace_id=str(sess.workspace_id), + title=sess.title, + messages=[ + ExtChatMessage( + id=m.id, + role=m.role, + query=m.query, + response=m.response, + intent=m.intent, + kb_focus=m.kb_focus, + created_at=m.created_at, + duration_ms=m.duration_ms, + ) for m in msgs + ], + ) + + +# --------------------------------------------------------------------------- +# GET /ext/history/builds +# --------------------------------------------------------------------------- + +@router.get( + "/history/builds", + response_model=ExtBuildJobsResponse, + summary="List recent build jobs", +) +async def ext_history_builds( + workspace_id: Optional[str] = Query(default=None), + limit: int = Query(default=50, ge=1, le=500), + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtBuildJobsResponse: + wid = await _resolve_workspace(ctx, workspace_id) + async with get_session() as s: + rows = (await s.execute( + select(BuildJobRow) + .where(BuildJobRow.workspace_id == wid) + .order_by(BuildJobRow.created_at.desc()) + .limit(limit) + )).scalars().all() + return ExtBuildJobsResponse( + builds=[ + ExtBuildJobSummary( + job_id=r.job_id, + workspace_id=str(r.workspace_id), + status=r.status, + stage=r.stage, + stage_name=r.stage_name, + percent=r.percent, + created_at=r.created_at, + finished_at=r.finished_at, + ) for r in rows + ], + ) + + +# --------------------------------------------------------------------------- +# GET /ext/history/builds/{job_id} +# --------------------------------------------------------------------------- + +@router.get( + "/history/builds/{job_id}", + response_model=ExtBuildJobSummary, + summary="Detail for a single build job", +) +async def ext_history_build_detail( + job_id: str, + ctx: ApiKeyContext = Depends(rate_limit_per_key), +) -> ExtBuildJobSummary: + async with get_session() as s: + row = (await s.execute( + select(BuildJobRow).where(BuildJobRow.job_id == job_id) + )).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Build job not found.") + # Verify the key's owner actually owns this build's workspace. + await _resolve_workspace(ctx, str(row.workspace_id)) + return ExtBuildJobSummary( + job_id=row.job_id, + workspace_id=str(row.workspace_id), + status=row.status, + stage=row.stage, + stage_name=row.stage_name, + percent=row.percent, + created_at=row.created_at, + finished_at=row.finished_at, + ) diff --git a/src/api/keys_routes.py b/src/api/keys_routes.py new file mode 100644 index 0000000..c3c6c7d --- /dev/null +++ b/src/api/keys_routes.py @@ -0,0 +1,238 @@ +""" +Developer API-key management for the signed-in user (JWT auth). + +These routes power the ``/api-keys`` dashboard page. They are intentionally +NOT reachable via API-key auth — only a logged-in browser session can mint +or revoke keys, otherwise a stolen key could mint siblings and extend its +own lifetime. + +Key wire format: ``og_live_<32 url-safe base64 chars>``. The plaintext is +shown exactly once in the ``POST /keys`` response; afterwards the server +stores only a bcrypt hash and an indexed prefix. Rotations happen by +revoking + creating a new key (no in-place rotation in v1). +""" + +from __future__ import annotations + +import logging +import secrets +import uuid +from datetime import datetime, timezone +from typing import Optional + +import bcrypt +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, update + +from src.api.api_key_auth import _KEY_MARKER, _PREFIX_LEN, invalidate_api_key_cache +from src.api.auth import require_user +from src.config import USE_NEON +from src.infra.audit import record_audit +from src.infra.db import get_session +from src.infra.db_models import ApiKey, User, Workspace + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["api-keys"]) + + +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + +class ApiKeyCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + workspace_id: Optional[str] = Field( + default=None, + description="Pin this key to a single workspace. Omit for account-scoped keys.", + ) + rate_limit_rpm: Optional[int] = Field( + default=None, ge=1, le=10_000, + description="Per-minute request cap. Defaults to 60 when omitted.", + ) + + +class ApiKeyPublic(BaseModel): + """What the dashboard sees — never includes the plaintext secret.""" + id: str + name: str + prefix: str + workspace_id: Optional[str] = None + rate_limit_rpm: int + scopes: list[str] + created_at: datetime + last_used_at: Optional[datetime] = None + revoked_at: Optional[datetime] = None + + +class ApiKeyCreateResponse(BaseModel): + """POST /keys response — includes the plaintext ONCE. Never stored. + + The dashboard is responsible for surfacing the ``plaintext`` exactly + once with a copy button; on dialog close the value is lost forever. + """ + key: ApiKeyPublic + plaintext: str = Field( + description=( + "The full secret key. Copy it now — the server stores only a " + "hash. If you lose it, revoke and create a new one." + ), + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _generate_plaintext() -> str: + """Mint ``og_live_<32 char urlsafe>``. + + ``secrets.token_urlsafe(24)`` gives 32 base64 chars of cryptographic + randomness — 192 bits of entropy, well above any reasonable guessing + threshold. The prefix is a fixed marker so middleware can disambiguate + API keys from JWTs that share the same Authorization header. + """ + return _KEY_MARKER + secrets.token_urlsafe(24) + + +def _to_public(row: ApiKey) -> ApiKeyPublic: + return ApiKeyPublic( + id=str(row.id), + name=row.name, + prefix=row.prefix, + workspace_id=str(row.workspace_id) if row.workspace_id else None, + rate_limit_rpm=int(row.rate_limit_rpm or 60), + scopes=list(row.scopes or []), + created_at=row.created_at, + last_used_at=row.last_used_at, + revoked_at=row.revoked_at, + ) + + +def _require_neon() -> None: + if not USE_NEON: + raise HTTPException( + status_code=503, + detail="API keys require DATABASE_URL (Neon).", + ) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@router.post( + "/keys", + response_model=ApiKeyCreateResponse, + status_code=201, + summary="Mint a new developer API key (plaintext returned once)", +) +async def create_key( + body: ApiKeyCreate, + user: User = Depends(require_user), +) -> ApiKeyCreateResponse: + _require_neon() + + workspace_uuid: Optional[uuid.UUID] = None + if body.workspace_id: + try: + workspace_uuid = uuid.UUID(body.workspace_id) + except ValueError: + raise HTTPException(status_code=400, detail="workspace_id must be a UUID.") + async with get_session() as s: + owner = (await s.execute( + select(Workspace.user_id).where( + Workspace.id == workspace_uuid, + Workspace.deleted_at.is_(None), + ) + )).scalar_one_or_none() + if owner is None or owner != user.id: + raise HTTPException(status_code=404, detail="Workspace not found.") + + plaintext = _generate_plaintext() + prefix = plaintext[:_PREFIX_LEN] + key_hash = bcrypt.hashpw(plaintext.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + row = ApiKey( + user_id=user.id, + workspace_id=workspace_uuid, + name=body.name, + prefix=prefix, + key_hash=key_hash, + scopes=["ext:read"], + rate_limit_rpm=body.rate_limit_rpm or 60, + ) + async with get_session() as s: + s.add(row) + await s.commit() + await s.refresh(row) + + record_audit( + user.id, "api_key.create", + target_type="api_key", target_id=str(row.id), + workspace_id=workspace_uuid, + metadata={"name": row.name, "prefix": row.prefix, "scopes": row.scopes}, + ) + return ApiKeyCreateResponse( + key=_to_public(row), + plaintext=plaintext, + ) + + +@router.get( + "/keys", + response_model=list[ApiKeyPublic], + summary="List API keys owned by the current user", +) +async def list_keys(user: User = Depends(require_user)) -> list[ApiKeyPublic]: + _require_neon() + async with get_session() as s: + rows = (await s.execute( + select(ApiKey) + .where(ApiKey.user_id == user.id) + .order_by(ApiKey.created_at.desc()) + )).scalars().all() + return [_to_public(r) for r in rows] + + +@router.delete( + "/keys/{key_id}", + summary="Revoke an API key (soft-delete; takes effect within ~5 min)", +) +async def revoke_key( + key_id: str, + user: User = Depends(require_user), +) -> dict: + _require_neon() + try: + kid = uuid.UUID(key_id) + except ValueError: + raise HTTPException(status_code=400, detail="key_id must be a UUID.") + + async with get_session() as s: + row = (await s.execute( + select(ApiKey).where(ApiKey.id == kid) + )).scalar_one_or_none() + if row is None or row.user_id != user.id: + # 404 (not 403) — same tenant-isolation rule we use elsewhere. + raise HTTPException(status_code=404, detail="API key not found.") + if row.revoked_at is not None: + return {"ok": True, "revoked_key_id": key_id, "already_revoked": True} + await s.execute( + update(ApiKey) + .where(ApiKey.id == kid) + .values(revoked_at=datetime.now(timezone.utc)) + ) + await s.commit() + prefix = row.prefix + + # Drop the cache entry immediately so the next call from this key + # fails fast. Other workers pick it up on TTL expiry (5 min). + invalidate_api_key_cache(prefix) + + record_audit( + user.id, "api_key.revoke", + target_type="api_key", target_id=key_id, + ) + return {"ok": True, "revoked_key_id": key_id} diff --git a/src/api/query_service.py b/src/api/query_service.py new file mode 100644 index 0000000..a97676d --- /dev/null +++ b/src/api/query_service.py @@ -0,0 +1,213 @@ +""" +Shared query pipeline used by both the internal ``POST /query`` endpoint +(JWT auth, interactive UI) and the public ``POST /api/v1/ext/query`` +endpoint (API-key auth, third-party callers). + +Keeping the pipeline in one place guarantees both surfaces see the same +LangGraph agent behaviour, the same usage accounting, the same audit +shape, and the same billing rules. The callers only differ in how they +resolve the identity — JWT user vs. API-key owner — and which audit verb +they emit. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from typing import Optional + +from fastapi import HTTPException + +from src.api.routes import ( + ChatUsage, + QueryRequest, + QueryResponse, + _get_kg, + _persist_chat_turn, +) +from src.infra.audit import record_audit + +logger = logging.getLogger(__name__) + + +async def run_query( + *, + workspace_id: str, + req: QueryRequest, + owner_user_id: uuid.UUID, + actor_type: str = "system", + audit_action: str = "chat.query", + audit_metadata_extra: Optional[dict] = None, + billing_source_type: str = "chat_session", + billing_reason: str = "chat", +) -> QueryResponse: + """Execute the agent pipeline and return a :class:`QueryResponse`. + + Parameters: + workspace_id: target workspace UUID (caller has already verified + ownership — this function does NOT re-check). + req: the parsed :class:`QueryRequest`. + owner_user_id: the principal whose credits/trial counter are debited. + For JWT callers this is ``user.id``; for API-key callers it's the + key's ``user_id`` (resolved in ext_routes). + actor_type: audit field. ``"user"`` for JWT calls; ``"api_key"`` for + the ext surface. Does not change agent behaviour. + audit_action: the verb written to ``user_audit_log``. Use + ``"chat.query"`` for the internal endpoint and ``"api.query"`` for + the ext endpoint so operators can filter per-source in the audit. + audit_metadata_extra: merged into the audit metadata dict. Ext + callers put the API key id here. + billing_source_type / billing_reason: ledger fields. + + Behaviour is byte-for-byte identical to the previous in-route + implementation — this refactor is a pure extract, no semantic change. + """ + from src.agent.graph import KBGraphAgent + from src.billing import check_chat_allowed + from src.config import LLM_MODEL, USE_NEON + from src.infra.workspace_llm import get_workspace_llm_model + from src.observability.usage import ChatMetrics, chat_metrics_var + + await check_chat_allowed(owner_user_id) + + kg = await _get_kg(workspace_id) + agent = KBGraphAgent.from_graph(kg) + + session_id = req.session_id or str(uuid.uuid4()) + + resolved_model = (req.llm_model or "").strip() or None + if resolved_model is None: + resolved_model = await get_workspace_llm_model(workspace_id) + effective_model = resolved_model or LLM_MODEL + + chat_metrics = ChatMetrics(model=effective_model) + chat_token = chat_metrics_var.set(chat_metrics) + + start = time.perf_counter() + try: + try: + state = agent.query(req.query, llm_model=resolved_model) + except Exception as exc: + logger.error(f"Agent query failed: {exc}", exc_info=True) + if USE_NEON: + try: + await _persist_chat_turn( + workspace_id=workspace_id, + session_id=session_id, + query=req.query, + resp=None, + duration_ms=int((time.perf_counter() - start) * 1000), + error=str(exc), + ) + except Exception: + pass + raise HTTPException(status_code=500, detail=str(exc)) + finally: + try: + chat_metrics_var.reset(chat_token) + except Exception: + pass + duration_ms = int((time.perf_counter() - start) * 1000) + + usage_payload = ChatUsage(**chat_metrics.usage_dict()) if ( + chat_metrics.llm_calls + or chat_metrics.llm_prompt_tokens + or chat_metrics.llm_completion_tokens + ) else None + + resp = QueryResponse( + query=state["query"], + intent=state.get("intent", ""), + kb_focus=state.get("kb_focus", "both"), + extracted_topics=state.get("extracted_topics", []), + response=state.get("response", ""), + steps=state.get("steps", []), + tools_referenced=state.get("tools_referenced", []), + knowledge_concepts=state.get("knowledge_concepts", []), + follow_up_suggestions=state.get("follow_up_suggestions", []), + traversal_path=state.get("traversal_path", []), + session_id=session_id, + duration_ms=duration_ms, + error=state.get("error"), + llm_model=effective_model, + usage=usage_payload, + ) + + if USE_NEON: + persisted = False + last_err: Optional[Exception] = None + for _attempt in range(2): + try: + await _persist_chat_turn( + workspace_id=workspace_id, + session_id=session_id, + query=req.query, + resp=resp, + duration_ms=duration_ms, + ) + persisted = True + break + except Exception as exc: + last_err = exc + if not persisted: + logger.warning( + "Neon chat persistence failed after retry for session %s: %s", + session_id, last_err, + ) + resp.history_persisted = False + + audit_md = { + "model": effective_model, + "intent": resp.intent, + "duration_ms": duration_ms, + "query_preview": (req.query[:80] + "…") if len(req.query) > 80 else req.query, + } + if audit_metadata_extra: + audit_md.update(audit_metadata_extra) + record_audit( + owner_user_id, audit_action, + target_type="chat_session", target_id=session_id, + workspace_id=workspace_id, + metadata=audit_md, + ) + + # Billing — post-response so a debit failure never blocks the user's + # answer. BYOK applies when the workspace has its own OpenRouter key on + # file; the ext surface honours the same waiver. + try: + from sqlalchemy import select as _select + from src.billing import cost_chat, debit + from src.billing.ledger import bump_trial_counter + from src.infra.db import get_session as _get_session + from src.infra.db_models import WorkspaceApiKey as _WorkspaceApiKey + ws_uuid = uuid.UUID(workspace_id) + async with _get_session() as _s: + has_byok = (await _s.execute( + _select(_WorkspaceApiKey.workspace_id).where( + _WorkspaceApiKey.workspace_id == ws_uuid + ) + )).scalar_one_or_none() is not None + usage_dict = usage_payload.model_dump() if usage_payload else {} + credits = cost_chat(usage=usage_dict, has_byok=has_byok) + await debit( + owner_user_id, + credits, + reason=billing_reason, + source_type=billing_source_type, + source_id=session_id, + actor_type=actor_type, + metadata={ + "workspace_id": workspace_id, + "has_byok": has_byok, + "model": effective_model, + }, + ) + await bump_trial_counter(owner_user_id, kind="chat") + except Exception as bill_exc: + logger.warning( + "chat billing debit failed for session %s (actor=%s): %s", + session_id, actor_type, bill_exc, + ) + + return resp diff --git a/src/api/routes.py b/src/api/routes.py index ec41848..ccb0519 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -241,173 +241,23 @@ async def query_graph( workspace_id: str = Depends(require_workspace_id), user: User = Depends(require_user), ): - """ - Main query endpoint. Runs the full LangGraph agent pipeline and - returns a step-by-step response with tool details and follow-ups. + """Main query endpoint for the interactive (JWT-auth) UI. - If `session_id` is provided, the user query and agent response are - persisted to the chat history (when Neon is configured). If omitted, - a new session_id is generated, returned to the caller, and used for - persistence. + Runs the LangGraph agent pipeline. All pipeline logic — agent execution, + chat persistence, audit, billing — lives in + :func:`src.api.query_service.run_query` so the public ``/api/v1/ext/query`` + endpoint can share it byte-for-byte. This wrapper only resolves the + caller's identity and delegates. """ - import time - import uuid - from src.agent.graph import KBGraphAgent - from src.billing import check_chat_allowed - from src.config import LLM_MODEL, USE_NEON - from src.infra.workspace_llm import get_workspace_llm_model - - from src.observability.usage import ChatMetrics, chat_metrics_var - - # Billing gate — raises HTTP 402 on trial-cap hit or overdraft. - await check_chat_allowed(user.id) - - kg = await _get_kg(workspace_id) - agent = KBGraphAgent.from_graph(kg) - - session_id = req.session_id or str(uuid.uuid4()) - - # Resolve the effective LLM model: request override > workspace default. - # (Env LLM_MODEL is the final fallback inside src.agent.nodes._get_llm.) - resolved_model = (req.llm_model or "").strip() or None - if resolved_model is None: - resolved_model = await get_workspace_llm_model(workspace_id) - # Effective model = what actually answers. If the chain above produced - # None, the agent nodes fall back to env LLM_MODEL — surface that so the - # response is self-describing. - effective_model = resolved_model or LLM_MODEL - - # Install the per-turn accumulator before the agent runs. Reset in - # ``finally`` so even a failing agent call doesn't leak the var. - chat_metrics = ChatMetrics(model=effective_model) - chat_token = chat_metrics_var.set(chat_metrics) - - start = time.perf_counter() - try: - try: - state = agent.query(req.query, llm_model=resolved_model) - except Exception as exc: - logger.error(f"Agent query failed: {exc}", exc_info=True) - # best-effort record the failure turn - if USE_NEON: - try: - await _persist_chat_turn( - session_id=session_id, - query=req.query, - resp=None, - duration_ms=int((time.perf_counter() - start) * 1000), - error=str(exc), - ) - except Exception: - pass - raise HTTPException(status_code=500, detail=str(exc)) - finally: - try: - chat_metrics_var.reset(chat_token) - except Exception: - pass - duration_ms = int((time.perf_counter() - start) * 1000) - - usage_payload = ChatUsage(**chat_metrics.usage_dict()) if ( - chat_metrics.llm_calls or chat_metrics.llm_prompt_tokens or chat_metrics.llm_completion_tokens - ) else None - - resp = QueryResponse( - query=state["query"], - intent=state.get("intent", ""), - kb_focus=state.get("kb_focus", "both"), - extracted_topics=state.get("extracted_topics", []), - response=state.get("response", ""), - steps=state.get("steps", []), - tools_referenced=state.get("tools_referenced", []), - knowledge_concepts=state.get("knowledge_concepts", []), - follow_up_suggestions=state.get("follow_up_suggestions", []), - traversal_path=state.get("traversal_path", []), - session_id=session_id, - duration_ms=duration_ms, - error=state.get("error"), - llm_model=effective_model, - usage=usage_payload, - ) - - if USE_NEON: - # Persist before returning, with one quick retry on transient failure - # (connection reset, pool timeout, etc.). If both attempts fail we - # still return the answer but mark ``history_persisted=False`` so the - # UI can surface a warning — losing history silently is worse than - # telling the user to retry. - persisted = False - last_err: Optional[Exception] = None - for _attempt in range(2): - try: - await _persist_chat_turn( - workspace_id=workspace_id, - session_id=session_id, - query=req.query, - resp=resp, - duration_ms=duration_ms, - ) - persisted = True - break - except Exception as exc: - last_err = exc - if not persisted: - logger.warning( - "Neon chat persistence failed after retry for session %s: %s", - session_id, last_err, - ) - resp.history_persisted = False - - record_audit( - user.id, "chat.query", - target_type="chat_session", target_id=session_id, + from src.api.query_service import run_query + return await run_query( workspace_id=workspace_id, - metadata={ - "model": effective_model, - "intent": resp.intent, - "duration_ms": duration_ms, - "query_preview": (req.query[:80] + "…") if len(req.query) > 80 else req.query, - }, + req=req, + owner_user_id=user.id, + actor_type="user", + audit_action="chat.query", ) - # Billing deduction — post-commit so a failure here never blocks the - # user's response. BYOK detection: presence of a row in - # ``workspace_api_keys`` for this workspace waives LLM/embedding tokens. - try: - from src.billing import cost_chat, debit - from src.billing.ledger import bump_trial_counter - from src.infra.db_models import WorkspaceApiKey - import uuid as _uuid - ws_uuid = _uuid.UUID(workspace_id) - async with get_session() as _s: - from sqlalchemy import select as _select - has_byok = (await _s.execute( - _select(WorkspaceApiKey.workspace_id).where( - WorkspaceApiKey.workspace_id == ws_uuid - ) - )).scalar_one_or_none() is not None - usage_dict = usage_payload.model_dump() if usage_payload else {} - credits = cost_chat(usage=usage_dict, has_byok=has_byok) - await debit( - user.id, - credits, - reason="chat", - source_type="chat_session", - source_id=session_id, - actor_type="system", - metadata={ - "workspace_id": workspace_id, - "has_byok": has_byok, - "model": effective_model, - }, - ) - # Trial users: bump lifetime chat counter. No-op for non-trial. - await bump_trial_counter(user.id, kind="chat") - except Exception as bill_exc: - logger.warning("chat billing debit failed for session %s: %s", session_id, bill_exc) - - return resp - async def _persist_chat_turn( workspace_id: str, diff --git a/src/api/server.py b/src/api/server.py index 5e58fa2..270f764 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -106,6 +106,67 @@ async def add_timing_header(request: Request, call_next): # Mount API routes app.include_router(router, prefix="/api/v1") + # Developer API: key management (JWT-auth) + public /ext/* surface (API-key auth). + # Kept in separate routers from the internal surface so their security + # schemes and route prefixes stay isolated in the OpenAPI spec. + from src.api.ext_routes import router as ext_router + from src.api.keys_routes import router as keys_router + app.include_router(keys_router, prefix="/api/v1") + app.include_router(ext_router, prefix="/api/v1") + + # ---- Custom OpenAPI override ---- + # FastAPI's auto-generated spec has no security scheme; we inject one + # here so SDK codegen (datamodel-code-generator, openapi-typescript) + # understands /ext/* paths require an API-key bearer. Path-level security + # is applied only to /ext/* — the rest of the internal surface stays + # unauthenticated in the spec (the dashboard passes JWTs via cookie). + from fastapi.openapi.utils import get_openapi + + def _custom_openapi() -> dict: + if app.openapi_schema: + return app.openapi_schema + schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + schema.setdefault("components", {}).setdefault("securitySchemes", {})["ApiKeyAuth"] = { + "type": "http", + "scheme": "bearer", + "bearerFormat": "og_live", + "description": ( + "Developer API key in the format ``og_live_``. " + "Mint one at /api-keys in the dashboard." + ), + } + for path, methods in schema.get("paths", {}).items(): + if "/ext/" not in path: + continue + for op in methods.values(): + if not isinstance(op, dict): + continue + op.setdefault("security", [{"ApiKeyAuth": []}]) + app.openapi_schema = schema + return schema + + app.openapi = _custom_openapi # type: ignore[method-assign] + + @app.get("/api/v1/ext/openapi.json", include_in_schema=False, tags=["ext"]) + async def ext_only_openapi(): + """Return the OpenAPI spec filtered to the public /ext/* surface. + + Used by the dashboard ``/api-docs`` page and by the SDK codegen + pipelines so generated types only cover the third-party-safe API, + not the internal workspace / billing routes. + """ + full = app.openapi() + paths = {k: v for k, v in full.get("paths", {}).items() if "/ext/" in k} + return { + **{k: v for k, v in full.items() if k != "paths"}, + "paths": paths, + } + # Health check @app.get("/health", tags=["health"]) async def health(): diff --git a/src/infra/db.py b/src/infra/db.py index 8bef9b7..e2a7522 100644 --- a/src/infra/db.py +++ b/src/infra/db.py @@ -216,6 +216,15 @@ async def init_db() -> None: "ON build_jobs (workspace_id) " "WHERE status IN ('queued','running')" )) + # --- api_keys: developer API credentials -------------------------- + # Shipped with the /api/v1/ext/* public surface. create_all() picks + # up the table from the ORM model; these statements are defensive + # repeats that keep pre-existing deployments in sync when the model + # evolves (e.g. adding columns later). + await conn.execute(text( + "CREATE INDEX IF NOT EXISTS ix_api_keys_prefix_live " + "ON api_keys (prefix) WHERE revoked_at IS NULL" + )) # --- workspaces: delete-saga columns ------------------------------ # ``deleted_at`` IS NULL means live; any non-null value means the # cross-store cascade is in progress or has been retried. The HTTP diff --git a/src/infra/db_models.py b/src/infra/db_models.py index e0c4d9c..09d89dc 100644 --- a/src/infra/db_models.py +++ b/src/infra/db_models.py @@ -613,6 +613,73 @@ class WorkspaceApiKey(Base): last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) +# Developer API keys (the /api/v1/ext/* surface). +# +# Distinct from ``WorkspaceApiKey`` above — that stores the user's BYOK +# OpenRouter secret for billing waivers. This table holds the credentials +# a third-party developer uses to CALL us. The plaintext key is shown to +# the user ONCE at creation; we persist only a bcrypt hash plus a plaintext +# ``prefix`` (first 16 chars) used for O(1) lookup. +# +# Key wire format: ``og_live_<32-char-base64-urlsafe>``. The ``og_live_`` +# marker is reserved so we can later introduce ``og_test_`` without a +# schema change. + +class ApiKey(Base): + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + # Optional workspace scope. When set, the key can only call endpoints + # that resolve to this workspace. NULL means "any workspace I own" — + # the ext auth dep verifies ownership on every call. + workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=True, + ) + # User-visible label. Shown on the dashboard, not used for lookup. + name: Mapped[str] = mapped_column(String(128), nullable=False) + # First 16 chars of the plaintext key — safe to show and to index. + # ``og_live_`` + the first 8 secret chars. Unique across the table. + prefix: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) + # bcrypt hash of the full plaintext key. Cannot be recovered. + key_hash: Mapped[str] = mapped_column(String(256), nullable=False) + # JSONB list of scope strings. v1 always ``["ext:read"]``. Kept JSONB + # so future writes (``ext:workspace:write``) don't need a migration. + scopes: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + # Fixed-window rate limit applied to this key, requests per minute. + # Default 60; Team-plan users can request higher. + rate_limit_rpm: Mapped[int] = mapped_column(Integer, nullable=False, default=60) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_used_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + # Soft-delete. All auth paths filter ``revoked_at IS NULL``. + revoked_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_api_keys_user_id", "user_id"), + # Partial index keeps the auth lookup cheap at 1M keys — only live + # rows hit the index; revoked rows stay out of the hot path. + Index( + "ix_api_keys_prefix_live", + "prefix", + postgresql_where=text("revoked_at IS NULL"), + ), + ) + + # Fractional daily storage accumulator. A workspace that costs 0.05 credits # per day (small graph + vectors) would otherwise round to 0 every single # day and never be billed. This table carries the sub-credit remainder as diff --git a/src/infra/rate_limit.py b/src/infra/rate_limit.py new file mode 100644 index 0000000..fbfad32 --- /dev/null +++ b/src/infra/rate_limit.py @@ -0,0 +1,185 @@ +""" +Fixed-window rate limiting on Upstash Redis. + +Each limited request issues an ``INCR counter:`` against Upstash. +When the counter returns 1 (i.e. the key was just created) we follow with +``EXPIRE counter: 60`` so Upstash cleans it up at the end of the +window. Steady-state requests pay one Upstash round-trip; only the first +request of every window pays two. + +Why fixed-window vs. sliding-window: + + - We need atomic counting without a Lua script (Upstash REST can't run + MULTI/EXEC blocks cheaply). + - INCR is naturally atomic per-key, and EXPIRE-on-1 gives us a clean + lifecycle. + - The "burst across window boundary" flaw (a caller can spend 60 req at + T=59s and another 60 at T=61s) is acceptable at v1 abuse thresholds. + If it shows up in real traffic, switch to sliding-window with a + sorted-set (still one Upstash command via ZADD + ZCOUNT). + +The limiter degrades open when Upstash is misconfigured / offline — a +broken limiter should never take the product down. Operators can tighten +this to fail-closed via env if needed. +""" + +from __future__ import annotations + +import logging +import os +import time +from dataclasses import dataclass +from typing import Optional + +from fastapi import Depends, HTTPException, Response +from fastapi import status as http_status + +from src.api.api_key_auth import ApiKeyContext, require_api_key +from src.infra import upstash + +logger = logging.getLogger(__name__) + + +# Fail-closed means: if Upstash returns None (offline / misconfigured) we +# reject the request. Default is fail-open — cache outages MUST NOT cascade +# into user-facing 5xx on the hot path. Operators who care about strict +# quota enforcement during outages flip ``RATE_LIMIT_FAIL_CLOSED=1``. +_FAIL_CLOSED = os.environ.get("RATE_LIMIT_FAIL_CLOSED", "0") == "1" + +# Window size for every limiter. v1 ships with a single minute-long window; +# multi-window (per-hour / per-day) can layer later without API changes. +_WINDOW_SECONDS = 60 + +# Bump this to invalidate all live counters on deploy (e.g. after a bad +# surge). Matches the pattern used elsewhere for cache version tags. +_VERSION = os.environ.get("RATE_LIMIT_VERSION", "v1") + + +@dataclass(frozen=True) +class RateLimitDecision: + """Return value from :func:`check` — tells the caller whether the + request should proceed and carries the quota headers for the response. + """ + allowed: bool + limit: int + remaining: int + reset_at_unix: int + + +def _bucket_key(scope: str, subject: str) -> str: + """Namespace the counter so different limiters can share Upstash without + colliding — e.g. ``rl:v1:ext_key::``. + + The minute is included in the key itself so an old window's counter + naturally orphans when its TTL fires; no separate reset logic. + """ + minute = int(time.time()) // _WINDOW_SECONDS + return f"rl:{_VERSION}:{scope}:{subject}:{minute}" + + +def check(scope: str, subject: str, limit: int) -> RateLimitDecision: + """Increment the counter for ``(scope, subject)`` and decide whether the + request is under ``limit`` for the current minute. + + Args: + scope: coarse limiter name — ``"ext_key"`` for API keys today. + subject: identity within the scope — an API key UUID string. + limit: requests per ``_WINDOW_SECONDS``. + + Returns a :class:`RateLimitDecision`. The caller is expected to + (a) copy ``X-RateLimit-*`` headers onto the response, and (b) 429 if + ``allowed`` is False. + """ + key = _bucket_key(scope, subject) + raw = upstash._request(["INCR", key]) + # INCR returns the new count as an int (or sometimes a numeric string + # depending on the Upstash client version). Treat anything else as + # "we couldn't reach Upstash". + count: Optional[int] = None + if isinstance(raw, int): + count = raw + elif isinstance(raw, str) and raw.isdigit(): + count = int(raw) + + # Second command: set the TTL when we just created the counter. We + # don't need to do this on every hit — only the window's first request + # needs to pin expiry. Upstash auto-extends neither; a missing EXPIRE + # would leak the key forever, which would eventually fill the free + # tier. Hence: always fire on count == 1. + if count == 1: + upstash._request(["EXPIRE", key, _WINDOW_SECONDS]) + + now_unix = int(time.time()) + reset_at_unix = ((now_unix // _WINDOW_SECONDS) + 1) * _WINDOW_SECONDS + + if count is None: + # Upstash misconfigured or down. Behaviour toggles on _FAIL_CLOSED. + if _FAIL_CLOSED: + return RateLimitDecision( + allowed=False, limit=limit, remaining=0, reset_at_unix=reset_at_unix, + ) + logger.warning("Rate limit check for %s/%s: Upstash unreachable, allowing.", + scope, subject) + return RateLimitDecision( + allowed=True, limit=limit, remaining=limit, reset_at_unix=reset_at_unix, + ) + + remaining = max(0, limit - count) + return RateLimitDecision( + allowed=count <= limit, + limit=limit, + remaining=remaining, + reset_at_unix=reset_at_unix, + ) + + +def apply_headers(response: Response, decision: RateLimitDecision) -> None: + """Copy ``X-RateLimit-*`` headers onto *response*. Follows the + draft-ietf-httpapi-ratelimit-headers convention so SDKs can parse + them without per-vendor logic. + """ + response.headers["X-RateLimit-Limit"] = str(decision.limit) + response.headers["X-RateLimit-Remaining"] = str(decision.remaining) + response.headers["X-RateLimit-Reset"] = str(decision.reset_at_unix) + + +# --------------------------------------------------------------------------- +# FastAPI dependency +# --------------------------------------------------------------------------- + +def rate_limit_per_key( + response: Response, + ctx: ApiKeyContext = Depends(require_api_key), +) -> ApiKeyContext: + """Dependency that enforces the API key's per-minute quota. + + Counter bucket is keyed on ``ext_key::`` so every key + has its own budget, and the minute component rolls the window + automatically via the key name (no separate reset routine). + """ + decision = check( + scope="ext_key", + subject=str(ctx.key_id), + limit=ctx.rate_limit_rpm, + ) + apply_headers(response, decision) + + if not decision.allowed: + response.headers["Retry-After"] = str( + max(1, decision.reset_at_unix - int(time.time())) + ) + raise HTTPException( + status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, + detail=( + f"Rate limit exceeded — {decision.limit} requests per minute. " + f"Retry after {decision.reset_at_unix - int(time.time())} seconds." + ), + headers={ + "X-RateLimit-Limit": str(decision.limit), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(decision.reset_at_unix), + "Retry-After": str(max(1, decision.reset_at_unix - int(time.time()))), + }, + ) + + return ctx From 30c4b9dc379395423c1f893a83b42e09b7777155 Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 14:11:44 +0530 Subject: [PATCH 5/8] feat: add API key management features and update app shell navigation - Introduced API key management functions in the API layer, including listing, creating, and revoking API keys. - Added ApiKeyRow and CreateApiKeyResponse interfaces to define the structure of API key data. - Updated app shell navigation to include a new "API Keys" section with an appropriate icon. --- frontend/app/api-docs/page.tsx | 366 ++++++++++++++++++ frontend/app/api-keys/page.tsx | 266 +++++++++++++ frontend/components/api-docs/code-snippet.tsx | 61 +++ .../components/api-keys/create-key-dialog.tsx | 250 ++++++++++++ frontend/components/app-shell.tsx | 2 + frontend/lib/api.ts | 8 + frontend/lib/schema.ts | 20 + 7 files changed, 973 insertions(+) create mode 100644 frontend/app/api-docs/page.tsx create mode 100644 frontend/app/api-keys/page.tsx create mode 100644 frontend/components/api-docs/code-snippet.tsx create mode 100644 frontend/components/api-keys/create-key-dialog.tsx diff --git a/frontend/app/api-docs/page.tsx b/frontend/app/api-docs/page.tsx new file mode 100644 index 0000000..dd71357 --- /dev/null +++ b/frontend/app/api-docs/page.tsx @@ -0,0 +1,366 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + BookOpen, + ExternalLink, + KeyRound, + Package, + Plus, + ShieldCheck, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CodeSnippet } from "@/components/api-docs/code-snippet"; +import { api } from "@/lib/api"; + +const DEFAULT_KEY_PLACEHOLDER = "og_live_"; +const DEFAULT_WS_PLACEHOLDER = ""; + +type Tab = "curl" | "python" | "typescript" | "node"; + +export default function ApiDocsPage() { + const base = typeof window !== "undefined" + ? `${window.location.protocol}//${window.location.hostname}:8000` + : "https://api.opengraph.example"; + + const keysQuery = useQuery({ queryKey: ["api-keys"], queryFn: api.listApiKeys }); + const workspacesQuery = useQuery({ + queryKey: ["workspaces"], + queryFn: api.listWorkspaces, + }); + + const firstLiveKey = (keysQuery.data ?? []).find((k) => !k.revoked_at); + const firstWorkspace = workspacesQuery.data?.workspaces?.[0]; + + // Keys aren't retrievable in plaintext post-creation. The "working example" + // therefore substitutes the prefix only (``og_live_abcd1234…``) so the + // snippet reads correctly; the user pastes their own secret when running. + const keyExample = firstLiveKey ? `${firstLiveKey.prefix}…` : DEFAULT_KEY_PLACEHOLDER; + const wsExample = firstWorkspace?.id ?? DEFAULT_WS_PLACEHOLDER; + + const [tab, setTab] = React.useState("curl"); + + return ( +
+
+
+

+ API documentation +

+

+ Call your knowledge graphs from any language. Public surface at{" "} + /api/v1/ext/*, authenticated with{" "} + + API keys + + . Full OpenAPI spec:{" "} + + /api/v1/ext/openapi.json + +

+
+
+ + {!firstLiveKey && ( + + )} +
+
+ + + +
+
+

+ Query a graph +

+
+ {(["curl", "python", "typescript", "node"] as Tab[]).map((t) => ( + + ))} +
+
+ +

+ Ask a natural-language question. The assistant replies as markdown.{" "} + {firstLiveKey ? ( + <> + Using key {firstLiveKey.prefix}… + {firstWorkspace ? ( + <> + {" "}and workspace{" "} + {firstWorkspace.name}. + + ) : null} + + ) : ( + <>Replace {DEFAULT_KEY_PLACEHOLDER} with your real key. + )} +

+ + {tab === "curl" && ( + + )} + {tab === "python" && ( + + )} + {tab === "typescript" && ( + + )} + {tab === "node" && ( + = 18, no extra deps +const res = await fetch("${base}/api/v1/ext/query", { + method: "POST", + headers: { + "Authorization": "Bearer ${keyExample}", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + workspace_id: "${wsExample}", + query: "What are the tools used in the payments pipeline?", + }), +}); +if (!res.ok) throw new Error(\`Query failed: \${res.status}\`); +const data = await res.json(); +console.log(data.response);`} + /> + )} +
+ + + + + + + Conventions + + + +

+ Auth.{" "} + Every request carries{" "} + Authorization: Bearer og_live_…. Invalid + or revoked keys return 401. +

+

+ Workspace scope.{" "} + Account-scoped keys require a workspace_id on every call; + workspace-pinned keys may omit it. Mismatches return 404 rather than{" "} + 403. +

+

+ Rate limits.{" "} + Every response carries X-RateLimit-Limit,{" "} + X-RateLimit-Remaining, and X-RateLimit-Reset headers. + Over-quota returns 429 with Retry-After. +

+

+ Errors.{" "} + All error responses are JSON with at least a{" "} + detail field. Network errors and{" "} + 5xx are safe to retry with exponential backoff. +

+

+ Versioning.{" "} + Path is /api/v1/ext/*. Response shape is stable within v1 — + fields may be added, never removed or retyped, without a version bump. +

+
+
+
+ ); +} + +function SetupCards() { + return ( +
+ + + + 1. Mint a key + + + +

+ Go to /api-keys{" "} + and click New key. The plaintext is shown once — copy it + somewhere safe. +

+
+
+ + + + 2. Install an SDK + + + +

+ Official SDKs handle auth, retries, and typed responses. +

+
+ pip install opengraph-sdk + npm i @opengraph/sdk +
+
+
+ + + + 3. Query the graph + + + +

+ Pass workspace_id + query and get back a markdown + answer with follow-up suggestions and token usage. +

+
+
+
+ ); +} + +function EndpointGrid({ + base, + keyExample, + wsExample, +}: { + base: string; + keyExample: string; + wsExample: string; +}) { + const rows = [ + { method: "POST", path: "/api/v1/ext/query", desc: "Ask a natural-language question" }, + { method: "GET", path: "/api/v1/ext/graph/stats", desc: "Node + edge counts by type" }, + { method: "GET", path: "/api/v1/ext/graph/search", desc: "Hybrid keyword + semantic search" }, + { method: "GET", path: "/api/v1/ext/graph/node/{id}", desc: "Node + edges + children + parent" }, + { method: "POST", path: "/api/v1/ext/graph/traverse", desc: "BFS traverse with edge filter" }, + { method: "GET", path: "/api/v1/ext/graph/tree", desc: "Hierarchical navigation tree" }, + { method: "GET", path: "/api/v1/ext/graph/tools", desc: "All extracted Tool nodes" }, + { method: "GET", path: "/api/v1/ext/graph/chapters", desc: "Flat list of chapters" }, + { method: "GET", path: "/api/v1/ext/history/chats", desc: "Recent chat sessions" }, + { method: "GET", path: "/api/v1/ext/history/chats/{id}", desc: "Full message thread" }, + { method: "GET", path: "/api/v1/ext/history/builds", desc: "Recent build jobs" }, + { method: "GET", path: "/api/v1/ext/history/builds/{id}", desc: "Single build status + stats" }, + ]; + + return ( +
+

Endpoints

+
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
MethodPathDescription
+ + {r.method} + + {r.path}{r.desc}
+
+

+ Full reference with request/response schemas lives in the{" "} + + Swagger UI + + . +

+
+ ); +} diff --git a/frontend/app/api-keys/page.tsx b/frontend/app/api-keys/page.tsx new file mode 100644 index 0000000..d1f568b --- /dev/null +++ b/frontend/app/api-keys/page.tsx @@ -0,0 +1,266 @@ +"use client"; + +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + KeyRound, + Plus, + Trash2, + AlertCircle, + Briefcase, + Clock, + ExternalLink, +} from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CreateKeyDialog } from "@/components/api-keys/create-key-dialog"; +import { api } from "@/lib/api"; +import type { ApiKeyRow } from "@/lib/schema"; +import { cn } from "@/lib/utils"; + +export default function ApiKeysPage() { + const qc = useQueryClient(); + const [createOpen, setCreateOpen] = React.useState(false); + const [toRevoke, setToRevoke] = React.useState(null); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["api-keys"], + queryFn: api.listApiKeys, + refetchInterval: 30_000, + }); + + const revokeMut = useMutation({ + mutationFn: (id: string) => api.revokeApiKey(id), + onMutate: async (id: string) => { + await qc.cancelQueries({ queryKey: ["api-keys"] }); + const prev = qc.getQueryData(["api-keys"]); + if (prev) { + qc.setQueryData( + ["api-keys"], + prev.map((k) => + k.id === id ? { ...k, revoked_at: new Date().toISOString() } : k, + ), + ); + } + return { prev }; + }, + onError: (err: Error, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(["api-keys"], ctx.prev); + toast.error(err.message); + }, + onSuccess: () => toast.success("Key revoked"), + onSettled: () => qc.invalidateQueries({ queryKey: ["api-keys"] }), + }); + + const keys = data ?? []; + const live = keys.filter((k) => !k.revoked_at); + const revoked = keys.filter((k) => k.revoked_at); + + return ( +
+
+
+

+ API keys +

+

+ Developer credentials for the public /api/v1/ext/* surface. + Keys are shown once at creation; lost secrets can only be replaced by revoking and + minting a new one.{" "} + + View the docs → + +

+
+ +
+ + {isError && ( + + + + {(error as Error).message} + + + )} + + {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : keys.length === 0 ? ( + + + +
+

No API keys yet

+

+ Mint one to start calling the API from scripts, SDKs, or CI. +

+
+ +
+
+ ) : ( +
+ {live.map((k) => ( + setToRevoke(k)} /> + ))} + {revoked.length > 0 && ( + <> +

+ Revoked +

+ {revoked.map((k) => ( + + ))} + + )} +
+ )} + + + + !v && setToRevoke(null)}> + + + Revoke "{toRevoke?.name}"? + + Revocation is effective within ~5 minutes across all workers. + In-flight requests using this key complete normally. + You can't un-revoke — create a fresh key if you need to roll back. + + + + + + + + +
+ ); +} + +function KeyRow({ + k, + onRevoke, +}: { + k: ApiKeyRow; + onRevoke: (() => void) | null; +}) { + const isRevoked = Boolean(k.revoked_at); + return ( +
+
+ +
+
+
+

{k.name}

+ + {k.prefix}… + + {isRevoked && ( + + revoked + + )} +
+
+ {k.workspace_id ? ( + + + {k.workspace_id.slice(0, 8)}… + + ) : ( + account-scoped + )} + · + {k.rate_limit_rpm} rpm + · + + + {k.last_used_at + ? `last used ${relativeTime(k.last_used_at)}` + : "never used"} + +
+
+ {onRevoke && ( + + )} +
+ ); +} + +function relativeTime(iso: string): string { + const t = new Date(iso).getTime(); + const diff = Date.now() - t; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} diff --git a/frontend/components/api-docs/code-snippet.tsx b/frontend/components/api-docs/code-snippet.tsx new file mode 100644 index 0000000..56e762d --- /dev/null +++ b/frontend/components/api-docs/code-snippet.tsx @@ -0,0 +1,61 @@ +"use client"; + +import * as React from "react"; +import { Check, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Lang = "curl" | "python" | "typescript" | "node"; + +export function CodeSnippet({ + code, + lang, + className, +}: { + code: string; + lang: Lang; + className?: string; +}) { + const [copied, setCopied] = React.useState(false); + + async function onCopy() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // No-op — the
 is selectable anyway.
+    }
+  }
+
+  return (
+    
+
+ + {lang === "typescript" ? "TypeScript" : lang === "node" ? "Node" : lang} + + +
+
+        {code}
+      
+
+ ); +} diff --git a/frontend/components/api-keys/create-key-dialog.tsx b/frontend/components/api-keys/create-key-dialog.tsx new file mode 100644 index 0000000..401a682 --- /dev/null +++ b/frontend/components/api-keys/create-key-dialog.tsx @@ -0,0 +1,250 @@ +"use client"; + +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Check, Copy, Eye, EyeOff, KeyRound } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import type { CreateApiKeyResponse } from "@/lib/schema"; +import { cn } from "@/lib/utils"; + +type Props = { + open?: boolean; + onOpenChange?: (open: boolean) => void; + trigger?: React.ReactNode; +}; + +/** + * Two-step dialog: + * + * Step 1 — Form: name + optional workspace pin + rate limit override. + * Step 2 — Reveal: display the plaintext key ONCE with a copy button. + * + * The plaintext is only available in the POST response; if the user + * closes the dialog without copying, the only recovery is revoking and + * minting again. + */ +export function CreateKeyDialog({ open, onOpenChange, trigger }: Props) { + const qc = useQueryClient(); + const [internalOpen, setInternalOpen] = React.useState(false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + const setOpen = React.useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); + + const [name, setName] = React.useState(""); + const [workspaceId, setWorkspaceId] = React.useState(""); + const [rateLimit, setRateLimit] = React.useState("60"); + const [minted, setMinted] = React.useState(null); + const [revealed, setRevealed] = React.useState(false); + const [copied, setCopied] = React.useState(false); + + const workspacesQuery = useQuery({ + queryKey: ["workspaces"], + queryFn: api.listWorkspaces, + enabled: isOpen && minted === null, + }); + + const createMut = useMutation({ + mutationFn: () => + api.createApiKey({ + name: name.trim(), + workspace_id: workspaceId || null, + rate_limit_rpm: rateLimit ? parseInt(rateLimit, 10) || undefined : undefined, + }), + onSuccess: (res) => { + setMinted(res); + setRevealed(true); + qc.invalidateQueries({ queryKey: ["api-keys"] }); + toast.success(`Key "${res.key.name}" created`); + }, + onError: (err: Error) => toast.error(err.message), + }); + + const reset = () => { + setName(""); + setWorkspaceId(""); + setRateLimit("60"); + setMinted(null); + setRevealed(false); + setCopied(false); + }; + + const onOpenChangeInternal = (v: boolean) => { + setOpen(v); + if (!v) setTimeout(reset, 200); + }; + + async function copyPlaintext() { + if (!minted) return; + try { + await navigator.clipboard.writeText(minted.plaintext); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Clipboard copy failed — select the text manually."); + } + } + + return ( + + {trigger ? {trigger} : null} + + {minted === null ? ( + <> + + + Create API key + + + Each key carries its own rate limit and can be revoked independently. + The full secret is shown exactly once on the next screen. + + +
{ + e.preventDefault(); + createMut.mutate(); + }} + > +
+ + setName(e.target.value)} + placeholder="e.g. production-backend" + /> +

+ A human-friendly label. Doesn't affect how the key works. +

+
+
+ + +

+ Leaving this blank means the key can target any workspace you own, + with the caller passing workspace_id on each request. +

+
+
+ + setRateLimit(e.target.value)} + /> +
+ + + + +
+ + ) : ( + <> + + Your new API key + + Copy it now — it won't be shown again. Store it somewhere safe + (a password manager or your secret store). + + +
+ +
+
+ {minted.plaintext} +
+ + +
+

+ Key id: {minted.key.id} + {" · "}prefix {minted.key.prefix} + {" · "}limit {minted.key.rate_limit_rpm} rpm +

+
+ + + + + )} +
+
+ ); +} diff --git a/frontend/components/app-shell.tsx b/frontend/components/app-shell.tsx index 5375531..b0559bc 100644 --- a/frontend/components/app-shell.tsx +++ b/frontend/components/app-shell.tsx @@ -14,6 +14,7 @@ import { CircleUser, CreditCard, CheckCircle2, + KeyRound, Moon, Sun, Briefcase, @@ -56,6 +57,7 @@ const PRIMARY_NAV: NavItem[] = [ { href: "/workspaces", label: "My Graphs", icon: GitBranch, matchPrefixes: GRAPH_MATCH_PREFIXES }, { href: "/playground", label: "Playground", icon: GitCompareArrows }, { href: "/history", label: "History", icon: HistoryIcon }, + { href: "/api-keys", label: "API Keys", icon: KeyRound, matchPrefixes: ["/api-keys", "/api-docs"] }, { href: "/billing", label: "Billing", icon: CreditCard }, { href: "/profile", label: "Profile", icon: CircleUser }, ]; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 325a977..b95a6a7 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,12 +1,14 @@ "use client"; import type { + ApiKeyRow, BuildHistoryDetail, BuildHistoryRow, BuildJobStatus, ChatHistoryDetail, ChatHistoryRow, ConfigHistoryRow, + CreateApiKeyResponse, DomainPayload, GraphConfigPayload, GraphStats, @@ -251,6 +253,12 @@ export const api = { deleteWorkspaceFile: (ws_id: string, file_id: number) => json<{ ok: true }>(`/api/v1/workspaces/${ws_id}/files/${file_id}`, "DELETE"), + // developer API keys — powers /api-keys dashboard + listApiKeys: () => get("/api/v1/keys"), + createApiKey: (body: { name: string; workspace_id?: string | null; rate_limit_rpm?: number }) => + json("/api/v1/keys", "POST", body), + revokeApiKey: (id: string) => json<{ ok: true }>(`/api/v1/keys/${id}`, "DELETE"), + // config getDomain: () => get("/api/v1/config/domain"), putDomain: (p: DomainPayload) => json<{ ok: boolean; path: string }>("/api/v1/config/domain", "PUT", p), diff --git a/frontend/lib/schema.ts b/frontend/lib/schema.ts index 032181d..8a93d97 100644 --- a/frontend/lib/schema.ts +++ b/frontend/lib/schema.ts @@ -27,6 +27,26 @@ export interface WorkspaceFileInfo { created_at: string; } +// ---------- Developer API keys ---------- +export interface ApiKeyRow { + id: string; + name: string; + prefix: string; + workspace_id?: string | null; + rate_limit_rpm: number; + scopes: string[]; + created_at: string; + last_used_at?: string | null; + revoked_at?: string | null; +} + +export interface CreateApiKeyResponse { + key: ApiKeyRow; + // Plaintext secret. Shown exactly once on creation; never retrievable + // afterward. UI must surface a copy button + one-shot reveal. + plaintext: string; +} + // ---------- Domain ---------- export const DomainPayloadSchema = z.object({ domain_name: z.string().min(1, "Required"), From baef3976ea1b2adef29ff6056abcd567f5001a31 Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 14:34:07 +0530 Subject: [PATCH 6/8] feat: improve API key management and enhance user experience - Added functionality to list, create, and revoke API keys within the API layer. - Introduced new interfaces for API key data structure. - Updated app navigation to include an "API Keys" section for easier access. - Enhanced error handling and user feedback during API key operations. --- sdks/python/README.md | 102 +++++ sdks/python/examples/query.py | 62 +++ sdks/python/opengraph_sdk/__init__.py | 69 +++ sdks/python/opengraph_sdk/client.py | 579 ++++++++++++++++++++++++++ sdks/python/opengraph_sdk/errors.py | 67 +++ sdks/python/opengraph_sdk/models.py | 119 ++++++ sdks/python/pyproject.toml | 48 +++ sdks/python/tests/test_client.py | 118 ++++++ 8 files changed, 1164 insertions(+) create mode 100644 sdks/python/README.md create mode 100644 sdks/python/examples/query.py create mode 100644 sdks/python/opengraph_sdk/__init__.py create mode 100644 sdks/python/opengraph_sdk/client.py create mode 100644 sdks/python/opengraph_sdk/errors.py create mode 100644 sdks/python/opengraph_sdk/models.py create mode 100644 sdks/python/pyproject.toml create mode 100644 sdks/python/tests/test_client.py diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 0000000..95abd85 --- /dev/null +++ b/sdks/python/README.md @@ -0,0 +1,102 @@ +# opengraph-sdk + +Official Python SDK for the [OpenGraph](https://opengraph.example) developer API. + +```bash +pip install opengraph-sdk +``` + +## Quick start + +```python +from opengraph_sdk import Client + +client = Client( + api_key="og_live_…", # or env OPENGRAPH_API_KEY + base_url="https://api.opengraph.example", # or env OPENGRAPH_BASE_URL +) + +resp = client.query( + "What are the tools used in the payments pipeline?", + workspace_id="…", # or env OPENGRAPH_WORKSPACE_ID +) +print(resp.response) +for s in resp.follow_up_suggestions: + print("→", s) +``` + +### Async + +```python +import asyncio +from opengraph_sdk import AsyncClient + +async def main(): + async with AsyncClient() as client: + resp = await client.query("hello", workspace_id="…") + print(resp.response) + +asyncio.run(main()) +``` + +## Surface + +All methods live under the `Client` / `AsyncClient` classes. + +| Call | Method | Endpoint | +|---|---|---| +| `client.query(q, workspace_id=…)` | POST | `/api/v1/ext/query` | +| `client.graph.stats(workspace_id=…)` | GET | `/api/v1/ext/graph/stats` | +| `client.graph.search(q, top_k=10)` | GET | `/api/v1/ext/graph/search` | +| `client.graph.node(id)` | GET | `/api/v1/ext/graph/node/{id}` | +| `client.graph.traverse(node_id, max_depth=3)` | POST | `/api/v1/ext/graph/traverse` | +| `client.graph.tree()` | GET | `/api/v1/ext/graph/tree` | +| `client.graph.tools()` | GET | `/api/v1/ext/graph/tools` | +| `client.graph.chapters()` | GET | `/api/v1/ext/graph/chapters` | +| `client.history.chats()` | GET | `/api/v1/ext/history/chats` | +| `client.history.chat(id)` | GET | `/api/v1/ext/history/chats/{id}` | +| `client.history.builds()` | GET | `/api/v1/ext/history/builds` | +| `client.history.build(id)` | GET | `/api/v1/ext/history/builds/{id}` | + +## Errors + +Every network / server error subclasses `OpenGraphError`: + +```python +from opengraph_sdk import ( + AuthError, # 401 — bad or missing API key + NotFoundError, # 404 — workspace/node doesn't exist (or isn't yours) + ValidationError, # 400 / 422 — request payload rejected + RateLimitError, # 429 — per-key quota exhausted; .retry_after_seconds + ServerError, # 5xx or network — safe to retry with backoff + OpenGraphError, # base +) +``` + +## Configuration + +Every option can come from an env var: + +| Env var | Purpose | +|---|---| +| `OPENGRAPH_API_KEY` | Default API key if `api_key=` is omitted | +| `OPENGRAPH_BASE_URL` | Override the API base | +| `OPENGRAPH_WORKSPACE_ID` | Default workspace for every call | + +Constructor overrides always win. + +## Retries + +Transient failures (429, 502, 503, 504, network errors) are retried with +exponential backoff (and honour `Retry-After` when present). 4xx responses +that aren't 429 are raised immediately — retrying won't fix them. + +Override the retry count: + +```python +Client(api_key=..., max_retries=5) +``` + +## License + +MIT. diff --git a/sdks/python/examples/query.py b/sdks/python/examples/query.py new file mode 100644 index 0000000..ce9457f --- /dev/null +++ b/sdks/python/examples/query.py @@ -0,0 +1,62 @@ +"""Minimal SDK example — ask a graph a question. + +Run with: + + OPENGRAPH_API_KEY=og_live_... \\ + OPENGRAPH_WORKSPACE_ID=... \\ + OPENGRAPH_BASE_URL=http://localhost:8000 \\ + python examples/query.py + +The SDK picks up all three from env vars, so you never need to hardcode +credentials in source. +""" + +from __future__ import annotations + +import os +import sys + +from opengraph_sdk import Client, OpenGraphError + + +def main() -> int: + try: + client = Client() + except OpenGraphError as exc: + print(f"Configuration error: {exc}", file=sys.stderr) + return 1 + + workspace_id = os.environ.get("OPENGRAPH_WORKSPACE_ID") + if not workspace_id: + print( + "Set OPENGRAPH_WORKSPACE_ID to a workspace you own, or pass " + "workspace_id= explicitly.", + file=sys.stderr, + ) + return 1 + + question = " ".join(sys.argv[1:]) or "What's in this knowledge base?" + print(f"Q: {question}\n") + + try: + resp = client.query(question, workspace_id=workspace_id) + except OpenGraphError as exc: + print(f"API error ({exc.status}): {exc}", file=sys.stderr) + return 2 + + print("A:", resp.response) + if resp.follow_up_suggestions: + print("\nFollow-ups:") + for s in resp.follow_up_suggestions: + print(f" - {s}") + if resp.usage: + print( + f"\nTokens: {resp.usage.llm_total_tokens} " + f"(prompt {resp.usage.llm_prompt_tokens} / completion {resp.usage.llm_completion_tokens})" + ) + print(f"Session id: {resp.session_id} · took {resp.duration_ms} ms") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sdks/python/opengraph_sdk/__init__.py b/sdks/python/opengraph_sdk/__init__.py new file mode 100644 index 0000000..9ff9c9c --- /dev/null +++ b/sdks/python/opengraph_sdk/__init__.py @@ -0,0 +1,69 @@ +""" +Official Python SDK for the OpenGraph developer API. + +Quick start:: + + from opengraph_sdk import Client + + client = Client(api_key="og_live_...") # or env OPENGRAPH_API_KEY + resp = client.query(workspace_id="...", query="What's my churn rate?") + print(resp.response) + +Async usage mirrors the sync client:: + + from opengraph_sdk import AsyncClient + + async with AsyncClient() as client: + resp = await client.query(workspace_id="...", query="…") + +All responses are strongly typed via :mod:`pydantic`. Errors raise +subclasses of :class:`OpenGraphError` — see :mod:`opengraph_sdk.errors`. +""" + +from opengraph_sdk.client import AsyncClient, Client +from opengraph_sdk.errors import ( + AuthError, + NotFoundError, + OpenGraphError, + RateLimitError, + ServerError, + ValidationError, +) +from opengraph_sdk.models import ( + BuildJobSummary, + ChatMessage, + ChatSessionDetail, + ChatSessionSummary, + ChatUsage, + GraphStats, + NodeDetail, + QueryResponse, + SearchResponse, + SearchResult, + TraverseResponse, +) + +__version__ = "0.1.0" + +__all__ = [ + "Client", + "AsyncClient", + "OpenGraphError", + "AuthError", + "RateLimitError", + "NotFoundError", + "ServerError", + "ValidationError", + "QueryResponse", + "ChatUsage", + "GraphStats", + "SearchResponse", + "SearchResult", + "NodeDetail", + "TraverseResponse", + "ChatSessionSummary", + "ChatSessionDetail", + "ChatMessage", + "BuildJobSummary", + "__version__", +] diff --git a/sdks/python/opengraph_sdk/client.py b/sdks/python/opengraph_sdk/client.py new file mode 100644 index 0000000..6325425 --- /dev/null +++ b/sdks/python/opengraph_sdk/client.py @@ -0,0 +1,579 @@ +""" +Sync + async clients for the OpenGraph developer API. + +Both clients share their entire request/response code path via the +``_request`` helper; the sync variant wraps ``httpx.Client`` and the async +variant wraps ``httpx.AsyncClient``. Keeping them twins avoids the common +bug where the sync and async surfaces drift in retry / error semantics. + +Every call routes through ``/api/v1/ext/*`` and carries the API key as a +bearer header. Responses are parsed into :mod:`opengraph_sdk.models`; +non-2xx responses are raised as subclasses of +:class:`opengraph_sdk.errors.OpenGraphError` with the parsed JSON body +attached for inspection. + +Retry policy: a bounded backoff retries transient failures (429 / +502 / 503 / 504 / network errors). 4xx responses that aren't 429 raise +immediately — they signal caller-side bugs that retrying won't fix. +""" + +from __future__ import annotations + +import os +import random +import time +from typing import Any, Optional + +import httpx + +from opengraph_sdk.errors import ( + AuthError, + NotFoundError, + OpenGraphError, + RateLimitError, + ServerError, + ValidationError, +) +from opengraph_sdk.models import ( + BuildJobSummary, + ChatSessionDetail, + ChatSessionSummary, + GraphStats, + NodeDetail, + QueryResponse, + SearchResponse, + TraverseResponse, +) + +_DEFAULT_BASE_URL = "https://api.opengraph.example" +_DEFAULT_TIMEOUT_SECONDS = 60.0 +_USER_AGENT = "opengraph-sdk-python/0.1.0 (+https://opengraph.example)" + +# Retries cover the kinds of failure the client can safely replay. +# 401/404/400 aren't retried — the caller can't fix them by trying again. +_RETRY_STATUSES = {429, 502, 503, 504} +_DEFAULT_RETRIES = 2 + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + +def _raise_for_status(resp: httpx.Response) -> None: + """Translate an error response into the right typed exception.""" + if resp.status_code < 400: + return + # Try to parse a JSON body; fall back to empty payload. + payload: Any = None + try: + payload = resp.json() + except Exception: # noqa: BLE001 — body may be HTML / truncated / missing + payload = None + + detail = None + if isinstance(payload, dict): + # FastAPI uses {"detail": "..."}; RFC 7807 payloads use "detail" too. + detail = payload.get("detail") if isinstance(payload.get("detail"), str) else None + if detail is None and "title" in payload: + detail = payload.get("title") + + message = detail or f"HTTP {resp.status_code}" + status = resp.status_code + + if status == 401: + raise AuthError(message, status=status, detail=detail, payload=payload) + if status == 404: + raise NotFoundError(message, status=status, detail=detail, payload=payload) + if status in (400, 422): + raise ValidationError(message, status=status, detail=detail, payload=payload) + if status == 429: + retry_after = None + ra = resp.headers.get("Retry-After") + if ra and ra.isdigit(): + retry_after = int(ra) + raise RateLimitError( + message, + retry_after_seconds=retry_after, + status=status, detail=detail, payload=payload, + ) + if status >= 500: + raise ServerError(message, status=status, detail=detail, payload=payload) + raise OpenGraphError(message, status=status, detail=detail, payload=payload) + + +def _sleep_for_attempt(attempt: int, resp: Optional[httpx.Response]) -> float: + """Exponential backoff with a Retry-After override, jittered.""" + if resp is not None: + ra = resp.headers.get("Retry-After") + if ra and ra.isdigit(): + return max(1.0, float(int(ra))) + base = min(30.0, 0.5 * (2 ** attempt)) + return base + random.uniform(0, 0.25 * base) + + +# --------------------------------------------------------------------------- +# Sync client +# --------------------------------------------------------------------------- + +class _Namespace: + """Tiny helper so ``client.graph.stats(...)`` reads naturally. + + Each resource namespace below is an instance of this bound to the + parent client — keeps dotted access without duplicating transport + logic. + """ + + +class Client: + """Synchronous client for the OpenGraph developer API.""" + + def __init__( + self, + api_key: Optional[str] = None, + *, + base_url: Optional[str] = None, + workspace_id: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT_SECONDS, + max_retries: int = _DEFAULT_RETRIES, + ) -> None: + self._api_key = api_key or os.environ.get("OPENGRAPH_API_KEY", "") + if not self._api_key: + raise OpenGraphError( + "Missing API key. Pass api_key=... or set OPENGRAPH_API_KEY.", + ) + self._base_url = ( + base_url + or os.environ.get("OPENGRAPH_BASE_URL") + or _DEFAULT_BASE_URL + ).rstrip("/") + self._default_workspace_id = ( + workspace_id or os.environ.get("OPENGRAPH_WORKSPACE_ID") + ) + self._max_retries = max_retries + self._http = httpx.Client( + timeout=timeout, + headers={ + "Authorization": f"Bearer {self._api_key}", + "User-Agent": _USER_AGENT, + }, + ) + self.graph = _GraphNamespace(self) + self.history = _HistoryNamespace(self) + + # Context-manager support — close the underlying HTTP client cleanly. + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + self._http.close() + + # -- Core request helper ------------------------------------------------- + + def _request( + self, + method: str, + path: str, + *, + params: Optional[dict[str, Any]] = None, + json: Optional[dict[str, Any]] = None, + ) -> Any: + url = f"{self._base_url}{path}" + last_exc: Optional[Exception] = None + last_resp: Optional[httpx.Response] = None + for attempt in range(self._max_retries + 1): + try: + resp = self._http.request(method, url, params=params, json=json) + except (httpx.TransportError, httpx.ReadTimeout) as exc: + last_exc = exc + last_resp = None + if attempt >= self._max_retries: + raise ServerError(f"Network error: {exc}", status=0) from exc + time.sleep(_sleep_for_attempt(attempt, None)) + continue + if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries: + last_resp = resp + time.sleep(_sleep_for_attempt(attempt, resp)) + continue + _raise_for_status(resp) + return resp.json() + # Should be unreachable — either we returned or we raised above. + if last_exc: + raise ServerError(f"Retry budget exhausted: {last_exc}", status=0) from last_exc + if last_resp is not None: + _raise_for_status(last_resp) + raise ServerError("Retry budget exhausted.") + + # -- Workspace helper ---------------------------------------------------- + + def _ws(self, explicit: Optional[str]) -> Optional[str]: + return explicit or self._default_workspace_id + + # -- Endpoints ----------------------------------------------------------- + + def query( + self, + query: str, + *, + workspace_id: Optional[str] = None, + session_id: Optional[str] = None, + llm_model: Optional[str] = None, + debug: bool = False, + ) -> QueryResponse: + """Ask the graph a natural-language question.""" + body = { + "workspace_id": self._ws(workspace_id), + "query": query, + "session_id": session_id, + "llm_model": llm_model, + "debug": debug, + } + data = self._request("POST", "/api/v1/ext/query", json={k: v for k, v in body.items() if v is not None or k in ("debug",)}) + return QueryResponse.model_validate(data) + + +class _GraphNamespace: + def __init__(self, client: Client) -> None: + self._c = client + + def stats(self, *, workspace_id: Optional[str] = None) -> GraphStats: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = self._c._request("GET", "/api/v1/ext/graph/stats", params=params) + return GraphStats.model_validate(data) + + def search( + self, + q: str, + *, + workspace_id: Optional[str] = None, + top_k: int = 10, + node_type: Optional[str] = None, + ) -> SearchResponse: + params: dict[str, Any] = {"q": q, "top_k": top_k} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + if node_type: + params["node_type"] = node_type + data = self._c._request("GET", "/api/v1/ext/graph/search", params=params) + return SearchResponse.model_validate(data) + + def node(self, node_id: str, *, workspace_id: Optional[str] = None) -> NodeDetail: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = self._c._request( + "GET", f"/api/v1/ext/graph/node/{node_id}", params=params, + ) + return NodeDetail.model_validate(data) + + def traverse( + self, + node_id: str, + *, + workspace_id: Optional[str] = None, + max_depth: int = 3, + edge_types: Optional[list[str]] = None, + ) -> TraverseResponse: + body: dict[str, Any] = { + "workspace_id": self._c._ws(workspace_id), + "node_id": node_id, + "max_depth": max_depth, + } + if edge_types: + body["edge_types"] = edge_types + data = self._c._request("POST", "/api/v1/ext/graph/traverse", json=body) + return TraverseResponse.model_validate(data) + + def tree(self, *, workspace_id: Optional[str] = None) -> dict[str, Any]: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + return self._c._request("GET", "/api/v1/ext/graph/tree", params=params) + + def tools( + self, + *, + workspace_id: Optional[str] = None, + category: Optional[str] = None, + provider: Optional[str] = None, + search: Optional[str] = None, + limit: int = 50, + ) -> dict[str, Any]: + params: dict[str, Any] = {"limit": limit} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + if category: + params["category"] = category + if provider: + params["provider"] = provider + if search: + params["search"] = search + return self._c._request("GET", "/api/v1/ext/graph/tools", params=params) + + def chapters(self, *, workspace_id: Optional[str] = None) -> dict[str, Any]: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + return self._c._request("GET", "/api/v1/ext/graph/chapters", params=params) + + +class _HistoryNamespace: + def __init__(self, client: Client) -> None: + self._c = client + + def chats( + self, + *, + workspace_id: Optional[str] = None, + limit: int = 50, + ) -> list[ChatSessionSummary]: + params: dict[str, Any] = {"limit": limit} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = self._c._request("GET", "/api/v1/ext/history/chats", params=params) + return [ChatSessionSummary.model_validate(s) for s in data.get("sessions", [])] + + def chat( + self, + session_id: str, + *, + workspace_id: Optional[str] = None, + ) -> ChatSessionDetail: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = self._c._request( + "GET", f"/api/v1/ext/history/chats/{session_id}", params=params, + ) + return ChatSessionDetail.model_validate(data) + + def builds( + self, + *, + workspace_id: Optional[str] = None, + limit: int = 50, + ) -> list[BuildJobSummary]: + params: dict[str, Any] = {"limit": limit} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = self._c._request("GET", "/api/v1/ext/history/builds", params=params) + return [BuildJobSummary.model_validate(b) for b in data.get("builds", [])] + + def build(self, job_id: str) -> BuildJobSummary: + data = self._c._request("GET", f"/api/v1/ext/history/builds/{job_id}") + return BuildJobSummary.model_validate(data) + + +# --------------------------------------------------------------------------- +# Async client — mirror of Client via httpx.AsyncClient +# --------------------------------------------------------------------------- + +class AsyncClient: + """Asynchronous client for the OpenGraph developer API. + + Shares its code path with :class:`Client` via a parallel implementation. + Prefer this when calling from inside an asyncio application. + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + base_url: Optional[str] = None, + workspace_id: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT_SECONDS, + max_retries: int = _DEFAULT_RETRIES, + ) -> None: + self._api_key = api_key or os.environ.get("OPENGRAPH_API_KEY", "") + if not self._api_key: + raise OpenGraphError( + "Missing API key. Pass api_key=... or set OPENGRAPH_API_KEY.", + ) + self._base_url = ( + base_url + or os.environ.get("OPENGRAPH_BASE_URL") + or _DEFAULT_BASE_URL + ).rstrip("/") + self._default_workspace_id = ( + workspace_id or os.environ.get("OPENGRAPH_WORKSPACE_ID") + ) + self._max_retries = max_retries + self._http = httpx.AsyncClient( + timeout=timeout, + headers={ + "Authorization": f"Bearer {self._api_key}", + "User-Agent": _USER_AGENT, + }, + ) + self.graph = _AsyncGraphNamespace(self) + self.history = _AsyncHistoryNamespace(self) + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def close(self) -> None: + await self._http.aclose() + + def _ws(self, explicit: Optional[str]) -> Optional[str]: + return explicit or self._default_workspace_id + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[dict[str, Any]] = None, + json: Optional[dict[str, Any]] = None, + ) -> Any: + import asyncio + url = f"{self._base_url}{path}" + last_exc: Optional[Exception] = None + last_resp: Optional[httpx.Response] = None + for attempt in range(self._max_retries + 1): + try: + resp = await self._http.request(method, url, params=params, json=json) + except (httpx.TransportError, httpx.ReadTimeout) as exc: + last_exc = exc + last_resp = None + if attempt >= self._max_retries: + raise ServerError(f"Network error: {exc}", status=0) from exc + await asyncio.sleep(_sleep_for_attempt(attempt, None)) + continue + if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries: + last_resp = resp + await asyncio.sleep(_sleep_for_attempt(attempt, resp)) + continue + _raise_for_status(resp) + return resp.json() + if last_exc: + raise ServerError(f"Retry budget exhausted: {last_exc}", status=0) from last_exc + if last_resp is not None: + _raise_for_status(last_resp) + raise ServerError("Retry budget exhausted.") + + async def query( + self, + query: str, + *, + workspace_id: Optional[str] = None, + session_id: Optional[str] = None, + llm_model: Optional[str] = None, + debug: bool = False, + ) -> QueryResponse: + body = { + "workspace_id": self._ws(workspace_id), + "query": query, + "session_id": session_id, + "llm_model": llm_model, + "debug": debug, + } + data = await self._request( + "POST", "/api/v1/ext/query", + json={k: v for k, v in body.items() if v is not None or k in ("debug",)}, + ) + return QueryResponse.model_validate(data) + + +class _AsyncGraphNamespace: + def __init__(self, client: AsyncClient) -> None: + self._c = client + + async def stats(self, *, workspace_id: Optional[str] = None) -> GraphStats: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = await self._c._request("GET", "/api/v1/ext/graph/stats", params=params) + return GraphStats.model_validate(data) + + async def search( + self, + q: str, + *, + workspace_id: Optional[str] = None, + top_k: int = 10, + node_type: Optional[str] = None, + ) -> SearchResponse: + params: dict[str, Any] = {"q": q, "top_k": top_k} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + if node_type: + params["node_type"] = node_type + data = await self._c._request("GET", "/api/v1/ext/graph/search", params=params) + return SearchResponse.model_validate(data) + + async def node(self, node_id: str, *, workspace_id: Optional[str] = None) -> NodeDetail: + params = {} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = await self._c._request( + "GET", f"/api/v1/ext/graph/node/{node_id}", params=params, + ) + return NodeDetail.model_validate(data) + + async def traverse( + self, + node_id: str, + *, + workspace_id: Optional[str] = None, + max_depth: int = 3, + edge_types: Optional[list[str]] = None, + ) -> TraverseResponse: + body: dict[str, Any] = { + "workspace_id": self._c._ws(workspace_id), + "node_id": node_id, + "max_depth": max_depth, + } + if edge_types: + body["edge_types"] = edge_types + data = await self._c._request("POST", "/api/v1/ext/graph/traverse", json=body) + return TraverseResponse.model_validate(data) + + +class _AsyncHistoryNamespace: + def __init__(self, client: AsyncClient) -> None: + self._c = client + + async def chats( + self, + *, + workspace_id: Optional[str] = None, + limit: int = 50, + ) -> list[ChatSessionSummary]: + params: dict[str, Any] = {"limit": limit} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = await self._c._request("GET", "/api/v1/ext/history/chats", params=params) + return [ChatSessionSummary.model_validate(s) for s in data.get("sessions", [])] + + async def builds( + self, + *, + workspace_id: Optional[str] = None, + limit: int = 50, + ) -> list[BuildJobSummary]: + params: dict[str, Any] = {"limit": limit} + wid = self._c._ws(workspace_id) + if wid: + params["workspace_id"] = wid + data = await self._c._request("GET", "/api/v1/ext/history/builds", params=params) + return [BuildJobSummary.model_validate(b) for b in data.get("builds", [])] diff --git a/sdks/python/opengraph_sdk/errors.py b/sdks/python/opengraph_sdk/errors.py new file mode 100644 index 0000000..28f9360 --- /dev/null +++ b/sdks/python/opengraph_sdk/errors.py @@ -0,0 +1,67 @@ +"""Exception types for the OpenGraph SDK. + +Every network failure surfaces as a subclass of :class:`OpenGraphError` +so callers can catch one base and inspect ``.status`` for the HTTP code. +""" + +from __future__ import annotations + +from typing import Any, Optional + + +class OpenGraphError(Exception): + """Base class for every error raised by the SDK. + + Attributes: + status: the HTTP status code returned by the server, or 0 for + client-side / network errors. + detail: the human-readable message from the server's JSON body. + payload: the full parsed JSON body, or None. + """ + + def __init__( + self, + message: str, + *, + status: int = 0, + detail: Optional[str] = None, + payload: Optional[Any] = None, + ) -> None: + super().__init__(message) + self.status = status + self.detail = detail + self.payload = payload + + +class AuthError(OpenGraphError): + """401 — missing or invalid API key.""" + + +class RateLimitError(OpenGraphError): + """429 — per-key quota exhausted. + + Attributes: + retry_after_seconds: value of the ``Retry-After`` header, or None. + """ + + def __init__( + self, + message: str, + *, + retry_after_seconds: Optional[int] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + self.retry_after_seconds = retry_after_seconds + + +class NotFoundError(OpenGraphError): + """404 — workspace, node, or resource doesn't exist (or isn't yours).""" + + +class ValidationError(OpenGraphError): + """400 / 422 — request payload failed server-side validation.""" + + +class ServerError(OpenGraphError): + """5xx — transient backend failure. Safe to retry with backoff.""" diff --git a/sdks/python/opengraph_sdk/models.py b/sdks/python/opengraph_sdk/models.py new file mode 100644 index 0000000..e09a014 --- /dev/null +++ b/sdks/python/opengraph_sdk/models.py @@ -0,0 +1,119 @@ +"""Typed response models for /api/v1/ext/*. + +Mirrors :mod:`src.api.ext_models` on the server side one-for-one. These +are hand-written (not codegen'd) so we can add properties + docstrings +for a cleaner DX — but the field set matches the wire format exactly so +they can be swapped out for codegen output later without a breaking change. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class _Base(BaseModel): + # Allow extra fields from the server so a future field-addition in v1 + # doesn't break older SDK versions. + model_config = ConfigDict(extra="allow") + + +class ChatUsage(_Base): + llm_prompt_tokens: int = 0 + llm_completion_tokens: int = 0 + llm_total_tokens: int = 0 + llm_calls: int = 0 + model: Optional[str] = None + + +class QueryResponse(_Base): + session_id: str + response: str + intent: str = "" + kb_focus: str = "both" + extracted_topics: list[str] = Field(default_factory=list) + follow_up_suggestions: list[str] = Field(default_factory=list) + llm_model: Optional[str] = None + usage: Optional[ChatUsage] = None + duration_ms: int = 0 + history_persisted: bool = True + + # Populated only when the request had ``debug=True``. + steps: Optional[list[dict[str, Any]]] = None + traversal_path: Optional[list[str]] = None + knowledge_concepts: Optional[list[dict[str, Any]]] = None + tools_referenced: Optional[list[dict[str, Any]]] = None + + +class GraphStats(_Base): + total_nodes: int = 0 + total_edges: int = 0 + nodes_by_type: dict[str, int] = Field(default_factory=dict) + edges_by_type: dict[str, int] = Field(default_factory=dict) + backends: dict[str, Any] = Field(default_factory=dict) + + +class SearchResult(_Base): + node_id: str + node_type: str + heading: str + kb_source: str + score: float + + +class SearchResponse(_Base): + query: str + results: list[SearchResult] = Field(default_factory=list) + + +class NodeDetail(_Base): + node: dict[str, Any] + edges: list[dict[str, Any]] = Field(default_factory=list) + children: list[dict[str, Any]] = Field(default_factory=list) + parent: Optional[str] = None + + +class TraverseResponse(_Base): + root_node_id: str + traversal_path: list[str] = Field(default_factory=list) + nodes: list[dict[str, Any]] = Field(default_factory=list) + edge_count: int = 0 + + +class ChatSessionSummary(_Base): + session_id: str + title: Optional[str] = None + message_count: int = 0 + created_at: datetime + last_activity_at: datetime + + +class ChatMessage(_Base): + id: int + role: str + query: Optional[str] = None + response: Optional[dict[str, Any]] = None + intent: Optional[str] = None + kb_focus: Optional[str] = None + created_at: datetime + duration_ms: int = 0 + + +class ChatSessionDetail(_Base): + session_id: str + workspace_id: str + title: Optional[str] = None + messages: list[ChatMessage] = Field(default_factory=list) + + +class BuildJobSummary(_Base): + job_id: str + workspace_id: str + status: str + stage: int + stage_name: str + percent: int + created_at: datetime + finished_at: Optional[datetime] = None diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 0000000..7879ce5 --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opengraph-sdk" +version = "0.1.0" +description = "Official Python SDK for the OpenGraph developer API." +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.9" +keywords = ["knowledge-graph", "llm", "rag", "opengraph"] +authors = [{ name = "OpenGraph" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "respx>=0.21", + "ruff>=0.6", +] + +[project.urls] +Homepage = "https://github.com/anthropics/opengraph" +Documentation = "https://opengraph.example/api-docs" +Issues = "https://github.com/anthropics/opengraph/issues" + +[tool.hatch.build.targets.wheel] +packages = ["opengraph_sdk"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py new file mode 100644 index 0000000..3651b23 --- /dev/null +++ b/sdks/python/tests/test_client.py @@ -0,0 +1,118 @@ +"""Unit tests for the Python SDK using `respx` mocks. + +Run with: pip install -e ".[dev]" && pytest + +These tests verify client-side contract only (payload shapes + error +mapping). A contract test against a real backend lives in +``examples/query.py``. +""" + +from __future__ import annotations + +import pytest +import respx +from httpx import Response + +from opengraph_sdk import AuthError, Client, NotFoundError, RateLimitError + + +BASE = "http://test.example" +API_KEY = "og_live_test" + + +def _client() -> Client: + return Client(api_key=API_KEY, base_url=BASE, max_retries=0) + + +def test_query_happy_path() -> None: + with respx.mock: + respx.post(f"{BASE}/api/v1/ext/query").mock( + return_value=Response( + 200, + json={ + "session_id": "sess-1", + "response": "hello world", + "intent": "greet", + "kb_focus": "both", + "extracted_topics": [], + "follow_up_suggestions": ["what next?"], + "llm_model": "test-model", + "usage": { + "llm_prompt_tokens": 10, + "llm_completion_tokens": 5, + "llm_total_tokens": 15, + "llm_calls": 1, + }, + "duration_ms": 42, + "history_persisted": True, + }, + ) + ) + with _client() as c: + resp = c.query("hi", workspace_id="ws-1") + assert resp.session_id == "sess-1" + assert resp.response == "hello world" + assert resp.usage is not None + assert resp.usage.llm_total_tokens == 15 + assert resp.follow_up_suggestions == ["what next?"] + + +def test_auth_error_maps_to_auth_error() -> None: + with respx.mock: + respx.get(f"{BASE}/api/v1/ext/graph/stats").mock( + return_value=Response(401, json={"detail": "Invalid or missing API key."}) + ) + with _client() as c: + with pytest.raises(AuthError) as info: + c.graph.stats(workspace_id="ws-1") + assert info.value.status == 401 + assert "Invalid" in (info.value.detail or "") + + +def test_not_found_error() -> None: + with respx.mock: + respx.get(f"{BASE}/api/v1/ext/graph/node/missing").mock( + return_value=Response(404, json={"detail": "Node 'missing' not found."}) + ) + with _client() as c: + with pytest.raises(NotFoundError): + c.graph.node("missing", workspace_id="ws-1") + + +def test_rate_limit_includes_retry_after() -> None: + with respx.mock: + respx.get(f"{BASE}/api/v1/ext/graph/stats").mock( + return_value=Response( + 429, + json={"detail": "Rate limit exceeded — 60 per minute."}, + headers={"Retry-After": "42"}, + ) + ) + with _client() as c: + with pytest.raises(RateLimitError) as info: + c.graph.stats(workspace_id="ws-1") + assert info.value.retry_after_seconds == 42 + + +def test_workspace_id_env_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENGRAPH_WORKSPACE_ID", "ws-from-env") + captured: dict[str, object] = {} + + with respx.mock: + def _capture(request): + captured["body"] = request.read() + return Response( + 200, + json={ + "session_id": "sess-1", "response": "ok", "intent": "", + "kb_focus": "both", "extracted_topics": [], + "follow_up_suggestions": [], "duration_ms": 0, + "history_persisted": True, + }, + ) + respx.post(f"{BASE}/api/v1/ext/query").mock(side_effect=_capture) + with Client(api_key=API_KEY, base_url=BASE, max_retries=0) as c: + c.query("hi") + import json + payload = json.loads(captured["body"]) # type: ignore[arg-type] + assert payload["workspace_id"] == "ws-from-env" From e0be8f5d515c5d836ee4b47dea3c532ea1715fda Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 14:37:36 +0530 Subject: [PATCH 7/8] feat: refine API key management and enhance user interface - Improved the user interface for API key management, including better navigation and accessibility features. - Updated error handling to provide clearer feedback during API key operations. - Streamlined the API key creation and revocation processes for a more intuitive user experience. --- sdks/typescript/README.md | 102 ++++++++ sdks/typescript/examples/query.mjs | 34 +++ sdks/typescript/package.json | 60 +++++ sdks/typescript/src/client.ts | 367 +++++++++++++++++++++++++++ sdks/typescript/src/errors.ts | 61 +++++ sdks/typescript/src/index.ts | 27 ++ sdks/typescript/src/types.ts | 144 +++++++++++ sdks/typescript/tests/client.test.ts | 135 ++++++++++ sdks/typescript/tsconfig.json | 22 ++ sdks/typescript/vitest.config.ts | 8 + 10 files changed, 960 insertions(+) create mode 100644 sdks/typescript/README.md create mode 100644 sdks/typescript/examples/query.mjs create mode 100644 sdks/typescript/package.json create mode 100644 sdks/typescript/src/client.ts create mode 100644 sdks/typescript/src/errors.ts create mode 100644 sdks/typescript/src/index.ts create mode 100644 sdks/typescript/src/types.ts create mode 100644 sdks/typescript/tests/client.test.ts create mode 100644 sdks/typescript/tsconfig.json create mode 100644 sdks/typescript/vitest.config.ts diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md new file mode 100644 index 0000000..63f099f --- /dev/null +++ b/sdks/typescript/README.md @@ -0,0 +1,102 @@ +# @opengraph/sdk + +Official TypeScript/JavaScript SDK for the [OpenGraph](https://opengraph.example) developer API. + +```bash +npm install @opengraph/sdk +# or +pnpm add @opengraph/sdk +# or +yarn add @opengraph/sdk +``` + +Works in Node ≥ 18 (global `fetch`) and modern browsers. Zero runtime deps. + +## Quick start + +```ts +import { OpenGraphClient } from "@opengraph/sdk"; + +const client = new OpenGraphClient({ + apiKey: "og_live_…", // or env OPENGRAPH_API_KEY + baseUrl: "https://api.opengraph.example", // or env OPENGRAPH_BASE_URL +}); + +const resp = await client.query({ + workspaceId: "…", // or env OPENGRAPH_WORKSPACE_ID + query: "What are the tools used in the payments pipeline?", +}); + +console.log(resp.response); +resp.follow_up_suggestions.forEach((s) => console.log("→", s)); +``` + +## Surface + +```ts +await client.query({ workspaceId, query, sessionId?, llmModel?, debug? }); + +// Graph reads +await client.graph.stats({ workspaceId }); +await client.graph.search({ q, workspaceId, topK, nodeType }); +await client.graph.node(nodeId, { workspaceId }); +await client.graph.traverse({ nodeId, workspaceId, maxDepth, edgeTypes }); +await client.graph.tree({ workspaceId }); +await client.graph.tools({ workspaceId, category, provider, search, limit }); +await client.graph.chapters({ workspaceId }); + +// History +await client.history.chats({ workspaceId, limit }); +await client.history.chat(sessionId, { workspaceId }); +await client.history.builds({ workspaceId, limit }); +await client.history.build(jobId); +``` + +## Errors + +```ts +import { + AuthError, // 401 + NotFoundError, // 404 + ValidationError, // 400 / 422 + RateLimitError, // 429 — .retryAfterSeconds + ServerError, // 5xx or network + OpenGraphError, // base +} from "@opengraph/sdk"; + +try { + await client.query({ workspaceId, query: "…" }); +} catch (err) { + if (err instanceof RateLimitError) { + await new Promise((r) => setTimeout(r, (err.retryAfterSeconds ?? 60) * 1000)); + } else if (err instanceof AuthError) { + console.error("API key rejected — check /api-keys dashboard."); + } else { + throw err; + } +} +``` + +## Configuration + +Every option can come from an env var: + +| Env var | Purpose | +|---|---| +| `OPENGRAPH_API_KEY` | Default API key when `apiKey` is omitted | +| `OPENGRAPH_BASE_URL` | Override the API base | +| `OPENGRAPH_WORKSPACE_ID` | Default workspace for every call | + +## Retries + +Transient failures (429, 502, 503, 504, network errors) retry with +exponential backoff and honour `Retry-After`. 4xx responses that aren't +429 throw immediately. + +```ts +new OpenGraphClient({ apiKey: "…", maxRetries: 5 }); +``` + +## License + +MIT. diff --git a/sdks/typescript/examples/query.mjs b/sdks/typescript/examples/query.mjs new file mode 100644 index 0000000..fa57854 --- /dev/null +++ b/sdks/typescript/examples/query.mjs @@ -0,0 +1,34 @@ +// Minimal example — query a graph and print the answer. +// +// Build first: npm run build +// Then run: OPENGRAPH_API_KEY=og_live_... \ +// OPENGRAPH_WORKSPACE_ID=... \ +// OPENGRAPH_BASE_URL=http://localhost:8000 \ +// node examples/query.mjs "your question" + +import { OpenGraphClient } from "../dist/index.js"; + +const client = new OpenGraphClient(); + +const question = process.argv.slice(2).join(" ") || "What's in this knowledge base?"; +console.log(`Q: ${question}\n`); + +try { + const resp = await client.query({ query: question }); + console.log("A:", resp.response); + if (resp.follow_up_suggestions?.length) { + console.log("\nFollow-ups:"); + for (const s of resp.follow_up_suggestions) console.log(` - ${s}`); + } + if (resp.usage) { + console.log( + `\nTokens: ${resp.usage.llm_total_tokens} ` + + `(prompt ${resp.usage.llm_prompt_tokens} / completion ${resp.usage.llm_completion_tokens})`, + ); + } + console.log(`Session id: ${resp.session_id} · took ${resp.duration_ms} ms`); +} catch (err) { + const e = /** @type {Error & { status?: number }} */ (err); + console.error(`API error${e.status ? ` (${e.status})` : ""}: ${e.message}`); + process.exit(1); +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json new file mode 100644 index 0000000..5b1a4db --- /dev/null +++ b/sdks/typescript/package.json @@ -0,0 +1,60 @@ +{ + "name": "@opengraph/sdk", + "version": "0.1.0", + "description": "Official TypeScript/JavaScript SDK for the OpenGraph developer API.", + "license": "MIT", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist", + "README.md" + ], + "sideEffects": false, + "keywords": [ + "opengraph", + "knowledge-graph", + "llm", + "rag", + "sdk" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "msw": "^2.4.0", + "tsup": "^8.2.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/anthropics/opengraph.git", + "directory": "sdks/typescript" + }, + "homepage": "https://opengraph.example/api-docs" +} diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts new file mode 100644 index 0000000..e0e8b7f --- /dev/null +++ b/sdks/typescript/src/client.ts @@ -0,0 +1,367 @@ +/** + * OpenGraphClient — single class covering the public /api/v1/ext/* surface. + * + * Works in any runtime that exposes `fetch` (Node ≥ 18 and modern browsers + * both qualify). Zero runtime dependencies; types come from `./types`, + * errors from `./errors`. + * + * The client groups related endpoints under `graph` and `history` to keep + * call sites readable: + * + * await client.query({ workspaceId, query: "..." }) + * await client.graph.stats({ workspaceId }) + * await client.history.chats({ workspaceId }) + * + * Retries: the `_request` helper replays 429 / 502 / 503 / 504 / network + * errors with exponential backoff (and honours `Retry-After`). 4xx errors + * other than 429 throw immediately. + */ + +import { + AuthError, + NotFoundError, + OpenGraphError, + RateLimitError, + ServerError, + ValidationError, +} from "./errors"; +import type { + BuildJobSummary, + ChatSessionDetail, + ChatSessionSummary, + GraphSearchRequest, + GraphStats, + NodeDetail, + QueryRequest, + QueryResponse, + SearchResponse, + TraverseRequestArgs, + TraverseResponse, +} from "./types"; + +const DEFAULT_BASE_URL = "https://api.opengraph.example"; +const DEFAULT_TIMEOUT_MS = 60_000; +const USER_AGENT = "opengraph-sdk-ts/0.1.0"; +const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); +const DEFAULT_RETRIES = 2; + +export interface OpenGraphClientOptions { + apiKey?: string; + baseUrl?: string; + /** Default workspace id; individual calls can still override. */ + workspaceId?: string; + timeoutMs?: number; + maxRetries?: number; + /** Injectable for tests; defaults to the global `fetch`. */ + fetch?: typeof fetch; +} + +interface RequestInit { + method: "GET" | "POST" | "DELETE"; + path: string; + query?: Record; + body?: unknown; +} + +function envVar(name: string): string | undefined { + if (typeof process !== "undefined" && process.env) return process.env[name]; + return undefined; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function backoffMs(attempt: number, retryAfter?: number): number { + if (retryAfter && retryAfter > 0) return Math.max(1000, retryAfter * 1000); + const base = Math.min(30_000, 500 * 2 ** attempt); + return base + Math.random() * 0.25 * base; +} + +function parseRetryAfter(h: Headers): number | undefined { + const ra = h.get("retry-after"); + if (!ra) return undefined; + const n = parseInt(ra, 10); + return Number.isFinite(n) ? n : undefined; +} + +function buildUrl( + baseUrl: string, + path: string, + query?: Record, +): string { + const url = new URL(path, baseUrl + "/"); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v === undefined || v === null) continue; + url.searchParams.set(k, String(v)); + } + } + return url.toString(); +} + +async function throwForStatus(resp: Response): Promise { + let payload: unknown = undefined; + try { + payload = await resp.json(); + } catch { + // Body might be HTML / empty — that's fine. + } + let detail: string | undefined; + if (payload && typeof payload === "object" && "detail" in payload) { + const d = (payload as { detail?: unknown }).detail; + if (typeof d === "string") detail = d; + else if (d && typeof d === "object" && "message" in d) { + const m = (d as { message?: unknown }).message; + if (typeof m === "string") detail = m; + } + } + const message = detail ?? `HTTP ${resp.status}`; + const opts = { status: resp.status, detail, payload }; + + if (resp.status === 401) throw new AuthError(message, opts); + if (resp.status === 404) throw new NotFoundError(message, opts); + if (resp.status === 400 || resp.status === 422) throw new ValidationError(message, opts); + if (resp.status === 429) { + throw new RateLimitError(message, { ...opts, retryAfterSeconds: parseRetryAfter(resp.headers) }); + } + if (resp.status >= 500) throw new ServerError(message, opts); + throw new OpenGraphError(message, opts); +} + +export class OpenGraphClient { + readonly graph: GraphNamespace; + readonly history: HistoryNamespace; + + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly defaultWorkspaceId?: string; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly fetchImpl: typeof fetch; + + constructor(opts: OpenGraphClientOptions = {}) { + this.apiKey = opts.apiKey ?? envVar("OPENGRAPH_API_KEY") ?? ""; + if (!this.apiKey) { + throw new OpenGraphError( + "Missing API key. Pass { apiKey } or set OPENGRAPH_API_KEY.", + ); + } + this.baseUrl = (opts.baseUrl ?? envVar("OPENGRAPH_BASE_URL") ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.defaultWorkspaceId = opts.workspaceId ?? envVar("OPENGRAPH_WORKSPACE_ID"); + this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.maxRetries = opts.maxRetries ?? DEFAULT_RETRIES; + this.fetchImpl = opts.fetch ?? globalThis.fetch; + if (!this.fetchImpl) { + throw new OpenGraphError( + "No global `fetch` — pass one in via { fetch } or upgrade to Node >= 18.", + ); + } + this.graph = new GraphNamespace(this); + this.history = new HistoryNamespace(this); + } + + /** Resolve the effective workspace id for a call. */ + resolveWorkspace(explicit?: string): string | undefined { + return explicit ?? this.defaultWorkspaceId; + } + + /** Low-level request helper. Exposed for advanced callers; prefer the + * typed methods on this class. */ + async request(init: RequestInit): Promise { + const url = buildUrl(this.baseUrl, init.path, init.query); + const headers: Record = { + Authorization: `Bearer ${this.apiKey}`, + "User-Agent": USER_AGENT, + }; + let body: string | undefined; + if (init.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(init.body); + } + + let lastErr: unknown; + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let resp: Response; + try { + resp = await this.fetchImpl(url, { + method: init.method, + headers, + body, + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + lastErr = err; + if (attempt >= this.maxRetries) { + const msg = err instanceof Error ? err.message : String(err); + throw new ServerError(`Network error: ${msg}`); + } + await sleep(backoffMs(attempt)); + continue; + } + clearTimeout(timer); + if (RETRYABLE_STATUSES.has(resp.status) && attempt < this.maxRetries) { + const ra = parseRetryAfter(resp.headers); + await sleep(backoffMs(attempt, ra)); + continue; + } + if (!resp.ok) { + await throwForStatus(resp); + } + return (await resp.json()) as T; + } + if (lastErr instanceof Error) throw new ServerError(`Retry budget exhausted: ${lastErr.message}`); + throw new ServerError("Retry budget exhausted."); + } + + // ------------------------------------------------------------------------- + // Top-level endpoints + // ------------------------------------------------------------------------- + + query(req: QueryRequest): Promise { + const workspaceId = this.resolveWorkspace(req.workspaceId); + return this.request({ + method: "POST", + path: "/api/v1/ext/query", + body: { + workspace_id: workspaceId, + query: req.query, + session_id: req.sessionId, + llm_model: req.llmModel, + debug: req.debug ?? false, + }, + }); + } +} + +// --------------------------------------------------------------------------- +// Resource namespaces +// --------------------------------------------------------------------------- + +class GraphNamespace { + constructor(private readonly c: OpenGraphClient) {} + + stats(opts: { workspaceId?: string } = {}): Promise { + return this.c.request({ + method: "GET", + path: "/api/v1/ext/graph/stats", + query: { workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined }, + }); + } + + search(req: GraphSearchRequest): Promise { + return this.c.request({ + method: "GET", + path: "/api/v1/ext/graph/search", + query: { + q: req.q, + workspace_id: this.c.resolveWorkspace(req.workspaceId) ?? undefined, + top_k: req.topK ?? 10, + node_type: req.nodeType, + }, + }); + } + + node(nodeId: string, opts: { workspaceId?: string } = {}): Promise { + return this.c.request({ + method: "GET", + path: `/api/v1/ext/graph/node/${encodeURIComponent(nodeId)}`, + query: { workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined }, + }); + } + + traverse(req: TraverseRequestArgs): Promise { + return this.c.request({ + method: "POST", + path: "/api/v1/ext/graph/traverse", + body: { + workspace_id: this.c.resolveWorkspace(req.workspaceId), + node_id: req.nodeId, + max_depth: req.maxDepth ?? 3, + edge_types: req.edgeTypes, + }, + }); + } + + tree(opts: { workspaceId?: string } = {}): Promise<{ tree: unknown }> { + return this.c.request<{ tree: unknown }>({ + method: "GET", + path: "/api/v1/ext/graph/tree", + query: { workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined }, + }); + } + + tools(opts: { + workspaceId?: string; + category?: string; + provider?: string; + search?: string; + limit?: number; + } = {}): Promise<{ total: number; tools: Record[] }> { + return this.c.request({ + method: "GET", + path: "/api/v1/ext/graph/tools", + query: { + workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined, + category: opts.category, + provider: opts.provider, + search: opts.search, + limit: opts.limit ?? 50, + }, + }); + } + + chapters(opts: { workspaceId?: string } = {}): Promise<{ chapters: Record[] }> { + return this.c.request({ + method: "GET", + path: "/api/v1/ext/graph/chapters", + query: { workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined }, + }); + } +} + +class HistoryNamespace { + constructor(private readonly c: OpenGraphClient) {} + + async chats(opts: { workspaceId?: string; limit?: number } = {}): Promise { + const data = await this.c.request<{ sessions: ChatSessionSummary[] }>({ + method: "GET", + path: "/api/v1/ext/history/chats", + query: { + workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined, + limit: opts.limit ?? 50, + }, + }); + return data.sessions ?? []; + } + + chat(sessionId: string, opts: { workspaceId?: string } = {}): Promise { + return this.c.request({ + method: "GET", + path: `/api/v1/ext/history/chats/${encodeURIComponent(sessionId)}`, + query: { workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined }, + }); + } + + async builds(opts: { workspaceId?: string; limit?: number } = {}): Promise { + const data = await this.c.request<{ builds: BuildJobSummary[] }>({ + method: "GET", + path: "/api/v1/ext/history/builds", + query: { + workspace_id: this.c.resolveWorkspace(opts.workspaceId) ?? undefined, + limit: opts.limit ?? 50, + }, + }); + return data.builds ?? []; + } + + build(jobId: string): Promise { + return this.c.request({ + method: "GET", + path: `/api/v1/ext/history/builds/${encodeURIComponent(jobId)}`, + }); + } +} diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts new file mode 100644 index 0000000..402fe82 --- /dev/null +++ b/sdks/typescript/src/errors.ts @@ -0,0 +1,61 @@ +/** + * Typed errors raised by the OpenGraph SDK. + * + * Catch `OpenGraphError` to handle any failure; narrow further when you + * want to react to a specific condition (401 vs 429 vs 5xx). + */ + +export class OpenGraphError extends Error { + readonly status: number; + readonly detail?: string; + readonly payload?: unknown; + + constructor(message: string, opts: { status?: number; detail?: string; payload?: unknown } = {}) { + super(message); + this.name = "OpenGraphError"; + this.status = opts.status ?? 0; + this.detail = opts.detail; + this.payload = opts.payload; + } +} + +export class AuthError extends OpenGraphError { + constructor(msg: string, opts: ConstructorParameters[1] = {}) { + super(msg, opts); + this.name = "AuthError"; + } +} + +export class NotFoundError extends OpenGraphError { + constructor(msg: string, opts: ConstructorParameters[1] = {}) { + super(msg, opts); + this.name = "NotFoundError"; + } +} + +export class ValidationError extends OpenGraphError { + constructor(msg: string, opts: ConstructorParameters[1] = {}) { + super(msg, opts); + this.name = "ValidationError"; + } +} + +export class ServerError extends OpenGraphError { + constructor(msg: string, opts: ConstructorParameters[1] = {}) { + super(msg, opts); + this.name = "ServerError"; + } +} + +export class RateLimitError extends OpenGraphError { + readonly retryAfterSeconds?: number; + + constructor( + msg: string, + opts: ConstructorParameters[1] & { retryAfterSeconds?: number } = {}, + ) { + super(msg, opts); + this.name = "RateLimitError"; + this.retryAfterSeconds = opts.retryAfterSeconds; + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts new file mode 100644 index 0000000..15c1ce5 --- /dev/null +++ b/sdks/typescript/src/index.ts @@ -0,0 +1,27 @@ +export { OpenGraphClient } from "./client"; +export type { OpenGraphClientOptions } from "./client"; +export { + OpenGraphError, + AuthError, + NotFoundError, + ValidationError, + RateLimitError, + ServerError, +} from "./errors"; +export type { + BuildJobSummary, + ChatMessage, + ChatSessionDetail, + ChatSessionSummary, + ChatUsage, + GraphSearchRequest, + GraphStats, + KBFocus, + NodeDetail, + QueryRequest, + QueryResponse, + SearchResponse, + SearchResult, + TraverseRequestArgs, + TraverseResponse, +} from "./types"; diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts new file mode 100644 index 0000000..310b70c --- /dev/null +++ b/sdks/typescript/src/types.ts @@ -0,0 +1,144 @@ +/** + * Public type definitions for the OpenGraph developer API. + * + * Matches src/api/ext_models.py one-for-one. Hand-written for better DX + * than codegen would produce (no `components["schemas"]["…"]` noise), but + * the field set mirrors the wire format exactly so the shapes round-trip + * with the Python SDK. + * + * Unknown fields on the wire are preserved via `readonly [key: string]: + * unknown` so adding a new v1 response field never breaks older SDK + * versions. + */ + +export type KBFocus = "knowledge" | "tool" | "both"; + +export interface ChatUsage { + llm_prompt_tokens: number; + llm_completion_tokens: number; + llm_total_tokens: number; + llm_calls: number; + model?: string | null; +} + +export interface QueryResponse { + session_id: string; + response: string; + intent: string; + kb_focus: KBFocus; + extracted_topics: string[]; + follow_up_suggestions: string[]; + llm_model?: string | null; + usage?: ChatUsage | null; + duration_ms: number; + history_persisted: boolean; + + /** Present only when `debug: true` was passed. Shape is not stable. */ + steps?: Record[] | null; + traversal_path?: string[] | null; + knowledge_concepts?: Record[] | null; + tools_referenced?: Record[] | null; +} + +export interface GraphStats { + total_nodes: number; + total_edges: number; + nodes_by_type: Record; + edges_by_type: Record; + backends: Record; +} + +export interface SearchResult { + node_id: string; + node_type: string; + heading: string; + kb_source: string; + score: number; +} + +export interface SearchResponse { + query: string; + results: SearchResult[]; +} + +export interface NodeDetail { + node: Record; + edges: Record[]; + children: Record[]; + parent?: string | null; +} + +export interface TraverseResponse { + root_node_id: string; + traversal_path: string[]; + nodes: Record[]; + edge_count: number; +} + +export interface ChatSessionSummary { + session_id: string; + title?: string | null; + message_count: number; + created_at: string; + last_activity_at: string; +} + +export interface ChatMessage { + id: number; + role: "user" | "assistant"; + query?: string | null; + response?: Record | null; + intent?: string | null; + kb_focus?: string | null; + created_at: string; + duration_ms: number; +} + +export interface ChatSessionDetail { + session_id: string; + workspace_id: string; + title?: string | null; + messages: ChatMessage[]; +} + +export interface BuildJobSummary { + job_id: string; + workspace_id: string; + status: "queued" | "running" | "done" | "error" | string; + stage: number; + stage_name: string; + percent: number; + created_at: string; + finished_at?: string | null; +} + +// --------------------------------------------------------------------------- +// Request types +// --------------------------------------------------------------------------- + +export interface QueryRequest { + /** Target workspace UUID. Optional if the client was constructed with a default. */ + workspaceId?: string; + /** Natural-language question. */ + query: string; + /** Continue an existing chat session; omit for a new one. */ + sessionId?: string; + /** Override the LLM model for this single call. */ + llmModel?: string; + /** When true, include internal reasoning steps (unstable shape). */ + debug?: boolean; +} + +export interface GraphSearchRequest { + q: string; + workspaceId?: string; + topK?: number; + nodeType?: string; +} + +export interface TraverseRequestArgs { + nodeId: string; + workspaceId?: string; + maxDepth?: number; + edgeTypes?: string[]; +} diff --git a/sdks/typescript/tests/client.test.ts b/sdks/typescript/tests/client.test.ts new file mode 100644 index 0000000..75610cf --- /dev/null +++ b/sdks/typescript/tests/client.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { + AuthError, + NotFoundError, + OpenGraphClient, + RateLimitError, +} from "../src"; + +function mockFetchOnce(response: { + status: number; + body?: unknown; + headers?: Record; +}): typeof fetch { + return async () => + new Response(response.body === undefined ? null : JSON.stringify(response.body), { + status: response.status, + headers: { + "Content-Type": "application/json", + ...(response.headers ?? {}), + }, + }); +} + +describe("OpenGraphClient", () => { + it("parses a happy-path query response", async () => { + const fetchStub = mockFetchOnce({ + status: 200, + body: { + session_id: "sess-1", + response: "hello", + intent: "greet", + kb_focus: "both", + extracted_topics: [], + follow_up_suggestions: ["what next?"], + duration_ms: 42, + history_persisted: true, + }, + }); + const client = new OpenGraphClient({ + apiKey: "og_live_test", + baseUrl: "http://test.example", + maxRetries: 0, + fetch: fetchStub, + }); + const resp = await client.query({ workspaceId: "ws-1", query: "hi" }); + expect(resp.session_id).toBe("sess-1"); + expect(resp.response).toBe("hello"); + expect(resp.follow_up_suggestions).toEqual(["what next?"]); + }); + + it("maps 401 to AuthError", async () => { + const fetchStub = mockFetchOnce({ + status: 401, + body: { detail: "Invalid or missing API key." }, + }); + const client = new OpenGraphClient({ + apiKey: "og_live_test", + baseUrl: "http://test.example", + maxRetries: 0, + fetch: fetchStub, + }); + await expect(client.graph.stats({ workspaceId: "ws-1" })).rejects.toBeInstanceOf(AuthError); + }); + + it("maps 404 to NotFoundError", async () => { + const fetchStub = mockFetchOnce({ + status: 404, + body: { detail: "Node 'missing' not found." }, + }); + const client = new OpenGraphClient({ + apiKey: "og_live_test", + baseUrl: "http://test.example", + maxRetries: 0, + fetch: fetchStub, + }); + await expect( + client.graph.node("missing", { workspaceId: "ws-1" }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("propagates Retry-After on 429", async () => { + const fetchStub = mockFetchOnce({ + status: 429, + body: { detail: "Rate limit exceeded — 60 per minute." }, + headers: { "Retry-After": "42" }, + }); + const client = new OpenGraphClient({ + apiKey: "og_live_test", + baseUrl: "http://test.example", + maxRetries: 0, + fetch: fetchStub, + }); + try { + await client.graph.stats({ workspaceId: "ws-1" }); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(RateLimitError); + expect((err as RateLimitError).retryAfterSeconds).toBe(42); + } + }); + + it("falls back to OPENGRAPH_WORKSPACE_ID env", async () => { + let capturedBody = ""; + const fetchStub: typeof fetch = async (_url, init) => { + capturedBody = init?.body ? String(init.body) : ""; + return new Response( + JSON.stringify({ + session_id: "s", + response: "ok", + intent: "", + kb_focus: "both", + extracted_topics: [], + follow_up_suggestions: [], + duration_ms: 0, + history_persisted: true, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + process.env.OPENGRAPH_WORKSPACE_ID = "ws-from-env"; + try { + const client = new OpenGraphClient({ + apiKey: "og_live_test", + baseUrl: "http://test.example", + maxRetries: 0, + fetch: fetchStub, + }); + await client.query({ query: "hello" }); + } finally { + delete process.env.OPENGRAPH_WORKSPACE_ID; + } + const payload = JSON.parse(capturedBody); + expect(payload.workspace_id).toBe("ws-from-env"); + }); +}); diff --git a/sdks/typescript/tsconfig.json b/sdks/typescript/tsconfig.json new file mode 100644 index 0000000..2d09320 --- /dev/null +++ b/sdks/typescript/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowJs": false, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests", "examples"] +} diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts new file mode 100644 index 0000000..faa6d98 --- /dev/null +++ b/sdks/typescript/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); From b0a4c3f6c94061ec0d9b7cda04c690e9202b28d6 Mon Sep 17 00:00:00 2001 From: souvik roy Date: Thu, 23 Apr 2026 15:01:42 +0530 Subject: [PATCH 8/8] chore: add SDK release workflows + top-level SDK README Wires the final piece of the developer-API rollout: - .github/workflows/sdk-python.yml runs pytest on Python 3.9-3.12 on every PR/push to sdks/python and publishes to PyPI via trusted publishing (OIDC) on sdk-python-v* tags. Tag version must match pyproject.toml. - .github/workflows/sdk-typescript.yml runs vitest + tsc on Node 18/20/22 and publishes to npm with provenance on sdk-typescript-v* tags. - sdks/README.md is the landing page for both packages with install commands, the release tag convention, and a contract-compatibility note for v1. - .gitignore now covers sdks/typescript/{node_modules,dist} and sdks/python/.pytest_cache so future checkouts stay tidy. - sdks/typescript/package-lock.json is checked in so CI and local builds resolve the same transitive tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sdk-python.yml | 86 + .github/workflows/sdk-typescript.yml | 82 + .gitignore | 5 + sdks/README.md | 78 + sdks/typescript/package-lock.json | 3051 ++++++++++++++++++++++++++ 5 files changed, 3302 insertions(+) create mode 100644 .github/workflows/sdk-python.yml create mode 100644 .github/workflows/sdk-typescript.yml create mode 100644 sdks/README.md create mode 100644 sdks/typescript/package-lock.json diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml new file mode 100644 index 0000000..f4bf07b --- /dev/null +++ b/.github/workflows/sdk-python.yml @@ -0,0 +1,86 @@ +name: sdk-python + +# Two triggers: +# - push to main touching sdks/python/** → runs the test job only. +# - a git tag of the form sdk-python-vX.Y.Z → runs tests AND publishes +# to PyPI via trusted publishing (OIDC, no long-lived token). +# +# Tag convention: ``git tag sdk-python-v0.1.0 && git push origin +# sdk-python-v0.1.0``. The version in the tag must match the version in +# pyproject.toml; the publish step fails fast if they disagree. + +on: + push: + branches: [main] + paths: + - "sdks/python/**" + - ".github/workflows/sdk-python.yml" + tags: + - "sdk-python-v*" + pull_request: + paths: + - "sdks/python/**" + - ".github/workflows/sdk-python.yml" + +concurrency: + group: sdk-python-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + defaults: + run: + working-directory: sdks/python + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install --upgrade pip + - run: pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Test + run: pytest -q + + publish: + if: startsWith(github.ref, 'refs/tags/sdk-python-v') + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/python + # Trusted publishing — configure once on PyPI: + # https://docs.pypi.org/trusted-publishers/ + # Repository owner + name + workflow filename + environment all must + # match what's configured there. No secrets live in the repo. + environment: pypi-release + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build twine + - name: Verify tag matches pyproject version + run: | + TAG_VERSION="${GITHUB_REF_NAME#sdk-python-v}" + PKG_VERSION="$(python -c 'import tomllib; print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])')" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version $TAG_VERSION does not match pyproject version $PKG_VERSION" + exit 1 + fi + - name: Build + run: python -m build + - name: Check + run: twine check dist/* + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdks/python/dist diff --git a/.github/workflows/sdk-typescript.yml b/.github/workflows/sdk-typescript.yml new file mode 100644 index 0000000..9f79967 --- /dev/null +++ b/.github/workflows/sdk-typescript.yml @@ -0,0 +1,82 @@ +name: sdk-typescript + +# Two triggers: +# - push / PR touching sdks/typescript/** → test + typecheck. +# - tag sdk-typescript-vX.Y.Z → tests then publishes to npm with +# provenance (SLSA-style attestation visible on the npm page). +# +# Tag convention: ``git tag sdk-typescript-v0.1.0 && git push origin +# sdk-typescript-v0.1.0``. The tag version must match package.json; the +# publish job fails fast if they disagree. + +on: + push: + branches: [main] + paths: + - "sdks/typescript/**" + - ".github/workflows/sdk-typescript.yml" + tags: + - "sdk-typescript-v*" + pull_request: + paths: + - "sdks/typescript/**" + - ".github/workflows/sdk-typescript.yml" + +concurrency: + group: sdk-typescript-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["18", "20", "22"] + defaults: + run: + working-directory: sdks/typescript + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + + publish: + if: startsWith(github.ref, 'refs/tags/sdk-typescript-v') + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/typescript + # npm provenance requires id-token:write. The NPM_TOKEN secret is a + # classic automation token with publish permission on @opengraph/sdk. + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + cache: npm + cache-dependency-path: sdks/typescript/package-lock.json + - run: npm ci + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#sdk-typescript-v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version $TAG_VERSION does not match package.json version $PKG_VERSION" + exit 1 + fi + - run: npm run build + - name: Publish to npm with provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index 7947ec6..878128b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ htmlcov/ frontend/node_modules/ frontend/.next/ frontend/tsconfig.tsbuildinfo + +# SDK build artifacts +sdks/typescript/node_modules/ +sdks/typescript/dist/ +sdks/python/.pytest_cache/ diff --git a/sdks/README.md b/sdks/README.md new file mode 100644 index 0000000..4fddc73 --- /dev/null +++ b/sdks/README.md @@ -0,0 +1,78 @@ +# OpenGraph SDKs + +First-party SDKs for the [OpenGraph](https://opengraph.example) developer +API. The public surface lives at `/api/v1/ext/*` — see the [API docs +dashboard page](../README.md) or the auto-generated Swagger UI at +`/docs` for the full reference. + +## Packages + +| Language | Package | Install | Source | +|---|---|---|---| +| Python | [`opengraph-sdk`](https://pypi.org/project/opengraph-sdk/) | `pip install opengraph-sdk` | [`sdks/python/`](./python/) | +| TypeScript / JavaScript | [`@opengraph/sdk`](https://www.npmjs.com/package/@opengraph/sdk) | `npm install @opengraph/sdk` | [`sdks/typescript/`](./typescript/) | + +Both packages cover the same surface and return the same JSON shape, so +the same API key and workspace work from either language. + +## Release process + +SDKs release independently via Git tags so a bump in one package never +drags the other. + +```bash +# Python (after bumping version in sdks/python/pyproject.toml) +git tag sdk-python-v0.2.0 +git push origin sdk-python-v0.2.0 + +# TypeScript (after bumping version in sdks/typescript/package.json) +git tag sdk-typescript-v0.2.0 +git push origin sdk-typescript-v0.2.0 +``` + +GitHub Actions runs the test matrix, verifies the tag version matches +the package manifest, and publishes: + +- Python → **PyPI** via [trusted publishing](https://docs.pypi.org/trusted-publishers/) (no long-lived token). +- TypeScript → **npm** with [provenance](https://docs.npmjs.com/generating-provenance-statements) attached. + +## Contract compatibility + +- **v1 field stability.** `/api/v1/ext/*` responses may gain new fields + inside a minor release. Removing or retyping a field requires a v2 + path. +- **Cross-language parity.** When a new endpoint ships, both SDKs get the + method in the same release. If you see a feature in one but not the + other, it's a bug — file an issue. +- **Breaking change policy.** Any breaking change bumps the SDK major + version. Keep `api_version="1"` in your client config if you want the + SDK to refuse responses from a future incompatible server. + +## Local development + +```bash +# Python +cd sdks/python +pip install -e ".[dev]" +pytest + +# TypeScript +cd sdks/typescript +npm install +npm test +npm run build +``` + +Running the full example end-to-end against your local backend: + +```bash +OPENGRAPH_BASE_URL=http://localhost:8000 \ +OPENGRAPH_API_KEY=og_live_... \ +OPENGRAPH_WORKSPACE_ID=... \ +python sdks/python/examples/query.py "your question here" + +OPENGRAPH_BASE_URL=http://localhost:8000 \ +OPENGRAPH_API_KEY=og_live_... \ +OPENGRAPH_WORKSPACE_ID=... \ +node sdks/typescript/examples/query.mjs "your question here" +``` diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json new file mode 100644 index 0000000..83c2c92 --- /dev/null +++ b/sdks/typescript/package-lock.json @@ -0,0 +1,3051 @@ +{ + "name": "@opengraph/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@opengraph/sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.0", + "msw": "^2.4.0", + "tsup": "^8.2.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.5", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.5.tgz", + "integrity": "sha512-Fa2HztoLlZxRN6wVC2KB7q0SvRTKjfP0328NVnSit03+0nzm62syxyT46KGbgq3Vr1A/mmLeQwu3GprB0lNTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.13.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.5.tgz", + "integrity": "sha512-LuJem+CbqbywJtafv4zh5kcCQNmZnKwfJgJ/LcNYjeG3CU/xJLepJM1CNZcbp+oV8tXFGvUfswPGru34Mx7QGQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rettime": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz", + "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +}